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.

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