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.

g1t.sh
290 linesCodeBlameRaw
1import { Ban, Check, ChevronRight, CircleAlert, Copy, ExternalLink, LoaderCircle, Play, Rocket, SquareTerminal } from "lucide-react";
2import { useEffect, useState } from "react";
3
4import type { 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({ 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
119type Started = {
120 session_url: string | null;
121 provider_session_url: string | null;
122 handoff: boolean;
123 command: string | null;
124 prompt: string;
125 /** A new repository's proposal: what the card made first. */
126 repo_url?: string;
127 production_url?: string | null;
128 deploy_error?: string | null;
129};
130
131/**
132 * Work g1t proposed to hand to a session in the person's own harness. Ask
133 * never starts it: Start runs it on the workspace's Anthropic account (when
134 * one is connected), Copy command gives the Claude Code command to run it
135 * with the g1t plugin. Either way the session is tied to the issue.
136 */
137export function SessionProposal({ proposal, slug }: { proposal: Proposal; slug: string }) {
138 const [available, setAvailable] = useState<boolean | null>(null);
139 const [busy, setBusy] = useState<"start" | "copy" | null>(null);
140 const [started, setStarted] = useState<Started | null>(null);
141 const [copied, setCopied] = useState(false);
142 const [error, setError] = useState<string | null>(null);
143 const [made, setMade] = useState<Pick<Started, "repo_url" | "production_url" | "deploy_error"> | null>(null);
144
145 useEffect(() => {
146 let live = true;
147 fetch(`/${slug}/-/ask?sessions`, { headers: { accept: "application/json" } })
148 .then((response) => (response.ok ? (response.json() as Promise<{ available?: boolean }>) : null))
149 .then((setup) => live && setAvailable(Boolean(setup?.available)))
150 .catch(() => live && setAvailable(false));
151 return () => {
152 live = false;
153 };
154 }, [slug]);
155
156 const go = async (handoff: boolean) => {
157 setBusy(handoff ? "copy" : "start");
158 setError(null);
159 try {
160 const response = await fetch(`/${slug}/-/ask`, {
161 method: "POST",
162 headers: { "content-type": "application/json", accept: "application/json" },
163 body: JSON.stringify({ ...proposal, handoff }),
164 });
165 const body = (await response.json().catch(() => null)) as (Started & { error?: string }) | null;
166 // The repository may be made even when the session is not: keep where it is.
167 if (body?.repo_url) setMade({ repo_url: body.repo_url, production_url: body.production_url ?? null, deploy_error: body.deploy_error ?? null });
168 if (!response.ok || !body) throw new Error(body?.error ?? "The session could not be started.");
169 setStarted(body);
170 if (handoff && body.command) {
171 await navigator.clipboard?.writeText(body.command);
172 setCopied(true);
173 setTimeout(() => setCopied(false), 2000);
174 }
175 } catch (problem) {
176 setError(problem instanceof Error ? problem.message : String(problem));
177 } finally {
178 setBusy(null);
179 }
180 };
181
182 const running = started && !started.handoff;
183 return (
184 <Card className="flex flex-col gap-2.5 px-3.5 py-3">
185 <div className="flex min-w-0 items-start gap-2.5">
186 <SquareTerminal size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-accent" />
187 <div className="min-w-0 flex-1">
188 <div className="flex min-w-0 flex-wrap items-center gap-1.5">
189 <span className="truncate text-sm font-medium text-fg">{proposalTitle(proposal)}</span>
190 <Badge className="px-1.5 py-0 font-normal">Session</Badge>
191 </div>
192 <p className="mt-0.5 text-xs text-muted">{proposalSteps(proposal)}</p>
193 {proposal.instruction && <p className="mt-1 line-clamp-2 text-xs text-faint">{proposal.instruction}</p>}
194 </div>
195 </div>
196 {made && (
197 <div className="flex flex-col gap-1 text-xs">
198 <span className="flex flex-wrap items-center gap-1.5">
199 <Check size={13} aria-hidden="true" className="text-success" />
200 <span className="text-muted">Created</span>
201 <Button asChild variant="link" size="inline" className="text-xs">
202 <a href={made.repo_url}>{proposal.repo}</a>
203 </Button>
204 </span>
205 {made.production_url && (
206 <span className="flex flex-wrap items-center gap-1.5">
207 <Rocket size={13} aria-hidden="true" className="text-accent" />
208 <span className="text-muted">Live once the first version is pushed at</span>
209 <Button asChild variant="link" size="inline" className="text-xs">
210 <a href={made.production_url} target="_blank" rel="noreferrer">
211 {made.production_url.replace(/^https?:\/\//, "")} <ExternalLink size={11} aria-hidden="true" />
212 </a>
213 </Button>
214 </span>
215 )}
216 {made.deploy_error && (
217 <span className="flex items-start gap-1.5 text-warn">
218 <CircleAlert size={12} aria-hidden="true" className="mt-0.5 shrink-0" />
219 Not deployed: {made.deploy_error}
220 </span>
221 )}
222 </div>
223 )}
224 {running ? (
225 <div className="flex flex-wrap items-center gap-2 text-xs">
226 <Check size={13} aria-hidden="true" className="text-success" />
227 <span className="text-muted">Started</span>
228 {started.session_url && (
229 <Button asChild variant="link" size="inline" className="text-xs">
230 <a href={started.session_url}>Follow it on g1t</a>
231 </Button>
232 )}
233 {started.provider_session_url && (
234 <Button asChild variant="link" size="inline" className="text-xs">
235 <a href={started.provider_session_url} target="_blank" rel="noreferrer">
236 In Anthropic&rsquo;s console <ExternalLink size={11} aria-hidden="true" />
237 </a>
238 </Button>
239 )}
240 </div>
241 ) : (
242 <div className="flex flex-wrap items-center gap-2">
243 {available && (
244 <Button size="xs" variant="accent" busy={busy === "start"} disabled={busy !== null} onClick={() => void go(false)}>
245 <Play size={12} aria-hidden="true" />
246 Start session
247 </Button>
248 )}
249 <Button size="xs" variant="outline" busy={busy === "copy"} disabled={busy !== null} onClick={() => void go(true)}>
250 {copied ? <Check size={12} aria-hidden="true" className="text-success" /> : <Copy size={12} aria-hidden="true" />}
251 {copied ? "Copied" : "Copy Claude Code command"}
252 </Button>
253 {available === false && <span className="text-xs text-faint">Or connect Anthropic to start it here.</span>}
254 </div>
255 )}
256 {started?.handoff && started.command && (
257 <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>
258 )}
259 {error && (
260 <p className="flex items-start gap-1.5 text-xs text-danger" role="alert">
261 <CircleAlert size={12} aria-hidden="true" className="mt-0.5 shrink-0" />
262 {error}
263 </p>
264 )}
265 </Card>
266 );
267}
268
269/** The pages on g1t an answer drew on: the ones it links to first. */
270export function AskSources({ sources }: { sources: AskSource[] }) {
271 if (sources.length === 0) return null;
272 return (
273 <div className="text-xs">
274 <div className="mb-1 text-faint">Sources</div>
275 <ul className="flex flex-wrap gap-1.5" aria-label="Sources">
276 {sources.map((source) => (
277 <li key={source.url} className="min-w-0">
278 <Hint label={source.url}>
279 <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">
280 <Badge size="md" className="max-w-full group-hover:border-accent/40 group-hover:text-fg">
281 <span className="truncate">{source.title}</span>
282 </Badge>
283 </a>
284 </Hint>
285 </li>
286 ))}
287 </ul>
288 </div>
289 );
290}