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
251 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.1import { Ban, Check, ChevronRight, CircleAlert, Copy, ExternalLink, LoaderCircle, Play, SquareTerminal } from "lucide-react";
2import { useEffect, useState } from "react";
3
4import type { AskSource } from "@g1t/contracts";
5
6import { type Proposal, type Step, foldAnswer, 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 = { 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 */
127export 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&rsquo;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. */
231export 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}