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.css +194 -0
- package/dashboard/app.js +670 -41
- package/dist/cli/index.js +10804 -10418
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +38 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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 =
|
|
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 =
|
|
1398
|
-
if (el)
|
|
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="
|
|
1579
|
-
:
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
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.
|
|
3772
|
-
//
|
|
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
|
-
|
|
3784
|
-
|
|
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;
|