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.

333 linesCodeBlameRaw
1import { Ban, Check, ChevronRight, CircleAlert, Copy, ExternalLink, LoaderCircle, Play, Rocket, SquareTerminal } from "lucide-react";
2import { useEffect, useState } from "react";
3
4import type { AskProposalState, AskSource } from "@g1t/contracts";
5
6import { type Proposal, type Step, foldAnswer, proposalSteps, proposalTitle } from "../lib/ask";
7import { Badge } from "./ui/badge";
8import { Button } from "./ui/button";
9import { Card } from "./ui/card";
10import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
11import { 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 */
20export 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
55const STATUS_LABEL: Record<Step["status"], string> = {
56 running: "Running",
57 done: "Done",
58 failed: "Failed",
59 refused: "Not run",
60};
61
62function 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
76function 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 */
105export function AskTrail({
106 events,
107 working,
108 part,
109 slug,
110 message,
111}: {
112 events: unknown;
113 working: boolean;
114 part: "steps" | "sources" | "proposals";
115 slug?: string;
116 /** The answer they came in, where what each card did is kept. */
117 message?: { id: string; channel_id: string };
118}) {
119 const { steps, sources, proposals, kept } = foldAnswer(events);
120 if (part === "steps") return <AskSteps steps={steps} working={working} />;
121 if (part === "sources") return <AskSources sources={sources} />;
122 if (!slug || proposals.length === 0) return null;
123 return (
124 <div className="flex flex-col gap-2">
125 {proposals.map((proposal) => (
126 <SessionProposal key={proposal.id} proposal={proposal} slug={slug} message={message} kept={kept[proposal.id]} />
127 ))}
128 </div>
129 );
130}
131
132type Started = {
133 session_url: string | null;
134 provider_session_url: string | null;
135 handoff: boolean;
136 command: string | null;
137 prompt: string;
138 /** A new repository's proposal: what the card made first. */
139 repo_url?: string;
140 production_url?: string | null;
141 deploy_error?: string | null;
142};
143
144/**
145 * Work g1t proposed to hand to a session in the person's own harness. Ask
146 * never starts it: Start runs it on the workspace's Anthropic account (when
147 * one is connected), Copy command gives the Claude Code command to run it
148 * with the g1t plugin. Either way the session is tied to the work item.
149 */
150export function SessionProposal({
151 proposal,
152 slug,
153 message,
154 kept,
155}: {
156 proposal: Proposal;
157 slug: string;
158 message?: { id: string; channel_id: string };
159 /** What a click on this card already did, kept on its answer. */
160 kept?: AskProposalState;
161}) {
162 const [available, setAvailable] = useState<boolean | null>(null);
163 const [busy, setBusy] = useState<"start" | "copy" | null>(null);
164 const [started, setStarted] = useState<Started | null>(
165 kept?.started
166 ? { session_url: kept.session_url ?? null, provider_session_url: kept.provider_session_url ?? null, handoff: false, command: null, prompt: "" }
167 : null,
168 );
169 const [copied, setCopied] = useState(false);
170 const [error, setError] = useState<string | null>(null);
171 const [made, setMade] = useState<Pick<Started, "repo_url" | "production_url" | "deploy_error"> | null>(
172 kept?.repo_url ? { repo_url: kept.repo_url, production_url: kept.production_url ?? null, deploy_error: kept.deploy_error ?? null } : null,
173 );
174 // What another tab, or a click elsewhere, did shows here as it is kept.
175 useEffect(() => {
176 if (kept?.repo_url) setMade({ repo_url: kept.repo_url, production_url: kept.production_url ?? null, deploy_error: kept.deploy_error ?? null });
177 if (kept?.started) {
178 setStarted(
179 (now) =>
180 now ?? { session_url: kept.session_url ?? null, provider_session_url: kept.provider_session_url ?? null, handoff: false, command: null, prompt: "" },
181 );
182 }
183 }, [kept?.repo_url, kept?.production_url, kept?.deploy_error, kept?.started, kept?.session_url, kept?.provider_session_url]);
184
185 useEffect(() => {
186 let live = true;
187 fetch(`/${slug}/-/ask?sessions`, { headers: { accept: "application/json" } })
188 .then((response) => (response.ok ? (response.json() as Promise<{ available?: boolean }>) : null))
189 .then((setup) => live && setAvailable(Boolean(setup?.available)))
190 .catch(() => live && setAvailable(false));
191 return () => {
192 live = false;
193 };
194 }, [slug]);
195
196 const go = async (handoff: boolean) => {
197 setBusy(handoff ? "copy" : "start");
198 setError(null);
199 try {
200 const response = await fetch(`/${slug}/-/ask`, {
201 method: "POST",
202 headers: { "content-type": "application/json", accept: "application/json" },
203 body: JSON.stringify({ ...proposal, handoff, message_id: message?.id, channel_id: message?.channel_id }),
204 });
205 const body = (await response.json().catch(() => null)) as (Started & { error?: string }) | null;
206 // The repository may be made even when the session is not: keep where it is.
207 if (body?.repo_url) setMade({ repo_url: body.repo_url, production_url: body.production_url ?? null, deploy_error: body.deploy_error ?? null });
208 if (!response.ok || !body) throw new Error(body?.error ?? "The session could not be started.");
209 setStarted(body);
210 if (handoff && body.command) {
211 await navigator.clipboard?.writeText(body.command);
212 setCopied(true);
213 setTimeout(() => setCopied(false), 2000);
214 }
215 } catch (problem) {
216 setError(problem instanceof Error ? problem.message : String(problem));
217 } finally {
218 setBusy(null);
219 }
220 };
221
222 const running = started && !started.handoff;
223 return (
224 <Card className="flex flex-col gap-2.5 px-3.5 py-3">
225 <div className="flex min-w-0 items-start gap-2.5">
226 <SquareTerminal size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-accent" />
227 <div className="min-w-0 flex-1">
228 <div className="flex min-w-0 flex-wrap items-center gap-1.5">
229 <span className="truncate text-sm font-medium text-fg">{proposalTitle(proposal)}</span>
230 <Badge className="px-1.5 py-0 font-normal">Session</Badge>
231 </div>
232 <p className="mt-0.5 text-xs text-muted">{proposalSteps(proposal)}</p>
233 {proposal.instruction && <p className="mt-1 line-clamp-2 text-xs text-faint">{proposal.instruction}</p>}
234 </div>
235 </div>
236 {made && (
237 <div className="flex flex-col gap-1 text-xs">
238 <span className="flex flex-wrap items-center gap-1.5">
239 <Check size={13} aria-hidden="true" className="text-success" />
240 <span className="text-muted">Created</span>
241 <Button asChild variant="link" size="inline" className="text-xs">
242 <a href={made.repo_url}>{proposal.repo}</a>
243 </Button>
244 </span>
245 {made.production_url && (
246 <span className="flex flex-wrap items-center gap-1.5">
247 <Rocket size={13} aria-hidden="true" className="text-accent" />
248 <span className="text-muted">Live once the first version is pushed at</span>
249 <Button asChild variant="link" size="inline" className="text-xs">
250 <a href={made.production_url} target="_blank" rel="noreferrer">
251 {made.production_url.replace(/^https?:\/\//, "")} <ExternalLink size={11} aria-hidden="true" />
252 </a>
253 </Button>
254 </span>
255 )}
256 {made.deploy_error && (
257 <span className="flex items-start gap-1.5 text-warn">
258 <CircleAlert size={12} aria-hidden="true" className="mt-0.5 shrink-0" />
259 Not deployed: {made.deploy_error}
260 </span>
261 )}
262 </div>
263 )}
264 {running ? (
265 <div className="flex flex-wrap items-center gap-2 text-xs">
266 <Check size={13} aria-hidden="true" className="text-success" />
267 <span className="text-muted">Started</span>
268 {started.session_url && (
269 <Button asChild variant="link" size="inline" className="text-xs">
270 <a href={started.session_url}>Follow it on g1t</a>
271 </Button>
272 )}
273 {started.provider_session_url && (
274 <Button asChild variant="link" size="inline" className="text-xs">
275 <a href={started.provider_session_url} target="_blank" rel="noreferrer">
276 In Anthropic&rsquo;s console <ExternalLink size={11} aria-hidden="true" />
277 </a>
278 </Button>
279 )}
280 </div>
281 ) : (
282 <div className="flex flex-wrap items-center gap-2">
283 {available && (
284 <Button size="xs" variant="accent" busy={busy === "start"} disabled={busy !== null} onClick={() => void go(false)}>
285 <Play size={12} aria-hidden="true" />
286 Start session
287 </Button>
288 )}
289 <Button size="xs" variant="outline" busy={busy === "copy"} disabled={busy !== null} onClick={() => void go(true)}>
290 {copied ? <Check size={12} aria-hidden="true" className="text-success" /> : <Copy size={12} aria-hidden="true" />}
291 {copied ? "Copied" : "Copy Claude Code command"}
292 </Button>
293 {available === false && <span className="text-xs text-faint">Or connect Anthropic to start it here.</span>}
294 </div>
295 )}
296 {started?.handoff && started.command && (
297 <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>
298 )}
299 {error && (
300 <p className="flex items-start gap-1.5 text-xs text-danger" role="alert">
301 <CircleAlert size={12} aria-hidden="true" className="mt-0.5 shrink-0" />
302 {error}
303 </p>
304 )}
305 </Card>
306 );
307}
308
309/** The pages on g1t an answer drew on: the ones it links to first. */
310export function AskSources({ sources }: { sources: AskSource[] }) {
311 if (sources.length === 0) return null;
312 return (
313 <div className="text-xs">
314 <div className="mb-1 text-faint">Sources</div>
315 <ul className="flex flex-wrap gap-1.5" aria-label="Sources">
316 {sources.map((source) => (
317 <li key={source.url} className="min-w-0">
318 <Hint label={source.url}>
319 <a
320 href={source.url}
321 className="group inline-flex max-w-64 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
322 >
323 <Badge size="md" className="max-w-full group-hover:border-accent/40 group-hover:text-fg">
324 <span className="truncate">{source.title}</span>
325 </Badge>
326 </a>
327 </Hint>
328 </li>
329 ))}
330 </ul>
331 </div>
332 );
333}