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.

475 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 { useBusy } from "../lib/busy";
8import {
9 BACKEND_LABEL,
10 type DesktopRunnerAction,
11 type DesktopRunnerCapability,
12 type DesktopRunnerView,
13 desktopRunner,
14 jobHref,
15 kindStatusKey,
16 runnerStatusKey,
17 useDesktopRunner,
18} from "../lib/desktop-runner";
19import { EmptyState, TimeAgo } from "./ui";
20import {
21 AlertDialog,
22 AlertDialogAction,
23 AlertDialogCancel,
24 AlertDialogContent,
25 AlertDialogDescription,
26 AlertDialogFooter,
27 AlertDialogHeader,
28 AlertDialogTitle,
29} from "./ui/alert-dialog";
30import { Badge, type BadgeTone } from "./ui/badge";
31import { Button } from "./ui/button";
32import { Card } from "./ui/card";
33import { Progress } from "./ui/progress";
34import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
35
36/** A diagnosis's tone, as a dot. */
37export const DOT: Record<DesktopDiagnosis["tone"], string> = {
38 ok: "bg-success",
39 working: "bg-info",
40 off: "bg-faint",
41 warning: "bg-warn",
42 problem: "bg-danger",
43};
44
45/** A kind of job's tone, as its label's. */
46export const BADGE: Record<DesktopDiagnosis["tone"], BadgeTone> = {
47 ok: "success",
48 working: "info",
49 off: "neutral",
50 warning: "warn",
51 problem: "danger",
52};
53
54/** Each kind of job's icon: a terminal for Linux jobs, a window for Windows jobs, a laptop for macOS jobs. */
55export const ICON: Record<string, ReactNode> = {
56 linux: <SquareTerminal size={15} aria-hidden />,
57 windows: <AppWindow size={15} aria-hidden />,
58 macos: <Laptop size={15} aria-hidden />,
59};
60
61/** How a kind of job keeps each job from the computer, and what that means. */
62const ISOLATION: Record<string, { label: string; hint: string }> = {
63 "nested-container": {
64 label: "Fresh container in its VM",
65 hint: "Each job runs in a fresh container inside the runner's own Linux VM, and is removed after.",
66 },
67 vm: { label: "A VM of its own", hint: "Each job runs in a VM of its own, such as a Windows Sandbox, discarded after." },
68};
69
70type Row = {
71 machine: string;
72 person: string | null;
73 backend: DesktopBackend;
74 version: string | null;
75 diagnosis: DesktopDiagnosis;
76 /** This computer's fixes, which only work here. */
77 actions: DesktopRunnerAction[];
78 /** Sandboxed: each kind of job, Linux and Windows, with how it is (and here, its fixes and Start or Stop). */
79 capabilities: DesktopRunnerCapability[];
80 /** The kinds of job say it all: the whole runner's line isn't shown as well. */
81 inCapabilities: boolean;
82 here: boolean;
83 reportedAt: string | null;
84 /** How its runner's updates stand, while g1t is open there. */
85 update: DesktopUpdate | null;
86 /** Here only: the app's button that looks for a newer runner now. */
87 check: DesktopRunnerAction | null;
88 /** Here only: the app is looking for a newer runner now. */
89 checking: boolean;
90};
91
92/** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */
93export function desktopRows(desktops: DesktopRunner[], here: ReturnType<typeof useDesktopRunner>, slug: string): Row[] {
94 const rows: Row[] = [];
95 if (here && here.workspace === slug) {
96 const capabilities = here.diagnosis.capabilities ?? [];
97 rows.push({
98 machine: here.machine,
99 person: null,
100 backend: here.backend,
101 version: here.version,
102 diagnosis: here.diagnosis,
103 actions: here.diagnosis.actions,
104 capabilities,
105 inCapabilities: !!here.diagnosis.in_capabilities && capabilities.length > 0,
106 here: true,
107 reportedAt: null,
108 update: here.diagnosis.update ?? null,
109 check: here.diagnosis.update?.check ?? null,
110 checking: !!here.diagnosis.update?.checking,
111 });
112 }
113 for (const desktop of desktops) {
114 if (rows.some((row) => row.here && row.machine.toLowerCase() === desktop.machine.toLowerCase())) continue;
115 const capabilities = desktop.open ? (desktop.diagnosis.capabilities ?? []).map((c) => ({ ...c, actions: [] })) : [];
116 rows.push({
117 machine: desktop.machine,
118 person: desktop.person,
119 backend: desktop.backend,
120 version: desktop.version,
121 diagnosis: desktop.open ? desktop.diagnosis : desktop.closed,
122 actions: [],
123 capabilities,
124 inCapabilities: desktop.open && !!desktop.diagnosis.in_capabilities && capabilities.length > 0,
125 here: false,
126 reportedAt: desktop.reportedAt,
127 update: desktop.open ? (desktop.diagnosis.update ?? null) : null,
128 check: null,
129 checking: false,
130 });
131 }
132 return rows;
133}
134
135/**
136 * A diagnosis's fixes, as buttons: they work only on this computer, in the
137 * app. Each is busy from its click until the status it waits on changes
138 * (`status`, lib/busy.ts); one the app asks about first asks.
139 */
140export function Actions({
141 actions,
142 primary = true,
143 status,
144 working = false,
145 onError,
146}: {
147 actions: DesktopRunnerAction[];
148 primary?: boolean;
149 /** What a press waits on: the runner's status (`runnerStatusKey`, `kindStatusKey`). */
150 status: string;
151 /** Busy for a reason of the app's own, such as a check already under way. */
152 working?: boolean;
153 onError?: (error: unknown) => void;
154}) {
155 if (actions.length === 0) return null;
156 return (
157 <>
158 {actions.map((action, at) => (
159 <ActionButton
160 key={action.id}
161 action={action}
162 variant={primary && at === 0 ? "secondary" : "outline"}
163 status={status}
164 working={working}
165 onError={onError}
166 />
167 ))}
168 </>
169 );
170}
171
172function ActionButton({
173 action,
174 variant,
175 status,
176 working,
177 onError,
178}: {
179 action: DesktopRunnerAction;
180 variant: "secondary" | "outline";
181 status: string;
182 working: boolean;
183 onError?: (error: unknown) => void;
184}) {
185 const { busy, run } = useBusy(status, onError);
186 const [asking, setAsking] = useState(false);
187 const go = () => run(() => desktopRunner()?.act(action.id), !!action.waits);
188 // The app's question, as a title and what it means: "Remove this computer's sandbox?" and the rest.
189 const [title, ...rest] = (action.confirm ?? "").split(/(?<=\?)\s+/);
190 return (
191 <>
192 <Button size="xs" variant={variant} busy={busy || working} onClick={() => (action.confirm ? setAsking(true) : go())}>
193 {action.label}
194 </Button>
195 {action.confirm && (
196 <AlertDialog open={asking} onOpenChange={setAsking}>
197 <AlertDialogContent>
198 <AlertDialogHeader>
199 <AlertDialogTitle>{title}</AlertDialogTitle>
200 {rest.length > 0 && <AlertDialogDescription>{rest.join(" ")}</AlertDialogDescription>}
201 </AlertDialogHeader>
202 <AlertDialogFooter>
203 <AlertDialogCancel>Cancel</AlertDialogCancel>
204 <AlertDialogAction onClick={go}>{action.label}</AlertDialogAction>
205 </AlertDialogFooter>
206 </AlertDialogContent>
207 </AlertDialog>
208 )}
209 </>
210 );
211}
212
213/**
214 * Start or Stop for one kind of job on this computer, which leaves the
215 * others as they are: busy from the click until its status says it
216 * started or stopped.
217 */
218export function Toggle({ capability, status, onError }: { capability: DesktopRunnerCapability; status: string; onError?: (error: unknown) => void }) {
219 const { busy, run } = useBusy(status, onError);
220 const on = capability.enabled !== false;
221 return (
222 <Button
223 size="xs"
224 variant={on ? "outline" : "soft"}
225 className="ml-auto"
226 busy={busy}
227 aria-label={`${on ? "Stop" : "Start"} ${capability.label}`}
228 onClick={() => run(() => desktopRunner()?.act(on ? "stop" : "start", capability.id))}
229 >
230 {on ? "Stop" : "Start"}
231 </Button>
232 );
233}
234
235/**
236 * A button that asks the runner for something by its id: busy until the
237 * app has it (a page, a window, the log), or with `waits` until the
238 * status that follows changes.
239 */
240export function ActButton({
241 action,
242 label,
243 status,
244 fail,
245 waits = false,
246}: {
247 action: string;
248 label: string;
249 status: string;
250 fail?: (error: unknown) => void;
251 waits?: boolean;
252}) {
253 const { busy, run } = useBusy(status, fail);
254 return (
255 <Button size="xs" variant="outline" busy={busy} onClick={() => run(() => desktopRunner()?.act(action), waits)}>
256 {label}
257 </Button>
258 );
259}
260
261function Detail({ term, children }: { term: ReactNode; children: ReactNode }) {
262 return (
263 <div className="flex min-w-0 items-baseline gap-3">
264 <dt className="w-16 shrink-0 text-faint">{term}</dt>
265 <dd className="min-w-0 wrap-break-word text-fg-soft">{children}</dd>
266 </div>
267 );
268}
269
270/** When the app last looked for a newer runner: the app sends when, and this says how long ago, as "Heard from" does. */
271function Checked({ update }: { update: DesktopUpdate | null }) {
272 if (!update?.checked_at) return null;
273 return (
274 <span className="block">
275 Checked <TimeAgo at={update.checked_at} />
276 </span>
277 );
278}
279
280/** 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. */
281export function KindCard({
282 capability,
283 here,
284 runners,
285 update,
286 view = null,
287 onError,
288}: {
289 capability: DesktopRunnerCapability;
290 here: boolean;
291 runners: Runner[];
292 update: DesktopUpdate | null;
293 /** Here: the runner as the app says it now, which its buttons wait on. */
294 view?: DesktopRunnerView | null;
295 onError?: (error: unknown) => void;
296}) {
297 const href = jobHref(capability, runners);
298 const isolation = capability.isolation ? (ISOLATION[capability.isolation] ?? { label: capability.isolation, hint: null }) : null;
299 const edge = capability.tone === "problem" ? "border-danger/40" : capability.tone === "warning" ? "border-warn/35" : "";
300 const live = capability.state === "busy" || capability.state === "starting" || capability.state === "downloading" || capability.state === "updating";
301 const fixes = here ? capability.actions : [];
302 const toggle = here && capability.can_toggle;
303 return (
304 <Card tone="plain" radius="lg" className={`flex min-w-0 flex-col gap-3 p-4 ${edge}`} aria-label={capability.label} role="group">
305 <div className="flex items-center gap-3">
306 <span className={`grid size-9 shrink-0 place-items-center rounded-lg bg-raised ${capability.enabled === false ? "text-faint" : "text-fg-soft"}`}>
307 {ICON[capability.id] ?? ICON.linux}
308 </span>
309 <div className="min-w-0">
310 <p className="text-sm font-medium">{capability.label}</p>
311 <Badge tone={BADGE[capability.tone]} className="mt-1" role={here ? "status" : undefined}>
312 <span className={`size-1.5 rounded-full bg-current ${live ? "motion-safe:animate-pulse" : ""}`} aria-hidden />
313 {capability.title}
314 </Badge>
315 </div>
316 </div>
317 {capability.about && <p className="text-sm text-muted">{capability.about}</p>}
318 {capability.job && (
319 <div className="rounded-lg bg-raised px-3 py-2 text-sm">
320 <span className="block text-[0.6875rem] font-medium tracking-wide text-muted uppercase">
321 {capability.job.kind === "agent" ? "Sandbox run" : "Running"}
322 </span>
323 {href ? (
324 <Link to={href} className="font-medium text-fg underline-offset-4 hover:underline">
325 {capability.job.name}
326 </Link>
327 ) : (
328 <span className="font-medium text-fg">{capability.job.name}</span>
329 )}
330 <span className="text-muted"> in {capability.job.repo}</span>
331 </div>
332 )}
333 {capability.state === "downloading" && typeof capability.progress === "number" && (
334 <div className="flex items-center gap-3">
335 <Progress value={capability.progress} aria-label={`${capability.label}: the sandbox's image`} className="h-1.5" />
336 <span className="w-9 shrink-0 text-right text-xs text-muted tabular-nums">{capability.progress}%</span>
337 </div>
338 )}
339 {(capability.headline || capability.cause || capability.fix) && (
340 <div className="space-y-1 text-sm">
341 {capability.headline && <p className="font-medium text-fg">{capability.headline}</p>}
342 {capability.cause && <p className="text-muted">{capability.cause}</p>}
343 {capability.fix && <p className="text-fg-soft">{capability.fix}</p>}
344 </div>
345 )}
346 {(capability.runner || capability.version || isolation) && (
347 <dl className="space-y-0.5 border-t border-line pt-2 text-xs">
348 {capability.runner && <Detail term="On g1t">{capability.runner}</Detail>}
349 {capability.version && <Detail term="Version">g1t-runner {capability.version}</Detail>}
350 {(capability.update || (capability.version && update?.checked_at)) && (
351 <Detail term="Updates">
352 {capability.update && <span className="block text-accent">{capability.update}</span>}
353 <Checked update={update} />
354 </Detail>
355 )}
356 {isolation && (
357 <Detail term="Isolation">
358 {isolation.hint ? (
359 <Tooltip>
360 <TooltipTrigger asChild>
361 <span tabIndex={0} className="cursor-help underline decoration-line-strong decoration-dotted underline-offset-4">
362 {isolation.label}
363 </span>
364 </TooltipTrigger>
365 <TooltipContent>{isolation.hint}</TooltipContent>
366 </Tooltip>
367 ) : (
368 isolation.label
369 )}
370 </Detail>
371 )}
372 </dl>
373 )}
374 {(fixes.length > 0 || toggle) && (
375 <div className="mt-auto flex flex-wrap items-center gap-2">
376 <Actions
377 actions={fixes}
378 primary={capability.tone === "problem" || (capability.tone === "warning" && capability.state !== "waiting")}
379 status={kindStatusKey(capability, view)}
380 onError={onError}
381 />
382 {toggle && <Toggle capability={capability} status={kindStatusKey(capability, view)} onError={onError} />}
383 </div>
384 )}
385 </Card>
386 );
387}
388
389/**
390 * The workspace's desktops: each person's computer with the g1t desktop
391 * app, whose runner runs while g1t is open there. What each says, why it
392 * isn't running and the fix, is the app's own words (lib/desktop-runner.ts).
393 * A sandboxed one's kinds of job, Linux and Windows, are cards of their own:
394 * one can run while the other can't, or is stopped; on this computer, each
395 * has its own Start or Stop.
396 */
397export function DesktopRunners({ desktops, slug, runners = [] }: { desktops: DesktopRunner[] | null; slug: string; runners?: Runner[] }) {
398 const here = useDesktopRunner();
399 if (!desktops) return <p className="text-sm text-muted">The desktops can&apos;t be read right now.</p>;
400 const rows = desktopRows(desktops, here, slug);
401 if (rows.length === 0) {
402 return (
403 <EmptyState title="No desktops yet">
404 The{" "}
405 <Link to="/download" className="underline underline-offset-2">
406 g1t desktop app
407 </Link>{" "}
408 runs this workspace&apos;s self-hosted jobs while it&apos;s open, sandboxed, with nothing to set up. Open it signed in as an owner of {slug}.
409 </EmptyState>
410 );
411 }
412 return (
413 <Card asChild divided>
414 <ul>
415 {rows.map((row) => (
416 <li key={row.machine} className="space-y-3 px-4 py-4">
417 <div className="flex items-start gap-3">
418 <div className="min-w-0 grow">
419 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
420 <Laptop size={13} className="text-muted" aria-hidden />
421 <span className="font-medium">{row.machine}</span>
422 {row.here && <Badge tone="accent">This computer</Badge>}
423 <Badge tone={row.backend === "sandboxed" ? "neutral" : "warn"}>{BACKEND_LABEL[row.backend]}</Badge>
424 {row.person && <span className="text-xs text-faint">@{row.person}</span>}
425 </div>
426 </div>
427 <div className="shrink-0 text-right text-xs text-faint">
428 {row.version && <div>g1t-runner {row.version}</div>}
429 {/* With no cards to say it, what becomes of a newer runner, and when the app last looked. */}
430 {row.capabilities.length === 0 && row.update?.note && <div className="mt-0.5 text-accent">{row.update.note}</div>}
431 {row.capabilities.length === 0 && row.update?.checked_at && (
432 <div className="mt-0.5">
433 <Checked update={row.update} />
434 </div>
435 )}
436 {row.reportedAt && (
437 <div className="mt-0.5">
438 Heard from <TimeAgo at={row.reportedAt} />
439 </div>
440 )}
441 {row.check && (
442 <div className="mt-2">
443 <Actions actions={[row.check]} primary={false} status={here ? runnerStatusKey(here) : ""} working={row.checking} />
444 </div>
445 )}
446 </div>
447 </div>
448 {!row.inCapabilities && (
449 <div className="flex items-start gap-3" role={row.here ? "status" : undefined}>
450 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${DOT[row.diagnosis.tone]}`} aria-hidden />
451 <div className="min-w-0">
452 <p className="text-sm text-fg">{row.diagnosis.title}</p>
453 {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>}
454 {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>}
455 {row.actions.length > 0 && (
456 <div className="mt-2 flex flex-wrap gap-2">
457 <Actions actions={row.actions} status={here ? runnerStatusKey(here) : ""} />
458 </div>
459 )}
460 </div>
461 </div>
462 )}
463 {row.capabilities.length > 0 && (
464 <div className={`grid gap-3 ${row.capabilities.length > 1 ? "sm:grid-cols-2" : ""}`}>
465 {row.capabilities.map((capability) => (
466 <KindCard key={capability.id} capability={capability} here={row.here} runners={runners} update={row.update} view={row.here ? here : null} />
467 ))}
468 </div>
469 )}
470 </li>
471 ))}
472 </ul>
473 </Card>
474 );
475}