@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.
@@ -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
  }
@@ -47,12 +47,22 @@
47
47
  .app-content {
48
48
  grid-column: 3;
49
49
  grid-row: 2;
50
- overflow-y: auto;
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
 
@@ -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);
@@ -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
- if (view === "diff" && encodedPath) return `${baseRoute}?view=diff`;
713
- return baseRoute;
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 handleAppContentScroll = (e) => {
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
- class=${`app-content ${isBrowseRoute ? "browse-mode" : ""}`}
1022
- onscroll=${handleAppContentScroll}
1023
- >
1024
- <div class=${`mobile-topbar ${mobileTopbarScrolled ? "is-scrolled" : ""}`}>
1025
- <button
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
- <div style=${{ display: isBrowseRoute ? "none" : "block" }}>
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
- ${!isBrowseRoute && location !== "/general"
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-50 space-y-2 pointer-events-none"
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 renderEvidenceLine = (item = {}) => {
14
- if (item?.path) return item.path;
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
- (targetPath) => html`
103
- <span
104
- class="text-[11px] px-2 py-1 rounded-md bg-black/30 border border-border font-mono text-gray-300"
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
- ${targetPath}
107
- </span>
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-1">
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(item)}
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 { Badge } from "../badge.js";
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("Doctor fix request sent and finding marked fixed", "success");
48
+ showToast(
49
+ "Doctor fix request sent and finding marked fixed",
50
+ "success",
51
+ );
54
52
  } catch (statusError) {
55
53
  showToast(
56
- statusError.message || "Doctor fix request sent, but could not mark the finding fixed",
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-sm text-gray-400">
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 markers = [];
109
+ const highPriority = [];
110
110
  if (Number(run?.priorityCounts?.P0 || 0) > 0) {
111
- markers.push({
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
- markers.push({
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 (Number(run?.priorityCounts?.P2 || 0) > 0) {
125
- markers.push({
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="flex items-center gap-2 text-xs leading-5 text-gray-300"
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
  />
@@ -63,6 +63,7 @@ export const EditorSurface = ({
63
63
  <div
64
64
  class="file-viewer-editor-line-num"
65
65
  key=${lineNumber}
66
+ data-line-row
66
67
  ref=${(element) => {
67
68
  editorLineNumberRowRefs.current[lineNumber - 1] = element;
68
69
  }}
@@ -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
- const computedStyle = window.getComputedStyle(nextTextareaElement);
56
- const parsedLineHeight = Number.parseFloat(computedStyle.lineHeight || "");
57
- const lineHeight =
58
- Number.isFinite(parsedLineHeight) && parsedLineHeight > 0 ? parsedLineHeight : 20;
59
- const nextScrollTop = Math.max(
60
- 0,
61
- lineIndex * lineHeight - nextTextareaElement.clientHeight * 0.4,
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 normalizeEvidence = (value) => {
93
- if (Array.isArray(value)) {
94
- return value
95
- .map((item) => {
96
- if (item == null) return null;
97
- if (typeof item === "string") {
98
- const text = toTrimmedString(item);
99
- return text ? { type: "text", text } : null;
100
- }
101
- if (typeof item === "object") return item;
102
- return { type: "text", text: String(item) };
103
- })
104
- .filter(Boolean);
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
- return Array.from(
117
- new Set(
118
- values
119
- .map((item) => toTrimmedString(item))
120
- .filter(Boolean),
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 targetLine = targetPaths.length
127
- ? `Focus on these paths if relevant: ${targetPaths.join(", ")}.`
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": ["relative/path/one", "relative/path/two"],
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.4.6-beta.2",
3
+ "version": "0.4.6-beta.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },