Skip to content
298 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, 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};
61
62/** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */
63export function desktopRows(desktops: DesktopRunner[], here: ReturnType<typeof useDesktopRunner>, slug: string): Row[] {
64 const rows: Row[] = [];
65 if (here && here.workspace === slug) {
66 const capabilities = here.diagnosis.capabilities ?? [];
67 rows.push({
68 machine: here.machine,
69 person: null,
70 backend: here.backend,
71 version: here.version,
72 diagnosis: here.diagnosis,
73 actions: here.diagnosis.actions,
74 capabilities,
75 inCapabilities: !!here.diagnosis.in_capabilities && capabilities.length > 0,
76 here: true,
77 reportedAt: null,
78 });
79 }
80 for (const desktop of desktops) {
81 if (rows.some((row) => row.here && row.machine.toLowerCase() === desktop.machine.toLowerCase())) continue;
82 const capabilities = desktop.open ? (desktop.diagnosis.capabilities ?? []).map((c) => ({ ...c, actions: [] })) : [];
83 rows.push({
84 machine: desktop.machine,
85 person: desktop.person,
86 backend: desktop.backend,
87 version: desktop.version,
88 diagnosis: desktop.open ? desktop.diagnosis : desktop.closed,
89 actions: [],
90 capabilities,
91 inCapabilities: desktop.open && !!desktop.diagnosis.in_capabilities && capabilities.length > 0,
92 here: false,
93 reportedAt: desktop.reportedAt,
94 });
95 }
96 return rows;
97}
98
99/** A diagnosis's fixes, as buttons: they work only on this computer, in the app. */
100function Actions({ actions, primary = true }: { actions: DesktopRunnerAction[]; primary?: boolean }) {
101 if (actions.length === 0) return null;
102 return (
103 <>
104 {actions.map((action, at) => (
105 <Button key={action.id} size="xs" variant={primary && at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}>
106 {action.label}
107 </Button>
108 ))}
109 </>
110 );
111}
112
113/** Start or Stop for one kind of job on this computer, which leaves the others as they are. */
114function Toggle({ capability }: { capability: DesktopRunnerCapability }) {
115 const [asked, setAsked] = useState<boolean | null>(null);
116 const on = capability.enabled !== false;
117 const waiting = asked !== null && asked !== on;
118 return (
119 <Button
120 size="xs"
121 variant={on ? "outline" : "soft"}
122 className="ml-auto"
123 disabled={waiting}
124 aria-label={`${on ? "Stop" : "Start"} ${capability.label}`}
125 onClick={() => {
126 setAsked(!on);
127 void desktopRunner()
128 ?.act(on ? "stop" : "start", capability.id)
129 .catch(() => setAsked(null));
130 }}
131 >
132 {waiting ? (on ? "Stopping…" : "Starting…") : on ? "Stop" : "Start"}
133 </Button>
134 );
135}
136
137function Detail({ term, children }: { term: ReactNode; children: ReactNode }) {
138 return (
139 <div className="flex min-w-0 items-baseline gap-3">
140 <dt className="w-16 shrink-0 text-faint">{term}</dt>
141 <dd className="min-w-0 break-words text-fg-soft">{children}</dd>
142 </div>
143 );
144}
145
146/** 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. */
147function KindCard({ capability, here, runners }: { capability: DesktopRunnerCapability; here: boolean; runners: Runner[] }) {
148 const href = jobHref(capability, runners);
149 const isolation = capability.isolation ? (ISOLATION[capability.isolation] ?? { label: capability.isolation, hint: null }) : null;
150 const edge = capability.tone === "problem" ? "border-danger/40" : capability.tone === "warning" ? "border-warn/35" : "";
151 const live = capability.state === "busy" || capability.state === "starting" || capability.state === "downloading" || capability.state === "updating";
152 const fixes = here ? capability.actions : [];
153 const toggle = here && capability.can_toggle;
154 return (
155 <Card tone="plain" radius="lg" className={`flex min-w-0 flex-col gap-3 p-4 ${edge}`} aria-label={capability.label} role="group">
156 <div className="flex items-center gap-3">
157 <span className={`grid size-9 shrink-0 place-items-center rounded-lg bg-raised ${capability.enabled === false ? "text-faint" : "text-fg-soft"}`}>
158 {ICON[capability.id] ?? ICON.linux}
159 </span>
160 <div className="min-w-0">
161 <p className="text-sm font-medium">{capability.label}</p>
162 <Badge tone={BADGE[capability.tone]} className="mt-1" role={here ? "status" : undefined}>
163 <span className={`size-1.5 rounded-full bg-current ${live ? "motion-safe:animate-pulse" : ""}`} aria-hidden />
164 {capability.title}
165 </Badge>
166 </div>
167 </div>
168 {capability.about && <p className="text-sm text-muted">{capability.about}</p>}
169 {capability.job && (
170 <div className="rounded-lg bg-raised px-3 py-2 text-sm">
171 <span className="block text-[0.6875rem] font-medium tracking-wide text-muted uppercase">{capability.job.kind === "agent" ? "Agent work" : "Running"}</span>
172 {href ? (
173 <Link to={href} className="font-medium text-fg underline-offset-4 hover:underline">
174 {capability.job.name}
175 </Link>
176 ) : (
177 <span className="font-medium text-fg">{capability.job.name}</span>
178 )}
179 <span className="text-muted"> in {capability.job.repo}</span>
180 </div>
181 )}
182 {capability.state === "downloading" && typeof capability.progress === "number" && (
183 <div className="flex items-center gap-3">
184 <Progress value={capability.progress} aria-label={`${capability.label}: the sandbox's image`} className="h-1.5" />
185 <span className="w-9 shrink-0 text-right text-xs text-muted tabular-nums">{capability.progress}%</span>
186 </div>
187 )}
188 {(capability.headline || capability.cause || capability.fix) && (
189 <div className="space-y-1 text-sm">
190 {capability.headline && <p className="font-medium text-fg">{capability.headline}</p>}
191 {capability.cause && <p className="text-muted">{capability.cause}</p>}
192 {capability.fix && <p className="text-fg-soft">{capability.fix}</p>}
193 </div>
194 )}
195 {(capability.runner || capability.version || isolation) && (
196 <dl className="space-y-0.5 border-t border-line pt-2 text-xs">
197 {capability.runner && <Detail term="On g1t">{capability.runner}</Detail>}
198 {capability.version && <Detail term="Version">g1t-runner {capability.version}</Detail>}
199 {isolation && (
200 <Detail term="Isolation">
201 {isolation.hint ? (
202 <Tooltip>
203 <TooltipTrigger asChild>
204 <span tabIndex={0} className="cursor-help underline decoration-line-strong decoration-dotted underline-offset-4">
205 {isolation.label}
206 </span>
207 </TooltipTrigger>
208 <TooltipContent>{isolation.hint}</TooltipContent>
209 </Tooltip>
210 ) : (
211 isolation.label
212 )}
213 </Detail>
214 )}
215 </dl>
216 )}
217 {(fixes.length > 0 || toggle) && (
218 <div className="mt-auto flex flex-wrap items-center gap-2">
219 <Actions actions={fixes} primary={capability.tone === "problem" || (capability.tone === "warning" && capability.state !== "waiting")} />
220 {toggle && <Toggle key={String(capability.enabled)} capability={capability} />}
221 </div>
222 )}
223 </Card>
224 );
225}
226
227/**
228 * The workspace's desktops: each person's computer with the g1t desktop
229 * app, whose runner runs while g1t is open there. What each says, why it
230 * isn't running and the fix, is the app's own words (lib/desktop-runner.ts).
231 * A sandboxed one's kinds of job, Linux and Windows, are cards of their own:
232 * one can run while the other can't, or is stopped; on this computer, each
233 * has its own Start or Stop.
234 */
235export function DesktopRunners({ desktops, slug, runners = [] }: { desktops: DesktopRunner[] | null; slug: string; runners?: Runner[] }) {
236 const here = useDesktopRunner();
237 if (!desktops) return <p className="text-sm text-muted">The desktops can&apos;t be read right now.</p>;
238 const rows = desktopRows(desktops, here, slug);
239 if (rows.length === 0) {
240 return (
241 <EmptyState title="No desktops yet">
242 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.
243 Open it signed in as an owner of {slug}.
244 </EmptyState>
245 );
246 }
247 return (
248 <Card asChild divided>
249 <ul>
250 {rows.map((row) => (
251 <li key={row.machine} className="space-y-3 px-4 py-4">
252 <div className="flex items-start gap-3">
253 <div className="min-w-0 grow">
254 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
255 <Laptop size={13} className="text-muted" aria-hidden />
256 <span className="font-medium">{row.machine}</span>
257 {row.here && <Badge tone="accent">This computer</Badge>}
258 <Badge tone={row.backend === "sandboxed" ? "neutral" : "warn"}>{BACKEND_LABEL[row.backend]}</Badge>
259 {row.person && <span className="text-xs text-faint">@{row.person}</span>}
260 </div>
261 </div>
262 <div className="shrink-0 text-right text-xs text-faint">
263 {row.version && <div>g1t-runner {row.version}</div>}
264 {row.reportedAt && (
265 <div className="mt-0.5">
266 Heard from <TimeAgo at={row.reportedAt} />
267 </div>
268 )}
269 </div>
270 </div>
271 {!row.inCapabilities && (
272 <div className="flex items-start gap-3" role={row.here ? "status" : undefined}>
273 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${DOT[row.diagnosis.tone]}`} aria-hidden />
274 <div className="min-w-0">
275 <p className="text-sm text-fg">{row.diagnosis.title}</p>
276 {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>}
277 {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>}
278 {row.actions.length > 0 && (
279 <div className="mt-2 flex flex-wrap gap-2">
280 <Actions actions={row.actions} />
281 </div>
282 )}
283 </div>
284 </div>
285 )}
286 {row.capabilities.length > 0 && (
287 <div className={`grid gap-3 ${row.capabilities.length > 1 ? "sm:grid-cols-2" : ""}`}>
288 {row.capabilities.map((capability) => (
289 <KindCard key={capability.id} capability={capability} here={row.here} runners={runners} />
290 ))}
291 </div>
292 )}
293 </li>
294 ))}
295 </ul>
296 </Card>
297 );
298}