reasonix 0.12.8 → 0.12.14

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/dashboard/app.js CHANGED
@@ -1136,9 +1136,103 @@ function EditReviewModal({ modal, onResolve }) {
1136
1136
  `;
1137
1137
  }
1138
1138
 
1139
+ function WorkspaceModal({ modal, onResolve }) {
1140
+ return html`
1141
+ <${ModalCard}
1142
+ accent="#fbbf24"
1143
+ icon="◇"
1144
+ title="model wants to switch workspace"
1145
+ subtitle="every subsequent file / shell / memory tool resolves against the new root"
1146
+ >
1147
+ <div class="modal-cmd"><span class="modal-cmd-prompt">→</span> <code>${modal.path}</code></div>
1148
+ <div class="modal-actions">
1149
+ <button class="primary" onClick=${() => onResolve("workspace", "switch")}>Switch (Enter)</button>
1150
+ <button class="danger" onClick=${() => onResolve("workspace", "deny")}>Deny (Esc)</button>
1151
+ </div>
1152
+ <//>
1153
+ `;
1154
+ }
1155
+
1156
+ function CheckpointModal({ modal, onResolve }) {
1157
+ const [reviseText, setReviseText] = useState("");
1158
+ const [staged, setStaged] = useState(false);
1159
+ const label = modal.title ? `${modal.stepId} · ${modal.title}` : modal.stepId;
1160
+ const counter = modal.total > 0 ? ` (${modal.completed}/${modal.total})` : "";
1161
+ return html`
1162
+ <${ModalCard}
1163
+ accent="#a5f3fc"
1164
+ icon="✓"
1165
+ title=${`step complete${counter}`}
1166
+ subtitle=${label}
1167
+ >
1168
+ ${
1169
+ staged
1170
+ ? html`
1171
+ <textarea
1172
+ placeholder="What needs to change before the next step? Leave blank to just continue."
1173
+ rows="3"
1174
+ value=${reviseText}
1175
+ onInput=${(e) => setReviseText(e.target.value)}
1176
+ ></textarea>
1177
+ <div class="modal-actions">
1178
+ <button class="primary" onClick=${() => onResolve("checkpoint", "revise", reviseText)}>Send revision</button>
1179
+ <button onClick=${() => {
1180
+ setStaged(false);
1181
+ setReviseText("");
1182
+ }}>Back</button>
1183
+ </div>
1184
+ `
1185
+ : html`
1186
+ <div class="modal-actions">
1187
+ <button class="primary" onClick=${() => onResolve("checkpoint", "continue")}>Continue</button>
1188
+ <button onClick=${() => setStaged(true)}>Revise…</button>
1189
+ <button class="danger" onClick=${() => onResolve("checkpoint", "stop")}>Stop</button>
1190
+ </div>
1191
+ `
1192
+ }
1193
+ <//>
1194
+ `;
1195
+ }
1196
+
1197
+ function RevisionModal({ modal, onResolve }) {
1198
+ const riskColor = (r) =>
1199
+ r === "high" ? "#f87171" : r === "med" ? "#fbbf24" : r === "low" ? "#86efac" : "#9ca3af";
1200
+ return html`
1201
+ <${ModalCard}
1202
+ accent="#c4b5fd"
1203
+ icon="✎"
1204
+ title="model proposed a plan revision"
1205
+ subtitle=${modal.summary || modal.reason}
1206
+ >
1207
+ <div class="modal-revise-reason">${modal.reason}</div>
1208
+ <ol class="modal-revise-steps">
1209
+ ${modal.remainingSteps.map(
1210
+ (s) => html`
1211
+ <li key=${s.id}>
1212
+ <span class="modal-revise-dot" style=${`background:${riskColor(s.risk)}`}></span>
1213
+ <span class="modal-revise-id">${s.id}</span>
1214
+ <span class="modal-revise-title">${s.title}</span>
1215
+ <span class="modal-revise-action">${s.action}</span>
1216
+ </li>
1217
+ `,
1218
+ )}
1219
+ </ol>
1220
+ <div class="modal-actions">
1221
+ <button class="primary" onClick=${() => onResolve("revision", "accept")}>Accept</button>
1222
+ <button class="danger" onClick=${() => onResolve("revision", "reject")}>Reject</button>
1223
+ </div>
1224
+ <//>
1225
+ `;
1226
+ }
1227
+
1139
1228
  function ChatPanel() {
1140
1229
  const [messages, setMessages] = useState([]);
1141
1230
  const [streaming, setStreaming] = useState(null); // { id, text, reasoning }
1231
+ // Tool currently dispatched but not yet returning. Set on `tool_start`,
1232
+ // cleared on `tool` / `error`. Drives the in-flight row so the user
1233
+ // sees what's running (path, command, char counts) instead of a
1234
+ // generic "waiting" — file writes especially feel hung otherwise.
1235
+ const [activeTool, setActiveTool] = useState(null); // { id, toolName, args }
1142
1236
  const [busy, setBusy] = useState(false);
1143
1237
  const [input, setInput] = useState("");
1144
1238
  const [error, setError] = useState(null);
@@ -1164,11 +1258,54 @@ function ChatPanel() {
1164
1258
  // the input area.
1165
1259
  const [stats, setStats] = useState(null);
1166
1260
  const [overviewModel, setOverviewModel] = useState(null);
1261
+ // Whether the project has a built semantic index. Null = unknown
1262
+ // (poll hasn't landed) or non-attached. False = no index → show the
1263
+ // dismissible banner. True = index built → hide it.
1264
+ const [semanticIndex, setSemanticIndex] = useState(null);
1265
+ const [semanticBannerDismissed, setSemanticBannerDismissed] = useState(() => {
1266
+ try {
1267
+ return localStorage.getItem("rx.semanticBannerDismissed") === "1";
1268
+ } catch {
1269
+ return false;
1270
+ }
1271
+ });
1272
+ useEffect(() => {
1273
+ try {
1274
+ localStorage.setItem("rx.semanticBannerDismissed", semanticBannerDismissed ? "1" : "0");
1275
+ } catch {
1276
+ /* ignore */
1277
+ }
1278
+ }, [semanticBannerDismissed]);
1279
+ // Wall-clock timestamp the current turn started at — populated when
1280
+ // busy flips true, cleared when it flips false. Drives the "elapsed
1281
+ // Ns" readout in the in-flight indicator. Refreshed once per second
1282
+ // by `nowTick` so the seconds counter ticks visibly even between
1283
+ // SSE deltas.
1284
+ const [turnStartedAt, setTurnStartedAt] = useState(null);
1285
+ const [nowTick, setNowTick] = useState(0);
1286
+ useEffect(() => {
1287
+ if (!busy) return;
1288
+ const id = setInterval(() => setNowTick((n) => n + 1), 500);
1289
+ return () => clearInterval(id);
1290
+ }, [busy]);
1291
+ useEffect(() => {
1292
+ if (busy) {
1293
+ if (!turnStartedAt) setTurnStartedAt(Date.now());
1294
+ } else {
1295
+ setTurnStartedAt(null);
1296
+ }
1297
+ }, [busy, turnStartedAt]);
1167
1298
  // Sticks to bottom only while the user is already near the bottom.
1168
1299
  // Once they scroll up to read older content the streaming deltas no
1169
1300
  // longer yank the view back. Re-armed when they scroll back to the
1170
1301
  // bottom on their own. 80px threshold absorbs sub-pixel rounding.
1171
1302
  const shouldAutoScroll = useRef(true);
1303
+ // Ref to the scrollable feed container so we don't have to rely on
1304
+ // a global querySelector (which would race the conditional render
1305
+ // — `.chat-feed` only mounts when at least one message is present).
1306
+ // The feed is now always rendered, so `feedRef.current` is set on
1307
+ // first paint and the scroll listener attaches once.
1308
+ const feedRef = useRef(null);
1172
1309
 
1173
1310
  // Initial snapshot — messages + busy + any modal already up.
1174
1311
  useEffect(() => {
@@ -1235,17 +1372,16 @@ function ChatPanel() {
1235
1372
  return;
1236
1373
  }
1237
1374
  if (dash.kind === "tool_start") {
1238
- setMessages((prev) => [
1239
- ...prev,
1240
- {
1241
- id: `start-${dash.id}`,
1242
- role: "info",
1243
- text: `▸ ${dash.toolName} starting…`,
1244
- },
1245
- ]);
1375
+ // Surface the dispatched tool + its args in the in-flight row.
1376
+ // No info-row placeholder: the InFlightRow now renders the
1377
+ // detail (path / command / char count) and the result card
1378
+ // appears when the `tool` event lands. Two rows for one tool
1379
+ // call was redundant noise.
1380
+ setActiveTool({ id: dash.id, toolName: dash.toolName, args: dash.args });
1246
1381
  return;
1247
1382
  }
1248
1383
  if (dash.kind === "tool") {
1384
+ setActiveTool((cur) => (cur && cur.id === dash.id ? null : cur));
1249
1385
  setMessages((prev) => [
1250
1386
  ...prev,
1251
1387
  {
@@ -1259,6 +1395,9 @@ function ChatPanel() {
1259
1395
  return;
1260
1396
  }
1261
1397
  if (dash.kind === "warning" || dash.kind === "error" || dash.kind === "info") {
1398
+ if (dash.kind === "error") {
1399
+ setActiveTool(null);
1400
+ }
1262
1401
  setMessages((prev) => [...prev, { id: dash.id, role: dash.kind, text: dash.text }]);
1263
1402
  return;
1264
1403
  }
@@ -1325,6 +1464,7 @@ function ChatPanel() {
1325
1464
  await api("/submit", { method: "POST", body: { prompt: "/new" } });
1326
1465
  setMessages([]);
1327
1466
  setStreaming(null);
1467
+ setActiveTool(null);
1328
1468
  showToast("new conversation", "info");
1329
1469
  // Refetch to reconcile in case the slash queued an info row.
1330
1470
  setTimeout(async () => {
@@ -1345,6 +1485,7 @@ function ChatPanel() {
1345
1485
  await api("/submit", { method: "POST", body: { prompt: "/clear" } });
1346
1486
  setMessages([]);
1347
1487
  setStreaming(null);
1488
+ setActiveTool(null);
1348
1489
  showToast("scrollback cleared", "info");
1349
1490
  setTimeout(async () => {
1350
1491
  try {
@@ -1379,10 +1520,22 @@ function ChatPanel() {
1379
1520
  // immediately. The threshold is generous enough that overshoot
1380
1521
  // (smooth-scroll rebound, sub-pixel rounding) doesn't accidentally
1381
1522
  // re-arm tracking when the user is barely above bottom.
1523
+ //
1524
+ // We also distinguish *user* scroll events from auto-scroll's own
1525
+ // programmatic `scrollTop = scrollHeight` writes. Without that gate
1526
+ // the auto-scroll effect would briefly snap to bottom, fire its
1527
+ // own scroll event, re-set shouldAutoScroll = true, then wonder
1528
+ // why the user complained that they couldn't scroll up — because
1529
+ // every wheel-up was racing against the next delta's auto-snap.
1530
+ // We mark the ref as `auto-scrolling` for one tick around the
1531
+ // programmatic write; the listener ignores events it sees during
1532
+ // that window.
1533
+ const autoScrollInFlight = useRef(false);
1382
1534
  useEffect(() => {
1383
- const el = document.querySelector(".chat-feed");
1535
+ const el = feedRef.current;
1384
1536
  if (!el) return;
1385
1537
  const onScroll = () => {
1538
+ if (autoScrollInFlight.current) return;
1386
1539
  const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
1387
1540
  shouldAutoScroll.current = distFromBottom < 80;
1388
1541
  };
@@ -1394,8 +1547,16 @@ function ChatPanel() {
1394
1547
  // deltas no longer yank the view back; manual wheel/drag wins.
1395
1548
  useEffect(() => {
1396
1549
  if (!shouldAutoScroll.current) return;
1397
- const el = document.querySelector(".chat-feed");
1398
- if (el) el.scrollTop = el.scrollHeight;
1550
+ const el = feedRef.current;
1551
+ if (!el) return;
1552
+ autoScrollInFlight.current = true;
1553
+ el.scrollTop = el.scrollHeight;
1554
+ // Clear the gate after the browser has had a chance to fire the
1555
+ // resulting scroll event (microtask-ish — rAF is overkill, a 0ms
1556
+ // setTimeout is enough to land after the synchronous handler).
1557
+ setTimeout(() => {
1558
+ autoScrollInFlight.current = false;
1559
+ }, 0);
1399
1560
  }, [messages, streaming]);
1400
1561
 
1401
1562
  const allMessages = streaming
@@ -1440,6 +1601,7 @@ function ChatPanel() {
1440
1601
  setEffortLocal(o.reasoningEffort ?? null);
1441
1602
  setStats(o.stats ?? null);
1442
1603
  setOverviewModel(o.model ?? null);
1604
+ setSemanticIndex(o.semanticIndexExists);
1443
1605
  } catch {
1444
1606
  /* swallow */
1445
1607
  }
@@ -1574,11 +1736,31 @@ function ChatPanel() {
1574
1736
  </div>
1575
1737
 
1576
1738
  ${
1577
- busy
1578
- ? html`<div class="chat-status"><span class="spinner"></span> turn in flight · <button onClick=${abort}>Abort (Esc)</button>${statusLine ? html` · <span class="muted">${statusLine}</span>` : null}</div>`
1579
- : statusLine
1580
- ? html`<div class="chat-status"><span class="muted">${statusLine}</span></div>`
1581
- : null
1739
+ !busy && statusLine
1740
+ ? html`<div class="chat-status"><span class="muted">${statusLine}</span></div>`
1741
+ : null
1742
+ }
1743
+ ${
1744
+ semanticIndex === false && !semanticBannerDismissed
1745
+ ? html`<div class="chat-banner">
1746
+ <span class="chat-banner-icon">≈</span>
1747
+ <span class="chat-banner-text">
1748
+ <strong>Semantic search isn't enabled for this project.</strong>
1749
+ <span class="muted">
1750
+ Build the index once and the model can find code by meaning ("where do we handle auth failures?") instead of grep on exact strings.
1751
+ </span>
1752
+ </span>
1753
+ <button
1754
+ class="primary"
1755
+ onClick=${() => appBus.dispatchEvent(new CustomEvent("navigate-tab", { detail: { tabId: "semantic" } }))}
1756
+ >Build it →</button>
1757
+ <button
1758
+ class="chat-banner-close"
1759
+ onClick=${() => setSemanticBannerDismissed(true)}
1760
+ title="dismiss (don't show again)"
1761
+ >×</button>
1762
+ </div>`
1763
+ : null
1582
1764
  }
1583
1765
  ${error ? html`<div class="notice err">${error}</div>` : null}
1584
1766
 
@@ -1592,27 +1774,33 @@ function ChatPanel() {
1592
1774
  ? html`<${PlanModal} modal=${modal} onResolve=${resolveModal} />`
1593
1775
  : modal.kind === "edit-review"
1594
1776
  ? html`<${EditReviewModal} modal=${modal} onResolve=${resolveModal} />`
1595
- : null
1777
+ : modal.kind === "workspace"
1778
+ ? html`<${WorkspaceModal} modal=${modal} onResolve=${resolveModal} />`
1779
+ : modal.kind === "checkpoint"
1780
+ ? html`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />`
1781
+ : modal.kind === "revision"
1782
+ ? html`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />`
1783
+ : null
1596
1784
  : null
1597
1785
  }
1598
1786
 
1599
- ${
1600
- allMessages.length === 0
1601
- ? html`<div class="chat-empty">No conversation yet. Send a prompt below to begin.</div>`
1602
- : html`
1603
- <div class="chat-feed">
1604
- ${allMessages.map(
1605
- (m) => html`
1606
- <${ChatMessage}
1607
- key=${m.id}
1608
- msg=${m}
1609
- streaming=${streaming && streaming.id === m.id}
1610
- />
1611
- `,
1612
- )}
1613
- </div>
1614
- `
1615
- }
1787
+ <div class="chat-feed" ref=${feedRef}>
1788
+ ${
1789
+ allMessages.length === 0
1790
+ ? html`<div class="chat-empty">
1791
+ No conversation yet. Send a prompt below to begin.
1792
+ </div>`
1793
+ : allMessages.map(
1794
+ (m) => html`
1795
+ <${ChatMessage}
1796
+ key=${m.id}
1797
+ msg=${m}
1798
+ streaming=${streaming && streaming.id === m.id}
1799
+ />
1800
+ `,
1801
+ )
1802
+ }
1803
+ </div>
1616
1804
 
1617
1805
  <div class="chat-input-area">
1618
1806
  <textarea
@@ -1636,11 +1824,111 @@ function ChatPanel() {
1636
1824
  </div>
1637
1825
  </div>
1638
1826
 
1827
+ ${
1828
+ busy
1829
+ ? html`<${InFlightRow}
1830
+ streaming=${streaming}
1831
+ activeTool=${activeTool}
1832
+ startedAt=${turnStartedAt}
1833
+ statusLine=${statusLine}
1834
+ onAbort=${abort}
1835
+ tick=${nowTick}
1836
+ />`
1837
+ : null
1838
+ }
1639
1839
  <${ChatStatusBar} stats=${stats} model=${overviewModel} />
1640
1840
  </div>
1641
1841
  `;
1642
1842
  }
1643
1843
 
1844
+ // Summarize the dispatched tool in one line — what the user wants to
1845
+ // know is "is this hung or really doing X". Per-tool projection so a
1846
+ // write_file says "→ /path/foo (12,345 ch)" instead of just "tool is
1847
+ // running". Returns null for tools we don't have a custom shape for;
1848
+ // the row falls back to the bare tool name.
1849
+ function summarizeActiveTool(activeTool) {
1850
+ if (!activeTool) return null;
1851
+ const name = activeTool.toolName ?? "tool";
1852
+ const args = parseToolArgs(activeTool.args);
1853
+ const path = args?.path ?? args?.file_path ?? args?.filename;
1854
+ if (name === "write_file" && path) {
1855
+ const len = typeof args?.content === "string" ? args.content.length : null;
1856
+ return `${name} → ${path}${len != null ? ` (${len.toLocaleString()} ch)` : ""}`;
1857
+ }
1858
+ if ((name === "edit_file" || name.endsWith("_edit_file")) && path) {
1859
+ return `${name} → ${path}`;
1860
+ }
1861
+ if ((name === "run_command" || name === "run_background") && typeof args?.command === "string") {
1862
+ const c = args.command;
1863
+ return `${name} → $ ${c.length > 80 ? `${c.slice(0, 80)}…` : c}`;
1864
+ }
1865
+ if ((name === "read_file" || name === "list_files" || name === "search_files") && path) {
1866
+ return `${name} → ${path}`;
1867
+ }
1868
+ if (path) return `${name} → ${path}`;
1869
+ return name;
1870
+ }
1871
+
1872
+ // Live "what's the model doing right now" strip. Lives just above the
1873
+ // ChatStatusBar so the user's eyes don't have to leave the input area
1874
+ // to see whether the turn is alive — ticks every 500ms via the parent's
1875
+ // nowTick so the seconds counter shows visible motion even when the
1876
+ // SSE stream is silent (model thinking, waiting on a tool, etc).
1877
+ function InFlightRow({ streaming, activeTool, startedAt, statusLine, onAbort, tick: _tick }) {
1878
+ const elapsedMs = startedAt ? Date.now() - startedAt : 0;
1879
+ const elapsed = (elapsedMs / 1000).toFixed(1);
1880
+ const reasoningLen = streaming?.reasoning?.length ?? 0;
1881
+ const textLen = streaming?.text?.length ?? 0;
1882
+ // Tool-running phase wins over text/reasoning since the model is
1883
+ // blocked on the tool — even if assistant_delta has fired we want
1884
+ // to show the active dispatch.
1885
+ const toolSummary = summarizeActiveTool(activeTool);
1886
+ const phase = toolSummary
1887
+ ? "running"
1888
+ : reasoningLen > 0 && textLen === 0
1889
+ ? "thinking"
1890
+ : textLen > 0
1891
+ ? "streaming"
1892
+ : "waiting";
1893
+ return html`
1894
+ <div class="chat-inflight">
1895
+ <span class="spinner"></span>
1896
+ <span class="chat-inflight-phase">${phase}</span>
1897
+ <span class="chat-inflight-sep">·</span>
1898
+ <span class="muted">${elapsed}s</span>
1899
+ ${
1900
+ toolSummary
1901
+ ? html`
1902
+ <span class="chat-inflight-sep">·</span>
1903
+ <span class="chat-inflight-tool" title=${toolSummary}>${toolSummary}</span>
1904
+ `
1905
+ : null
1906
+ }
1907
+ ${
1908
+ !toolSummary && (textLen > 0 || reasoningLen > 0)
1909
+ ? html`
1910
+ <span class="chat-inflight-sep">·</span>
1911
+ <span class="muted">
1912
+ ${reasoningLen > 0 ? html`reasoning ${reasoningLen.toLocaleString()} ch` : null}
1913
+ ${reasoningLen > 0 && textLen > 0 ? html`<span> · </span>` : null}
1914
+ ${textLen > 0 ? html`out ${textLen.toLocaleString()} ch` : null}
1915
+ </span>
1916
+ `
1917
+ : null
1918
+ }
1919
+ ${
1920
+ statusLine
1921
+ ? html`
1922
+ <span class="chat-inflight-sep">·</span>
1923
+ <span class="muted">${statusLine}</span>
1924
+ `
1925
+ : null
1926
+ }
1927
+ <button class="chat-inflight-abort" onClick=${onAbort}>Abort (Esc)</button>
1928
+ </div>
1929
+ `;
1930
+ }
1931
+
1644
1932
  // ---------- Chat status bar ----------
1645
1933
  //
1646
1934
  // Mirrors the TUI's StatsPanel — turn / session cost, cache hit %,
@@ -2722,6 +3010,321 @@ function SkillsPanel() {
2722
3010
 
2723
3011
  // ---------- MCP ----------
2724
3012
 
3013
+ // ---------- Semantic index ----------
3014
+
3015
+ function SemanticPanel() {
3016
+ const [data, setData] = useState(null);
3017
+ const [error, setError] = useState(null);
3018
+ const [busy, setBusy] = useState(false);
3019
+ const [info, setInfo] = useState(null);
3020
+
3021
+ const load = useCallback(async () => {
3022
+ try {
3023
+ const r = await api("/semantic");
3024
+ setData(r);
3025
+ } catch (err) {
3026
+ setError(err.message);
3027
+ }
3028
+ }, []);
3029
+
3030
+ // Poll fast while a job is running OR while ollama is pulling a
3031
+ // model (the latest-line readout updates every few hundred ms during
3032
+ // a download). Slow when idle so the panel doesn't burn network just
3033
+ // sitting open in a tab.
3034
+ useEffect(() => {
3035
+ load();
3036
+ const phase = data?.job?.phase;
3037
+ const running = phase === "scan" || phase === "embed" || phase === "write";
3038
+ const pulling = data?.pull?.status === "pulling";
3039
+ const ms = running || pulling ? 1200 : 5000;
3040
+ const id = setInterval(load, ms);
3041
+ return () => clearInterval(id);
3042
+ }, [load, data?.job?.phase, data?.pull?.status]);
3043
+
3044
+ const start = useCallback(
3045
+ async (rebuild) => {
3046
+ setBusy(true);
3047
+ setError(null);
3048
+ setInfo(null);
3049
+ try {
3050
+ await api("/semantic/start", { method: "POST", body: { rebuild: !!rebuild } });
3051
+ setInfo(rebuild ? "rebuild started" : "incremental index started");
3052
+ await load();
3053
+ } catch (err) {
3054
+ setError(err.message);
3055
+ } finally {
3056
+ setBusy(false);
3057
+ }
3058
+ },
3059
+ [load],
3060
+ );
3061
+
3062
+ const stop = useCallback(async () => {
3063
+ setBusy(true);
3064
+ setError(null);
3065
+ try {
3066
+ await api("/semantic/stop", { method: "POST", body: {} });
3067
+ setInfo("stopping requested — current chunk batch will finish first");
3068
+ await load();
3069
+ } catch (err) {
3070
+ setError(err.message);
3071
+ } finally {
3072
+ setBusy(false);
3073
+ }
3074
+ }, [load]);
3075
+
3076
+ const startDaemon = useCallback(async () => {
3077
+ setBusy(true);
3078
+ setError(null);
3079
+ setInfo("starting ollama daemon (15s timeout)…");
3080
+ try {
3081
+ const r = await api("/semantic/ollama/start", { method: "POST", body: {} });
3082
+ setInfo(
3083
+ r.ready ? "daemon is up" : "daemon didn't come up in time — check `ollama serve` manually",
3084
+ );
3085
+ await load();
3086
+ } catch (err) {
3087
+ setError(err.message);
3088
+ } finally {
3089
+ setBusy(false);
3090
+ }
3091
+ }, [load]);
3092
+
3093
+ const pullModel = useCallback(
3094
+ async (model) => {
3095
+ setBusy(true);
3096
+ setError(null);
3097
+ setInfo(`pulling ${model} — this may take a few minutes on first install`);
3098
+ try {
3099
+ await api("/semantic/ollama/pull", { method: "POST", body: { model } });
3100
+ await load();
3101
+ } catch (err) {
3102
+ setError(err.message);
3103
+ } finally {
3104
+ setBusy(false);
3105
+ }
3106
+ },
3107
+ [load],
3108
+ );
3109
+
3110
+ if (!data && !error) return html`<div class="boot">loading semantic status…</div>`;
3111
+ if (error && !data) return html`<div class="notice err">${error}</div>`;
3112
+
3113
+ if (data && !data.attached) {
3114
+ return html`
3115
+ <div>
3116
+ <div class="panel-header">
3117
+ <h2 class="panel-title">Semantic</h2>
3118
+ <span class="panel-subtitle">code-mode required</span>
3119
+ </div>
3120
+ <div class="empty">${data.reason}</div>
3121
+ </div>
3122
+ `;
3123
+ }
3124
+
3125
+ const job = data.job;
3126
+ const phase = job?.phase;
3127
+ const running = phase === "scan" || phase === "embed" || phase === "write";
3128
+ const pull = data.pull;
3129
+ const pulling = pull?.status === "pulling";
3130
+
3131
+ // Tri-state Ollama check. Each level gates the next:
3132
+ // binary missing → user must install (we won't run a package
3133
+ // manager on their behalf).
3134
+ // daemon down → one-click start (`ollama serve`).
3135
+ // model missing → one-click pull.
3136
+ // all good → ready to index.
3137
+ const o = data.ollama ?? {};
3138
+ const binaryFound = o.binaryFound === true;
3139
+ const daemonRunning = o.daemonRunning === true;
3140
+ const modelPulled = o.modelPulled === true;
3141
+ const modelName = o.modelName ?? "nomic-embed-text";
3142
+ const installedModels = o.installedModels ?? [];
3143
+ const ready = binaryFound && daemonRunning && modelPulled;
3144
+
3145
+ return html`
3146
+ <div>
3147
+ <div class="panel-header">
3148
+ <h2 class="panel-title">Semantic</h2>
3149
+ <span class="panel-subtitle">${data.index.exists ? "index built" : "no index yet"}</span>
3150
+ </div>
3151
+ ${info ? html`<div class="notice">${info}</div>` : null}
3152
+ ${error ? html`<div class="notice err">${error}</div>` : null}
3153
+
3154
+ <div class="section-title">Status</div>
3155
+ <div class="kv">
3156
+ <div><span class="kv-key">project</span><code>${data.root}</code></div>
3157
+ <div>
3158
+ <span class="kv-key">ollama</span>
3159
+ ${
3160
+ binaryFound
3161
+ ? daemonRunning
3162
+ ? html`<span class="pill pill-ok">reachable</span><span class="muted" style="margin-left: 8px;">${installedModels.length} model(s)${
3163
+ installedModels.length > 0
3164
+ ? ` · ${installedModels.slice(0, 3).join(", ")}${installedModels.length > 3 ? "…" : ""}`
3165
+ : ""
3166
+ }</span>`
3167
+ : html`<span class="pill pill-warn">daemon down</span><span class="muted" style="margin-left: 8px;">binary on PATH but not serving</span>`
3168
+ : html`<span class="pill pill-err">not installed</span><span class="muted" style="margin-left: 8px;">${o.error ?? "ollama binary not on PATH"}</span>`
3169
+ }
3170
+ </div>
3171
+ <div>
3172
+ <span class="kv-key">model</span>
3173
+ <code>${modelName}</code>
3174
+ ${
3175
+ modelPulled
3176
+ ? html`<span class="pill pill-ok" style="margin-left: 8px;">pulled</span>`
3177
+ : daemonRunning
3178
+ ? html`<span class="pill pill-warn" style="margin-left: 8px;">not pulled</span>`
3179
+ : html`<span class="pill pill-dim" style="margin-left: 8px;">unknown (daemon down)</span>`
3180
+ }
3181
+ </div>
3182
+ <div>
3183
+ <span class="kv-key">index</span>
3184
+ ${
3185
+ data.index.exists
3186
+ ? html`<span class="muted">present at <code>.reasonix/semantic/</code></span>`
3187
+ : html`<span class="muted">none — run an index to enable <code>semantic_search</code></span>`
3188
+ }
3189
+ </div>
3190
+ </div>
3191
+
3192
+ ${
3193
+ !binaryFound
3194
+ ? html`
3195
+ <div class="section-title">Install Ollama</div>
3196
+ <div class="card" style="font-size: 13px;">
3197
+ Reasonix doesn't run package managers for you. Install Ollama
3198
+ first, then come back to this panel:
3199
+ <ul style="margin: 10px 0 4px 18px; padding: 0;">
3200
+ <li><strong>macOS / Windows:</strong> download from <a href="https://ollama.com/download" target="_blank" rel="noreferrer">ollama.com/download</a></li>
3201
+ <li><strong>Linux:</strong> <code>curl -fsSL https://ollama.com/install.sh | sh</code></li>
3202
+ </ul>
3203
+ <div class="muted" style="margin-top: 8px;">After install, this panel will offer to start the daemon and pull <code>${modelName}</code> for you. Refresh after installing.</div>
3204
+ </div>
3205
+ `
3206
+ : null
3207
+ }
3208
+
3209
+ ${
3210
+ binaryFound && !daemonRunning
3211
+ ? html`
3212
+ <div class="section-title">Daemon</div>
3213
+ <div class="card" style="font-size: 13px;">
3214
+ <code>ollama</code> is on your PATH but the HTTP daemon isn't reachable.
3215
+ <div class="row" style="margin-top: 10px;">
3216
+ <button class="primary" disabled=${busy} onClick=${startDaemon}>Start daemon</button>
3217
+ <span class="muted" style="font-size: 12px; align-self: center;">runs <code>ollama serve</code> detached — survives Reasonix exit</span>
3218
+ </div>
3219
+ </div>
3220
+ `
3221
+ : null
3222
+ }
3223
+
3224
+ ${
3225
+ daemonRunning && !modelPulled
3226
+ ? html`
3227
+ <div class="section-title">Model</div>
3228
+ <div class="card" style="font-size: 13px;">
3229
+ <code>${modelName}</code> isn't installed yet. ${pulling ? "" : "~270 MB download on first pull."}
3230
+ <div class="row" style="margin-top: 10px;">
3231
+ <button
3232
+ class="primary"
3233
+ disabled=${busy || pulling}
3234
+ onClick=${() => pullModel(modelName)}
3235
+ >${pulling ? "pulling…" : `Pull ${modelName}`}</button>
3236
+ </div>
3237
+ ${
3238
+ pull
3239
+ ? html`
3240
+ <div class="kv" style="margin-top: 10px;">
3241
+ <div>
3242
+ <span class="kv-key">status</span>
3243
+ <span class=${`pill ${pull.status === "done" ? "pill-ok" : pull.status === "error" ? "pill-err" : "pill-active"}`}>${pull.status}</span>
3244
+ <span class="muted" style="margin-left: 8px;">${((Date.now() - pull.startedAt) / 1000).toFixed(1)}s</span>
3245
+ </div>
3246
+ ${
3247
+ pull.lastLine
3248
+ ? html`<div><span class="kv-key">last</span><code style="font-size: 11.5px;">${pull.lastLine}</code></div>`
3249
+ : null
3250
+ }
3251
+ </div>
3252
+ `
3253
+ : null
3254
+ }
3255
+ </div>
3256
+ `
3257
+ : null
3258
+ }
3259
+
3260
+ <div class="section-title">Job</div>
3261
+ ${job ? html`<${SemanticJobView} job=${job} running=${running} />` : html`<div class="muted">No job has run in this dashboard yet.</div>`}
3262
+
3263
+ <div class="row" style="margin-top: 14px;">
3264
+ <button class="primary" disabled=${busy || running || !ready} onClick=${() => start(false)}>Index (incremental)</button>
3265
+ <button disabled=${busy || running || !ready} onClick=${() => start(true)}>Rebuild (wipe + full)</button>
3266
+ <button disabled=${busy || !running} onClick=${stop}>Stop</button>
3267
+ </div>
3268
+ </div>
3269
+ `;
3270
+ }
3271
+
3272
+ function SemanticJobView({ job, running }) {
3273
+ const phaseLabel =
3274
+ {
3275
+ scan: "scanning files",
3276
+ embed: "embedding chunks",
3277
+ write: "writing index",
3278
+ done: "done",
3279
+ error: "error",
3280
+ }[job.phase] ?? job.phase;
3281
+ const total = job.chunksTotal ?? 0;
3282
+ const doneN = job.chunksDone ?? 0;
3283
+ const ratio = total > 0 ? Math.min(1, doneN / total) : 0;
3284
+ const elapsed = ((Date.now() - job.startedAt) / 1000).toFixed(1);
3285
+
3286
+ return html`
3287
+ <div class="kv">
3288
+ <div><span class="kv-key">phase</span>
3289
+ <span class=${`pill ${job.phase === "error" ? "pill-err" : running ? "pill-active" : "pill-dim"}`}>${phaseLabel}</span>
3290
+ ${job.aborted ? html`<span class="pill pill-warn" style="margin-left: 6px;">stopping</span>` : null}
3291
+ <span class="muted" style="margin-left: 8px;">${elapsed}s</span>
3292
+ </div>
3293
+ ${
3294
+ job.filesScanned !== null && job.filesScanned !== undefined
3295
+ ? html`<div><span class="kv-key">files</span>scanned ${job.filesScanned}${
3296
+ job.filesChanged != null ? ` · changed ${job.filesChanged}` : ""
3297
+ }${job.filesSkipped ? ` · skipped ${job.filesSkipped}` : ""}</div>`
3298
+ : null
3299
+ }
3300
+ ${
3301
+ total > 0
3302
+ ? html`
3303
+ <div>
3304
+ <span class="kv-key">chunks</span>${doneN} / ${total} (${(ratio * 100).toFixed(0)}%)
3305
+ </div>
3306
+ <div class="bar" style="margin-top: 4px;">
3307
+ <div class="fill" style=${`width: ${(ratio * 100).toFixed(1)}%; background: var(--primary);`}></div>
3308
+ </div>
3309
+ `
3310
+ : null
3311
+ }
3312
+ ${
3313
+ job.error
3314
+ ? html`<div><span class="kv-key">error</span><span class="err">${job.error}</span></div>`
3315
+ : null
3316
+ }
3317
+ ${
3318
+ job.result
3319
+ ? html`<div><span class="kv-key">result</span>added ${job.result.chunksAdded} · removed ${job.result.chunksRemoved}${
3320
+ job.result.chunksSkipped ? ` · failed ${job.result.chunksSkipped}` : ""
3321
+ } · ${(job.result.durationMs / 1000).toFixed(1)}s</div>`
3322
+ : null
3323
+ }
3324
+ </div>
3325
+ `;
3326
+ }
3327
+
2725
3328
  function McpPanel() {
2726
3329
  const [data, setData] = useState(null);
2727
3330
  const [specs, setSpecs] = useState(null);
@@ -3518,6 +4121,14 @@ const TABS = [
3518
4121
  ready: true,
3519
4122
  badge: null,
3520
4123
  },
4124
+ {
4125
+ id: "semantic",
4126
+ name: "Semantic",
4127
+ glyph: "≈",
4128
+ panel: () => html`<${SemanticPanel} />`,
4129
+ ready: true,
4130
+ badge: null,
4131
+ },
3521
4132
  {
3522
4133
  id: "mcp",
3523
4134
  name: "MCP",
@@ -3768,23 +4379,41 @@ function ErrorOverlay() {
3768
4379
 
3769
4380
  // Preact ErrorBoundary — catches render-time exceptions in the App
3770
4381
  // subtree and dispatches them to the error overlay instead of leaving
3771
- // the user with a blank white page. After capturing, render falls
3772
- // back to a minimal "reload" prompt; the overlay handles the rest.
4382
+ // the user with a blank white page. Recovers automatically the first
4383
+ // few times so transient hiccups don't strand the user; if a panel
4384
+ // throws repeatedly we stop the loop and render a manual "Try again"
4385
+ // fallback so the page never looks blank-but-ticking.
3773
4386
  class ErrorBoundary extends Component {
3774
4387
  constructor(props) {
3775
4388
  super(props);
3776
- this.state = { caught: false };
4389
+ this.state = { caught: false, lastErr: null, attempts: 0 };
3777
4390
  }
3778
- static getDerivedStateFromError() {
3779
- return { caught: true };
4391
+ static getDerivedStateFromError(error) {
4392
+ return { caught: true, lastErr: error };
3780
4393
  }
3781
4394
  componentDidCatch(error, info) {
3782
4395
  reportAppError(error, "render", info?.componentStack ?? "");
3783
- // Recover after a tick overlay handles the user's next move.
3784
- setTimeout(() => this.setState({ caught: false }), 100);
4396
+ const attempts = (this.state.attempts ?? 0) + 1;
4397
+ if (attempts >= 3) {
4398
+ // Stop the auto-recover loop — the panel is genuinely broken,
4399
+ // surface a "Try again" button instead of flickering.
4400
+ this.setState({ attempts });
4401
+ return;
4402
+ }
4403
+ setTimeout(() => this.setState({ caught: false, attempts }), 100);
3785
4404
  }
3786
4405
  render() {
3787
4406
  if (this.state.caught) {
4407
+ if ((this.state.attempts ?? 0) >= 3) {
4408
+ return html`
4409
+ <div class="boot" style="flex-direction: column; gap: 12px;">
4410
+ <div>this panel keeps crashing — the error overlay has the trace.</div>
4411
+ <button onClick=${() => this.setState({ caught: false, attempts: 0 })}>
4412
+ Try again
4413
+ </button>
4414
+ </div>
4415
+ `;
4416
+ }
3788
4417
  return html`<div class="boot">recovering…</div>`;
3789
4418
  }
3790
4419
  return this.props.children;