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
334 linesCodeBlameRaw
1import { AppWindow, Laptop, SquareTerminal } from "lucide-react";
2import { type ReactNode, useState } from "react";
3import { Link } from "react-router";
4
5import type { DesktopBackend, DesktopDiagnosis, DesktopRunner, DesktopUpdate, Runner } from "@g1t/contracts";
6
7import { BACKEND_LABEL, type DesktopRunnerAction, type DesktopRunnerCapability, desktopRunner, jobHref, useDesktopRunner } from "../lib/desktop-runner";
8import { EmptyState, TimeAgo } from "./ui";
9import { Badge, type BadgeTone } from "./ui/badge";
10import { Button } from "./ui/button";
11import { Card } from "./ui/card";
12import { Progress } from "./ui/progress";
13import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
14
15/** A diagnosis's tone, as a dot. */
16const DOT: Record<DesktopDiagnosis["tone"], string> = {
17 ok: "bg-success",
18 working: "bg-info",
19 off: "bg-faint",
20 warning: "bg-warn",
21 problem: "bg-danger",
22};
23
24/** A kind of job's tone, as its label's. */
25const BADGE: Record<DesktopDiagnosis["tone"], BadgeTone> = {
26 ok: "success",
27 working: "info",
28 off: "neutral",
29 warning: "warn",
30 problem: "danger",
31};
32
33/** Each kind of job's icon: a terminal for Linux jobs, a window for Windows jobs, a laptop for macOS jobs. */
34const ICON: Record<string, ReactNode> = {
35 linux: <SquareTerminal size={15} aria-hidden />,
36 windows: <AppWindow size={15} aria-hidden />,
37 macos: <Laptop size={15} aria-hidden />,
38};
39
40/** How a kind of job keeps each job from the computer, and what that means. */
41const ISOLATION: Record<string, { label: string; hint: string }> = {
42 "nested-container": { label: "Fresh container in its VM", hint: "Each job runs in a fresh container inside the runner's own Linux VM, and is removed after." },
43 vm: { label: "A VM of its own", hint: "Each job runs in a VM of its own, such as a Windows Sandbox, discarded after." },
44};
45
46type Row = {
47 machine: string;
48 person: string | null;
49 backend: DesktopBackend;
50 version: string | null;
51 diagnosis: DesktopDiagnosis;
52 /** This computer's fixes, which only work here. */
53 actions: DesktopRunnerAction[];
54 /** Sandboxed: each kind of job, Linux and Windows, with how it is (and here, its fixes and Start or Stop). */
55 capabilities: DesktopRunnerCapability[];
56 /** The kinds of job say it all: the whole runner's line isn't shown as well. */
57 inCapabilities: boolean;
58 here: boolean;
59 reportedAt: string | null;
60 /** How its runner's updates stand, while g1t is open there. */
61 update: DesktopUpdate | null;
62 /** Here only: the app's button that looks for a newer runner now. */
63 check: DesktopRunnerAction | null;
64};
65
66/** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */
67export function desktopRows(desktops: DesktopRunner[], here: ReturnType<typeof useDesktopRunner>, slug: string): Row[] {
68 const rows: Row[] = [];
69 if (here && here.workspace === slug) {
70 const capabilities = here.diagnosis.capabilities ?? [];
71 rows.push({
72 machine: here.machine,
73 person: null,
74 backend: here.backend,
75 version: here.version,
76 diagnosis: here.diagnosis,
77 actions: here.diagnosis.actions,
78 capabilities,
79 inCapabilities: !!here.diagnosis.in_capabilities && capabilities.length > 0,
80 here: true,
81 reportedAt: null,
82 update: here.diagnosis.update ?? null,
83 check: here.diagnosis.update?.checking ? null : (here.diagnosis.update?.check ?? null),
84 });
85 }
86 for (const desktop of desktops) {
87 if (rows.some((row) => row.here && row.machine.toLowerCase() === desktop.machine.toLowerCase())) continue;
88 const capabilities = desktop.open ? (desktop.diagnosis.capabilities ?? []).map((c) => ({ ...c, actions: [] })) : [];
89 rows.push({
90 machine: desktop.machine,
91 person: desktop.person,
92 backend: desktop.backend,
93 version: desktop.version,
94 diagnosis: desktop.open ? desktop.diagnosis : desktop.closed,
95 actions: [],
96 capabilities,
97 inCapabilities: desktop.open && !!desktop.diagnosis.in_capabilities && capabilities.length > 0,
98 here: false,
99 reportedAt: desktop.reportedAt,
100 update: desktop.open ? (desktop.diagnosis.update ?? null) : null,
101 check: null,
102 });
103 }
104 return rows;
105}
106
107/** A diagnosis's fixes, as buttons: they work only on this computer, in the app. */
108function Actions({ actions, primary = true }: { actions: DesktopRunnerAction[]; primary?: boolean }) {
109 if (actions.length === 0) return null;
110 return (
111 <>
112 {actions.map((action, at) => (
113 <Button key={action.id} size="xs" variant={primary && at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}>
114 {action.label}
115 </Button>
116 ))}
117 </>
118 );
119}
120
121/** Start or Stop for one kind of job on this computer, which leaves the others as they are. */
122function Toggle({ capability }: { capability: DesktopRunnerCapability }) {
123 const [asked, setAsked] = useState<boolean | null>(null);
124 const on = capability.enabled !== false;
125 const waiting = asked !== null && asked !== on;
126 return (
127 <Button
128 size="xs"
129 variant={on ? "outline" : "soft"}
130 className="ml-auto"
131 disabled={waiting}
132 aria-label={`${on ? "Stop" : "Start"} ${capability.label}`}
133 onClick={() => {
134 setAsked(!on);
135 void desktopRunner()
136 ?.act(on ? "stop" : "start", capability.id)
137 .catch(() => setAsked(null));
138 }}
139 >
140 {waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start"}
141 </Button>
142 );
143}
144
145function Detail({ term, children }: { term: ReactNode; children: ReactNode }) {
146 return (
147 <div className="flex min-w-0 items-baseline gap-3">
148 <dt className="w-16 shrink-0 text-faint">{term}</dt>
149 <dd className="min-w-0 break-words text-fg-soft">{children}</dd>
150 </div>
151 );
152}
153
154/** When the app last looked for a newer runner: the app sends when, and this says how long ago, as "Heard from" does. */
155function Checked({ update }: { update: DesktopUpdate | null }) {
156 if (!update?.checked_at) return null;
157 return (
158 <span className="block">
159 Checked <TimeAgo at={update.checked_at} />
160 </span>
161 );
162}
163
164/** One kind of job, as a card of its own: its state, what it runs, why it doesn't and the fix, and here its own Start or Stop. */
165function KindCard({ capability, here, runners, update }: { capability: DesktopRunnerCapability; here: boolean; runners: Runner[]; update: DesktopUpdate | null }) {
166 const href = jobHref(capability, runners);
167 const isolation = capability.isolation ? (ISOLATION[capability.isolation] ?? { label: capability.isolation, hint: null }) : null;
168 const edge = capability.tone === "problem" ? "border-danger/40" : capability.tone === "warning" ? "border-warn/35" : "";
169 const live = capability.state === "busy" || capability.state === "starting" || capability.state === "downloading" || capability.state === "updating";
170 const fixes = here ? capability.actions : [];
171 const toggle = here && capability.can_toggle;
172 return (
173 <Card tone="plain" radius="lg" className={`flex min-w-0 flex-col gap-3 p-4 ${edge}`} aria-label={capability.label} role="group">
174 <div className="flex items-center gap-3">
175 <span className={`grid size-9 shrink-0 place-items-center rounded-lg bg-raised ${capability.enabled === false ? "text-faint" : "text-fg-soft"}`}>
176 {ICON[capability.id] ?? ICON.linux}
177 </span>
178 <div className="min-w-0">
179 <p className="text-sm font-medium">{capability.label}</p>
180 <Badge tone={BADGE[capability.tone]} className="mt-1" role={here ? "status" : undefined}>
181 <span className={`size-1.5 rounded-full bg-current ${live ? "motion-safe:animate-pulse" : ""}`} aria-hidden />
182 {capability.title}
183 </Badge>
184 </div>
185 </div>
186 {capability.about && <p className="text-sm text-muted">{capability.about}</p>}
187 {capability.job && (
188 <div className="rounded-lg bg-raised px-3 py-2 text-sm">
189 <span className="block text-[0.6875rem] font-medium tracking-wide text-muted uppercase">{capability.job.kind === "agent" ? "Agent work" : "Running"}</span>
190 {href ? (
191 <Link to={href} className="font-medium text-fg underline-offset-4 hover:underline">
192 {capability.job.name}
193 </Link>
194 ) : (
195 <span className="font-medium text-fg">{capability.job.name}</span>
196 )}
197 <span className="text-muted"> in {capability.job.repo}</span>
198 </div>
199 )}
200 {capability.state === "downloading" && typeof capability.progress === "number" && (
201 <div className="flex items-center gap-3">
202 <Progress value={capability.progress} aria-label={`${capability.label}: the sandbox's image`} className="h-1.5" />
203 <span className="w-9 shrink-0 text-right text-xs text-muted tabular-nums">{capability.progress}%</span>
204 </div>
205 )}
206 {(capability.headline || capability.cause || capability.fix) && (
207 <div className="space-y-1 text-sm">
208 {capability.headline && <p className="font-medium text-fg">{capability.headline}</p>}
209 {capability.cause && <p className="text-muted">{capability.cause}</p>}
210 {capability.fix && <p className="text-fg-soft">{capability.fix}</p>}
211 </div>
212 )}
213 {(capability.runner || capability.version || isolation) && (
214 <dl className="space-y-0.5 border-t border-line pt-2 text-xs">
215 {capability.runner && <Detail term="On g1t">{capability.runner}</Detail>}
216 {capability.version && <Detail term="Version">g1t-runner {capability.version}</Detail>}
217 {(capability.update || (capability.version && update?.checked_at)) && (
218 <Detail term="Updates">
219 {capability.update && <span className="block text-accent">{capability.update}</span>}
220 <Checked update={update} />
221 </Detail>
222 )}
223 {isolation && (
224 <Detail term="Isolation">
225 {isolation.hint ? (
226 <Tooltip>
227 <TooltipTrigger asChild>
228 <span tabIndex={0} className="cursor-help underline decoration-line-strong decoration-dotted underline-offset-4">
229 {isolation.label}
230 </span>
231 </TooltipTrigger>
232 <TooltipContent>{isolation.hint}</TooltipContent>
233 </Tooltip>
234 ) : (
235 isolation.label
236 )}
237 </Detail>
238 )}
239 </dl>
240 )}
241 {(fixes.length > 0 || toggle) && (
242 <div className="mt-auto flex flex-wrap items-center gap-2">
243 <Actions actions={fixes} primary={capability.tone === "problem" || (capability.tone === "warning" && capability.state !== "waiting")} />
244 {toggle && <Toggle key={String(capability.enabled)} capability={capability} />}
245 </div>
246 )}
247 </Card>
248 );
249}
250
251/**
252 * The workspace's desktops: each person's computer with the g1t desktop
253 * app, whose runner runs while g1t is open there. What each says, why it
254 * isn't running and the fix, is the app's own words (lib/desktop-runner.ts).
255 * A sandboxed one's kinds of job, Linux and Windows, are cards of their own:
256 * one can run while the other can't, or is stopped; on this computer, each
257 * has its own Start or Stop.
258 */
259export function DesktopRunners({ desktops, slug, runners = [] }: { desktops: DesktopRunner[] | null; slug: string; runners?: Runner[] }) {
260 const here = useDesktopRunner();
261 if (!desktops) return <p className="text-sm text-muted">The desktops can&apos;t be read right now.</p>;
262 const rows = desktopRows(desktops, here, slug);
263 if (rows.length === 0) {
264 return (
265 <EmptyState title="No desktops yet">
266 The <Link to="/download" className="underline underline-offset-2">g1t desktop app</Link> runs this workspace&apos;s self-hosted jobs while it&apos;s open, sandboxed, with nothing to set up.
267 Open it signed in as an owner of {slug}.
268 </EmptyState>
269 );
270 }
271 return (
272 <Card asChild divided>
273 <ul>
274 {rows.map((row) => (
275 <li key={row.machine} className="space-y-3 px-4 py-4">
276 <div className="flex items-start gap-3">
277 <div className="min-w-0 grow">
278 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
279 <Laptop size={13} className="text-muted" aria-hidden />
280 <span className="font-medium">{row.machine}</span>
281 {row.here && <Badge tone="accent">This computer</Badge>}
282 <Badge tone={row.backend === "sandboxed" ? "neutral" : "warn"}>{BACKEND_LABEL[row.backend]}</Badge>
283 {row.person && <span className="text-xs text-faint">@{row.person}</span>}
284 </div>
285 </div>
286 <div className="shrink-0 text-right text-xs text-faint">
287 {row.version && <div>g1t-runner {row.version}</div>}
288 {/* With no cards to say it, what becomes of a newer runner, and when the app last looked. */}
289 {row.capabilities.length === 0 && row.update?.note && <div className="mt-0.5 text-accent">{row.update.note}</div>}
290 {row.capabilities.length === 0 && row.update?.checked_at && (
291 <div className="mt-0.5">
292 <Checked update={row.update} />
293 </div>
294 )}
295 {row.reportedAt && (
296 <div className="mt-0.5">
297 Heard from <TimeAgo at={row.reportedAt} />
298 </div>
299 )}
300 {row.check && (
301 <div className="mt-2">
302 <Actions actions={[row.check]} primary={false} />
303 </div>
304 )}
305 </div>
306 </div>
307 {!row.inCapabilities && (
308 <div className="flex items-start gap-3" role={row.here ? "status" : undefined}>
309 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${DOT[row.diagnosis.tone]}`} aria-hidden />
310 <div className="min-w-0">
311 <p className="text-sm text-fg">{row.diagnosis.title}</p>
312 {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>}
313 {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>}
314 {row.actions.length > 0 && (
315 <div className="mt-2 flex flex-wrap gap-2">
316 <Actions actions={row.actions} />
317 </div>
318 )}
319 </div>
320 </div>
321 )}
322 {row.capabilities.length > 0 && (
323 <div className={`grid gap-3 ${row.capabilities.length > 1 ? "sm:grid-cols-2" : ""}`}>
324 {row.capabilities.map((capability) => (
325 <KindCard key={capability.id} capability={capability} here={row.here} runners={runners} update={row.update} />
326 ))}
327 </div>
328 )}
329 </li>
330 ))}
331 </ul>
332 </Card>
333 );
334}