@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.
- package/README.md +21 -18
- package/lib/public/css/theme.css +29 -0
- package/lib/public/js/app.js +41 -2
- package/lib/public/js/components/badge.js +4 -0
- package/lib/public/js/components/doctor/findings-list.js +191 -0
- package/lib/public/js/components/doctor/fix-card-modal.js +144 -0
- package/lib/public/js/components/doctor/general-warning.js +37 -0
- package/lib/public/js/components/doctor/helpers.js +169 -0
- package/lib/public/js/components/doctor/index.js +536 -0
- package/lib/public/js/components/doctor/summary-cards.js +24 -0
- package/lib/public/js/lib/api.js +79 -0
- package/lib/server/commands.js +8 -4
- package/lib/server/constants.js +22 -26
- package/lib/server/db/doctor/index.js +529 -0
- package/lib/server/db/doctor/schema.js +69 -0
- package/lib/server/doctor/constants.js +43 -0
- package/lib/server/doctor/normalize.js +214 -0
- package/lib/server/doctor/prompt.js +89 -0
- package/lib/server/doctor/service.js +392 -0
- package/lib/server/doctor/workspace-fingerprint.js +126 -0
- package/lib/server/gmail-push.js +102 -6
- package/lib/server/gmail-watch.js +5 -20
- package/lib/server/helpers.js +5 -21
- package/lib/server/routes/doctor.js +123 -0
- package/lib/server/routes/google.js +2 -10
- package/lib/server/routes/system.js +7 -1
- package/lib/server/routes/telegram.js +3 -14
- package/lib/server/routes/usage.js +1 -5
- package/lib/server/routes/webhooks.js +2 -6
- package/lib/server/utils/boolean.js +22 -0
- package/lib/server/utils/json.js +77 -0
- package/lib/server/utils/network.js +5 -0
- package/lib/server/utils/number.js +8 -0
- package/lib/server/utils/shell.js +16 -0
- package/lib/server/webhook-middleware.js +1 -2
- package/lib/server.js +42 -0
- 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
|
+
};
|