| 1 | import { Ban, Check, ChevronRight, CircleAlert, Copy, ExternalLink, LoaderCircle, Play, SquareTerminal } from "lucide-react"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | |
| 4 | import type { AskSource } from "@g1t/contracts"; |
| 5 | |
| 6 | import { type Proposal, type Step, foldAnswer, proposalTitle } from "../lib/ask"; |
| 7 | import { Badge } from "./ui/badge"; |
| 8 | import { Button } from "./ui/button"; |
| 9 | import { Card } from "./ui/card"; |
| 10 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; |
| 11 | import { Hint } from "./ui/hint"; |
| 12 | |
| 13 | /** |
| 14 | * What an Ask answer did to answer (lib/ask.ts `Step`): each of g1t's tools |
| 15 | * it ran as the person asking, one compact row each, with what it found and |
| 16 | * a link to the page it read. Open while the answer is being written, folded |
| 17 | * into one line once it is done. Each row is one tool call, as a chat's |
| 18 | * tool-call part is, so the same steps can be drawn there. |
| 19 | */ |
| 20 | export function AskSteps({ steps, working }: { steps: Step[]; working: boolean }) { |
| 21 | const [open, setOpen] = useState(working); |
| 22 | // Open while it works, folded when it is done; the person can open it again. |
| 23 | useEffect(() => setOpen(working), [working]); |
| 24 | if (steps.length === 0) return null; |
| 25 | const failed = steps.filter((step) => step.status === "failed" || step.status === "refused").length; |
| 26 | const running = steps.some((step) => step.status === "running"); |
| 27 | const title = running |
| 28 | ? (steps.findLast((step) => step.status === "running")?.label ?? "Reading g1t") |
| 29 | : `Read g1t in ${steps.length} ${steps.length === 1 ? "step" : "steps"}`; |
| 30 | return ( |
| 31 | <Collapsible open={open} onOpenChange={setOpen}> |
| 32 | <CollapsibleTrigger className="max-w-full text-xs"> |
| 33 | <ChevronRight size={12} aria-hidden="true" className="shrink-0" /> |
| 34 | {running && <LoaderCircle size={12} aria-hidden="true" className="shrink-0 animate-spin motion-reduce:animate-none" />} |
| 35 | <span className="truncate">{title}</span> |
| 36 | {failed > 0 && !running && ( |
| 37 | <Badge tone="warn" className="ml-1"> |
| 38 | {failed} not read |
| 39 | </Badge> |
| 40 | )} |
| 41 | </CollapsibleTrigger> |
| 42 | <CollapsibleContent> |
| 43 | <ol className="mt-1.5 space-y-1 border-l border-line pl-3" aria-label="Steps"> |
| 44 | {steps.map((step) => ( |
| 45 | <li key={step.id}> |
| 46 | <StepRow step={step} /> |
| 47 | </li> |
| 48 | ))} |
| 49 | </ol> |
| 50 | </CollapsibleContent> |
| 51 | </Collapsible> |
| 52 | ); |
| 53 | } |
| 54 | |
| 55 | const STATUS_LABEL: Record<Step["status"], string> = { |
| 56 | running: "Running", |
| 57 | done: "Done", |
| 58 | failed: "Failed", |
| 59 | refused: "Not run", |
| 60 | }; |
| 61 | |
| 62 | function StatusIcon({ status }: { status: Step["status"] }) { |
| 63 | const label = STATUS_LABEL[status]; |
| 64 | switch (status) { |
| 65 | case "running": |
| 66 | return <LoaderCircle size={12} aria-label={label} className="mt-0.5 shrink-0 animate-spin text-muted motion-reduce:animate-none" />; |
| 67 | case "done": |
| 68 | return <Check size={12} aria-label={label} className="mt-0.5 shrink-0 text-success" />; |
| 69 | case "failed": |
| 70 | return <CircleAlert size={12} aria-label={label} className="mt-0.5 shrink-0 text-danger" />; |
| 71 | case "refused": |
| 72 | return <Ban size={12} aria-label={label} className="mt-0.5 shrink-0 text-faint" />; |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | function StepRow({ step }: { step: Step }) { |
| 77 | const tool = step.action ? `${step.name} ${step.action}` : step.name; |
| 78 | return ( |
| 79 | <div className="flex min-w-0 items-start gap-1.5 text-xs leading-5"> |
| 80 | <StatusIcon status={step.status} /> |
| 81 | <div className="min-w-0 flex-1"> |
| 82 | <Hint label={<span className="font-mono">{tool}</span>} side="left"> |
| 83 | <span className="text-muted">{step.label}</span> |
| 84 | </Hint> |
| 85 | {step.summary && <span className={step.status === "done" ? "text-faint" : "text-warn"}> · {step.summary}</span>} |
| 86 | </div> |
| 87 | {step.link && ( |
| 88 | <a |
| 89 | href={step.link} |
| 90 | className="mt-0.5 shrink-0 text-faint hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" |
| 91 | aria-label={`Open ${step.label}`} |
| 92 | > |
| 93 | <ExternalLink size={12} aria-hidden="true" /> |
| 94 | </a> |
| 95 | )} |
| 96 | </div> |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * An answer's steps and sources from the raw `tool` and `sources` lines kept |
| 102 | * with it (a chat message's `meta.answer.steps`): steps above the answer's |
| 103 | * text, sources below. Mount each half where it goes with `part`. |
| 104 | */ |
| 105 | export function AskTrail({ events, working, part, slug }: { events: unknown; working: boolean; part: "steps" | "sources" | "proposals"; slug?: string }) { |
| 106 | const { steps, sources, proposals } = foldAnswer(events); |
| 107 | if (part === "steps") return <AskSteps steps={steps} working={working} />; |
| 108 | if (part === "sources") return <AskSources sources={sources} />; |
| 109 | if (!slug || proposals.length === 0) return null; |
| 110 | return ( |
| 111 | <div className="flex flex-col gap-2"> |
| 112 | {proposals.map((proposal) => ( |
| 113 | <SessionProposal key={proposal.id} proposal={proposal} slug={slug} /> |
| 114 | ))} |
| 115 | </div> |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | type Started = { session_url: string | null; provider_session_url: string | null; handoff: boolean; command: string | null; prompt: string }; |
| 120 | |
| 121 | /** |
| 122 | * Work g1t proposed to hand to a session in the person's own harness. Ask |
| 123 | * never starts it: Start runs it on the workspace's Anthropic account (when |
| 124 | * one is connected), Copy command gives the Claude Code command to run it |
| 125 | * with the g1t plugin. Either way the session is tied to the issue. |
| 126 | */ |
| 127 | export function SessionProposal({ proposal, slug }: { proposal: Proposal; slug: string }) { |
| 128 | const [available, setAvailable] = useState<boolean | null>(null); |
| 129 | const [busy, setBusy] = useState<"start" | "copy" | null>(null); |
| 130 | const [started, setStarted] = useState<Started | null>(null); |
| 131 | const [copied, setCopied] = useState(false); |
| 132 | const [error, setError] = useState<string | null>(null); |
| 133 | |
| 134 | useEffect(() => { |
| 135 | let live = true; |
| 136 | fetch(`/${slug}/-/ask?sessions`, { headers: { accept: "application/json" } }) |
| 137 | .then((response) => (response.ok ? (response.json() as Promise<{ available?: boolean }>) : null)) |
| 138 | .then((setup) => live && setAvailable(Boolean(setup?.available))) |
| 139 | .catch(() => live && setAvailable(false)); |
| 140 | return () => { |
| 141 | live = false; |
| 142 | }; |
| 143 | }, [slug]); |
| 144 | |
| 145 | const go = async (handoff: boolean) => { |
| 146 | setBusy(handoff ? "copy" : "start"); |
| 147 | setError(null); |
| 148 | try { |
| 149 | const response = await fetch(`/${slug}/-/ask`, { |
| 150 | method: "POST", |
| 151 | headers: { "content-type": "application/json", accept: "application/json" }, |
| 152 | body: JSON.stringify({ ...proposal, handoff }), |
| 153 | }); |
| 154 | const body = (await response.json().catch(() => null)) as (Started & { error?: string }) | null; |
| 155 | if (!response.ok || !body) throw new Error(body?.error ?? "The session could not be started."); |
| 156 | setStarted(body); |
| 157 | if (handoff && body.command) { |
| 158 | await navigator.clipboard?.writeText(body.command); |
| 159 | setCopied(true); |
| 160 | setTimeout(() => setCopied(false), 2000); |
| 161 | } |
| 162 | } catch (problem) { |
| 163 | setError(problem instanceof Error ? problem.message : String(problem)); |
| 164 | } finally { |
| 165 | setBusy(null); |
| 166 | } |
| 167 | }; |
| 168 | |
| 169 | const running = started && !started.handoff; |
| 170 | return ( |
| 171 | <Card className="flex flex-col gap-2.5 px-3.5 py-3"> |
| 172 | <div className="flex min-w-0 items-start gap-2.5"> |
| 173 | <SquareTerminal size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-accent" /> |
| 174 | <div className="min-w-0 flex-1"> |
| 175 | <div className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| 176 | <span className="truncate text-sm font-medium text-fg">{proposalTitle(proposal)}</span> |
| 177 | <Badge className="px-1.5 py-0 font-normal">Session</Badge> |
| 178 | </div> |
| 179 | <p className="mt-0.5 text-xs text-muted"> |
| 180 | {proposal.issue ? "Runs in your own harness, with your skills, tied to the issue." : "Opens the issue, then runs in your own harness, tied to it."} |
| 181 | </p> |
| 182 | {proposal.instruction && <p className="mt-1 line-clamp-2 text-xs text-faint">{proposal.instruction}</p>} |
| 183 | </div> |
| 184 | </div> |
| 185 | {running ? ( |
| 186 | <div className="flex flex-wrap items-center gap-2 text-xs"> |
| 187 | <Check size={13} aria-hidden="true" className="text-success" /> |
| 188 | <span className="text-muted">Started</span> |
| 189 | {started.session_url && ( |
| 190 | <Button asChild variant="link" size="inline" className="text-xs"> |
| 191 | <a href={started.session_url}>Follow it on g1t</a> |
| 192 | </Button> |
| 193 | )} |
| 194 | {started.provider_session_url && ( |
| 195 | <Button asChild variant="link" size="inline" className="text-xs"> |
| 196 | <a href={started.provider_session_url} target="_blank" rel="noreferrer"> |
| 197 | In Anthropic’s console <ExternalLink size={11} aria-hidden="true" /> |
| 198 | </a> |
| 199 | </Button> |
| 200 | )} |
| 201 | </div> |
| 202 | ) : ( |
| 203 | <div className="flex flex-wrap items-center gap-2"> |
| 204 | {available && ( |
| 205 | <Button size="xs" variant="accent" busy={busy === "start"} disabled={busy !== null} onClick={() => void go(false)}> |
| 206 | <Play size={12} aria-hidden="true" /> |
| 207 | Start session |
| 208 | </Button> |
| 209 | )} |
| 210 | <Button size="xs" variant="outline" busy={busy === "copy"} disabled={busy !== null} onClick={() => void go(true)}> |
| 211 | {copied ? <Check size={12} aria-hidden="true" className="text-success" /> : <Copy size={12} aria-hidden="true" />} |
| 212 | {copied ? "Copied" : "Copy Claude Code command"} |
| 213 | </Button> |
| 214 | {available === false && <span className="text-xs text-faint">Or connect Anthropic to start it here.</span>} |
| 215 | </div> |
| 216 | )} |
| 217 | {started?.handoff && started.command && ( |
| 218 | <pre className="overflow-x-auto rounded-md border border-line bg-bg px-2.5 py-1.5 font-mono text-xs text-fg-soft scrollbar-thin">{started.command}</pre> |
| 219 | )} |
| 220 | {error && ( |
| 221 | <p className="flex items-start gap-1.5 text-xs text-danger" role="alert"> |
| 222 | <CircleAlert size={12} aria-hidden="true" className="mt-0.5 shrink-0" /> |
| 223 | {error} |
| 224 | </p> |
| 225 | )} |
| 226 | </Card> |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | /** The pages on g1t an answer drew on: the ones it links to first. */ |
| 231 | export function AskSources({ sources }: { sources: AskSource[] }) { |
| 232 | if (sources.length === 0) return null; |
| 233 | return ( |
| 234 | <div className="text-xs"> |
| 235 | <div className="mb-1 text-faint">Sources</div> |
| 236 | <ul className="flex flex-wrap gap-1.5" aria-label="Sources"> |
| 237 | {sources.map((source) => ( |
| 238 | <li key={source.url} className="min-w-0"> |
| 239 | <Hint label={source.url}> |
| 240 | <a href={source.url} className="group inline-flex max-w-64 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"> |
| 241 | <Badge size="md" className="max-w-full group-hover:border-accent/40 group-hover:text-fg"> |
| 242 | <span className="truncate">{source.title}</span> |
| 243 | </Badge> |
| 244 | </a> |
| 245 | </Hint> |
| 246 | </li> |
| 247 | ))} |
| 248 | </ul> |
| 249 | </div> |
| 250 | ); |
| 251 | } |