reasonix 0.12.13 → 0.12.15

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 CHANGED
@@ -1300,6 +1300,13 @@ textarea:focus {
1300
1300
  .chat-inflight-sep {
1301
1301
  color: var(--fg-3);
1302
1302
  }
1303
+ .chat-inflight-tool {
1304
+ color: var(--fg-1);
1305
+ white-space: nowrap;
1306
+ overflow: hidden;
1307
+ text-overflow: ellipsis;
1308
+ max-width: 480px;
1309
+ }
1303
1310
  .chat-inflight-abort {
1304
1311
  margin-left: auto;
1305
1312
  background: transparent;
@@ -1475,6 +1482,11 @@ textarea:focus {
1475
1482
  padding: 8px 12px;
1476
1483
  font-family: var(--mono);
1477
1484
  font-size: 13px;
1485
+ overflow-x: auto;
1486
+ max-height: 240px;
1487
+ overflow-y: auto;
1488
+ white-space: pre-wrap;
1489
+ word-break: break-all;
1478
1490
  }
1479
1491
  .modal-cmd-prompt {
1480
1492
  color: var(--fg-3);
@@ -1484,6 +1496,7 @@ textarea:focus {
1484
1496
  color: var(--primary);
1485
1497
  background: transparent;
1486
1498
  padding: 0;
1499
+ word-break: break-all;
1487
1500
  }
1488
1501
 
1489
1502
  .modal-actions {
@@ -1554,6 +1567,64 @@ textarea:focus {
1554
1567
  overflow-y: auto;
1555
1568
  }
1556
1569
 
1570
+ /* Plan-revision modal — list of remaining steps with risk dots. */
1571
+ .modal-revise-reason {
1572
+ background: var(--bg-2);
1573
+ border-left: 3px solid #c4b5fd;
1574
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
1575
+ padding: 8px 12px;
1576
+ color: var(--fg-1);
1577
+ font-size: 13px;
1578
+ white-space: pre-wrap;
1579
+ }
1580
+ .modal-revise-steps {
1581
+ list-style: none;
1582
+ margin: 0;
1583
+ padding: 0;
1584
+ display: flex;
1585
+ flex-direction: column;
1586
+ gap: 6px;
1587
+ max-height: 280px;
1588
+ overflow-y: auto;
1589
+ }
1590
+ .modal-revise-steps li {
1591
+ display: grid;
1592
+ grid-template-columns: 12px 64px 1fr;
1593
+ grid-template-rows: auto auto;
1594
+ gap: 2px 10px;
1595
+ background: var(--bg-2);
1596
+ border: 1px solid var(--border);
1597
+ border-radius: var(--radius-sm);
1598
+ padding: 8px 12px;
1599
+ align-items: center;
1600
+ }
1601
+ .modal-revise-dot {
1602
+ grid-row: 1 / 3;
1603
+ width: 8px;
1604
+ height: 8px;
1605
+ border-radius: 50%;
1606
+ align-self: center;
1607
+ }
1608
+ .modal-revise-id {
1609
+ grid-row: 1;
1610
+ font-family: var(--mono);
1611
+ font-size: 12px;
1612
+ color: var(--fg-2);
1613
+ }
1614
+ .modal-revise-title {
1615
+ grid-row: 1;
1616
+ color: var(--fg-0);
1617
+ font-size: 13px;
1618
+ font-weight: 500;
1619
+ }
1620
+ .modal-revise-action {
1621
+ grid-column: 2 / 4;
1622
+ grid-row: 2;
1623
+ color: var(--fg-2);
1624
+ font-size: 12px;
1625
+ white-space: pre-wrap;
1626
+ }
1627
+
1557
1628
  .modal-edit-preview {
1558
1629
  background: var(--bg-2);
1559
1630
  border: 1px solid var(--border);
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);
@@ -1278,17 +1372,16 @@ function ChatPanel() {
1278
1372
  return;
1279
1373
  }
1280
1374
  if (dash.kind === "tool_start") {
1281
- setMessages((prev) => [
1282
- ...prev,
1283
- {
1284
- id: `start-${dash.id}`,
1285
- role: "info",
1286
- text: `▸ ${dash.toolName} starting…`,
1287
- },
1288
- ]);
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 });
1289
1381
  return;
1290
1382
  }
1291
1383
  if (dash.kind === "tool") {
1384
+ setActiveTool((cur) => (cur && cur.id === dash.id ? null : cur));
1292
1385
  setMessages((prev) => [
1293
1386
  ...prev,
1294
1387
  {
@@ -1302,6 +1395,9 @@ function ChatPanel() {
1302
1395
  return;
1303
1396
  }
1304
1397
  if (dash.kind === "warning" || dash.kind === "error" || dash.kind === "info") {
1398
+ if (dash.kind === "error") {
1399
+ setActiveTool(null);
1400
+ }
1305
1401
  setMessages((prev) => [...prev, { id: dash.id, role: dash.kind, text: dash.text }]);
1306
1402
  return;
1307
1403
  }
@@ -1368,6 +1464,7 @@ function ChatPanel() {
1368
1464
  await api("/submit", { method: "POST", body: { prompt: "/new" } });
1369
1465
  setMessages([]);
1370
1466
  setStreaming(null);
1467
+ setActiveTool(null);
1371
1468
  showToast("new conversation", "info");
1372
1469
  // Refetch to reconcile in case the slash queued an info row.
1373
1470
  setTimeout(async () => {
@@ -1388,6 +1485,7 @@ function ChatPanel() {
1388
1485
  await api("/submit", { method: "POST", body: { prompt: "/clear" } });
1389
1486
  setMessages([]);
1390
1487
  setStreaming(null);
1488
+ setActiveTool(null);
1391
1489
  showToast("scrollback cleared", "info");
1392
1490
  setTimeout(async () => {
1393
1491
  try {
@@ -1676,7 +1774,13 @@ function ChatPanel() {
1676
1774
  ? html`<${PlanModal} modal=${modal} onResolve=${resolveModal} />`
1677
1775
  : modal.kind === "edit-review"
1678
1776
  ? html`<${EditReviewModal} modal=${modal} onResolve=${resolveModal} />`
1679
- : 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
1680
1784
  : null
1681
1785
  }
1682
1786
 
@@ -1724,6 +1828,7 @@ function ChatPanel() {
1724
1828
  busy
1725
1829
  ? html`<${InFlightRow}
1726
1830
  streaming=${streaming}
1831
+ activeTool=${activeTool}
1727
1832
  startedAt=${turnStartedAt}
1728
1833
  statusLine=${statusLine}
1729
1834
  onAbort=${abort}
@@ -1736,18 +1841,55 @@ function ChatPanel() {
1736
1841
  `;
1737
1842
  }
1738
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
+
1739
1872
  // Live "what's the model doing right now" strip. Lives just above the
1740
1873
  // ChatStatusBar so the user's eyes don't have to leave the input area
1741
1874
  // to see whether the turn is alive — ticks every 500ms via the parent's
1742
1875
  // nowTick so the seconds counter shows visible motion even when the
1743
1876
  // SSE stream is silent (model thinking, waiting on a tool, etc).
1744
- function InFlightRow({ streaming, startedAt, statusLine, onAbort, tick: _tick }) {
1877
+ function InFlightRow({ streaming, activeTool, startedAt, statusLine, onAbort, tick: _tick }) {
1745
1878
  const elapsedMs = startedAt ? Date.now() - startedAt : 0;
1746
1879
  const elapsed = (elapsedMs / 1000).toFixed(1);
1747
1880
  const reasoningLen = streaming?.reasoning?.length ?? 0;
1748
1881
  const textLen = streaming?.text?.length ?? 0;
1749
- const phase =
1750
- reasoningLen > 0 && textLen === 0 ? "thinking" : textLen > 0 ? "streaming" : "waiting";
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";
1751
1893
  return html`
1752
1894
  <div class="chat-inflight">
1753
1895
  <span class="spinner"></span>
@@ -1755,7 +1897,15 @@ function InFlightRow({ streaming, startedAt, statusLine, onAbort, tick: _tick })
1755
1897
  <span class="chat-inflight-sep">·</span>
1756
1898
  <span class="muted">${elapsed}s</span>
1757
1899
  ${
1758
- textLen > 0 || reasoningLen > 0
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)
1759
1909
  ? html`
1760
1910
  <span class="chat-inflight-sep">·</span>
1761
1911
  <span class="muted">
@@ -4229,23 +4379,41 @@ function ErrorOverlay() {
4229
4379
 
4230
4380
  // Preact ErrorBoundary — catches render-time exceptions in the App
4231
4381
  // subtree and dispatches them to the error overlay instead of leaving
4232
- // the user with a blank white page. After capturing, render falls
4233
- // 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.
4234
4386
  class ErrorBoundary extends Component {
4235
4387
  constructor(props) {
4236
4388
  super(props);
4237
- this.state = { caught: false };
4389
+ this.state = { caught: false, lastErr: null, attempts: 0 };
4238
4390
  }
4239
- static getDerivedStateFromError() {
4240
- return { caught: true };
4391
+ static getDerivedStateFromError(error) {
4392
+ return { caught: true, lastErr: error };
4241
4393
  }
4242
4394
  componentDidCatch(error, info) {
4243
4395
  reportAppError(error, "render", info?.componentStack ?? "");
4244
- // Recover after a tick overlay handles the user's next move.
4245
- 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);
4246
4404
  }
4247
4405
  render() {
4248
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
+ }
4249
4417
  return html`<div class="boot">recovering…</div>`;
4250
4418
  }
4251
4419
  return this.props.children;