@chrysb/alphaclaw 0.4.6-beta.2 → 0.4.6-beta.4
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/lib/public/css/explorer.css +4 -7
- package/lib/public/css/shell.css +14 -2
- package/lib/public/css/theme.css +4 -0
- package/lib/public/js/app.js +62 -38
- package/lib/public/js/components/doctor/findings-list.js +190 -12
- package/lib/public/js/components/doctor/fix-card-modal.js +8 -20
- package/lib/public/js/components/doctor/helpers.js +5 -19
- package/lib/public/js/components/doctor/index.js +4 -3
- package/lib/public/js/components/file-viewer/editor-surface.js +1 -0
- package/lib/public/js/components/file-viewer/index.js +4 -0
- package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +130 -17
- package/lib/public/js/components/file-viewer/use-file-viewer.js +4 -0
- package/lib/public/js/components/general/use-general-tab.js +233 -0
- package/lib/server/doctor/normalize.js +57 -23
- package/lib/server/doctor/prompt.js +16 -3
- package/lib/server/doctor/service.js +37 -0
- package/package.json +1 -1
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
/* ── Browse/Explorer mode ─────────────────────── */
|
|
2
2
|
|
|
3
|
-
.app-content.browse-mode {
|
|
4
|
-
padding: 0;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
3
|
.sidebar-tabs {
|
|
8
4
|
display: flex;
|
|
9
5
|
align-items: center;
|
|
@@ -875,6 +871,10 @@
|
|
|
875
871
|
align-items: flex-start;
|
|
876
872
|
}
|
|
877
873
|
|
|
874
|
+
.line-highlight-flash {
|
|
875
|
+
color: #4ade80 !important;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
878
|
.file-viewer-editor {
|
|
879
879
|
width: 100%;
|
|
880
880
|
min-height: 0;
|
|
@@ -1608,7 +1608,4 @@
|
|
|
1608
1608
|
display: none;
|
|
1609
1609
|
}
|
|
1610
1610
|
|
|
1611
|
-
.app-content.browse-mode {
|
|
1612
|
-
padding: 0;
|
|
1613
|
-
}
|
|
1614
1611
|
}
|
package/lib/public/css/shell.css
CHANGED
|
@@ -47,12 +47,22 @@
|
|
|
47
47
|
.app-content {
|
|
48
48
|
grid-column: 3;
|
|
49
49
|
grid-row: 2;
|
|
50
|
-
overflow
|
|
51
|
-
padding: 24px 32px;
|
|
50
|
+
overflow: hidden;
|
|
52
51
|
position: relative;
|
|
53
52
|
z-index: 1;
|
|
54
53
|
}
|
|
55
54
|
|
|
55
|
+
.app-content-pane {
|
|
56
|
+
position: absolute;
|
|
57
|
+
inset: 0;
|
|
58
|
+
overflow-y: auto;
|
|
59
|
+
padding: 24px 32px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.app-content-pane.browse-pane {
|
|
63
|
+
padding: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
/* ── Sidebar ───────────────────────────────────── */
|
|
57
67
|
|
|
58
68
|
.app-sidebar {
|
|
@@ -312,6 +322,8 @@
|
|
|
312
322
|
.app-content {
|
|
313
323
|
grid-column: 1;
|
|
314
324
|
grid-row: 2;
|
|
325
|
+
}
|
|
326
|
+
.app-content-pane {
|
|
315
327
|
padding: 0 14px 12px;
|
|
316
328
|
}
|
|
317
329
|
|
package/lib/public/css/theme.css
CHANGED
|
@@ -74,6 +74,10 @@ body::before {
|
|
|
74
74
|
background: rgba(0, 0, 0, 0.12);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
.snippet-collapse-fade {
|
|
78
|
+
background: linear-gradient(to bottom, transparent 0%, rgba(0, 0, 0, 0.75) 70%);
|
|
79
|
+
}
|
|
80
|
+
|
|
77
81
|
/* Shared inset panel for "surface on surface" layouts. */
|
|
78
82
|
.ac-surface-inset {
|
|
79
83
|
border: 1px solid var(--panel-border-contrast);
|
package/lib/public/js/app.js
CHANGED
|
@@ -477,6 +477,7 @@ const App = () => {
|
|
|
477
477
|
const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false);
|
|
478
478
|
const menuRef = useRef(null);
|
|
479
479
|
const routeHistoryRef = useRef([]);
|
|
480
|
+
const menuPaneRef = useRef(null);
|
|
480
481
|
const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, {
|
|
481
482
|
enabled: onboarded === true,
|
|
482
483
|
});
|
|
@@ -709,8 +710,12 @@ const App = () => {
|
|
|
709
710
|
.map((segment) => encodeURIComponent(segment))
|
|
710
711
|
.join("/");
|
|
711
712
|
const baseRoute = encodedPath ? `/browse/${encodedPath}` : "/browse";
|
|
712
|
-
|
|
713
|
-
|
|
713
|
+
const params = new URLSearchParams();
|
|
714
|
+
if (view === "diff" && encodedPath) params.set("view", "diff");
|
|
715
|
+
if (options.line) params.set("line", String(options.line));
|
|
716
|
+
if (options.lineEnd) params.set("lineEnd", String(options.lineEnd));
|
|
717
|
+
const query = params.toString();
|
|
718
|
+
return query ? `${baseRoute}?${query}` : baseRoute;
|
|
714
719
|
};
|
|
715
720
|
const navigateToSubScreen = (screen) => {
|
|
716
721
|
setLocation(`/${screen}`);
|
|
@@ -767,7 +772,7 @@ const App = () => {
|
|
|
767
772
|
setLocation(`/${kDefaultUiTab}`);
|
|
768
773
|
setMobileSidebarOpen(false);
|
|
769
774
|
};
|
|
770
|
-
const
|
|
775
|
+
const handlePaneScroll = (e) => {
|
|
771
776
|
const nextScrolled = e.currentTarget.scrollTop > 0;
|
|
772
777
|
setMobileTopbarScrolled((currentScrolled) =>
|
|
773
778
|
currentScrolled === nextScrolled ? currentScrolled : nextScrolled,
|
|
@@ -820,11 +825,13 @@ const App = () => {
|
|
|
820
825
|
.join("/")
|
|
821
826
|
: "";
|
|
822
827
|
const activeBrowsePath = browsePreviewPath || selectedBrowsePath;
|
|
828
|
+
const browseQueryParams = isBrowseRoute ? new URLSearchParams(browseRouteQuery) : null;
|
|
823
829
|
const browseViewerMode =
|
|
824
|
-
!browsePreviewPath &&
|
|
825
|
-
new URLSearchParams(browseRouteQuery).get("view") === "diff"
|
|
830
|
+
!browsePreviewPath && browseQueryParams?.get("view") === "diff"
|
|
826
831
|
? "diff"
|
|
827
832
|
: "edit";
|
|
833
|
+
const browseLineTarget = Number.parseInt(browseQueryParams?.get("line") || "", 10) || 0;
|
|
834
|
+
const browseLineEndTarget = Number.parseInt(browseQueryParams?.get("lineEnd") || "", 10) || 0;
|
|
828
835
|
const selectedNavId = isBrowseRoute
|
|
829
836
|
? "browse"
|
|
830
837
|
: location === "/telegram"
|
|
@@ -1017,33 +1024,18 @@ const App = () => {
|
|
|
1017
1024
|
onclick=${() => setMobileSidebarOpen(false)}
|
|
1018
1025
|
/>
|
|
1019
1026
|
|
|
1020
|
-
<div
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
<
|
|
1026
|
-
class="mobile-topbar-menu"
|
|
1027
|
-
onclick=${() => setMobileSidebarOpen((open) => !open)}
|
|
1028
|
-
aria-label="Open menu"
|
|
1029
|
-
aria-expanded=${mobileSidebarOpen ? "true" : "false"}
|
|
1030
|
-
>
|
|
1031
|
-
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
|
1032
|
-
<path
|
|
1033
|
-
d="M2 3.75a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 8zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z"
|
|
1034
|
-
/>
|
|
1035
|
-
</svg>
|
|
1036
|
-
</button>
|
|
1037
|
-
<span class="mobile-topbar-title">
|
|
1038
|
-
<span style="color: var(--accent)">alpha</span>claw
|
|
1039
|
-
</span>
|
|
1040
|
-
</div>
|
|
1041
|
-
<div class=${isBrowseRoute ? "w-full" : "max-w-2xl w-full mx-auto"}>
|
|
1042
|
-
<div style=${{ display: isBrowseRoute ? "block" : "none" }}>
|
|
1027
|
+
<div class="app-content">
|
|
1028
|
+
<div
|
|
1029
|
+
class="app-content-pane browse-pane"
|
|
1030
|
+
style=${{ display: isBrowseRoute ? "block" : "none" }}
|
|
1031
|
+
>
|
|
1032
|
+
<div class="w-full">
|
|
1043
1033
|
<${FileViewer}
|
|
1044
1034
|
filePath=${activeBrowsePath}
|
|
1045
1035
|
isPreviewOnly=${false}
|
|
1046
1036
|
browseView=${browseViewerMode}
|
|
1037
|
+
lineTarget=${browseLineTarget}
|
|
1038
|
+
lineEndTarget=${browseLineEndTarget}
|
|
1047
1039
|
onRequestEdit=${(targetPath) => {
|
|
1048
1040
|
const normalizedTargetPath = String(targetPath || "");
|
|
1049
1041
|
if (
|
|
@@ -1061,7 +1053,31 @@ const App = () => {
|
|
|
1061
1053
|
}}
|
|
1062
1054
|
/>
|
|
1063
1055
|
</div>
|
|
1064
|
-
|
|
1056
|
+
</div>
|
|
1057
|
+
<div
|
|
1058
|
+
class="app-content-pane"
|
|
1059
|
+
ref=${menuPaneRef}
|
|
1060
|
+
onscroll=${handlePaneScroll}
|
|
1061
|
+
style=${{ display: isBrowseRoute ? "none" : "block" }}
|
|
1062
|
+
>
|
|
1063
|
+
<div class=${`mobile-topbar ${mobileTopbarScrolled ? "is-scrolled" : ""}`}>
|
|
1064
|
+
<button
|
|
1065
|
+
class="mobile-topbar-menu"
|
|
1066
|
+
onclick=${() => setMobileSidebarOpen((open) => !open)}
|
|
1067
|
+
aria-label="Open menu"
|
|
1068
|
+
aria-expanded=${mobileSidebarOpen ? "true" : "false"}
|
|
1069
|
+
>
|
|
1070
|
+
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
|
1071
|
+
<path
|
|
1072
|
+
d="M2 3.75a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 8zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z"
|
|
1073
|
+
/>
|
|
1074
|
+
</svg>
|
|
1075
|
+
</button>
|
|
1076
|
+
<span class="mobile-topbar-title">
|
|
1077
|
+
<span style="color: var(--accent)">alpha</span>claw
|
|
1078
|
+
</span>
|
|
1079
|
+
</div>
|
|
1080
|
+
<div class="max-w-2xl w-full mx-auto">
|
|
1065
1081
|
<div style=${{ display: location === "/general" ? "block" : "none" }}>
|
|
1066
1082
|
<div class="pt-4">
|
|
1067
1083
|
<${GeneralTab}
|
|
@@ -1086,7 +1102,22 @@ const App = () => {
|
|
|
1086
1102
|
/>
|
|
1087
1103
|
</div>
|
|
1088
1104
|
</div>
|
|
1089
|
-
|
|
1105
|
+
<div style=${{ display: location === "/doctor" ? "block" : "none" }}>
|
|
1106
|
+
<div class="pt-4">
|
|
1107
|
+
<${DoctorTab}
|
|
1108
|
+
isActive=${location === "/doctor"}
|
|
1109
|
+
onOpenFile=${(relativePath, options = {}) => {
|
|
1110
|
+
const browsePath = `workspace/${String(relativePath || "").trim().replace(/^workspace\//, "")}`;
|
|
1111
|
+
navigateToBrowseFile(browsePath, {
|
|
1112
|
+
view: "edit",
|
|
1113
|
+
...(options.line ? { line: options.line } : {}),
|
|
1114
|
+
...(options.lineEnd ? { lineEnd: options.lineEnd } : {}),
|
|
1115
|
+
});
|
|
1116
|
+
}}
|
|
1117
|
+
/>
|
|
1118
|
+
</div>
|
|
1119
|
+
</div>
|
|
1120
|
+
${!isBrowseRoute && location !== "/general" && location !== "/doctor"
|
|
1090
1121
|
? html`
|
|
1091
1122
|
<${Switch}>
|
|
1092
1123
|
<${Route} path="/telegram">
|
|
@@ -1136,13 +1167,6 @@ const App = () => {
|
|
|
1136
1167
|
/>
|
|
1137
1168
|
</div>
|
|
1138
1169
|
</${Route}>
|
|
1139
|
-
<${Route} path="/doctor">
|
|
1140
|
-
<div class="pt-4">
|
|
1141
|
-
<${DoctorTab}
|
|
1142
|
-
isActive=${location === "/doctor"}
|
|
1143
|
-
/>
|
|
1144
|
-
</div>
|
|
1145
|
-
</${Route}>
|
|
1146
1170
|
<${Route} path="/envars">
|
|
1147
1171
|
<div class="pt-4">
|
|
1148
1172
|
<${Envars} onRestartRequired=${setRestartRequired} />
|
|
@@ -1164,7 +1188,7 @@ const App = () => {
|
|
|
1164
1188
|
</div>
|
|
1165
1189
|
</div>
|
|
1166
1190
|
<${ToastContainer}
|
|
1167
|
-
className="fixed top-4 right-4 z-
|
|
1191
|
+
className="fixed top-4 right-4 z-[60] space-y-2 pointer-events-none"
|
|
1168
1192
|
/>
|
|
1169
1193
|
</div>
|
|
1170
1194
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import { useState } from "https://esm.sh/preact/hooks";
|
|
2
3
|
import htm from "https://esm.sh/htm";
|
|
3
4
|
import { Badge } from "../badge.js";
|
|
4
5
|
import { ActionButton } from "../action-button.js";
|
|
@@ -10,8 +11,159 @@ import {
|
|
|
10
11
|
|
|
11
12
|
const html = htm.bind(h);
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
-
if (item
|
|
14
|
+
const resolveTargetPath = (item) => {
|
|
15
|
+
if (!item) return null;
|
|
16
|
+
if (typeof item === "string") return { path: item };
|
|
17
|
+
if (typeof item === "object" && item.path) return item;
|
|
18
|
+
return null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const formatPathLabel = (filePath, startLine, endLine) => {
|
|
22
|
+
if (startLine && endLine && endLine > startLine)
|
|
23
|
+
return `${filePath}:${startLine}-${endLine}`;
|
|
24
|
+
if (startLine) return `${filePath}:${startLine}`;
|
|
25
|
+
return filePath;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const buildLineOptions = (startLine, endLine) => {
|
|
29
|
+
const options = {};
|
|
30
|
+
if (startLine) options.line = startLine;
|
|
31
|
+
if (endLine && endLine > startLine) options.lineEnd = endLine;
|
|
32
|
+
return options;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const renderPathLink = (filePath, onOpenFile, { startLine, endLine } = {}) => {
|
|
36
|
+
const label = formatPathLabel(filePath, startLine, endLine);
|
|
37
|
+
return html`
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
class="text-left font-mono ac-tip-link hover:underline cursor-pointer"
|
|
41
|
+
onClick=${(e) => {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
onOpenFile(
|
|
44
|
+
String(filePath || ""),
|
|
45
|
+
buildLineOptions(startLine, endLine),
|
|
46
|
+
);
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
${label}
|
|
50
|
+
</button>
|
|
51
|
+
`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const kSnippetCollapseThreshold = 7;
|
|
55
|
+
|
|
56
|
+
const SnippetBlock = ({ item, onOpenFile, isOutdated }) => {
|
|
57
|
+
const snippet = item.snippet;
|
|
58
|
+
const allLines = String(snippet.text || "").split("\n");
|
|
59
|
+
const isCollapsible = allLines.length > kSnippetCollapseThreshold;
|
|
60
|
+
const [expanded, setExpanded] = useState(!isCollapsible);
|
|
61
|
+
const visibleLines = expanded
|
|
62
|
+
? allLines
|
|
63
|
+
: allLines.slice(0, kSnippetCollapseThreshold);
|
|
64
|
+
const gutterWidth = String(snippet.endLine || snippet.startLine || 1).length;
|
|
65
|
+
return html`
|
|
66
|
+
<div class="mt-1.5 rounded-lg border border-border overflow-hidden">
|
|
67
|
+
<div
|
|
68
|
+
class="flex items-center justify-between px-3 py-1.5 bg-black/30 border-b border-border"
|
|
69
|
+
>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
class="text-[11px] font-mono ac-tip-link hover:underline cursor-pointer"
|
|
73
|
+
onClick=${(e) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
onOpenFile(
|
|
76
|
+
String(item.path || ""),
|
|
77
|
+
buildLineOptions(item.startLine, item.endLine),
|
|
78
|
+
);
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
${formatPathLabel(item.path, item.startLine, item.endLine)}
|
|
82
|
+
</button>
|
|
83
|
+
${isOutdated
|
|
84
|
+
? html`<span class="text-[10px] text-yellow-500/80"
|
|
85
|
+
>file changed since scan</span
|
|
86
|
+
>`
|
|
87
|
+
: html`<span class="text-[10px] text-gray-600">snapshot</span>`}
|
|
88
|
+
</div>
|
|
89
|
+
<div class="relative">
|
|
90
|
+
<div
|
|
91
|
+
class="px-3 py-2 text-[11px] leading-[18px] font-mono text-gray-300 bg-black/20"
|
|
92
|
+
style="white-space:pre-wrap;word-break:break-word"
|
|
93
|
+
>
|
|
94
|
+
${visibleLines.map(
|
|
95
|
+
(line, index) => html`
|
|
96
|
+
<div class="flex">
|
|
97
|
+
<span
|
|
98
|
+
class="text-gray-600 select-none shrink-0"
|
|
99
|
+
style="width:${gutterWidth +
|
|
100
|
+
1}ch;text-align:right;margin-right:1ch"
|
|
101
|
+
>${snippet.startLine + index}</span
|
|
102
|
+
><span>${line || " "}</span>
|
|
103
|
+
</div>
|
|
104
|
+
`,
|
|
105
|
+
)}
|
|
106
|
+
${expanded && snippet.truncated
|
|
107
|
+
? html`<div class="text-gray-600 italic pl-1">... truncated</div>`
|
|
108
|
+
: ""}
|
|
109
|
+
</div>
|
|
110
|
+
${isCollapsible && !expanded
|
|
111
|
+
? html`
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
class="absolute inset-x-0 bottom-0 flex items-end justify-center pb-2 pt-10 cursor-pointer snippet-collapse-fade"
|
|
115
|
+
onClick=${() => setExpanded(true)}
|
|
116
|
+
>
|
|
117
|
+
<span
|
|
118
|
+
class="text-[10px] text-gray-500 hover:text-white flex items-center gap-1 transition-colors"
|
|
119
|
+
>
|
|
120
|
+
<span
|
|
121
|
+
class="inline-block text-xs transition-transform"
|
|
122
|
+
aria-hidden="true"
|
|
123
|
+
>▾</span
|
|
124
|
+
>
|
|
125
|
+
${allLines.length} lines
|
|
126
|
+
</span>
|
|
127
|
+
</button>
|
|
128
|
+
`
|
|
129
|
+
: null}
|
|
130
|
+
${isCollapsible && expanded
|
|
131
|
+
? html`
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
class="w-full flex items-center justify-center py-1 cursor-pointer bg-black/20 border-t border-border"
|
|
135
|
+
onClick=${() => setExpanded(false)}
|
|
136
|
+
>
|
|
137
|
+
<span class="text-[10px] text-gray-500 flex items-center gap-1">
|
|
138
|
+
<span
|
|
139
|
+
class="inline-block transition-transform"
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
>▴</span
|
|
142
|
+
>
|
|
143
|
+
collapse
|
|
144
|
+
</span>
|
|
145
|
+
</button>
|
|
146
|
+
`
|
|
147
|
+
: null}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
`;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const renderEvidenceLine = (item = {}, onOpenFile, changedPathsSet) => {
|
|
154
|
+
if (item?.path && item?.snippet) {
|
|
155
|
+
const isOutdated = changedPathsSet.has(item.path);
|
|
156
|
+
return html`<${SnippetBlock}
|
|
157
|
+
item=${item}
|
|
158
|
+
onOpenFile=${onOpenFile}
|
|
159
|
+
isOutdated=${isOutdated}
|
|
160
|
+
/>`;
|
|
161
|
+
}
|
|
162
|
+
if (item?.path)
|
|
163
|
+
return renderPathLink(item.path, onOpenFile, {
|
|
164
|
+
startLine: item.startLine,
|
|
165
|
+
endLine: item.endLine,
|
|
166
|
+
});
|
|
15
167
|
if (item?.text) return item.text;
|
|
16
168
|
return JSON.stringify(item);
|
|
17
169
|
};
|
|
@@ -21,9 +173,12 @@ export const DoctorFindingsList = ({
|
|
|
21
173
|
busyCardId = 0,
|
|
22
174
|
onAskAgentFix = () => {},
|
|
23
175
|
onUpdateStatus = () => {},
|
|
176
|
+
onOpenFile = () => {},
|
|
177
|
+
changedPaths = [],
|
|
24
178
|
showRunMeta = false,
|
|
25
179
|
hideEmptyState = false,
|
|
26
180
|
}) => {
|
|
181
|
+
const changedPathsSet = new Set(changedPaths);
|
|
27
182
|
return html`
|
|
28
183
|
<div class="space-y-4">
|
|
29
184
|
${cards.length
|
|
@@ -98,15 +253,34 @@ export const DoctorFindingsList = ({
|
|
|
98
253
|
Target paths
|
|
99
254
|
</div>
|
|
100
255
|
<div class="mt-1 flex flex-wrap gap-1.5">
|
|
101
|
-
${card.targetPaths.map(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
256
|
+
${card.targetPaths.map((rawItem) => {
|
|
257
|
+
const resolved =
|
|
258
|
+
resolveTargetPath(rawItem);
|
|
259
|
+
if (!resolved) return null;
|
|
260
|
+
const label = formatPathLabel(
|
|
261
|
+
resolved.path,
|
|
262
|
+
resolved.startLine,
|
|
263
|
+
resolved.endLine,
|
|
264
|
+
);
|
|
265
|
+
return html`
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
class="text-[11px] px-2 py-1 rounded-md bg-black/30 border border-border font-mono text-gray-200 hover:text-white hover:border-gray-500 cursor-pointer transition-colors"
|
|
269
|
+
onClick=${(e) => {
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
onOpenFile(
|
|
272
|
+
String(resolved.path || ""),
|
|
273
|
+
buildLineOptions(
|
|
274
|
+
resolved.startLine,
|
|
275
|
+
resolved.endLine,
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
}}
|
|
105
279
|
>
|
|
106
|
-
${
|
|
107
|
-
</
|
|
108
|
-
|
|
109
|
-
)}
|
|
280
|
+
${label}
|
|
281
|
+
</button>
|
|
282
|
+
`;
|
|
283
|
+
})}
|
|
110
284
|
</div>
|
|
111
285
|
</div>
|
|
112
286
|
`
|
|
@@ -117,11 +291,15 @@ export const DoctorFindingsList = ({
|
|
|
117
291
|
? html`
|
|
118
292
|
<div>
|
|
119
293
|
<div class="ac-small-heading">Evidence</div>
|
|
120
|
-
<div class="mt-1 space-y-
|
|
294
|
+
<div class="mt-1 space-y-2">
|
|
121
295
|
${card.evidence.map(
|
|
122
296
|
(item) => html`
|
|
123
297
|
<div class="text-xs text-gray-400">
|
|
124
|
-
${renderEvidenceLine(
|
|
298
|
+
${renderEvidenceLine(
|
|
299
|
+
item,
|
|
300
|
+
onOpenFile,
|
|
301
|
+
changedPathsSet,
|
|
302
|
+
)}
|
|
125
303
|
</div>
|
|
126
304
|
`,
|
|
127
305
|
)}
|
|
@@ -3,13 +3,8 @@ import { useEffect, useState } from "https://esm.sh/preact/hooks";
|
|
|
3
3
|
import htm from "https://esm.sh/htm";
|
|
4
4
|
import { ModalShell } from "../modal-shell.js";
|
|
5
5
|
import { ActionButton } from "../action-button.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
sendDoctorCardFix,
|
|
9
|
-
updateDoctorCardStatus,
|
|
10
|
-
} from "../../lib/api.js";
|
|
6
|
+
import { sendDoctorCardFix, updateDoctorCardStatus } from "../../lib/api.js";
|
|
11
7
|
import { showToast } from "../toast.js";
|
|
12
|
-
import { getDoctorPriorityTone } from "./helpers.js";
|
|
13
8
|
import { useAgentSessions } from "../../hooks/useAgentSessions.js";
|
|
14
9
|
|
|
15
10
|
const html = htm.bind(h);
|
|
@@ -50,10 +45,14 @@ export const DoctorFixCardModal = ({
|
|
|
50
45
|
});
|
|
51
46
|
try {
|
|
52
47
|
await updateDoctorCardStatus({ cardId: card.id, status: "fixed" });
|
|
53
|
-
showToast(
|
|
48
|
+
showToast(
|
|
49
|
+
"Doctor fix request sent and finding marked fixed",
|
|
50
|
+
"success",
|
|
51
|
+
);
|
|
54
52
|
} catch (statusError) {
|
|
55
53
|
showToast(
|
|
56
|
-
statusError.message ||
|
|
54
|
+
statusError.message ||
|
|
55
|
+
"Doctor fix request sent, but could not mark the finding fixed",
|
|
57
56
|
"warning",
|
|
58
57
|
);
|
|
59
58
|
}
|
|
@@ -74,21 +73,10 @@ export const DoctorFixCardModal = ({
|
|
|
74
73
|
>
|
|
75
74
|
<div class="space-y-1">
|
|
76
75
|
<h2 class="text-base font-semibold">Ask agent to fix</h2>
|
|
77
|
-
<p class="text-
|
|
76
|
+
<p class="text-xs text-gray-400">
|
|
78
77
|
Send this Doctor finding to one of your agent sessions as a focused fix request.
|
|
79
78
|
</p>
|
|
80
79
|
</div>
|
|
81
|
-
<div class="ac-surface-inset p-3 space-y-1">
|
|
82
|
-
<div class="flex items-center gap-2 min-w-0">
|
|
83
|
-
<${Badge} tone=${getDoctorPriorityTone(card?.priority || "P2")}>
|
|
84
|
-
${card?.priority || "P2"}
|
|
85
|
-
</${Badge}>
|
|
86
|
-
<div class="text-sm font-semibold text-gray-200 leading-5 min-w-0">
|
|
87
|
-
${card?.title || "Doctor finding"}
|
|
88
|
-
</div>
|
|
89
|
-
</div>
|
|
90
|
-
<div class="text-xs text-gray-400">${card?.recommendation || ""}</div>
|
|
91
|
-
</div>
|
|
92
80
|
<div class="space-y-2">
|
|
93
81
|
<label class="text-xs text-gray-500">Send to session</label>
|
|
94
82
|
<select
|
|
@@ -106,29 +106,15 @@ export const buildDoctorRunMarkers = (run = null) => {
|
|
|
106
106
|
if ((run.cardCount || 0) === 0) {
|
|
107
107
|
return [{ tone: "success", count: 0, label: "No findings" }];
|
|
108
108
|
}
|
|
109
|
-
const
|
|
109
|
+
const highPriority = [];
|
|
110
110
|
if (Number(run?.priorityCounts?.P0 || 0) > 0) {
|
|
111
|
-
|
|
112
|
-
tone: "danger",
|
|
113
|
-
count: Number(run.priorityCounts.P0 || 0),
|
|
114
|
-
label: "P0",
|
|
115
|
-
});
|
|
111
|
+
highPriority.push({ tone: "danger", count: 0, label: "P0" });
|
|
116
112
|
}
|
|
117
113
|
if (Number(run?.priorityCounts?.P1 || 0) > 0) {
|
|
118
|
-
|
|
119
|
-
tone: "warning",
|
|
120
|
-
count: Number(run.priorityCounts.P1 || 0),
|
|
121
|
-
label: "P1",
|
|
122
|
-
});
|
|
114
|
+
highPriority.push({ tone: "warning", count: 0, label: "P1" });
|
|
123
115
|
}
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
tone: "neutral",
|
|
127
|
-
count: Number(run.priorityCounts.P2 || 0),
|
|
128
|
-
label: "P2",
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
return markers;
|
|
116
|
+
if (highPriority.length > 0) return highPriority.slice(0, 2);
|
|
117
|
+
return [{ tone: "neutral", count: 0, label: "P2" }];
|
|
132
118
|
};
|
|
133
119
|
|
|
134
120
|
export const buildDoctorStatusFilterOptions = () => [
|
|
@@ -43,7 +43,7 @@ const DoctorEmptyStateIcon = () => html`
|
|
|
43
43
|
</svg>
|
|
44
44
|
`;
|
|
45
45
|
|
|
46
|
-
export const DoctorTab = ({ isActive = false }) => {
|
|
46
|
+
export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => {
|
|
47
47
|
const statusPoll = usePolling(fetchDoctorStatus, kIdlePollMs, {
|
|
48
48
|
enabled: isActive,
|
|
49
49
|
});
|
|
@@ -462,9 +462,8 @@ export const DoctorTab = ({ isActive = false }) => {
|
|
|
462
462
|
? html`
|
|
463
463
|
<div class="ac-surface-inset rounded-xl p-4">
|
|
464
464
|
<div
|
|
465
|
-
class="
|
|
465
|
+
class="text-xs leading-5 text-gray-400"
|
|
466
466
|
>
|
|
467
|
-
<${LoadingSpinner} className="h-3.5 w-3.5" />
|
|
468
467
|
<span
|
|
469
468
|
>Run in progress. Findings will appear when analysis
|
|
470
469
|
completes.</span
|
|
@@ -479,6 +478,8 @@ export const DoctorTab = ({ isActive = false }) => {
|
|
|
479
478
|
busyCardId=${busyCardId}
|
|
480
479
|
onAskAgentFix=${setFixCard}
|
|
481
480
|
onUpdateStatus=${handleUpdateStatus}
|
|
481
|
+
onOpenFile=${onOpenFile}
|
|
482
|
+
changedPaths=${doctorStatus?.changeSummary?.changedPaths || []}
|
|
482
483
|
showRunMeta=${selectedRunFilter === "all"}
|
|
483
484
|
hideEmptyState=${selectedRunIsInProgress}
|
|
484
485
|
/>
|
|
@@ -20,6 +20,8 @@ export const FileViewer = ({
|
|
|
20
20
|
filePath = "",
|
|
21
21
|
isPreviewOnly = false,
|
|
22
22
|
browseView = "edit",
|
|
23
|
+
lineTarget = 0,
|
|
24
|
+
lineEndTarget = 0,
|
|
23
25
|
onRequestEdit = () => {},
|
|
24
26
|
onRequestClearSelection = () => {},
|
|
25
27
|
}) => {
|
|
@@ -28,6 +30,8 @@ export const FileViewer = ({
|
|
|
28
30
|
filePath,
|
|
29
31
|
isPreviewOnly,
|
|
30
32
|
browseView,
|
|
33
|
+
lineTarget,
|
|
34
|
+
lineEndTarget,
|
|
31
35
|
onRequestClearSelection,
|
|
32
36
|
onRequestEdit,
|
|
33
37
|
});
|
|
@@ -1,8 +1,58 @@
|
|
|
1
|
-
import { useEffect } from "https://esm.sh/preact/hooks";
|
|
1
|
+
import { useEffect, useRef } from "https://esm.sh/preact/hooks";
|
|
2
2
|
import { readStoredEditorSelection } from "./storage.js";
|
|
3
3
|
import { clampSelectionIndex } from "./utils.js";
|
|
4
4
|
import { getScrollRatio } from "./scroll-sync.js";
|
|
5
5
|
|
|
6
|
+
const getCharOffsetForLine = (text, lineNumber) => {
|
|
7
|
+
const lines = String(text || "").split("\n");
|
|
8
|
+
const targetIndex = Math.max(0, Math.min(lineNumber - 1, lines.length - 1));
|
|
9
|
+
let offset = 0;
|
|
10
|
+
for (let i = 0; i < targetIndex; i += 1) offset += lines[i].length + 1;
|
|
11
|
+
return offset;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const scrollEditorToLine = ({
|
|
15
|
+
lineIndex,
|
|
16
|
+
textareaElement,
|
|
17
|
+
editorLineNumbersRef,
|
|
18
|
+
editorHighlightRef,
|
|
19
|
+
viewScrollRatioRef,
|
|
20
|
+
}) => {
|
|
21
|
+
const computedStyle = window.getComputedStyle(textareaElement);
|
|
22
|
+
const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || "");
|
|
23
|
+
const lineHeight =
|
|
24
|
+
Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 ? parsedLineHeight : 20;
|
|
25
|
+
const nextScrollTop = Math.max(
|
|
26
|
+
0,
|
|
27
|
+
lineIndex * lineHeight - textareaElement.clientHeight * 0.4,
|
|
28
|
+
);
|
|
29
|
+
textareaElement.scrollTop = nextScrollTop;
|
|
30
|
+
if (editorLineNumbersRef.current) {
|
|
31
|
+
editorLineNumbersRef.current.scrollTop = nextScrollTop;
|
|
32
|
+
}
|
|
33
|
+
if (editorHighlightRef.current) {
|
|
34
|
+
editorHighlightRef.current.scrollTop = nextScrollTop;
|
|
35
|
+
}
|
|
36
|
+
viewScrollRatioRef.current = getScrollRatio(textareaElement);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const clearLineHighlights = (lineNumbersContainer) => {
|
|
40
|
+
if (!lineNumbersContainer) return;
|
|
41
|
+
const highlighted = lineNumbersContainer.querySelectorAll(".line-highlight-flash");
|
|
42
|
+
for (const row of highlighted) row.classList.remove("line-highlight-flash");
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const highlightLineRange = (lineNumbersContainer, startIndex, endIndex) => {
|
|
46
|
+
if (!lineNumbersContainer) return;
|
|
47
|
+
clearLineHighlights(lineNumbersContainer);
|
|
48
|
+
const rows = lineNumbersContainer.querySelectorAll("[data-line-row]");
|
|
49
|
+
const safeEnd = Math.min(endIndex, rows.length - 1);
|
|
50
|
+
for (let i = startIndex; i <= safeEnd; i += 1) {
|
|
51
|
+
const row = rows[i];
|
|
52
|
+
if (row) row.classList.add("line-highlight-flash");
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
6
56
|
export const useEditorSelectionRestore = ({
|
|
7
57
|
canEditFile,
|
|
8
58
|
isEditBlocked,
|
|
@@ -13,11 +63,81 @@ export const useEditorSelectionRestore = ({
|
|
|
13
63
|
restoredSelectionPathRef,
|
|
14
64
|
viewMode,
|
|
15
65
|
content,
|
|
66
|
+
lineTarget = 0,
|
|
67
|
+
lineEndTarget = 0,
|
|
16
68
|
editorTextareaRef,
|
|
17
69
|
editorLineNumbersRef,
|
|
18
70
|
editorHighlightRef,
|
|
19
71
|
viewScrollRatioRef,
|
|
20
72
|
}) => {
|
|
73
|
+
const appliedLineTargetRef = useRef("");
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (lineTarget && lineTarget >= 1) return;
|
|
77
|
+
if (!appliedLineTargetRef.current) return;
|
|
78
|
+
clearLineHighlights(editorLineNumbersRef.current);
|
|
79
|
+
appliedLineTargetRef.current = "";
|
|
80
|
+
}, [lineTarget, normalizedPath, editorLineNumbersRef]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (isEditBlocked || !canEditFile || loading || !hasSelectedPath) return () => {};
|
|
84
|
+
if (loadedFilePathRef.current !== normalizedPath) return () => {};
|
|
85
|
+
if (viewMode !== "edit") return () => {};
|
|
86
|
+
if (!lineTarget || lineTarget < 1) return () => {};
|
|
87
|
+
const effectiveEnd = lineEndTarget && lineEndTarget >= lineTarget ? lineEndTarget : lineTarget;
|
|
88
|
+
const lineKey = `${normalizedPath}:${lineTarget}-${effectiveEnd}`;
|
|
89
|
+
if (appliedLineTargetRef.current === lineKey) return () => {};
|
|
90
|
+
let frameId = 0;
|
|
91
|
+
let attempts = 0;
|
|
92
|
+
const applyLineTarget = () => {
|
|
93
|
+
const textareaElement = editorTextareaRef.current;
|
|
94
|
+
if (!textareaElement) {
|
|
95
|
+
attempts += 1;
|
|
96
|
+
if (attempts < 6) frameId = window.requestAnimationFrame(applyLineTarget);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const safeContent = String(content || "");
|
|
100
|
+
const charOffset = getCharOffsetForLine(safeContent, lineTarget);
|
|
101
|
+
textareaElement.setSelectionRange(charOffset, charOffset);
|
|
102
|
+
const startIndex = lineTarget - 1;
|
|
103
|
+
const endIndex = effectiveEnd - 1;
|
|
104
|
+
window.requestAnimationFrame(() => {
|
|
105
|
+
const nextTextareaElement = editorTextareaRef.current;
|
|
106
|
+
if (!nextTextareaElement) return;
|
|
107
|
+
scrollEditorToLine({
|
|
108
|
+
lineIndex: startIndex,
|
|
109
|
+
textareaElement: nextTextareaElement,
|
|
110
|
+
editorLineNumbersRef,
|
|
111
|
+
editorHighlightRef,
|
|
112
|
+
viewScrollRatioRef,
|
|
113
|
+
});
|
|
114
|
+
highlightLineRange(editorLineNumbersRef.current, startIndex, endIndex);
|
|
115
|
+
});
|
|
116
|
+
appliedLineTargetRef.current = lineKey;
|
|
117
|
+
restoredSelectionPathRef.current = normalizedPath;
|
|
118
|
+
};
|
|
119
|
+
frameId = window.requestAnimationFrame(applyLineTarget);
|
|
120
|
+
return () => {
|
|
121
|
+
if (frameId) window.cancelAnimationFrame(frameId);
|
|
122
|
+
};
|
|
123
|
+
}, [
|
|
124
|
+
canEditFile,
|
|
125
|
+
isEditBlocked,
|
|
126
|
+
loading,
|
|
127
|
+
hasSelectedPath,
|
|
128
|
+
normalizedPath,
|
|
129
|
+
content,
|
|
130
|
+
viewMode,
|
|
131
|
+
lineTarget,
|
|
132
|
+
lineEndTarget,
|
|
133
|
+
loadedFilePathRef,
|
|
134
|
+
restoredSelectionPathRef,
|
|
135
|
+
editorTextareaRef,
|
|
136
|
+
editorLineNumbersRef,
|
|
137
|
+
editorHighlightRef,
|
|
138
|
+
viewScrollRatioRef,
|
|
139
|
+
]);
|
|
140
|
+
|
|
21
141
|
useEffect(() => {
|
|
22
142
|
if (isEditBlocked) {
|
|
23
143
|
restoredSelectionPathRef.current = "";
|
|
@@ -27,6 +147,7 @@ export const useEditorSelectionRestore = ({
|
|
|
27
147
|
if (loadedFilePathRef.current !== normalizedPath) return () => {};
|
|
28
148
|
if (restoredSelectionPathRef.current === normalizedPath) return () => {};
|
|
29
149
|
if (viewMode !== "edit") return () => {};
|
|
150
|
+
if (lineTarget && lineTarget >= 1) return () => {};
|
|
30
151
|
const storedSelection = readStoredEditorSelection(normalizedPath);
|
|
31
152
|
if (!storedSelection) {
|
|
32
153
|
restoredSelectionPathRef.current = normalizedPath;
|
|
@@ -52,22 +173,13 @@ export const useEditorSelectionRestore = ({
|
|
|
52
173
|
const safeContent = String(content || "");
|
|
53
174
|
const safeStart = clampSelectionIndex(start, safeContent.length);
|
|
54
175
|
const lineIndex = safeContent.slice(0, safeStart).split("\n").length - 1;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
63
|
-
nextTextareaElement.scrollTop = nextScrollTop;
|
|
64
|
-
if (editorLineNumbersRef.current) {
|
|
65
|
-
editorLineNumbersRef.current.scrollTop = nextScrollTop;
|
|
66
|
-
}
|
|
67
|
-
if (editorHighlightRef.current) {
|
|
68
|
-
editorHighlightRef.current.scrollTop = nextScrollTop;
|
|
69
|
-
}
|
|
70
|
-
viewScrollRatioRef.current = getScrollRatio(nextTextareaElement);
|
|
176
|
+
scrollEditorToLine({
|
|
177
|
+
lineIndex,
|
|
178
|
+
textareaElement: nextTextareaElement,
|
|
179
|
+
editorLineNumbersRef,
|
|
180
|
+
editorHighlightRef,
|
|
181
|
+
viewScrollRatioRef,
|
|
182
|
+
});
|
|
71
183
|
});
|
|
72
184
|
restoredSelectionPathRef.current = normalizedPath;
|
|
73
185
|
};
|
|
@@ -83,6 +195,7 @@ export const useEditorSelectionRestore = ({
|
|
|
83
195
|
normalizedPath,
|
|
84
196
|
content,
|
|
85
197
|
viewMode,
|
|
198
|
+
lineTarget,
|
|
86
199
|
loadedFilePathRef,
|
|
87
200
|
restoredSelectionPathRef,
|
|
88
201
|
editorTextareaRef,
|
|
@@ -32,6 +32,8 @@ export const useFileViewer = ({
|
|
|
32
32
|
filePath = "",
|
|
33
33
|
isPreviewOnly = false,
|
|
34
34
|
browseView = "edit",
|
|
35
|
+
lineTarget = 0,
|
|
36
|
+
lineEndTarget = 0,
|
|
35
37
|
onRequestClearSelection = () => {},
|
|
36
38
|
onRequestEdit = () => {},
|
|
37
39
|
}) => {
|
|
@@ -242,6 +244,8 @@ export const useFileViewer = ({
|
|
|
242
244
|
restoredSelectionPathRef,
|
|
243
245
|
viewMode,
|
|
244
246
|
content,
|
|
247
|
+
lineTarget,
|
|
248
|
+
lineEndTarget,
|
|
245
249
|
editorTextareaRef,
|
|
246
250
|
editorLineNumbersRef,
|
|
247
251
|
editorHighlightRef,
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { useEffect, useState } from "https://esm.sh/preact/hooks";
|
|
2
|
+
import {
|
|
3
|
+
approveDevice,
|
|
4
|
+
approvePairing,
|
|
5
|
+
fetchDashboardUrl,
|
|
6
|
+
fetchDevicePairings,
|
|
7
|
+
fetchPairings,
|
|
8
|
+
rejectDevice,
|
|
9
|
+
rejectPairing,
|
|
10
|
+
triggerWatchdogRepair,
|
|
11
|
+
updateSyncCron,
|
|
12
|
+
} from "../../lib/api.js";
|
|
13
|
+
import { usePolling } from "../../hooks/usePolling.js";
|
|
14
|
+
import { showToast } from "../toast.js";
|
|
15
|
+
import { ALL_CHANNELS } from "../channels.js";
|
|
16
|
+
|
|
17
|
+
const kDefaultSyncCronSchedule = "0 * * * *";
|
|
18
|
+
|
|
19
|
+
export const useGeneralTab = ({
|
|
20
|
+
statusData = null,
|
|
21
|
+
watchdogData = null,
|
|
22
|
+
doctorStatusData = null,
|
|
23
|
+
onRefreshStatuses = () => {},
|
|
24
|
+
isActive = false,
|
|
25
|
+
restartSignal = 0,
|
|
26
|
+
} = {}) => {
|
|
27
|
+
const [dashboardLoading, setDashboardLoading] = useState(false);
|
|
28
|
+
const [repairingWatchdog, setRepairingWatchdog] = useState(false);
|
|
29
|
+
const [syncCronEnabled, setSyncCronEnabled] = useState(true);
|
|
30
|
+
const [syncCronSchedule, setSyncCronSchedule] = useState(kDefaultSyncCronSchedule);
|
|
31
|
+
const [savingSyncCron, setSavingSyncCron] = useState(false);
|
|
32
|
+
const [syncCronChoice, setSyncCronChoice] = useState(kDefaultSyncCronSchedule);
|
|
33
|
+
|
|
34
|
+
const status = statusData;
|
|
35
|
+
const watchdogStatus = watchdogData;
|
|
36
|
+
const doctorStatus = doctorStatusData;
|
|
37
|
+
const gatewayStatus = status?.gateway ?? null;
|
|
38
|
+
const channels = status?.channels ?? null;
|
|
39
|
+
const repo = status?.repo || null;
|
|
40
|
+
const syncCron = status?.syncCron || null;
|
|
41
|
+
const openclawVersion = status?.openclawVersion || null;
|
|
42
|
+
|
|
43
|
+
const hasUnpaired = ALL_CHANNELS.some((channel) => {
|
|
44
|
+
const info = channels?.[channel];
|
|
45
|
+
return info && info.status !== "paired";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const pairingsPoll = usePolling(
|
|
49
|
+
async () => {
|
|
50
|
+
const data = await fetchPairings();
|
|
51
|
+
return data.pending || [];
|
|
52
|
+
},
|
|
53
|
+
1000,
|
|
54
|
+
{ enabled: hasUnpaired && gatewayStatus === "running" },
|
|
55
|
+
);
|
|
56
|
+
const pending = pairingsPoll.data || [];
|
|
57
|
+
|
|
58
|
+
const devicePoll = usePolling(
|
|
59
|
+
async () => {
|
|
60
|
+
const data = await fetchDevicePairings();
|
|
61
|
+
return data.pending || [];
|
|
62
|
+
},
|
|
63
|
+
2000,
|
|
64
|
+
{ enabled: gatewayStatus === "running" },
|
|
65
|
+
);
|
|
66
|
+
const devicePending = devicePoll.data || [];
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!isActive) return;
|
|
70
|
+
onRefreshStatuses();
|
|
71
|
+
pairingsPoll.refresh();
|
|
72
|
+
devicePoll.refresh();
|
|
73
|
+
}, [devicePoll.refresh, isActive, onRefreshStatuses, pairingsPoll.refresh]);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!restartSignal || !isActive) return;
|
|
77
|
+
onRefreshStatuses();
|
|
78
|
+
pairingsPoll.refresh();
|
|
79
|
+
devicePoll.refresh();
|
|
80
|
+
const t1 = setTimeout(() => {
|
|
81
|
+
onRefreshStatuses();
|
|
82
|
+
pairingsPoll.refresh();
|
|
83
|
+
devicePoll.refresh();
|
|
84
|
+
}, 1200);
|
|
85
|
+
const t2 = setTimeout(() => {
|
|
86
|
+
onRefreshStatuses();
|
|
87
|
+
pairingsPoll.refresh();
|
|
88
|
+
devicePoll.refresh();
|
|
89
|
+
}, 3500);
|
|
90
|
+
return () => {
|
|
91
|
+
clearTimeout(t1);
|
|
92
|
+
clearTimeout(t2);
|
|
93
|
+
};
|
|
94
|
+
}, [
|
|
95
|
+
devicePoll.refresh,
|
|
96
|
+
isActive,
|
|
97
|
+
onRefreshStatuses,
|
|
98
|
+
pairingsPoll.refresh,
|
|
99
|
+
restartSignal,
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!syncCron) return;
|
|
104
|
+
setSyncCronEnabled(syncCron.enabled !== false);
|
|
105
|
+
setSyncCronSchedule(syncCron.schedule || kDefaultSyncCronSchedule);
|
|
106
|
+
setSyncCronChoice(
|
|
107
|
+
syncCron.enabled === false ? "disabled" : syncCron.schedule || kDefaultSyncCronSchedule,
|
|
108
|
+
);
|
|
109
|
+
}, [syncCron?.enabled, syncCron?.schedule]);
|
|
110
|
+
|
|
111
|
+
const refreshAfterPairingAction = () => {
|
|
112
|
+
setTimeout(pairingsPoll.refresh, 500);
|
|
113
|
+
setTimeout(pairingsPoll.refresh, 2000);
|
|
114
|
+
setTimeout(onRefreshStatuses, 3000);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const saveSyncCronSettings = async ({
|
|
118
|
+
enabled = syncCronEnabled,
|
|
119
|
+
schedule = syncCronSchedule,
|
|
120
|
+
} = {}) => {
|
|
121
|
+
if (savingSyncCron) return;
|
|
122
|
+
setSavingSyncCron(true);
|
|
123
|
+
try {
|
|
124
|
+
const data = await updateSyncCron({ enabled, schedule });
|
|
125
|
+
if (!data.ok) {
|
|
126
|
+
throw new Error(data.error || "Could not save sync settings");
|
|
127
|
+
}
|
|
128
|
+
showToast("Sync schedule updated", "success");
|
|
129
|
+
onRefreshStatuses();
|
|
130
|
+
} catch (err) {
|
|
131
|
+
showToast(err.message || "Could not save sync settings", "error");
|
|
132
|
+
} finally {
|
|
133
|
+
setSavingSyncCron(false);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleSyncCronChoiceChange = async (nextChoice) => {
|
|
138
|
+
setSyncCronChoice(nextChoice);
|
|
139
|
+
const nextEnabled = nextChoice !== "disabled";
|
|
140
|
+
const nextSchedule = nextEnabled ? nextChoice : syncCronSchedule;
|
|
141
|
+
setSyncCronEnabled(nextEnabled);
|
|
142
|
+
setSyncCronSchedule(nextSchedule);
|
|
143
|
+
await saveSyncCronSettings({
|
|
144
|
+
enabled: nextEnabled,
|
|
145
|
+
schedule: nextSchedule,
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handleApprove = async (id, channel) => {
|
|
150
|
+
await approvePairing(id, channel);
|
|
151
|
+
refreshAfterPairingAction();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleReject = async (id, channel) => {
|
|
155
|
+
await rejectPairing(id, channel);
|
|
156
|
+
refreshAfterPairingAction();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const handleDeviceApprove = async (id) => {
|
|
160
|
+
await approveDevice(id);
|
|
161
|
+
setTimeout(devicePoll.refresh, 500);
|
|
162
|
+
setTimeout(devicePoll.refresh, 2000);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const handleDeviceReject = async (id) => {
|
|
166
|
+
await rejectDevice(id);
|
|
167
|
+
setTimeout(devicePoll.refresh, 500);
|
|
168
|
+
setTimeout(devicePoll.refresh, 2000);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleWatchdogRepair = async () => {
|
|
172
|
+
if (repairingWatchdog) return;
|
|
173
|
+
setRepairingWatchdog(true);
|
|
174
|
+
try {
|
|
175
|
+
const data = await triggerWatchdogRepair();
|
|
176
|
+
if (!data.ok) throw new Error(data.error || "Repair failed");
|
|
177
|
+
showToast("Repair triggered", "success");
|
|
178
|
+
setTimeout(() => {
|
|
179
|
+
onRefreshStatuses();
|
|
180
|
+
}, 800);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
showToast(err.message || "Could not run repair", "error");
|
|
183
|
+
} finally {
|
|
184
|
+
setRepairingWatchdog(false);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const handleOpenDashboard = async () => {
|
|
189
|
+
if (dashboardLoading) return;
|
|
190
|
+
setDashboardLoading(true);
|
|
191
|
+
try {
|
|
192
|
+
const data = await fetchDashboardUrl();
|
|
193
|
+
console.log("[dashboard] response:", JSON.stringify(data));
|
|
194
|
+
window.open(data.url || "/openclaw", "_blank");
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error("[dashboard] error:", err);
|
|
197
|
+
window.open("/openclaw", "_blank");
|
|
198
|
+
} finally {
|
|
199
|
+
setDashboardLoading(false);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
state: {
|
|
205
|
+
channels,
|
|
206
|
+
dashboardLoading,
|
|
207
|
+
devicePending,
|
|
208
|
+
doctorStatus,
|
|
209
|
+
gatewayStatus,
|
|
210
|
+
hasUnpaired,
|
|
211
|
+
openclawVersion,
|
|
212
|
+
pending,
|
|
213
|
+
repairingWatchdog,
|
|
214
|
+
repo,
|
|
215
|
+
savingSyncCron,
|
|
216
|
+
syncCron,
|
|
217
|
+
syncCronChoice,
|
|
218
|
+
syncCronEnabled,
|
|
219
|
+
syncCronSchedule,
|
|
220
|
+
syncCronStatusText: syncCronEnabled ? "Enabled" : "Disabled",
|
|
221
|
+
watchdogStatus,
|
|
222
|
+
},
|
|
223
|
+
actions: {
|
|
224
|
+
handleApprove,
|
|
225
|
+
handleDeviceApprove,
|
|
226
|
+
handleDeviceReject,
|
|
227
|
+
handleOpenDashboard,
|
|
228
|
+
handleReject,
|
|
229
|
+
handleSyncCronChoiceChange,
|
|
230
|
+
handleWatchdogRepair,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
};
|
|
@@ -89,42 +89,76 @@ const normalizeCardStatus = (value) => {
|
|
|
89
89
|
return kDoctorCardStatus.open;
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
92
|
+
const normalizeEvidenceItem = (item) => {
|
|
93
|
+
if (item == null) return null;
|
|
94
|
+
if (typeof item === "string") {
|
|
95
|
+
const text = toTrimmedString(item);
|
|
96
|
+
return text ? { type: "text", text } : null;
|
|
97
|
+
}
|
|
98
|
+
if (typeof item === "object") {
|
|
99
|
+
const entry = { ...item };
|
|
100
|
+
if (entry.type === "path" && entry.path) {
|
|
101
|
+
entry.path = toTrimmedString(entry.path);
|
|
102
|
+
if (Number.isFinite(entry.startLine) && entry.startLine > 0) {
|
|
103
|
+
entry.startLine = entry.startLine;
|
|
104
|
+
} else {
|
|
105
|
+
delete entry.startLine;
|
|
106
|
+
}
|
|
107
|
+
if (Number.isFinite(entry.endLine) && entry.endLine > 0) {
|
|
108
|
+
entry.endLine = entry.endLine;
|
|
109
|
+
} else {
|
|
110
|
+
delete entry.endLine;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return entry;
|
|
105
114
|
}
|
|
115
|
+
return { type: "text", text: String(item) };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const normalizeEvidence = (value) => {
|
|
119
|
+
if (Array.isArray(value)) return value.map(normalizeEvidenceItem).filter(Boolean);
|
|
106
120
|
if (typeof value === "string") {
|
|
107
121
|
const text = toTrimmedString(value);
|
|
108
122
|
return text ? [{ type: "text", text }] : [];
|
|
109
123
|
}
|
|
110
|
-
if (value && typeof value === "object") return [value];
|
|
124
|
+
if (value && typeof value === "object") return [normalizeEvidenceItem(value)].filter(Boolean);
|
|
111
125
|
return [];
|
|
112
126
|
};
|
|
113
127
|
|
|
128
|
+
const normalizeTargetPathItem = (item) => {
|
|
129
|
+
if (item == null) return null;
|
|
130
|
+
if (typeof item === "string") {
|
|
131
|
+
const path = toTrimmedString(item);
|
|
132
|
+
return path ? { path } : null;
|
|
133
|
+
}
|
|
134
|
+
if (typeof item === "object" && item.path) {
|
|
135
|
+
const path = toTrimmedString(item.path);
|
|
136
|
+
if (!path) return null;
|
|
137
|
+
const entry = { path };
|
|
138
|
+
if (Number.isFinite(item.startLine) && item.startLine > 0) entry.startLine = item.startLine;
|
|
139
|
+
if (Number.isFinite(item.endLine) && item.endLine > 0) entry.endLine = item.endLine;
|
|
140
|
+
return entry;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
};
|
|
144
|
+
|
|
114
145
|
const normalizeTargetPaths = (value) => {
|
|
115
146
|
const values = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
return values
|
|
149
|
+
.map(normalizeTargetPathItem)
|
|
150
|
+
.filter((item) => {
|
|
151
|
+
if (!item) return false;
|
|
152
|
+
if (seen.has(item.path)) return false;
|
|
153
|
+
seen.add(item.path);
|
|
154
|
+
return true;
|
|
155
|
+
});
|
|
123
156
|
};
|
|
124
157
|
|
|
125
158
|
const buildFallbackFixPrompt = ({ title, recommendation, targetPaths }) => {
|
|
126
|
-
const
|
|
127
|
-
|
|
159
|
+
const pathStrings = targetPaths.map((item) => item?.path || String(item)).filter(Boolean);
|
|
160
|
+
const targetLine = pathStrings.length
|
|
161
|
+
? `Focus on these paths if relevant: ${pathStrings.join(", ")}.`
|
|
128
162
|
: "Inspect the relevant workspace files before making changes.";
|
|
129
163
|
return (
|
|
130
164
|
`Please address this Doctor finding safely.\n\n` +
|
|
@@ -22,7 +22,8 @@ const buildDoctorPrompt = ({
|
|
|
22
22
|
lockedPaths = [],
|
|
23
23
|
resolvedCards = [],
|
|
24
24
|
promptVersion = "doctor-v1",
|
|
25
|
-
}) =>
|
|
25
|
+
}) =>
|
|
26
|
+
`
|
|
26
27
|
You are AlphaClaw Doctor. Analyze this OpenClaw workspace for guidance drift, redundancy, misplacement, and cleanup opportunities.
|
|
27
28
|
|
|
28
29
|
Important:
|
|
@@ -35,6 +36,12 @@ Important:
|
|
|
35
36
|
- A fresh install can be healthy even if it includes broad default guidance.
|
|
36
37
|
- Return ONLY valid JSON. No markdown fences. No extra prose.
|
|
37
38
|
|
|
39
|
+
OpenClaw context injection:
|
|
40
|
+
- OpenClaw automatically injects a fixed set of named workspace files into the agent's context window ("Project Context") on every turn. The exact set is: \`AGENTS.md\`, \`SOUL.md\`, \`TOOLS.md\`, \`IDENTITY.md\`, \`USER.md\`, \`HEARTBEAT.md\`, and \`BOOTSTRAP.md\` (first-run only).
|
|
41
|
+
- Only these specific files are auto-injected. Other \`.md\` files at the workspace root (e.g. INTERESTS.md, MEMORY.md, README.md) are NOT injected and must be explicitly read by the agent.
|
|
42
|
+
- Do not flag auto-injected files as orphaned or unreferenced — they are loaded by the runtime, not by explicit file references in AGENTS.md.
|
|
43
|
+
- Additionally, AlphaClaw injects bootstrap files from \`hooks/bootstrap/\` (e.g. AGENTS.md, TOOLS.md) as extra context on every turn.
|
|
44
|
+
|
|
38
45
|
OpenClaw default context:
|
|
39
46
|
- \`AGENTS.md\` is the workspace home file in the default OpenClaw template. It may intentionally include first-run instructions, session-startup guidance, memory conventions, safety rules, tool pointers, and optional behavioral guidance.
|
|
40
47
|
- Do not treat default-template content as drift just because it is broad or multi-purpose.
|
|
@@ -79,10 +86,13 @@ Return exactly this JSON shape:
|
|
|
79
86
|
"summary": "what is wrong and why it matters",
|
|
80
87
|
"recommendation": "clear recommended action",
|
|
81
88
|
"evidence": [
|
|
82
|
-
{ "type": "path", "path": "relative/path" },
|
|
89
|
+
{ "type": "path", "path": "relative/path", "startLine": 10, "endLine": 25 },
|
|
83
90
|
{ "type": "note", "text": "short supporting note" }
|
|
84
91
|
],
|
|
85
|
-
"targetPaths": [
|
|
92
|
+
"targetPaths": [
|
|
93
|
+
{ "path": "relative/path/one", "startLine": 10 },
|
|
94
|
+
{ "path": "relative/path/two" }
|
|
95
|
+
],
|
|
86
96
|
"fixPrompt": "a concise message another agent can use to fix just this finding safely",
|
|
87
97
|
"status": "open"
|
|
88
98
|
}
|
|
@@ -92,10 +102,13 @@ Return exactly this JSON shape:
|
|
|
92
102
|
${renderResolvedCards(resolvedCards)}Constraints:
|
|
93
103
|
- Maximum 12 cards
|
|
94
104
|
- Use relative paths in evidence and targetPaths
|
|
105
|
+
- Include startLine (and optionally endLine) in evidence and targetPaths when the finding relates to a specific section of a file
|
|
106
|
+
- targetPaths items can be strings or objects with { path, startLine? }
|
|
95
107
|
- Do not include duplicate cards
|
|
96
108
|
- Do not re-suggest findings that appear in the "Previously resolved" list above
|
|
97
109
|
- Do not create cards for healthy default-template behavior
|
|
98
110
|
- Do not create cards whose primary recommendation is to refactor AlphaClaw-managed file structure
|
|
111
|
+
- fixPrompt must only reference files the agent can edit. Never suggest editing files listed in "AlphaClaw locked/managed paths" above — they are managed by AlphaClaw, so manual edits would be lost.
|
|
99
112
|
- If there are no meaningful findings, return an empty cards array
|
|
100
113
|
- promptVersion: ${promptVersion}
|
|
101
114
|
`.trim();
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
1
3
|
const { buildDoctorPrompt } = require("./prompt");
|
|
2
4
|
const { normalizeDoctorResult } = require("./normalize");
|
|
3
5
|
const { calculateWorkspaceDelta, computeWorkspaceSnapshot } = require("./workspace-fingerprint");
|
|
@@ -10,6 +12,8 @@ const {
|
|
|
10
12
|
kDoctorStaleThresholdMs,
|
|
11
13
|
} = require("./constants");
|
|
12
14
|
|
|
15
|
+
const kMaxSnippetLines = 20;
|
|
16
|
+
|
|
13
17
|
const shellEscapeArg = (value) => {
|
|
14
18
|
const safeValue = String(value || "");
|
|
15
19
|
return `'${safeValue.replace(/'/g, `'\\''`)}'`;
|
|
@@ -35,6 +39,37 @@ const formatElapsedSince = (isoTime) => {
|
|
|
35
39
|
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ago`;
|
|
36
40
|
};
|
|
37
41
|
|
|
42
|
+
const readFileSnippet = (rootDir, relativePath, startLine, endLine) => {
|
|
43
|
+
try {
|
|
44
|
+
const fullPath = path.join(rootDir, String(relativePath || ""));
|
|
45
|
+
const content = fs.readFileSync(fullPath, "utf-8");
|
|
46
|
+
const lines = content.split("\n");
|
|
47
|
+
const start = Math.max(0, (startLine || 1) - 1);
|
|
48
|
+
const end = endLine && endLine >= startLine ? Math.min(lines.length, endLine) : start + 1;
|
|
49
|
+
const cappedEnd = Math.min(end, start + kMaxSnippetLines);
|
|
50
|
+
return {
|
|
51
|
+
text: lines.slice(start, cappedEnd).join("\n"),
|
|
52
|
+
startLine: start + 1,
|
|
53
|
+
endLine: start + (cappedEnd - start),
|
|
54
|
+
truncated: cappedEnd < end,
|
|
55
|
+
totalFileLines: lines.length,
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const captureEvidenceSnippets = (cards, rootDir) => {
|
|
63
|
+
for (const card of cards) {
|
|
64
|
+
if (!Array.isArray(card.evidence)) continue;
|
|
65
|
+
for (const item of card.evidence) {
|
|
66
|
+
if (!item || item.type !== "path" || !item.path || !item.startLine) continue;
|
|
67
|
+
const snippet = readFileSnippet(rootDir, item.path, item.startLine, item.endLine);
|
|
68
|
+
if (snippet) item.snippet = snippet;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
38
73
|
const buildDoctorSessionKey = (runId) => `agent:main:doctor:${Number(runId || 0)}`;
|
|
39
74
|
const buildDoctorSessionId = (runId) => buildDoctorSessionKey(runId);
|
|
40
75
|
const buildDoctorIdempotencyKey = (runId) => `doctor-run-${Number(runId || 0)}`;
|
|
@@ -213,6 +248,7 @@ const createDoctorService = ({
|
|
|
213
248
|
console.error(`[doctor] run ${runId} stderr end`);
|
|
214
249
|
throw error;
|
|
215
250
|
}
|
|
251
|
+
captureEvidenceSnippets(normalizedResult.cards, workspaceRoot);
|
|
216
252
|
insertDoctorCards({
|
|
217
253
|
runId,
|
|
218
254
|
cards: normalizedResult.cards,
|
|
@@ -309,6 +345,7 @@ const createDoctorService = ({
|
|
|
309
345
|
throw new Error("Doctor import requires raw output");
|
|
310
346
|
}
|
|
311
347
|
const normalizedResult = normalizeDoctorResult(normalizedRawOutput);
|
|
348
|
+
captureEvidenceSnippets(normalizedResult.cards, workspaceRoot);
|
|
312
349
|
const workspaceSnapshot = getCurrentWorkspaceSnapshot();
|
|
313
350
|
const runId = createDoctorRun({
|
|
314
351
|
status: kDoctorRunStatus.completed,
|