Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

243 linesCodeBlameRaw
1/**
2 * What a session recorded, step by step: the prompt folded, messages as
3 * the markdown they are, and the tool calls between them run together, a
4 * line each, with what came back folded under each. The pull request's
5 * Sessions page and a harness session's page both list them.
6 */
7import { ChevronRight, FileText, MessageSquare, Plug, Sparkles, StickyNote, Terminal, Wrench } from "lucide-react";
8import { type ReactNode, useState } from "react";
9
10import type { SessionEntry } from "@g1t/contracts";
11
12import { MarkGlyph } from "./logo";
13import { Markdown } from "./markdown";
14import { TimeAgo } from "./ui";
15import { Badge } from "./ui/badge";
16import { Card } from "./ui/card";
17import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
18import { Hint } from "./ui/hint";
19import { repoAt } from "../lib/markdown-plugins";
20import { type ToolCall, type ToolStep, quotedBlocks, readToolCall, transcript } from "../lib/session-entries";
21
22/** Runs of more tool calls than this fold into one line. */
23const OPEN_RUN = 3;
24/** What a tool was called with, longer than this, has the rest to unfold. */
25const LONG_DETAIL = 90;
26
27function At({ at }: { at: string }) {
28 return (
29 <span className="shrink-0 text-xs text-faint">
30 <TimeAgo at={at} />
31 </span>
32 );
33}
34
35/** A prompt someone else typed: that there was one, not what it said. */
36function KeptPrompt({ entry }: { entry: SessionEntry }) {
37 return (
38 <li className="flex items-center gap-2 px-4 py-3">
39 <Terminal size={14} className="shrink-0 text-faint" />
40 <span className="shrink-0 font-medium text-fg-soft">Prompt</span>
41 <span className="min-w-0 grow truncate text-xs text-faint">Shows only to whoever typed it.</span>
42 <At at={entry.at} />
43 </li>
44 );
45}
46
47/** The prompt, folded under its first line. */
48function Prompt({ entry, base }: { entry: SessionEntry; base?: string }) {
49 const [open, setOpen] = useState(false);
50 const first = entry.text.split("\n").find((line) => line.trim()) ?? "";
51 return (
52 <li className="px-4 py-3">
53 <Collapsible open={open} onOpenChange={setOpen}>
54 <div className="flex items-center gap-2">
55 <Terminal size={14} className="shrink-0 text-accent" />
56 <CollapsibleTrigger className="min-w-0 grow text-left">
57 <span className="shrink-0 font-medium text-fg">Prompt</span>
58 {!open && <span className="min-w-0 truncate text-xs text-faint">{first}</span>}
59 <ChevronRight size={13} className="shrink-0" />
60 </CollapsibleTrigger>
61 <At at={entry.at} />
62 </div>
63 <CollapsibleContent>
64 <Card tone="bg" radius="lg" className="mt-2 max-h-128 overflow-auto p-4 text-sm">
65 <Markdown source={quotedBlocks(entry.text)} repo={repoAt(base)} />
66 </Card>
67 </CollapsibleContent>
68 </Collapsible>
69 </li>
70 );
71}
72
73/** What a tool was called on: g1t's tools by resource and action, a command as code, a file by its path. */
74function ToolLabel({ call }: { call: ToolCall }) {
75 if (call.server) {
76 return (
77 <span className="flex min-w-0 items-baseline gap-1.5">
78 {call.server !== "g1t" && <span className="shrink-0 text-faint">{call.server}</span>}
79 <span className="shrink-0 font-medium text-fg-soft">{call.name}</span>
80 {call.detail && (
81 <>
82 <span className="text-faint">·</span>
83 <span className="min-w-0 truncate text-muted">{call.detail}</span>
84 </>
85 )}
86 </span>
87 );
88 }
89 return (
90 <span className="flex min-w-0 items-center gap-2">
91 <Badge shape="tag" className="font-mono font-normal">
92 {call.name.toLowerCase()}
93 </Badge>
94 {call.detail && <code className="min-w-0 truncate font-mono text-fg/85">{call.detail}</code>}
95 </span>
96 );
97}
98
99function ToolIcon({ call }: { call: ToolCall }) {
100 if (call.server === "g1t") return <MarkGlyph className="h-3" />;
101 const Icon = call.server ? Plug : call.sort === "shell" ? Terminal : call.sort === "file" ? FileText : Wrench;
102 return <Icon size={13} className="text-faint" />;
103}
104
105/** A block of text a tool was called with or gave back. */
106function Block({ children }: { children: ReactNode }) {
107 return (
108 <Card asChild tone="bg" radius="lg" className="mt-1.5 max-h-72 overflow-auto px-3 py-2 font-mono text-xs whitespace-pre-wrap break-all text-muted">
109 <pre>{children}</pre>
110 </Card>
111 );
112}
113
114/** One tool call in a line; a long one, and what came back, unfold under it. */
115function ToolRow({ step }: { step: ToolStep }) {
116 const [open, setOpen] = useState(false);
117 const call = step.call ? readToolCall(step.call) : null;
118 const long = Boolean(call && call.detail.length > LONG_DETAIL);
119 const label = call ? <ToolLabel call={call} /> : <span className="text-muted">Result</span>;
120 return (
121 <li className="flex gap-2 py-0.5 text-xs">
122 <span className="flex h-4.5 w-4 shrink-0 items-center justify-center">{call ? <ToolIcon call={call} /> : <Wrench size={13} className="text-faint" />}</span>
123 {long || step.results.length > 0 ? (
124 <Collapsible open={open} onOpenChange={setOpen} className="min-w-0 grow">
125 <CollapsibleTrigger className="flex w-full min-w-0 text-left text-xs">
126 {label}
127 <span className="ml-auto flex shrink-0 items-center gap-1 text-faint">
128 {call && step.results.length > 0 && (step.results.length === 1 ? "result" : `${step.results.length} results`)}
129 <ChevronRight size={12} />
130 </span>
131 </CollapsibleTrigger>
132 <CollapsibleContent>
133 {long && call && <Block>{call.detail}</Block>}
134 {step.results.map((result) =>
135 result.withheld ? (
136 <p key={result.seq} className="text-xs text-faint">
137 Its output shows only to whoever ran it.
138 </p>
139 ) : (
140 <Block key={result.seq}>{result.text}</Block>
141 ),
142 )}
143 </CollapsibleContent>
144 </Collapsible>
145 ) : (
146 <Hint label={call && call.detail.length > 40 ? call.detail : null}>
147 <span className="flex min-w-0 grow">{label}</span>
148 </Hint>
149 )}
150 </li>
151 );
152}
153
154/** The tools of a run, each once, for its folded line. */
155function toolNames(steps: ToolStep[]): string {
156 const names = new Set<string>();
157 for (const step of steps) {
158 if (!step.call) continue;
159 const call = readToolCall(step.call);
160 names.add(call.server && call.server !== "g1t" ? call.server : call.name.toLowerCase());
161 }
162 return [...names].join(", ");
163}
164
165/** Tool calls one after another: a few in a tight list, more folded into one line that opens to it. */
166function ToolRun({ steps }: { steps: ToolStep[] }) {
167 const [open, setOpen] = useState(false);
168 const calls = steps.filter((step) => step.call).length;
169 const list = (
170 <ol className="space-y-0.5">
171 {steps.map((step) => (
172 <ToolRow key={(step.call ?? step.results[0]).seq} step={step} />
173 ))}
174 </ol>
175 );
176 if (steps.length <= OPEN_RUN) return <li className="px-4 py-2">{list}</li>;
177 return (
178 <li className="px-4 py-2">
179 <Collapsible open={open} onOpenChange={setOpen}>
180 <div className="flex items-center gap-2 text-xs">
181 <span className="flex w-4 shrink-0 justify-center">
182 <Wrench size={13} className="text-faint" />
183 </span>
184 <CollapsibleTrigger className="min-w-0 text-left text-xs">
185 <span className="shrink-0 font-medium text-fg-soft">{calls === 1 ? "1 tool call" : `${calls} tool calls`}</span>
186 {!open && <span className="min-w-0 truncate text-faint">{toolNames(steps)}</span>}
187 <ChevronRight size={12} className="shrink-0" />
188 </CollapsibleTrigger>
189 </div>
190 <CollapsibleContent className="mt-1">{list}</CollapsibleContent>
191 </Collapsible>
192 </li>
193 );
194}
195
196function Entry({ entry, base }: { entry: SessionEntry; base?: string }) {
197 if (entry.kind === "prompt") return entry.withheld ? <KeptPrompt entry={entry} /> : <Prompt entry={entry} base={base} />;
198 if (entry.kind === "message") {
199 return (
200 <li className="flex gap-2.5 px-4 py-3 text-sm">
201 <MessageSquare size={14} className="mt-1 shrink-0 text-faint" />
202 <div className="min-w-0 grow">
203 <Markdown source={entry.text} repo={repoAt(base)} />
204 </div>
205 <At at={entry.at} />
206 </li>
207 );
208 }
209 if (entry.kind === "skill") {
210 return (
211 <li className="flex items-center gap-2.5 px-4 py-2 text-xs text-muted">
212 <Sparkles size={13} className="shrink-0 text-accent" />
213 <span className="min-w-0 grow">
214 Used skill <Badge shape="tag" className="font-mono">{entry.text}</Badge>
215 </span>
216 <At at={entry.at} />
217 </li>
218 );
219 }
220 return (
221 <li className="flex gap-2.5 px-4 py-2.5 text-xs text-muted">
222 <StickyNote size={13} className="mt-0.5 shrink-0 text-faint" />
223 <span className="min-w-0 grow">{entry.text}</span>
224 <At at={entry.at} />
225 </li>
226 );
227}
228
229/**
230 * A session's entries in a card, oldest first. `base`, the repository's
231 * path (`/acme/web`), links `#12` and the like in what it wrote.
232 */
233export function SessionEntries({ entries, base }: { entries: SessionEntry[]; base?: string }) {
234 return (
235 <Card asChild divided>
236 <ol>
237 {transcript(entries).map((item) =>
238 item.kind === "tools" ? <ToolRun key={item.first.seq} steps={item.steps} /> : <Entry key={item.entry.seq} entry={item.entry} base={base} />,
239 )}
240 </ol>
241 </Card>
242 );
243}