@chrysb/alphaclaw 0.4.4 → 0.4.6-beta.0

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 (37) hide show
  1. package/README.md +21 -18
  2. package/lib/public/css/theme.css +29 -0
  3. package/lib/public/js/app.js +41 -2
  4. package/lib/public/js/components/badge.js +4 -0
  5. package/lib/public/js/components/doctor/findings-list.js +191 -0
  6. package/lib/public/js/components/doctor/fix-card-modal.js +144 -0
  7. package/lib/public/js/components/doctor/general-warning.js +37 -0
  8. package/lib/public/js/components/doctor/helpers.js +169 -0
  9. package/lib/public/js/components/doctor/index.js +536 -0
  10. package/lib/public/js/components/doctor/summary-cards.js +24 -0
  11. package/lib/public/js/lib/api.js +79 -0
  12. package/lib/server/commands.js +8 -4
  13. package/lib/server/constants.js +22 -26
  14. package/lib/server/db/doctor/index.js +529 -0
  15. package/lib/server/db/doctor/schema.js +69 -0
  16. package/lib/server/doctor/constants.js +43 -0
  17. package/lib/server/doctor/normalize.js +214 -0
  18. package/lib/server/doctor/prompt.js +89 -0
  19. package/lib/server/doctor/service.js +392 -0
  20. package/lib/server/doctor/workspace-fingerprint.js +126 -0
  21. package/lib/server/gmail-push.js +102 -6
  22. package/lib/server/gmail-watch.js +5 -20
  23. package/lib/server/helpers.js +5 -21
  24. package/lib/server/routes/doctor.js +123 -0
  25. package/lib/server/routes/google.js +2 -10
  26. package/lib/server/routes/system.js +7 -1
  27. package/lib/server/routes/telegram.js +3 -14
  28. package/lib/server/routes/usage.js +1 -5
  29. package/lib/server/routes/webhooks.js +2 -6
  30. package/lib/server/utils/boolean.js +22 -0
  31. package/lib/server/utils/json.js +77 -0
  32. package/lib/server/utils/network.js +5 -0
  33. package/lib/server/utils/number.js +8 -0
  34. package/lib/server/utils/shell.js +16 -0
  35. package/lib/server/webhook-middleware.js +1 -2
  36. package/lib/server.js +42 -0
  37. package/package.json +1 -1
@@ -0,0 +1,169 @@
1
+ export const getDoctorPriorityTone = (priority = "") => {
2
+ const normalized = String(priority || "").trim().toUpperCase();
3
+ if (normalized === "P0") return "danger";
4
+ if (normalized === "P1") return "warning";
5
+ return "neutral";
6
+ };
7
+
8
+ export const getDoctorStatusTone = (status = "") => {
9
+ const normalized = String(status || "").trim().toLowerCase();
10
+ if (normalized === "fixed") return "success";
11
+ if (normalized === "dismissed") return "neutral";
12
+ return "warning";
13
+ };
14
+
15
+ export const getDoctorCategoryTone = (category = "") => {
16
+ const normalized = String(category || "")
17
+ .trim()
18
+ .toLowerCase()
19
+ .replace(/[_-]+/g, " ");
20
+ if (normalized === "token efficiency") return "info";
21
+ if (normalized === "redundancy") return "accent";
22
+ if (normalized === "mixed concerns") return "cyan";
23
+ if (normalized === "workspace state") return "secondary";
24
+ return "info";
25
+ };
26
+
27
+ export const formatDoctorCategory = (category = "") => {
28
+ const normalized = String(category || "")
29
+ .trim()
30
+ .replace(/[_-]+/g, " ");
31
+ if (!normalized) return "Workspace";
32
+ return normalized.replace(/\b\w/g, (character) => character.toUpperCase());
33
+ };
34
+
35
+ export const buildDoctorPriorityCounts = (cards = []) =>
36
+ cards.reduce(
37
+ (totals, card) => {
38
+ const priority = String(card?.priority || "").trim().toUpperCase();
39
+ if (priority === "P0" || priority === "P1" || priority === "P2") {
40
+ totals[priority] += 1;
41
+ }
42
+ return totals;
43
+ },
44
+ { P0: 0, P1: 0, P2: 0 },
45
+ );
46
+
47
+ export const groupDoctorCardsByStatus = (cards = []) =>
48
+ cards.reduce(
49
+ (groups, card) => {
50
+ const status = String(card?.status || "open").trim().toLowerCase();
51
+ if (status === "fixed") {
52
+ groups.fixed.push(card);
53
+ return groups;
54
+ }
55
+ if (status === "dismissed") {
56
+ groups.dismissed.push(card);
57
+ return groups;
58
+ }
59
+ groups.open.push(card);
60
+ return groups;
61
+ },
62
+ { open: [], dismissed: [], fixed: [] },
63
+ );
64
+
65
+ export const shouldShowDoctorWarning = (
66
+ doctorStatus = null,
67
+ dismissedUntilMs = 0,
68
+ ) => {
69
+ if (!doctorStatus || doctorStatus.runInProgress) return false;
70
+ if (doctorStatus.needsInitialRun || !doctorStatus.stale) return false;
71
+ if (!doctorStatus.changeSummary?.hasMeaningfulChanges) return false;
72
+ return Number(dismissedUntilMs || 0) <= Date.now();
73
+ };
74
+
75
+ export const getDoctorWarningMessage = (doctorStatus = null) => {
76
+ if (!doctorStatus) return "";
77
+ const changedFilesCount = Number(doctorStatus.changeSummary?.changedFilesCount || 0);
78
+ if (changedFilesCount > 0) {
79
+ return `Drift Doctor has not been run in the last week and ${changedFilesCount} file${changedFilesCount === 1 ? "" : "s"} changed since the last review.`;
80
+ }
81
+ return "Doctor has not been run in the last week.";
82
+ };
83
+
84
+ export const getDoctorDriftRisk = (changeSummary = null) => {
85
+ const deltaScore = Number(changeSummary?.deltaScore || 0);
86
+ const changedFilesCount = Number(changeSummary?.changedFilesCount || 0);
87
+ const hasBaseline = !!changeSummary?.hasBaseline;
88
+ const baselineSource = String(changeSummary?.baselineSource || "none");
89
+ if (!hasBaseline) {
90
+ return {
91
+ label: "Unknown",
92
+ tone: "neutral",
93
+ detail: "No prior baseline yet",
94
+ };
95
+ }
96
+ if (deltaScore >= 8 || changedFilesCount >= 8) {
97
+ return {
98
+ label: "High",
99
+ tone: "danger",
100
+ detail: `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`,
101
+ };
102
+ }
103
+ if (deltaScore >= 4 || changedFilesCount >= 4) {
104
+ return {
105
+ label: "Moderate",
106
+ tone: "warning",
107
+ detail: `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`,
108
+ };
109
+ }
110
+ return {
111
+ label: "Low",
112
+ tone: "info",
113
+ detail: changedFilesCount
114
+ ? `${changedFilesCount} changed file${changedFilesCount === 1 ? "" : "s"}`
115
+ : baselineSource === "initial_install"
116
+ ? "Compared to initial install"
117
+ : "No detected changes",
118
+ };
119
+ };
120
+
121
+ export const getDoctorRunPillDetail = (run = null) => {
122
+ if (!run || typeof run !== "object") return "";
123
+ if (run.status === "running") return "Running";
124
+ if (run.status === "failed") return "Failed";
125
+ if ((run.cardCount || 0) === 0) return "No findings";
126
+ return `${run.cardCount || 0} finding${run.cardCount === 1 ? "" : "s"}`;
127
+ };
128
+
129
+ export const buildDoctorRunMarkers = (run = null) => {
130
+ if (!run || typeof run !== "object") return [];
131
+ if (run.status === "running") {
132
+ return [{ tone: "cyan", count: 0, label: "Running" }];
133
+ }
134
+ if (run.status === "failed") {
135
+ return [{ tone: "neutral", count: 0, label: "Failed" }];
136
+ }
137
+ if ((run.cardCount || 0) === 0) {
138
+ return [{ tone: "success", count: 0, label: "No findings" }];
139
+ }
140
+ const markers = [];
141
+ if (Number(run?.priorityCounts?.P0 || 0) > 0) {
142
+ markers.push({
143
+ tone: "danger",
144
+ count: Number(run.priorityCounts.P0 || 0),
145
+ label: "P0",
146
+ });
147
+ }
148
+ if (Number(run?.priorityCounts?.P1 || 0) > 0) {
149
+ markers.push({
150
+ tone: "warning",
151
+ count: Number(run.priorityCounts.P1 || 0),
152
+ label: "P1",
153
+ });
154
+ }
155
+ if (Number(run?.priorityCounts?.P2 || 0) > 0) {
156
+ markers.push({
157
+ tone: "neutral",
158
+ count: Number(run.priorityCounts.P2 || 0),
159
+ label: "P2",
160
+ });
161
+ }
162
+ return markers;
163
+ };
164
+
165
+ export const buildDoctorStatusFilterOptions = () => [
166
+ { value: "open", label: "Open" },
167
+ { value: "dismissed", label: "Dismissed" },
168
+ { value: "fixed", label: "Fixed" },
169
+ ];
@@ -0,0 +1,536 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import { usePolling } from "../../hooks/usePolling.js";
5
+ import {
6
+ fetchDoctorCards,
7
+ fetchDoctorStatus,
8
+ fetchDoctorRuns,
9
+ startDoctorRun,
10
+ updateDoctorCardStatus,
11
+ } from "../../lib/api.js";
12
+ import { formatLocaleDateTime } from "../../lib/format.js";
13
+ import { ActionButton } from "../action-button.js";
14
+ import { Badge } from "../badge.js";
15
+ import { LoadingSpinner } from "../loading-spinner.js";
16
+ import { PageHeader } from "../page-header.js";
17
+ import { showToast } from "../toast.js";
18
+ import { DoctorSummaryCards } from "./summary-cards.js";
19
+ import { DoctorFindingsList } from "./findings-list.js";
20
+ import { DoctorFixCardModal } from "./fix-card-modal.js";
21
+ import {
22
+ buildDoctorRunMarkers,
23
+ buildDoctorStatusFilterOptions,
24
+ getDoctorDriftRisk,
25
+ getDoctorRunPillDetail,
26
+ shouldShowDoctorWarning,
27
+ } from "./helpers.js";
28
+
29
+ const html = htm.bind(h);
30
+
31
+ const kIdlePollMs = 15000;
32
+ const kActivePollMs = 2000;
33
+
34
+ const DoctorEmptyStateIcon = () => html`
35
+ <svg
36
+ xmlns="http://www.w3.org/2000/svg"
37
+ viewBox="0 0 24 24"
38
+ fill="currentColor"
39
+ class="h-12 w-12 text-cyan-400"
40
+ >
41
+ <path
42
+ d="M8 20V14H16V20H19V4H5V20H8ZM10 20H14V16H10V20ZM21 20H23V22H1V20H3V3C3 2.44772 3.44772 2 4 2H20C20.5523 2 21 2.44772 21 3V20ZM11 8V6H13V8H15V10H13V12H11V10H9V8H11Z"
43
+ ></path>
44
+ </svg>
45
+ `;
46
+
47
+ export const DoctorTab = ({ isActive = false }) => {
48
+ const statusPoll = usePolling(fetchDoctorStatus, kIdlePollMs, {
49
+ enabled: isActive,
50
+ });
51
+ const doctorStatus = statusPoll.data?.status || null;
52
+ const runPollIntervalMs = doctorStatus?.runInProgress
53
+ ? kActivePollMs
54
+ : kIdlePollMs;
55
+ const runsPoll = usePolling(() => fetchDoctorRuns(10), runPollIntervalMs, {
56
+ enabled: isActive,
57
+ });
58
+ const [selectedRunFilter, setSelectedRunFilter] = useState("all");
59
+ const [selectedStatusFilter, setSelectedStatusFilter] = useState("open");
60
+ const [busyCardId, setBusyCardId] = useState(0);
61
+ const [fixCard, setFixCard] = useState(null);
62
+ const [pendingRunSelectionId, setPendingRunSelectionId] = useState("");
63
+
64
+ const runs = runsPoll.data?.runs || [];
65
+ const activeRunId = String(doctorStatus?.activeRunId || "");
66
+ const selectedRunId = String(selectedRunFilter || "");
67
+ const shouldRenderPendingRunTab =
68
+ selectedRunId !== "" &&
69
+ selectedRunId !== "all" &&
70
+ !runs.some((run) => String(run.id || "") === selectedRunId) &&
71
+ (pendingRunSelectionId === selectedRunId ||
72
+ (doctorStatus?.runInProgress && activeRunId === selectedRunId));
73
+ const pendingRun = shouldRenderPendingRunTab
74
+ ? {
75
+ id: Number(selectedRunId || 0),
76
+ status: "running",
77
+ summary: "",
78
+ priorityCounts: { P0: 0, P1: 0, P2: 0 },
79
+ statusCounts: { open: 0, dismissed: 0, fixed: 0 },
80
+ }
81
+ : null;
82
+ const displayRuns = pendingRun ? [pendingRun, ...runs] : runs;
83
+ const selectedRunIsActiveRun =
84
+ selectedRunFilter !== "all" &&
85
+ !!activeRunId &&
86
+ String(selectedRunFilter || "") === activeRunId;
87
+ const selectedRun =
88
+ selectedRunFilter === "all"
89
+ ? null
90
+ : displayRuns.find(
91
+ (run) => String(run.id || "") === String(selectedRunFilter || ""),
92
+ ) || null;
93
+ const cardsPoll = usePolling(
94
+ () => fetchDoctorCards({ runId: selectedRunFilter || "all" }),
95
+ doctorStatus?.runInProgress || selectedRun?.status === "running"
96
+ ? kActivePollMs
97
+ : kIdlePollMs,
98
+ { enabled: isActive },
99
+ );
100
+ const allCards = cardsPoll.data?.cards || [];
101
+
102
+ useEffect(() => {
103
+ if (!isActive) return;
104
+ statusPoll.refresh();
105
+ runsPoll.refresh();
106
+ }, [isActive]);
107
+
108
+ useEffect(() => {
109
+ if (!runs.length) {
110
+ if (pendingRunSelectionId && selectedRunId === pendingRunSelectionId)
111
+ return;
112
+ if (selectedRunIsActiveRun && doctorStatus?.runInProgress) return;
113
+ if (selectedRunFilter !== "all") setSelectedRunFilter("all");
114
+ return;
115
+ }
116
+ if (selectedRunFilter === "all") return;
117
+ const hasSelectedRun = runs.some(
118
+ (run) => String(run.id || "") === String(selectedRunFilter || ""),
119
+ );
120
+ if (hasSelectedRun) return;
121
+ if (selectedRunIsActiveRun && doctorStatus?.runInProgress) return;
122
+ setSelectedRunFilter("all");
123
+ }, [
124
+ runs,
125
+ selectedRunId,
126
+ selectedRunFilter,
127
+ selectedRunIsActiveRun,
128
+ pendingRunSelectionId,
129
+ doctorStatus?.runInProgress,
130
+ ]);
131
+
132
+ useEffect(() => {
133
+ if (!pendingRunSelectionId) return;
134
+ if (selectedRunFilter !== pendingRunSelectionId) {
135
+ setSelectedRunFilter(pendingRunSelectionId);
136
+ return;
137
+ }
138
+ const hasPendingRun = runs.some(
139
+ (run) => String(run.id || "") === String(pendingRunSelectionId || ""),
140
+ );
141
+ const activePendingRun =
142
+ !!activeRunId &&
143
+ activeRunId === pendingRunSelectionId &&
144
+ !!doctorStatus?.runInProgress;
145
+ if (!hasPendingRun && !activePendingRun) return;
146
+ setPendingRunSelectionId("");
147
+ }, [
148
+ activeRunId,
149
+ doctorStatus?.runInProgress,
150
+ pendingRunSelectionId,
151
+ runs,
152
+ selectedRunFilter,
153
+ ]);
154
+
155
+ useEffect(() => {
156
+ cardsPoll.refresh();
157
+ }, [selectedRunFilter]);
158
+
159
+ const selectedRunIsInProgress =
160
+ selectedRun?.status === "running" ||
161
+ (selectedRunIsActiveRun && doctorStatus?.runInProgress);
162
+ const selectedRunSummary = useMemo(
163
+ () => (selectedRunIsInProgress ? "" : selectedRun?.summary || ""),
164
+ [selectedRun, selectedRunIsInProgress],
165
+ );
166
+ const statusFilterOptions = useMemo(
167
+ () => buildDoctorStatusFilterOptions(),
168
+ [],
169
+ );
170
+ const driftRisk = useMemo(
171
+ () => getDoctorDriftRisk(doctorStatus?.changeSummary || null),
172
+ [doctorStatus],
173
+ );
174
+ const canRunDoctor = useMemo(() => {
175
+ if (doctorStatus?.runInProgress) return true;
176
+ if (doctorStatus?.needsInitialRun) return true;
177
+ return Number(doctorStatus?.changeSummary?.changedFilesCount || 0) > 0;
178
+ }, [doctorStatus]);
179
+ const runDoctorDisabledReason = canRunDoctor
180
+ ? ""
181
+ : "No workspace changes since the last completed Drift Doctor run.";
182
+ const showDoctorStaleBanner = useMemo(
183
+ () => shouldShowDoctorWarning(doctorStatus, 0),
184
+ [doctorStatus],
185
+ );
186
+ const hasRuns = runs.length > 0;
187
+ const hasLoadedRuns = runsPoll.data !== null || runsPoll.error !== null;
188
+ const hasLoadedCards = cardsPoll.data !== null || cardsPoll.error !== null;
189
+ const showInitialLoadingState =
190
+ !hasLoadedRuns || (hasRuns && !hasLoadedCards);
191
+ const cards = useMemo(() => {
192
+ if (selectedStatusFilter === "all") return allCards;
193
+ return allCards.filter(
194
+ (card) =>
195
+ String(card?.status || "open")
196
+ .trim()
197
+ .toLowerCase() === selectedStatusFilter,
198
+ );
199
+ }, [allCards, selectedStatusFilter]);
200
+ const openCards = useMemo(
201
+ () =>
202
+ allCards.filter(
203
+ (card) =>
204
+ String(card?.status || "open")
205
+ .trim()
206
+ .toLowerCase() === "open",
207
+ ),
208
+ [allCards],
209
+ );
210
+ const visibleRuns = useMemo(() => displayRuns.slice(0, 2), [displayRuns]);
211
+ const overflowRuns = useMemo(() => displayRuns.slice(2), [displayRuns]);
212
+ const selectedOverflowRunValue = useMemo(() => {
213
+ if (selectedRunFilter === "all") return "";
214
+ return overflowRuns.some(
215
+ (run) => String(run.id || "") === String(selectedRunFilter || ""),
216
+ )
217
+ ? String(selectedRunFilter || "")
218
+ : "";
219
+ }, [overflowRuns, selectedRunFilter]);
220
+
221
+ const getRunTabClassName = (selected = false) =>
222
+ [
223
+ "inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors",
224
+ selected
225
+ ? "border-cyan-500/40 bg-cyan-500/10 text-cyan-200 shadow-[0_0_0_1px_rgba(34,211,238,0.08)]"
226
+ : "border-border bg-black/20 text-gray-300 hover:border-gray-500 hover:text-gray-100",
227
+ ].join(" ");
228
+
229
+ const getRunMarkerClassName = (tone = "neutral") => {
230
+ if (tone === "success") return "bg-green-400";
231
+ if (tone === "warning") return "bg-yellow-400";
232
+ if (tone === "danger") return "bg-red-400";
233
+ if (tone === "cyan") return "ac-status-dot ac-status-dot--info";
234
+ return "bg-gray-500";
235
+ };
236
+ const showRunLayout =
237
+ !showInitialLoadingState &&
238
+ (runs.length > 0 ||
239
+ !!pendingRunSelectionId ||
240
+ !!activeRunId ||
241
+ !!doctorStatus?.runInProgress);
242
+
243
+ const handleRunDoctor = async () => {
244
+ try {
245
+ const result = await startDoctorRun();
246
+ showToast(
247
+ result?.reusedPreviousRun
248
+ ? "No workspace changes since the last scan; reused previous findings"
249
+ : "Doctor run started",
250
+ "success",
251
+ );
252
+ if (result?.runId) {
253
+ const runId = String(result.runId);
254
+ setPendingRunSelectionId(runId);
255
+ setSelectedRunFilter(runId);
256
+ }
257
+ statusPoll.refresh();
258
+ runsPoll.refresh();
259
+ cardsPoll.refresh();
260
+ setTimeout(statusPoll.refresh, 1200);
261
+ setTimeout(runsPoll.refresh, 1200);
262
+ setTimeout(cardsPoll.refresh, 1200);
263
+ } catch (error) {
264
+ showToast(error.message || "Could not start Doctor run", "error");
265
+ }
266
+ };
267
+
268
+ const handleUpdateStatus = async (card, status) => {
269
+ if (!card?.id || busyCardId) return;
270
+ try {
271
+ setBusyCardId(card.id);
272
+ await updateDoctorCardStatus({ cardId: card.id, status });
273
+ showToast("Doctor card updated", "success");
274
+ await cardsPoll.refresh();
275
+ await runsPoll.refresh();
276
+ await statusPoll.refresh();
277
+ } catch (error) {
278
+ showToast(error.message || "Could not update Doctor card", "error");
279
+ } finally {
280
+ setBusyCardId(0);
281
+ }
282
+ };
283
+
284
+ return html`
285
+ <div class="space-y-4">
286
+ ${showRunLayout
287
+ ? html`
288
+ <${PageHeader}
289
+ title="Drift Doctor"
290
+ actions=${html`
291
+ <${ActionButton}
292
+ onClick=${handleRunDoctor}
293
+ disabled=${!canRunDoctor}
294
+ loading=${!!doctorStatus?.runInProgress}
295
+ idleLabel="Run Drift Doctor"
296
+ loadingLabel="Running..."
297
+ title=${runDoctorDisabledReason}
298
+ />
299
+ `}
300
+ />
301
+ `
302
+ : null}
303
+ ${showInitialLoadingState
304
+ ? html`
305
+ <div class="bg-surface border border-border rounded-xl p-5">
306
+ <div class="flex items-center gap-3 text-sm text-gray-400">
307
+ <${LoadingSpinner} className="h-4 w-4" />
308
+ <span>Loading Drift Doctor...</span>
309
+ </div>
310
+ </div>
311
+ `
312
+ : null}
313
+ ${!showInitialLoadingState && hasRuns
314
+ ? html`
315
+ <${DoctorSummaryCards} cards=${openCards} />
316
+ <div class="space-y-2">
317
+ <div
318
+ class="bg-surface border border-border rounded-xl p-4 flex flex-wrap items-center justify-between gap-3"
319
+ >
320
+ <div class="flex flex-wrap items-center gap-2 text-xs text-gray-500">
321
+ <${Badge} tone=${driftRisk.tone}
322
+ >${driftRisk.label} drift risk${
323
+ driftRisk.detail ? ` · ${driftRisk.detail}` : ""
324
+ }</${Badge}
325
+ >
326
+ </div>
327
+ <div class="flex items-center gap-3 text-xs text-gray-500">
328
+ <span>Last run</span>
329
+ <span class="text-gray-300">
330
+ ${formatLocaleDateTime(doctorStatus?.lastRunAt, {
331
+ fallback: "Never",
332
+ })}
333
+ </span>
334
+ </div>
335
+ </div>
336
+ ${
337
+ showDoctorStaleBanner
338
+ ? html`
339
+ <div
340
+ class="text-xs text-yellow-300 bg-yellow-500/10 border border-yellow-500/35 rounded-lg px-3 py-2"
341
+ >
342
+ Doctor should be run again because the latest completed
343
+ run is older than one week and the workspace has
344
+ changed.
345
+ </div>
346
+ `
347
+ : null
348
+ }
349
+ </div>
350
+ `
351
+ : null}
352
+ ${showRunLayout
353
+ ? html`
354
+ <div class="space-y-4 pt-2">
355
+ <div class="flex flex-wrap items-center justify-between gap-3">
356
+ <h2 class="font-semibold text-base">Findings</h2>
357
+ </div>
358
+ <div class="flex flex-wrap items-center gap-2">
359
+ <button
360
+ type="button"
361
+ class=${getRunTabClassName(selectedRunFilter === "all")}
362
+ onClick=${() => setSelectedRunFilter("all")}
363
+ >
364
+ <span class="font-medium">All runs</span>
365
+ </button>
366
+ ${visibleRuns.map((run) => {
367
+ const selected =
368
+ String(selectedRunFilter || "") === String(run.id || "");
369
+ const markers = buildDoctorRunMarkers(run);
370
+ return html`
371
+ <button
372
+ key=${run.id}
373
+ type="button"
374
+ class=${getRunTabClassName(selected)}
375
+ onClick=${() =>
376
+ setSelectedRunFilter(String(run.id || ""))}
377
+ >
378
+ <span class="font-medium">Run #${run.id}</span>
379
+ <span class="inline-flex items-center gap-1.5">
380
+ ${markers.map(
381
+ (marker) => html`
382
+ <span
383
+ class="inline-flex items-center"
384
+ title=${marker.label}
385
+ >
386
+ <span
387
+ class=${getRunMarkerClassName(
388
+ marker.tone,
389
+ ).startsWith("ac-status-dot")
390
+ ? getRunMarkerClassName(marker.tone)
391
+ : `h-2 w-2 rounded-full ${getRunMarkerClassName(marker.tone)}`}
392
+ ></span>
393
+ </span>
394
+ `,
395
+ )}
396
+ </span>
397
+ </button>
398
+ `;
399
+ })}
400
+ ${overflowRuns.length
401
+ ? html`
402
+ <label
403
+ class="flex items-center gap-2 text-xs text-gray-500"
404
+ >
405
+ <select
406
+ value=${selectedOverflowRunValue}
407
+ onChange=${(event) => {
408
+ const nextValue = String(
409
+ event.currentTarget?.value || "",
410
+ );
411
+ if (!nextValue) return;
412
+ setSelectedRunFilter(nextValue);
413
+ }}
414
+ class="bg-black/20 border border-border rounded-full px-3 py-1.5 text-xs text-gray-300 focus:border-gray-500"
415
+ >
416
+ <option value="">More runs</option>
417
+ ${overflowRuns.map(
418
+ (run) => html`
419
+ <option value=${String(run.id || "")}>
420
+ Run #${run.id} · ${getDoctorRunPillDetail(run)}
421
+ </option>
422
+ `,
423
+ )}
424
+ </select>
425
+ </label>
426
+ `
427
+ : null}
428
+ <label class="flex items-center gap-2 text-xs text-gray-500">
429
+ <select
430
+ value=${selectedStatusFilter}
431
+ onChange=${(event) =>
432
+ setSelectedStatusFilter(
433
+ String(event.currentTarget?.value || "open"),
434
+ )}
435
+ class="bg-black/20 border border-border rounded-full px-3 py-1.5 text-xs text-gray-300 focus:border-gray-500"
436
+ >
437
+ ${statusFilterOptions.map(
438
+ (option) => html`
439
+ <option value=${option.value}>${option.label}</option>
440
+ `,
441
+ )}
442
+ </select>
443
+ </label>
444
+ </div>
445
+ ${selectedRunSummary
446
+ ? html`
447
+ <div class="ac-surface-inset rounded-xl p-4 space-y-1.5">
448
+ <div
449
+ class="text-[11px] uppercase tracking-wide text-gray-500"
450
+ >
451
+ ${selectedRun?.id
452
+ ? `Run #${selectedRun.id} summary`
453
+ : "Run summary"}
454
+ </div>
455
+ <p class="text-xs text-gray-300 leading-5">
456
+ ${selectedRunSummary}
457
+ </p>
458
+ </div>
459
+ `
460
+ : null}
461
+ ${selectedRunIsInProgress
462
+ ? html`
463
+ <div class="ac-surface-inset rounded-xl p-4">
464
+ <div
465
+ class="flex items-center gap-2 text-xs leading-5 text-gray-300"
466
+ >
467
+ <${LoadingSpinner} className="h-3.5 w-3.5" />
468
+ <span
469
+ >Run in progress. Findings will appear when analysis
470
+ completes.</span
471
+ >
472
+ </div>
473
+ </div>
474
+ `
475
+ : null}
476
+ <div>
477
+ <${DoctorFindingsList}
478
+ cards=${cards}
479
+ busyCardId=${busyCardId}
480
+ onAskAgentFix=${setFixCard}
481
+ onUpdateStatus=${handleUpdateStatus}
482
+ showRunMeta=${selectedRunFilter === "all"}
483
+ hideEmptyState=${selectedRunIsInProgress}
484
+ />
485
+ </div>
486
+ </div>
487
+ `
488
+ : null}
489
+ ${!showInitialLoadingState && !showRunLayout
490
+ ? html`
491
+ <div
492
+ class="bg-surface border border-border rounded-xl px-6 py-10 min-h-[26rem] flex flex-col items-center justify-center text-center"
493
+ >
494
+ <div class="max-w-md w-full flex flex-col items-center gap-4">
495
+ <${DoctorEmptyStateIcon} />
496
+ <div class="space-y-2">
497
+ <h2 class="font-semibold text-lg text-gray-100">
498
+ Workspace health review
499
+ </h2>
500
+ <p class="text-xs text-gray-400 leading-5">
501
+ Drift Doctor scans the workspace for guidance drift,
502
+ misplaced instructions, redundant docs, and cleanup
503
+ opportunities.
504
+ </p>
505
+ </div>
506
+ <div class="flex flex-col items-center gap-2 mt-8">
507
+ <${ActionButton}
508
+ onClick=${handleRunDoctor}
509
+ disabled=${!canRunDoctor}
510
+ loading=${!!doctorStatus?.runInProgress}
511
+ size="lg"
512
+ idleLabel="Run Drift Doctor"
513
+ loadingLabel="Running..."
514
+ title=${runDoctorDisabledReason}
515
+ />
516
+ <p class="text-xs text-gray-500 leading-5 mt-10">
517
+ No changes will be made without your direct approval
518
+ </p>
519
+ </div>
520
+ </div>
521
+ </div>
522
+ `
523
+ : null}
524
+ <${DoctorFixCardModal}
525
+ visible=${!!fixCard}
526
+ card=${fixCard}
527
+ onClose=${() => setFixCard(null)}
528
+ onComplete=${async () => {
529
+ await statusPoll.refresh();
530
+ await runsPoll.refresh();
531
+ await cardsPoll.refresh();
532
+ }}
533
+ />
534
+ </div>
535
+ `;
536
+ };