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.

248 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">
123 {call ? <ToolIcon call={call} /> : <Wrench size={13} className="text-faint" />}
124 </span>
125 {long || step.results.length > 0 ? (
126 <Collapsible open={open} onOpenChange={setOpen} className="min-w-0 grow">
127 <CollapsibleTrigger className="flex w-full min-w-0 text-left text-xs">
128 {label}
129 <span className="ml-auto flex shrink-0 items-center gap-1 text-faint">
130 {call && step.results.length > 0 && (step.results.length === 1 ? "result" : `${step.results.length} results`)}
131 <ChevronRight size={12} />
132 </span>
133 </CollapsibleTrigger>
134 <CollapsibleContent>
135 {long && call && <Block>{call.detail}</Block>}
136 {step.results.map((result) =>
137 result.withheld ? (
138 <p key={result.seq} className="text-xs text-faint">
139 Its output shows only to whoever ran it.
140 </p>
141 ) : (
142 <Block key={result.seq}>{result.text}</Block>
143 ),
144 )}
145 </CollapsibleContent>
146 </Collapsible>
147 ) : (
148 <Hint label={call && call.detail.length > 40 ? call.detail : null}>
149 <span className="flex min-w-0 grow">{label}</span>
150 </Hint>
151 )}
152 </li>
153 );
154}
155
156/** The tools of a run, each once, for its folded line. */
157function toolNames(steps: ToolStep[]): string {
158 const names = new Set<string>();
159 for (const step of steps) {
160 if (!step.call) continue;
161 const call = readToolCall(step.call);
162 names.add(call.server && call.server !== "g1t" ? call.server : call.name.toLowerCase());
163 }
164 return [...names].join(", ");
165}
166
167/** Tool calls one after another: a few in a tight list, more folded into one line that opens to it. */
168function ToolRun({ steps }: { steps: ToolStep[] }) {
169 const [open, setOpen] = useState(false);
170 const calls = steps.filter((step) => step.call).length;
171 const list = (
172 <ol className="space-y-0.5">
173 {steps.map((step) => (
174 <ToolRow key={(step.call ?? step.results[0]).seq} step={step} />
175 ))}
176 </ol>
177 );
178 if (steps.length <= OPEN_RUN) return <li className="px-4 py-2">{list}</li>;
179 return (
180 <li className="px-4 py-2">
181 <Collapsible open={open} onOpenChange={setOpen}>
182 <div className="flex items-center gap-2 text-xs">
183 <span className="flex w-4 shrink-0 justify-center">
184 <Wrench size={13} className="text-faint" />
185 </span>
186 <CollapsibleTrigger className="min-w-0 text-left text-xs">
187 <span className="shrink-0 font-medium text-fg-soft">{calls === 1 ? "1 tool call" : `${calls} tool calls`}</span>
188 {!open && <span className="min-w-0 truncate text-faint">{toolNames(steps)}</span>}
189 <ChevronRight size={12} className="shrink-0" />
190 </CollapsibleTrigger>
191 </div>
192 <CollapsibleContent className="mt-1">{list}</CollapsibleContent>
193 </Collapsible>
194 </li>
195 );
196}
197
198function Entry({ entry, base }: { entry: SessionEntry; base?: string }) {
199 if (entry.kind === "prompt") return entry.withheld ? <KeptPrompt entry={entry} /> : <Prompt entry={entry} base={base} />;
200 if (entry.kind === "message") {
201 return (
202 <li className="flex gap-2.5 px-4 py-3 text-sm">
203 <MessageSquare size={14} className="mt-1 shrink-0 text-faint" />
204 <div className="min-w-0 grow">
205 <Markdown source={entry.text} repo={repoAt(base)} />
206 </div>
207 <At at={entry.at} />
208 </li>
209 );
210 }
211 if (entry.kind === "skill") {
212 return (
213 <li className="flex items-center gap-2.5 px-4 py-2 text-xs text-muted">
214 <Sparkles size={13} className="shrink-0 text-accent" />
215 <span className="min-w-0 grow">
216 Used skill{" "}
217 <Badge shape="tag" className="font-mono">
218 {entry.text}
219 </Badge>
220 </span>
221 <At at={entry.at} />
222 </li>
223 );
224 }
225 return (
226 <li className="flex gap-2.5 px-4 py-2.5 text-xs text-muted">
227 <StickyNote size={13} className="mt-0.5 shrink-0 text-faint" />
228 <span className="min-w-0 grow">{entry.text}</span>
229 <At at={entry.at} />
230 </li>
231 );
232}
233
234/**
235 * A session's entries in a card, oldest first. `base`, the repository's
236 * path (`/acme/web`), links `#12` and the like in what it wrote.
237 */
238export function SessionEntries({ entries, base }: { entries: SessionEntry[]; base?: string }) {
239 return (
240 <Card asChild divided>
241 <ol>
242 {transcript(entries).map((item) =>
243 item.kind === "tools" ? <ToolRun key={item.first.seq} steps={item.steps} /> : <Entry key={item.entry.seq} entry={item.entry} base={base} />,
244 )}
245 </ol>
246 </Card>
247 );
248}