@saadnvd1/agent-os 0.3.620 → 0.3.624

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/app/api/health/anomalies/route.ts +45 -0
  3. package/app/api/health/config/route.ts +31 -0
  4. package/app/api/health/notes/route.ts +33 -0
  5. package/components/Health/HealthDot.tsx +35 -0
  6. package/components/Health/HealthPanel.tsx +412 -0
  7. package/components/Pane/DesktopTabBar.tsx +3 -0
  8. package/components/Pane/MobileTabBar.tsx +4 -0
  9. package/components/Pane/MobileViewPicker.test.ts +8 -0
  10. package/components/Pane/MobileViewPicker.tsx +4 -1
  11. package/components/Pane/PaneViewToggle.test.ts +82 -0
  12. package/components/Pane/PaneViewToggle.tsx +12 -7
  13. package/components/Pane/git-visibility.ts +9 -0
  14. package/components/Pane/index.tsx +18 -2
  15. package/components/SessionList/SidebarHeader.tsx +2 -0
  16. package/components/Settings/SettingsDialog.tsx +9 -0
  17. package/data/git/queries.ts +16 -3
  18. package/data/health/index.ts +111 -0
  19. package/lib/db/migrations/20261015T1500_health_watch.ts +68 -0
  20. package/lib/health/boots.ts +79 -0
  21. package/lib/health/ci.ts +90 -0
  22. package/lib/health/config.ts +79 -0
  23. package/lib/health/detectors.ts +227 -0
  24. package/lib/health/errors.ts +90 -0
  25. package/lib/health/evidence.ts +172 -0
  26. package/lib/health/facts.ts +159 -0
  27. package/lib/health/fixtures.ts +30 -0
  28. package/lib/health/notes.ts +76 -0
  29. package/lib/health/pieces.ts +120 -0
  30. package/lib/health/route.ts +113 -0
  31. package/lib/health/store.ts +212 -0
  32. package/lib/health/tick.ts +149 -0
  33. package/lib/health/types.ts +148 -0
  34. package/lib/health/view.ts +120 -0
  35. package/lib/health/watchers.ts +176 -0
  36. package/lib/load/alert.ts +4 -8
  37. package/lib/models/plan-step.ts +18 -3
  38. package/lib/models/roles.ts +6 -0
  39. package/lib/orchestrator/deliver.ts +1 -0
  40. package/lib/orchestrator/events.ts +13 -4
  41. package/lib/orchestrator/watcher.ts +12 -0
  42. package/lib/plans/release.ts +7 -0
  43. package/lib/settings-url.ts +1 -0
  44. package/lib/tasks/refresh-base.ts +78 -0
  45. package/package.json +1 -1
  46. package/scripts/autodeploy +2 -2
  47. package/scripts/autodeploy.test.ts +2 -2
  48. package/server.ts +11 -0
package/README.md CHANGED
@@ -28,6 +28,7 @@ and check on all of it from your phone.
28
28
  - **Schedules and recipes.** Start work on a timer, or save a job that comes up again. [More](docs/guides/schedules.md)
29
29
  - **Plans in pieces and stacks.** A plan splits into several PRs only for a real reason; a board can run as stacked PRs. [More](docs/guides/tasks.md#plans-in-pieces)
30
30
  - **Machine load and status.** A load gauge, each session's share of CPU and memory, and Needs you, Working, In review and Done as it changes. [More](docs/guides/sessions.md)
31
+ - **A health watcher.** Zero-token checks on the orchestrator's tick catch a red machine load, a held queue, a quiet background job, a restart loop, slow CI, a PR armed for hours and a piece started twice. Each trip wakes one short, tool-less Sonnet call that judges it and posts one line to the orchestrator; only a thing you alone can clear reaches your phone. Settings → Health lists what is open, the history, the notes on what normal looks like and the thresholds. Report only: it never acts. [More](docs/reference/health.md)
31
32
  - **Workspaces.** Group projects, finish work with Done, and get a log of every merged task. [More](docs/reference/workspaces.md)
32
33
  - **LumifyHub (optional).** Boards, cards and docs for a workspace, if you want them. [More](docs/lumifyhub.md)
33
34
  - **No approvals mode.** One switch in Settings that approves every ask, lets brakes and plan-only tasks through, and starts every agent with its permission prompts off; each approval it makes is logged. Off by default. [More](docs/reference/orchestrator.md#no-approvals-mode)
@@ -0,0 +1,45 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ allSince,
4
+ closedSince,
5
+ dismiss,
6
+ openAnomalies,
7
+ } from "@/lib/health/store";
8
+ import { healthView } from "@/lib/health/view";
9
+ import { MIN } from "@/lib/health/types";
10
+
11
+ const WEEK = 7 * 24 * 60 * MIN;
12
+
13
+ // GET /api/health/anomalies - what's open, what closed this week and a
14
+ // per-check summary. ?count=1 is only the numbers the sidebar dot needs.
15
+ export function GET(request: NextRequest) {
16
+ const now = Date.now();
17
+ const view = healthView(
18
+ openAnomalies(),
19
+ closedSince(now - WEEK),
20
+ allSince(now - WEEK)
21
+ );
22
+ if (request.nextUrl.searchParams.has("count"))
23
+ return NextResponse.json({
24
+ open: view.open.length,
25
+ needsYou: view.needsYou,
26
+ });
27
+ return NextResponse.json(view);
28
+ }
29
+
30
+ // PATCH /api/health/anomalies { id, action: "dismiss" } - close it and mute
31
+ // that subject for a day.
32
+ export async function PATCH(request: NextRequest) {
33
+ const body = (await request.json().catch(() => null)) as {
34
+ id?: unknown;
35
+ action?: unknown;
36
+ } | null;
37
+ if (!body || typeof body.id !== "number" || body.action !== "dismiss")
38
+ return NextResponse.json(
39
+ { error: "Send { id, action: 'dismiss' }" },
40
+ { status: 400 }
41
+ );
42
+ if (!dismiss(body.id))
43
+ return NextResponse.json({ error: "No such anomaly" }, { status: 404 });
44
+ return NextResponse.json({ ok: true });
45
+ }
@@ -0,0 +1,31 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ HEALTH_DEFAULTS,
4
+ HEALTH_LABELS,
5
+ healthConfig,
6
+ setHealthConfig,
7
+ } from "@/lib/health/config";
8
+
9
+ const body = () => ({
10
+ config: healthConfig(),
11
+ defaults: HEALTH_DEFAULTS,
12
+ labels: HEALTH_LABELS,
13
+ });
14
+
15
+ // GET /api/health/config - the thresholds, their defaults and labels.
16
+ export function GET() {
17
+ return NextResponse.json(body());
18
+ }
19
+
20
+ // PUT { patch: { name: number | null } } - null puts the default back.
21
+ export async function PUT(request: NextRequest) {
22
+ const input = (await request.json().catch(() => null)) as {
23
+ patch?: Record<string, unknown>;
24
+ } | null;
25
+ if (!input?.patch || typeof input.patch !== "object")
26
+ return NextResponse.json({ error: "Send { patch }" }, { status: 400 });
27
+ const { errors } = setHealthConfig(input.patch);
28
+ if (errors.length)
29
+ return NextResponse.json({ error: errors.join("; ") }, { status: 400 });
30
+ return NextResponse.json(body());
31
+ }
@@ -0,0 +1,33 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { addNote, deleteNote, listNotes, SEED_NOTES } from "@/lib/health/notes";
3
+ import { AREAS, type Area } from "@/lib/health/types";
4
+
5
+ // GET /api/health/notes - what normal looks like, per area: the built-in
6
+ // lines and the ones learned or typed here.
7
+ export function GET() {
8
+ return NextResponse.json({ seeds: SEED_NOTES, notes: listNotes() });
9
+ }
10
+
11
+ // POST { area, line } adds a line as Saad's own.
12
+ export async function POST(request: NextRequest) {
13
+ const body = (await request.json().catch(() => null)) as {
14
+ area?: unknown;
15
+ line?: unknown;
16
+ } | null;
17
+ if (
18
+ !body ||
19
+ typeof body.line !== "string" ||
20
+ !AREAS.includes(body.area as Area)
21
+ )
22
+ return NextResponse.json({ error: "Send { area, line }" }, { status: 400 });
23
+ const id = addNote(body.area as Area, body.line, "saad");
24
+ return NextResponse.json({ id });
25
+ }
26
+
27
+ // DELETE ?id= removes a learned or typed line.
28
+ export function DELETE(request: NextRequest) {
29
+ const id = Number(request.nextUrl.searchParams.get("id"));
30
+ if (!Number.isInteger(id))
31
+ return NextResponse.json({ error: "Send ?id=" }, { status: 400 });
32
+ return NextResponse.json({ ok: deleteNote(id) });
33
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { useHealthCount } from "@/data/health";
5
+ import { settingsUiActions } from "@/stores/settingsUi";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ // Beside the load gauge: shown only while something is open. Amber for an
9
+ // anomaly, red when it needs the person. A tap opens Settings → Health.
10
+ export function HealthDot() {
11
+ const { data } = useHealthCount();
12
+ if (!data || data.open === 0) return null;
13
+ const urgent = data.needsYou > 0;
14
+ const label = urgent
15
+ ? `${data.needsYou} health issue${data.needsYou === 1 ? "" : "s"} need you`
16
+ : `${data.open} health issue${data.open === 1 ? "" : "s"} open`;
17
+ return (
18
+ <Button
19
+ variant="ghost"
20
+ size="icon-sm"
21
+ aria-label={label}
22
+ title={label}
23
+ data-health-dot={urgent ? "red" : "amber"}
24
+ onClick={() => settingsUiActions.open("health")}
25
+ className="h-11 w-11 shrink-0 md:h-8 md:w-6 [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
26
+ >
27
+ <span
28
+ className={cn(
29
+ "h-2 w-2 rounded-sm",
30
+ urgent ? "bg-destructive" : "bg-amber-500"
31
+ )}
32
+ />
33
+ </Button>
34
+ );
35
+ }
@@ -0,0 +1,412 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import {
7
+ useAddNote,
8
+ useDeleteNote,
9
+ useDismissAnomaly,
10
+ useHealthConfig,
11
+ useHealthNotes,
12
+ useHealthQuery,
13
+ useSaveConfig,
14
+ } from "@/data/health";
15
+ import { AREAS, AREA_LABELS, type Area } from "@/lib/health/types";
16
+ import {
17
+ duration,
18
+ type AnomalyView,
19
+ type CheckSummary,
20
+ } from "@/lib/health/view";
21
+ import { cn } from "@/lib/utils";
22
+
23
+ const TABS = ["open", "history", "notes", "thresholds"] as const;
24
+ type Tab = (typeof TABS)[number];
25
+ const TAB_LABEL: Record<Tab, string> = {
26
+ open: "Open",
27
+ history: "History",
28
+ notes: "Notes",
29
+ thresholds: "Thresholds",
30
+ };
31
+
32
+ const tokens = (n: number) =>
33
+ n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
34
+
35
+ function dot(a: AnomalyView): string {
36
+ if (a.needsYou) return "bg-destructive";
37
+ if (a.verdict && !a.verdict.real) return "bg-green-500";
38
+ return a.severity === "high" ? "bg-destructive" : "bg-amber-500";
39
+ }
40
+
41
+ function Chip({
42
+ children,
43
+ tone,
44
+ }: {
45
+ children: React.ReactNode;
46
+ tone?: "red" | "amber";
47
+ }) {
48
+ return (
49
+ <span
50
+ className={cn(
51
+ "rounded px-1.5 py-0.5 text-[11px]",
52
+ tone === "red"
53
+ ? "bg-destructive/10 text-destructive"
54
+ : tone === "amber"
55
+ ? "bg-amber-500/15 text-amber-700 dark:text-amber-400"
56
+ : "bg-muted text-muted-foreground"
57
+ )}
58
+ >
59
+ {children}
60
+ </span>
61
+ );
62
+ }
63
+
64
+ function Row({
65
+ a,
66
+ now,
67
+ onDismiss,
68
+ }: {
69
+ a: AnomalyView;
70
+ now: number;
71
+ onDismiss?: () => void;
72
+ }) {
73
+ const v = a.verdict;
74
+ const end = a.closedAt ?? now;
75
+ return (
76
+ <li
77
+ data-anomaly={a.id}
78
+ className="border-border/60 flex gap-3 border-b px-3 py-3 last:border-b-0"
79
+ >
80
+ <span
81
+ className={cn("mt-1.5 h-2 w-2 shrink-0 rounded-full", dot(a))}
82
+ aria-hidden
83
+ />
84
+ <div className="min-w-0 flex-1 space-y-1">
85
+ <p className="text-sm font-medium break-words">
86
+ {AREA_LABELS[a.area]} · {a.line}
87
+ </p>
88
+ <div className="flex flex-wrap items-center gap-1.5">
89
+ {a.needsYou && <Chip tone="red">needs you</Chip>}
90
+ {a.severity === "high" && !a.needsYou && (
91
+ <Chip tone="amber">high</Chip>
92
+ )}
93
+ {v?.unjudged && <Chip>unjudged</Chip>}
94
+ {a.judging && <Chip>judging…</Chip>}
95
+ {v && !v.real && <Chip>judged normal</Chip>}
96
+ <span className="text-muted-foreground text-xs">
97
+ {a.check} ·{" "}
98
+ {a.closedAt
99
+ ? `open ${duration(end - a.openedAt)}`
100
+ : `open ${duration(end - a.openedAt)} so far`}
101
+ {a.closedAt ? ` · ${a.closeReason}` : ""}
102
+ {a.tokens > 0 ? ` · ${tokens(a.tokens)} tokens` : ""}
103
+ </span>
104
+ </div>
105
+ {v && !v.unjudged && <p className="text-sm break-words">{v.cause}</p>}
106
+ {v?.real && v.action && !v.unjudged && (
107
+ <p className="text-muted-foreground text-sm break-words">
108
+ Suggest: <span className="text-foreground">{v.action}</span>
109
+ </p>
110
+ )}
111
+ {v?.unjudged && v.why && (
112
+ <p className="text-muted-foreground text-xs">No watcher: {v.why}</p>
113
+ )}
114
+ </div>
115
+ {onDismiss && (
116
+ <Button
117
+ variant="outline"
118
+ size="sm"
119
+ className="h-11 shrink-0 self-start md:h-8"
120
+ onClick={onDismiss}
121
+ >
122
+ Dismiss
123
+ </Button>
124
+ )}
125
+ </li>
126
+ );
127
+ }
128
+
129
+ function List({
130
+ items,
131
+ now,
132
+ empty,
133
+ onDismiss,
134
+ }: {
135
+ items: AnomalyView[];
136
+ now: number;
137
+ empty: string;
138
+ onDismiss?: (id: number) => void;
139
+ }) {
140
+ if (!items.length)
141
+ return (
142
+ <p className="text-muted-foreground border-border/60 rounded-xl border px-3 py-6 text-center text-sm">
143
+ {empty}
144
+ </p>
145
+ );
146
+ return (
147
+ <ul className="border-border/60 overflow-hidden rounded-xl border">
148
+ {items.map((a) => (
149
+ <Row
150
+ key={a.id}
151
+ a={a}
152
+ now={now}
153
+ onDismiss={onDismiss && (() => onDismiss(a.id))}
154
+ />
155
+ ))}
156
+ </ul>
157
+ );
158
+ }
159
+
160
+ function Summary({ rows }: { rows: CheckSummary[] }) {
161
+ if (!rows.length) return null;
162
+ const sum = (f: (r: CheckSummary) => number) =>
163
+ rows.reduce((n, r) => n + f(r), 0);
164
+ return (
165
+ <div className="space-y-2">
166
+ <h3 className="label-mono text-muted-foreground">Last 7 days</h3>
167
+ <div className="border-border/60 overflow-x-auto rounded-xl border">
168
+ <table className="w-full text-sm tabular-nums">
169
+ <thead className="text-muted-foreground text-xs">
170
+ <tr className="border-border/60 border-b">
171
+ <th className="px-3 py-2 text-left font-medium">Check</th>
172
+ <th className="px-3 py-2 text-right font-medium">Opened</th>
173
+ <th className="px-3 py-2 text-right font-medium">Real</th>
174
+ <th className="px-3 py-2 text-right font-medium">Median open</th>
175
+ <th className="px-3 py-2 text-right font-medium">Wakes</th>
176
+ <th className="px-3 py-2 text-right font-medium">Tokens</th>
177
+ </tr>
178
+ </thead>
179
+ <tbody>
180
+ {rows.map((r) => (
181
+ <tr key={r.check} className="border-border/60 border-b">
182
+ <td className="px-3 py-2">{r.check}</td>
183
+ <td className="px-3 py-2 text-right">{r.opened}</td>
184
+ <td className="px-3 py-2 text-right">{r.real}</td>
185
+ <td className="px-3 py-2 text-right">
186
+ {r.medianOpenMs === null ? "—" : duration(r.medianOpenMs)}
187
+ </td>
188
+ <td className="px-3 py-2 text-right">{r.wakes}</td>
189
+ <td className="px-3 py-2 text-right">{tokens(r.tokens)}</td>
190
+ </tr>
191
+ ))}
192
+ </tbody>
193
+ <tfoot>
194
+ <tr className="font-semibold">
195
+ <td className="px-3 py-2">Total</td>
196
+ <td className="px-3 py-2 text-right">{sum((r) => r.opened)}</td>
197
+ <td className="px-3 py-2 text-right">{sum((r) => r.real)}</td>
198
+ <td className="px-3 py-2 text-right">—</td>
199
+ <td className="px-3 py-2 text-right">{sum((r) => r.wakes)}</td>
200
+ <td className="px-3 py-2 text-right">
201
+ {tokens(sum((r) => r.tokens))}
202
+ </td>
203
+ </tr>
204
+ </tfoot>
205
+ </table>
206
+ </div>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ function Notes() {
212
+ const { data } = useHealthNotes();
213
+ const add = useAddNote();
214
+ const del = useDeleteNote();
215
+ const [area, setArea] = useState<Area>("scheduler");
216
+ const [line, setLine] = useState("");
217
+ if (!data) return null;
218
+ return (
219
+ <div className="space-y-5">
220
+ <p className="text-muted-foreground text-sm">
221
+ What normal looks like. A watcher reads these before it judges, and adds
222
+ a line when it learns something is normal.
223
+ </p>
224
+ {AREAS.map((a) => (
225
+ <div key={a} className="space-y-2">
226
+ <h3 className="label-mono text-muted-foreground">{AREA_LABELS[a]}</h3>
227
+ <ul className="border-border/60 overflow-hidden rounded-xl border">
228
+ {data.seeds[a].map((s) => (
229
+ <li
230
+ key={s}
231
+ className="border-border/60 border-b px-3 py-2 text-sm last:border-b-0"
232
+ >
233
+ {s}
234
+ </li>
235
+ ))}
236
+ {data.notes
237
+ .filter((n) => n.area === a)
238
+ .map((n) => (
239
+ <li
240
+ key={n.id}
241
+ className="border-border/60 flex items-start gap-2 border-b px-3 py-2 text-sm last:border-b-0"
242
+ >
243
+ <span className="min-w-0 flex-1 break-words">{n.line}</span>
244
+ <Chip>{n.source === "saad" ? "yours" : "learned"}</Chip>
245
+ <button
246
+ type="button"
247
+ className="text-muted-foreground hover:text-foreground -my-2 min-h-11 px-2 text-xs"
248
+ onClick={() => del.mutate(n.id)}
249
+ aria-label={`Delete note: ${n.line}`}
250
+ >
251
+ Delete
252
+ </button>
253
+ </li>
254
+ ))}
255
+ </ul>
256
+ </div>
257
+ ))}
258
+ <form
259
+ className="flex flex-col gap-2 md:flex-row"
260
+ onSubmit={(e) => {
261
+ e.preventDefault();
262
+ if (!line.trim()) return;
263
+ add.mutate({ area, line }, { onSuccess: () => setLine("") });
264
+ }}
265
+ >
266
+ <select
267
+ value={area}
268
+ onChange={(e) => setArea(e.target.value as Area)}
269
+ aria-label="Area"
270
+ className="border-border/60 bg-card/60 h-11 rounded-lg border px-2 text-sm md:h-9"
271
+ >
272
+ {AREAS.map((a) => (
273
+ <option key={a} value={a}>
274
+ {AREA_LABELS[a]}
275
+ </option>
276
+ ))}
277
+ </select>
278
+ <Input
279
+ value={line}
280
+ onChange={(e) => setLine(e.target.value)}
281
+ placeholder="Add a line about what's normal"
282
+ maxLength={200}
283
+ aria-label="New note"
284
+ className="h-11 flex-1 md:h-9"
285
+ />
286
+ <Button type="submit" className="h-11 md:h-9" disabled={!line.trim()}>
287
+ Add
288
+ </Button>
289
+ </form>
290
+ </div>
291
+ );
292
+ }
293
+
294
+ function Thresholds() {
295
+ const { data } = useHealthConfig();
296
+ const save = useSaveConfig();
297
+ const [draft, setDraft] = useState<Record<string, string>>({});
298
+ if (!data) return null;
299
+ return (
300
+ <div className="space-y-3">
301
+ <p className="text-muted-foreground text-sm">
302
+ When a check trips. Clear a box to go back to the default.
303
+ </p>
304
+ <ul className="border-border/60 overflow-hidden rounded-xl border">
305
+ {Object.keys(data.defaults).map((k) => {
306
+ const value = draft[k] ?? String(data.config[k]);
307
+ return (
308
+ <li
309
+ key={k}
310
+ className="border-border/60 flex items-center gap-3 border-b px-3 py-2 last:border-b-0"
311
+ >
312
+ <label htmlFor={`health-${k}`} className="min-w-0 flex-1 text-sm">
313
+ {data.labels[k]}
314
+ <span className="text-muted-foreground block text-xs">
315
+ default {data.defaults[k]}
316
+ </span>
317
+ </label>
318
+ <Input
319
+ id={`health-${k}`}
320
+ inputMode="decimal"
321
+ value={value}
322
+ onChange={(e) => setDraft({ ...draft, [k]: e.target.value })}
323
+ onBlur={() => {
324
+ if (draft[k] === undefined) return;
325
+ const raw = draft[k].trim();
326
+ save.mutate(
327
+ { [k]: raw === "" ? null : Number(raw) },
328
+ {
329
+ onSettled: () => setDraft(({ [k]: _, ...rest }) => rest),
330
+ }
331
+ );
332
+ }}
333
+ className="h-11 w-24 text-right tabular-nums md:h-9"
334
+ />
335
+ </li>
336
+ );
337
+ })}
338
+ </ul>
339
+ {save.error && (
340
+ <p role="alert" className="text-destructive text-sm">
341
+ {save.error.message}
342
+ </p>
343
+ )}
344
+ </div>
345
+ );
346
+ }
347
+
348
+ export function HealthPanel() {
349
+ const { data, error } = useHealthQuery();
350
+ const dismiss = useDismissAnomaly();
351
+ const [tab, setTab] = useState<Tab>("open");
352
+ return (
353
+ <div className="space-y-4">
354
+ <div className="flex flex-wrap items-center gap-2">
355
+ {data && data.open.length > 0 && (
356
+ <Chip tone="amber">{data.open.length} open</Chip>
357
+ )}
358
+ {data && data.needsYou > 0 && (
359
+ <Chip tone="red">{data.needsYou} needs you</Chip>
360
+ )}
361
+ <div
362
+ role="tablist"
363
+ aria-label="Health"
364
+ className="flex flex-wrap gap-1 md:ml-auto"
365
+ >
366
+ {TABS.map((t) => (
367
+ <button
368
+ key={t}
369
+ type="button"
370
+ role="tab"
371
+ aria-selected={tab === t}
372
+ onClick={() => setTab(t)}
373
+ className={cn(
374
+ "min-h-11 rounded-md px-3 text-sm md:min-h-8",
375
+ tab === t
376
+ ? "bg-muted text-foreground"
377
+ : "text-muted-foreground hover:text-foreground"
378
+ )}
379
+ >
380
+ {TAB_LABEL[t]}
381
+ </button>
382
+ ))}
383
+ </div>
384
+ </div>
385
+ {error && (
386
+ <p role="alert" className="text-destructive text-sm">
387
+ {error.message}
388
+ </p>
389
+ )}
390
+ {tab === "open" && data && (
391
+ <>
392
+ <List
393
+ items={data.open}
394
+ now={data.now}
395
+ empty="Nothing is wrong. The checks run every minute and cost nothing until one trips."
396
+ onDismiss={(id) => dismiss.mutate(id)}
397
+ />
398
+ <Summary rows={data.summary} />
399
+ </>
400
+ )}
401
+ {tab === "history" && data && (
402
+ <List
403
+ items={data.history}
404
+ now={data.now}
405
+ empty="Nothing has closed in the last 7 days."
406
+ />
407
+ )}
408
+ {tab === "notes" && <Notes />}
409
+ {tab === "thresholds" && <Thresholds />}
410
+ </div>
411
+ );
412
+ }
@@ -41,6 +41,7 @@ interface DesktopTabBarProps {
41
41
  canClose: boolean;
42
42
  hasAttachedTmux: boolean;
43
43
  gitDrawerOpen: boolean;
44
+ showGit: boolean;
44
45
  shellDrawerOpen: boolean;
45
46
  onTabSwitch: (tabId: string) => void;
46
47
  onTabClose: (tabId: string) => void;
@@ -71,6 +72,7 @@ export function DesktopTabBar({
71
72
  canClose,
72
73
  hasAttachedTmux,
73
74
  gitDrawerOpen,
75
+ showGit,
74
76
  shellDrawerOpen,
75
77
  onTabSwitch,
76
78
  onTabClose,
@@ -179,6 +181,7 @@ export function DesktopTabBar({
179
181
  isConductor={isConductor}
180
182
  hasLog={hasLog}
181
183
  workerCount={workerCount}
184
+ showGit={showGit}
182
185
  gitDrawerOpen={gitDrawerOpen}
183
186
  shellDrawerOpen={shellDrawerOpen}
184
187
  onViewModeChange={onViewModeChange}
@@ -48,6 +48,8 @@ interface MobileTabBarProps {
48
48
  isConductor: boolean;
49
49
  hasLog?: boolean;
50
50
  workerCount: number;
51
+ // Local, and the folder is not known to be outside a git repository.
52
+ showGit: boolean;
51
53
  onMenuClick?: () => void;
52
54
  onViewModeChange: (mode: ViewMode) => void;
53
55
  onSelectSession?: (sessionId: string) => void;
@@ -66,6 +68,7 @@ export function MobileTabBar({
66
68
  isConductor,
67
69
  hasLog,
68
70
  workerCount,
71
+ showGit,
69
72
  onMenuClick,
70
73
  onViewModeChange,
71
74
  onSelectSession,
@@ -358,6 +361,7 @@ export function MobileTabBar({
358
361
  viewMode={viewMode}
359
362
  onViewModeChange={onViewModeChange}
360
363
  isLocalSession={isLocalSession}
364
+ showGit={showGit}
361
365
  hasLog={hasLog}
362
366
  isConductor={isConductor}
363
367
  workerCount={workerCount}
@@ -12,6 +12,7 @@ Object.defineProperty(globalThis, "CustomEvent", {
12
12
 
13
13
  const all = {
14
14
  isLocalSession: true,
15
+ showGit: true,
15
16
  hasLog: true,
16
17
  isConductor: true,
17
18
  workerCount: 3,
@@ -34,11 +35,18 @@ describe("mobileViews", () => {
34
35
  expect(
35
36
  mobileViews({
36
37
  isLocalSession: false,
38
+ showGit: false,
37
39
  isConductor: false,
38
40
  workerCount: 0,
39
41
  }).map((v) => v.label)
40
42
  ).toEqual(["Chat", "Artifacts"]);
41
43
  });
44
+
45
+ it("drops only Git when the folder is not a repo", () => {
46
+ expect(mobileViews({ ...all, showGit: false }).map((v) => v.label)).toEqual(
47
+ ["Chat", "Files", "Artifacts", "Log", "Workers"]
48
+ );
49
+ });
42
50
  });
43
51
 
44
52
  describe("MobileViewPicker", () => {
@@ -28,6 +28,8 @@ export interface MobileView {
28
28
 
29
29
  export interface MobileViewAvailability {
30
30
  isLocalSession: boolean;
31
+ // Local, and the folder is not known to be outside a git repository.
32
+ showGit: boolean;
31
33
  hasLog?: boolean;
32
34
  isConductor: boolean;
33
35
  workerCount: number;
@@ -36,6 +38,7 @@ export interface MobileViewAvailability {
36
38
  // The one list of views on a phone; the picker renders it.
37
39
  export function mobileViews({
38
40
  isLocalSession,
41
+ showGit,
39
42
  hasLog,
40
43
  isConductor,
41
44
  workerCount,
@@ -43,7 +46,7 @@ export function mobileViews({
43
46
  const views: (MobileView | false)[] = [
44
47
  { mode: "terminal", label: "Chat", icon: TerminalIcon },
45
48
  isLocalSession && { mode: "files", label: "Files", icon: FolderOpen },
46
- isLocalSession && { mode: "git", label: "Git", icon: GitBranch },
49
+ showGit && { mode: "git", label: "Git", icon: GitBranch },
47
50
  { mode: "artifacts", label: "Artifacts", icon: Package },
48
51
  !!hasLog && { mode: "log", label: "Log", icon: ScrollText },
49
52
  isConductor && {