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
325 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 issue.
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 ? { session_url: kept.session_url ?? null, provider_session_url: kept.provider_session_url ?? null, handoff: false, command: null, prompt: "" } : null,
166 );
167 const [copied, setCopied] = useState(false);
168 const [error, setError] = useState<string | null>(null);
169 const [made, setMade] = useState<Pick<Started, "repo_url" | "production_url" | "deploy_error"> | null>(
170 kept?.repo_url ? { repo_url: kept.repo_url, production_url: kept.production_url ?? null, deploy_error: kept.deploy_error ?? null } : null,
171 );
172 // What another tab, or a click elsewhere, did shows here as it is kept.
173 useEffect(() => {
174 if (kept?.repo_url) setMade({ repo_url: kept.repo_url, production_url: kept.production_url ?? null, deploy_error: kept.deploy_error ?? null });
175 if (kept?.started) {
176 setStarted((now) => now ?? { session_url: kept.session_url ?? null, provider_session_url: kept.provider_session_url ?? null, handoff: false, command: null, prompt: "" });
177 }
178 }, [kept?.repo_url, kept?.production_url, kept?.deploy_error, kept?.started, kept?.session_url, kept?.provider_session_url]);
179
180 useEffect(() => {
181 let live = true;
182 fetch(`/${slug}/-/ask?sessions`, { headers: { accept: "application/json" } })
183 .then((response) => (response.ok ? (response.json() as Promise<{ available?: boolean }>) : null))
184 .then((setup) => live && setAvailable(Boolean(setup?.available)))
185 .catch(() => live && setAvailable(false));
186 return () => {
187 live = false;
188 };
189 }, [slug]);
190
191 const go = async (handoff: boolean) => {
192 setBusy(handoff ? "copy" : "start");
193 setError(null);
194 try {
195 const response = await fetch(`/${slug}/-/ask`, {
196 method: "POST",
197 headers: { "content-type": "application/json", accept: "application/json" },
198 body: JSON.stringify({ ...proposal, handoff, message_id: message?.id, channel_id: message?.channel_id }),
199 });
200 const body = (await response.json().catch(() => null)) as (Started & { error?: string }) | null;
201 // The repository may be made even when the session is not: keep where it is.
202 if (body?.repo_url) setMade({ repo_url: body.repo_url, production_url: body.production_url ?? null, deploy_error: body.deploy_error ?? null });
203 if (!response.ok || !body) throw new Error(body?.error ?? "The session could not be started.");
204 setStarted(body);
205 if (handoff && body.command) {
206 await navigator.clipboard?.writeText(body.command);
207 setCopied(true);
208 setTimeout(() => setCopied(false), 2000);
209 }
210 } catch (problem) {
211 setError(problem instanceof Error ? problem.message : String(problem));
212 } finally {
213 setBusy(null);
214 }
215 };
216
217 const running = started && !started.handoff;
218 return (
219 <Card className="flex flex-col gap-2.5 px-3.5 py-3">
220 <div className="flex min-w-0 items-start gap-2.5">
221 <SquareTerminal size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-accent" />
222 <div className="min-w-0 flex-1">
223 <div className="flex min-w-0 flex-wrap items-center gap-1.5">
224 <span className="truncate text-sm font-medium text-fg">{proposalTitle(proposal)}</span>
225 <Badge className="px-1.5 py-0 font-normal">Session</Badge>
226 </div>
227 <p className="mt-0.5 text-xs text-muted">{proposalSteps(proposal)}</p>
228 {proposal.instruction && <p className="mt-1 line-clamp-2 text-xs text-faint">{proposal.instruction}</p>}
229 </div>
230 </div>
231 {made && (
232 <div className="flex flex-col gap-1 text-xs">
233 <span className="flex flex-wrap items-center gap-1.5">
234 <Check size={13} aria-hidden="true" className="text-success" />
235 <span className="text-muted">Created</span>
236 <Button asChild variant="link" size="inline" className="text-xs">
237 <a href={made.repo_url}>{proposal.repo}</a>
238 </Button>
239 </span>
240 {made.production_url && (
241 <span className="flex flex-wrap items-center gap-1.5">
242 <Rocket size={13} aria-hidden="true" className="text-accent" />
243 <span className="text-muted">Live once the first version is pushed at</span>
244 <Button asChild variant="link" size="inline" className="text-xs">
245 <a href={made.production_url} target="_blank" rel="noreferrer">
246 {made.production_url.replace(/^https?:\/\//, "")} <ExternalLink size={11} aria-hidden="true" />
247 </a>
248 </Button>
249 </span>
250 )}
251 {made.deploy_error && (
252 <span className="flex items-start gap-1.5 text-warn">
253 <CircleAlert size={12} aria-hidden="true" className="mt-0.5 shrink-0" />
254 Not deployed: {made.deploy_error}
255 </span>
256 )}
257 </div>
258 )}
259 {running ? (
260 <div className="flex flex-wrap items-center gap-2 text-xs">
261 <Check size={13} aria-hidden="true" className="text-success" />
262 <span className="text-muted">Started</span>
263 {started.session_url && (
264 <Button asChild variant="link" size="inline" className="text-xs">
265 <a href={started.session_url}>Follow it on g1t</a>
266 </Button>
267 )}
268 {started.provider_session_url && (
269 <Button asChild variant="link" size="inline" className="text-xs">
270 <a href={started.provider_session_url} target="_blank" rel="noreferrer">
271 In Anthropic&rsquo;s console <ExternalLink size={11} aria-hidden="true" />
272 </a>
273 </Button>
274 )}
275 </div>
276 ) : (
277 <div className="flex flex-wrap items-center gap-2">
278 {available && (
279 <Button size="xs" variant="accent" busy={busy === "start"} disabled={busy !== null} onClick={() => void go(false)}>
280 <Play size={12} aria-hidden="true" />
281 Start session
282 </Button>
283 )}
284 <Button size="xs" variant="outline" busy={busy === "copy"} disabled={busy !== null} onClick={() => void go(true)}>
285 {copied ? <Check size={12} aria-hidden="true" className="text-success" /> : <Copy size={12} aria-hidden="true" />}
286 {copied ? "Copied" : "Copy Claude Code command"}
287 </Button>
288 {available === false && <span className="text-xs text-faint">Or connect Anthropic to start it here.</span>}
289 </div>
290 )}
291 {started?.handoff && started.command && (
292 <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>
293 )}
294 {error && (
295 <p className="flex items-start gap-1.5 text-xs text-danger" role="alert">
296 <CircleAlert size={12} aria-hidden="true" className="mt-0.5 shrink-0" />
297 {error}
298 </p>
299 )}
300 </Card>
301 );
302}
303
304/** The pages on g1t an answer drew on: the ones it links to first. */
305export function AskSources({ sources }: { sources: AskSource[] }) {
306 if (sources.length === 0) return null;
307 return (
308 <div className="text-xs">
309 <div className="mb-1 text-faint">Sources</div>
310 <ul className="flex flex-wrap gap-1.5" aria-label="Sources">
311 {sources.map((source) => (
312 <li key={source.url} className="min-w-0">
313 <Hint label={source.url}>
314 <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">
315 <Badge size="md" className="max-w-full group-hover:border-accent/40 group-hover:text-fg">
316 <span className="truncate">{source.title}</span>
317 </Badge>
318 </a>
319 </Hint>
320 </li>
321 ))}
322 </ul>
323 </div>
324 );
325}