| 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 | */ |
| 7 | import { ChevronRight, FileText, MessageSquare, Plug, Sparkles, StickyNote, Terminal, Wrench } from "lucide-react"; |
| 8 | import { type ReactNode, useState } from "react"; |
| 9 | |
| 10 | import type { SessionEntry } from "@g1t/contracts"; |
| 11 | |
| 12 | import { MarkGlyph } from "./logo"; |
| 13 | import { Markdown } from "./markdown"; |
| 14 | import { TimeAgo } from "./ui"; |
| 15 | import { Badge } from "./ui/badge"; |
| 16 | import { Card } from "./ui/card"; |
| 17 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 18 | import { Hint } from "./ui/hint"; |
| 19 | import { repoAt } from "../lib/markdown-plugins"; |
| 20 | import { type ToolCall, type ToolStep, quotedBlocks, readToolCall, transcript } from "../lib/session-entries"; |
| 21 | |
| 22 | /** Runs of more tool calls than this fold into one line. */ |
| 23 | const OPEN_RUN = 3; |
| 24 | /** What a tool was called with, longer than this, has the rest to unfold. */ |
| 25 | const LONG_DETAIL = 90; |
| 26 | |
| 27 | function 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. */ |
| 36 | function 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. */ |
| 62 | function 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 | |
| 87 | function 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. */ |
| 94 | function 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. */ |
| 103 | function 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. */ |
| 137 | function 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. */ |
| 148 | function 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 | |
| 178 | function 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 | */ |
| 215 | export 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 | } |