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.

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