Skip to content
247 linesCodeBlameRaw
1import { Settings2 } from "lucide-react";
2import { useCallback, useLayoutEffect, useRef, useState } from "react";
3import { Link } from "react-router";
4
5import { useBusy } from "../lib/busy";
6import { BACKEND_LABEL, type DesktopRunnerCapability, type DesktopRunnerView, desktopRunner, jobHref, kindStatusKey, runnerStatusKey } from "../lib/desktop-runner";
7import { cn } from "../lib/cn";
8import { ActButton, Actions, BADGE, DOT, ICON, Toggle } from "./desktop-runners";
9import { TimeAgo } from "./ui";
10import { Badge } from "./ui/badge";
11import { Button } from "./ui/button";
12import { Popover, PopoverAnchor, PopoverContent } from "./ui/popover";
13import { Progress } from "./ui/progress";
14import { Separator } from "./ui/separator";
15
16/** How many of the log's last lines the menu shows. */
17const LOG_LINES = 6;
18
19/**
20 * The runner's menu, hanging under its dot in the desktop app's title bar,
21 * beside the settings button: what the runner is doing, the whole
22 * runner's line when it needs the person, each kind of job with the job it
23 * runs and its own Start or Stop, Start or Stop for the whole runner, its
24 * updates, the last of its log, and the way to its settings. The title bar
25 * is a page of its own above this one, so the menu is drawn here, anchored
26 * at `at` (where the dot is, as a share of the window's width) along the
27 * top edge. Escape, a click outside, or the dot again closes it.
28 */
29export function DesktopRunnerMenu({
30 open,
31 at,
32 runner,
33 onOpenChange,
34 onSettings,
35}: {
36 open: boolean;
37 /** Where the dot's right edge is across the window, 0 to 1. */
38 at: number;
39 runner: DesktopRunnerView | null;
40 onOpenChange: (open: boolean) => void;
41 /** "Runner settings…": the settings dialog at its Runner section. */
42 onSettings: () => void;
43}) {
44 const [error, setError] = useState<string | null>(null);
45 const fail = useCallback((reason: unknown) => setError(reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "That didn't work."), []);
46 return (
47 <Popover
48 open={open && runner !== null}
49 onOpenChange={(next) => {
50 if (!next) setError(null);
51 onOpenChange(next);
52 }}
53 >
54 {/* The dot is in the title bar, above this page: the menu hangs from the top edge under it. */}
55 <PopoverAnchor asChild>
56 <span aria-hidden className="pointer-events-none fixed top-0 size-0" style={{ left: `${at * 100}%` }} />
57 </PopoverAnchor>
58 <PopoverContent
59 align="end"
60 side="bottom"
61 sideOffset={4}
62 aria-label="Runner"
63 // Focus goes to the menu itself, so Escape and Tab work, without a button looking chosen.
64 onOpenAutoFocus={(event) => {
65 event.preventDefault();
66 (event.currentTarget as HTMLElement | null)?.focus();
67 }}
68 className="w-92 max-h-[calc(var(--radix-popover-content-available-height)-0.5rem)] overflow-y-auto p-0"
69 >
70 {runner && <MenuBody runner={runner} fail={fail} onSettings={onSettings} />}
71 {error && (
72 <p role="alert" className="border-t border-line px-4 py-2.5 text-xs text-danger">
73 {error}
74 </p>
75 )}
76 </PopoverContent>
77 </Popover>
78 );
79}
80
81function MenuBody({ runner, fail, onSettings }: { runner: DesktopRunnerView; fail: (reason: unknown) => void; onSettings: () => void }) {
82 const status = runnerStatusKey(runner);
83 const diagnosis = runner.diagnosis;
84 const kinds = diagnosis.capabilities ?? [];
85 const chip = runner.chip ?? { tone: diagnosis.tone, label: diagnosis.title };
86 // The whole runner's line, when the kinds of job don't already say it all.
87 const line = !(diagnosis.in_capabilities && kinds.length > 0);
88 const on = runner.enabled && !runner.paused;
89 const engine = useBusy(status, fail);
90 const update = diagnosis.update ?? null;
91 const log = runner.log.slice(-LOG_LINES);
92 const working = chip.tone === "working";
93 return (
94 <>
95 <div className="flex items-center gap-3 px-4 pt-3.5 pb-3">
96 <span className={cn("size-2.5 shrink-0 rounded-full", DOT[chip.tone], working && "motion-safe:animate-pulse")} aria-hidden />
97 <div className="min-w-0 grow">
98 <p className="truncate text-sm font-medium text-fg" role="status">
99 {chip.label}
100 </p>
101 <p className="truncate text-xs text-muted">
102 {[BACKEND_LABEL[runner.backend], runner.workspace ? `for ${runner.workspace}` : null, runner.version ? `g1t-runner ${runner.version}` : null].filter(Boolean).join(" · ")}
103 </p>
104 </div>
105 <Button
106 size="xs"
107 variant={on ? "outline" : "soft"}
108 busy={engine.busy}
109 aria-label={on ? "Stop the runner" : "Start the runner"}
110 onClick={() => engine.run(() => desktopRunner()?.act(on ? "stop" : "start"))}
111 >
112 {on ? "Stop" : "Start"}
113 </Button>
114 </div>
115
116 {line && (
117 <div className="space-y-1 border-t border-line px-4 py-3 text-sm">
118 <p className="font-medium text-fg">{diagnosis.title}</p>
119 {diagnosis.cause && <p className="text-muted">{diagnosis.cause}</p>}
120 {diagnosis.fix && <p className="text-fg-soft">{diagnosis.fix}</p>}
121 {diagnosis.actions.length > 0 && (
122 <div className="flex flex-wrap gap-2 pt-1.5">
123 <Actions actions={diagnosis.actions} status={status} onError={fail} />
124 </div>
125 )}
126 </div>
127 )}
128
129 {kinds.length > 0 && (
130 <ul className="divide-y divide-line border-t border-line" aria-label="Kinds of job this computer runs">
131 {kinds.map((kind) => (
132 <KindRow key={kind.id} kind={kind} runner={runner} fail={fail} />
133 ))}
134 </ul>
135 )}
136
137 {update && (
138 <div className="flex items-center gap-3 border-t border-line px-4 py-2.5">
139 <p className="min-w-0 grow text-xs text-muted">
140 {update.note ? <span className="block text-accent">{update.note}</span> : null}
141 {update.checked_at ? (
142 <>
143 Checked <TimeAgo at={update.checked_at} />
144 </>
145 ) : (
146 update.checked
147 )}
148 </p>
149 <Actions actions={[update.check]} primary={false} status={status} working={update.checking} onError={fail} />
150 </div>
151 )}
152
153 <div className="border-t border-line px-4 py-3">
154 <LogTail lines={log} />
155 <div className="mt-2 flex justify-end">
156 <ActButton action="view_log" label="Open the whole log" status={status} fail={fail} />
157 </div>
158 </div>
159
160 <Separator />
161 <div className="p-1.5">
162 <Button variant="ghost" size="sm" className="w-full justify-start" onClick={onSettings}>
163 <Settings2 size={15} aria-hidden />
164 Runner settings…
165 </Button>
166 </div>
167 </>
168 );
169}
170
171/** The log's last lines, kept scrolled to the newest unless scrolled back to read. */
172function LogTail({ lines }: { lines: string[] }) {
173 const box = useRef<HTMLPreElement>(null);
174 const following = useRef(true);
175 useLayoutEffect(() => {
176 const pre = box.current;
177 if (pre && following.current) pre.scrollTop = pre.scrollHeight;
178 }, [lines]);
179 return (
180 <pre
181 ref={box}
182 tabIndex={0}
183 aria-label="The runner's recent log"
184 onScroll={(event) => {
185 const pre = event.currentTarget;
186 following.current = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 8;
187 }}
188 className="max-h-32 overflow-auto rounded-md bg-bg px-2.5 py-2 font-mono text-[0.6875rem] leading-relaxed whitespace-pre-wrap wrap-break-word text-muted"
189 >
190 {lines.length ? lines.join("\n") : "Nothing yet."}
191 </pre>
192 );
193}
194
195/** One kind of job, in a line: its state, the job it runs, a newer runner waiting, its fixes, and its own Start or Stop. */
196function KindRow({ kind, runner, fail }: { kind: DesktopRunnerCapability; runner: DesktopRunnerView; fail: (reason: unknown) => void }) {
197 const status = kindStatusKey(kind, runner);
198 const href = jobHref(kind, []);
199 const live = kind.state === "busy" || kind.state === "starting" || kind.state === "downloading" || kind.state === "updating";
200 const urgent = kind.tone === "problem" || (kind.tone === "warning" && kind.state !== "waiting");
201 return (
202 <li className="space-y-2 px-4 py-3">
203 <div className="flex items-center gap-2.5">
204 <span className={cn("grid size-7 shrink-0 place-items-center rounded-md bg-raised", kind.enabled === false ? "text-faint" : "text-fg-soft")}>{ICON[kind.id] ?? ICON.linux}</span>
205 <div className="min-w-0 grow">
206 <p className="truncate text-sm font-medium text-fg">{kind.label}</p>
207 <Badge tone={BADGE[kind.tone]} role="status">
208 <span className={cn("size-1.5 rounded-full bg-current", live && "motion-safe:animate-pulse")} aria-hidden />
209 {kind.title}
210 </Badge>
211 </div>
212 {kind.can_toggle && <Toggle capability={kind} status={status} onError={fail} />}
213 </div>
214 {kind.job && (
215 <p className="truncate pl-9.5 text-xs text-muted">
216 Running{" "}
217 {href ? (
218 <Link to={href} className="font-medium text-fg underline-offset-4 hover:underline">
219 {kind.job.name}
220 </Link>
221 ) : (
222 <span className="font-medium text-fg">{kind.job.name}</span>
223 )}{" "}
224 in {kind.job.repo}
225 </p>
226 )}
227 {kind.state === "downloading" && typeof kind.progress === "number" && (
228 <div className="flex items-center gap-2 pl-9.5">
229 <Progress value={kind.progress} aria-label={`${kind.label}: the sandbox's image`} className="h-1" />
230 <span className="w-8 shrink-0 text-right text-[0.6875rem] text-muted tabular-nums">{kind.progress}%</span>
231 </div>
232 )}
233 {(kind.cause || kind.fix) && kind.tone !== "ok" && (
234 <div className="space-y-0.5 pl-9.5 text-xs">
235 {kind.cause && <p className="text-muted">{kind.cause}</p>}
236 {kind.fix && <p className="text-fg-soft">{kind.fix}</p>}
237 </div>
238 )}
239 {kind.update && <p className="pl-9.5 text-xs text-accent">{kind.update}</p>}
240 {kind.actions.length > 0 && (
241 <div className="flex flex-wrap gap-2 pl-9.5">
242 <Actions actions={kind.actions} primary={urgent} status={status} onError={fail} />
243 </div>
244 )}
245 </li>
246 );
247}