reasonix 0.11.3 → 0.12.6

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.
@@ -0,0 +1,3913 @@
1
+ // Reasonix dashboard SPA — Preact 10 + HTM, no build step.
2
+ //
3
+ // CDN imports use esm.sh (provides ESM bundles of npm packages with
4
+ // caching). We pin minor versions; bumping is a deliberate choice.
5
+
6
+ import hljs from "https://esm.sh/highlight.js@11.10.0/lib/common";
7
+ import htm from "https://esm.sh/htm@3.1.1";
8
+ import { Marked, marked } from "https://esm.sh/marked@12.0.2";
9
+ import { Component, h, render } from "https://esm.sh/preact@10.22.0";
10
+ import {
11
+ useCallback,
12
+ useEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from "https://esm.sh/preact@10.22.0/hooks";
17
+
18
+ const html = htm.bind(h);
19
+
20
+ // ---------- Markdown rendering ----------
21
+ //
22
+ // Rules:
23
+ // - GFM (tables, strikethrough, autolinks).
24
+ // - Code blocks with a known language → highlight.js. Auto-detect
25
+ // when the fence has no language. The CSS in app.css picks colors
26
+ // from our palette so the result reads as Reasonix's TUI, not as
27
+ // GitHub's.
28
+ // - Code blocks that look like Reasonix's edit_file SEARCH/REPLACE
29
+ // or a unified diff → custom diff renderer with red/green lines
30
+ // matching the TUI's edit-block view.
31
+
32
+ function escapeHtml(s) {
33
+ // Defensive: marked occasionally hands the renderer `text === undefined`
34
+ // for empty / malformed fences (and the token-vs-positional ambiguity
35
+ // around v12 makes it easier to crash than not). Coerce to string
36
+ // instead of letting `.replace` blow up.
37
+ if (s == null) return "";
38
+ return String(s)
39
+ .replace(/&/g, "&")
40
+ .replace(/</g, "&lt;")
41
+ .replace(/>/g, "&gt;")
42
+ .replace(/"/g, "&quot;")
43
+ .replace(/'/g, "&#39;");
44
+ }
45
+
46
+ const SEARCH_REPLACE_RE = /<{7}\s*SEARCH\s*\n([\s\S]*?)\n={7}\s*\n([\s\S]*?)\n>{7}\s*REPLACE/;
47
+
48
+ function renderSearchReplace(search, replace, file) {
49
+ const safeSearch = typeof search === "string" ? search : String(search ?? "");
50
+ const safeReplace = typeof replace === "string" ? replace : String(replace ?? "");
51
+ const oldLines = safeSearch
52
+ .split("\n")
53
+ .map((l) => `<span class="diff-line del">- ${escapeHtml(l)}</span>`)
54
+ .join("\n");
55
+ const newLines = safeReplace
56
+ .split("\n")
57
+ .map((l) => `<span class="diff-line ins">+ ${escapeHtml(l)}</span>`)
58
+ .join("\n");
59
+ const header = file ? `<span class="diff-line hunk">▸ edit ${escapeHtml(file)}</span>\n` : "";
60
+ return `<pre class="diff-block">${header}${oldLines}\n${newLines}</pre>`;
61
+ }
62
+
63
+ function renderUnifiedDiff(text) {
64
+ const safe = typeof text === "string" ? text : String(text ?? "");
65
+ const lines = safe
66
+ .split("\n")
67
+ .map((l) => {
68
+ if (l.startsWith("+++") || l.startsWith("---")) {
69
+ return `<span class="diff-line meta">${escapeHtml(l)}</span>`;
70
+ }
71
+ if (l.startsWith("+")) {
72
+ return `<span class="diff-line ins">${escapeHtml(l)}</span>`;
73
+ }
74
+ if (l.startsWith("-")) {
75
+ return `<span class="diff-line del">${escapeHtml(l)}</span>`;
76
+ }
77
+ if (l.startsWith("@@")) {
78
+ return `<span class="diff-line hunk">${escapeHtml(l)}</span>`;
79
+ }
80
+ return escapeHtml(l);
81
+ })
82
+ .join("\n");
83
+ return `<pre class="diff-block">${lines}</pre>`;
84
+ }
85
+
86
+ const renderer = new marked.Renderer();
87
+ // Accept BOTH calling conventions so we don't crash if marked changes
88
+ // shape between versions:
89
+ // - v12+ token object: renderer.code({ type, raw, lang, text, ... })
90
+ // - legacy positional: renderer.code(text, lang, escaped)
91
+ // Either path normalizes to `{ text, lang }`. Empty / nullable text is
92
+ // coerced to "" (escapeHtml does the same) so an empty fence renders
93
+ // as an empty code block rather than throwing.
94
+ renderer.code = function reasonixCode(arg1, arg2 /* legacy lang */) {
95
+ let text;
96
+ let lang;
97
+ if (arg1 && typeof arg1 === "object" && !Array.isArray(arg1)) {
98
+ text = arg1.text;
99
+ lang = arg1.lang;
100
+ } else {
101
+ text = arg1;
102
+ lang = arg2;
103
+ }
104
+ if (text == null) text = "";
105
+ if (typeof text !== "string") text = String(text);
106
+ // Reasonix's edit_file marker block. Show as red/green diff with a
107
+ // small "▸ edit <file>" header lifted from the language tag (e.g.
108
+ // ```edit:src/foo.ts → file = src/foo.ts).
109
+ const sr = SEARCH_REPLACE_RE.exec(text);
110
+ if (sr) {
111
+ const file = typeof lang === "string" && lang.startsWith("edit:") ? lang.slice(5) : "";
112
+ return renderSearchReplace(sr[1], sr[2], file);
113
+ }
114
+ if (lang === "diff") {
115
+ return renderUnifiedDiff(text);
116
+ }
117
+ // Standard highlight.js path — explicit language wins, otherwise auto.
118
+ if (lang && typeof lang === "string" && hljs.getLanguage(lang)) {
119
+ try {
120
+ const h = hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
121
+ return `<pre><code class="hljs language-${lang}">${h}</code></pre>`;
122
+ } catch {
123
+ /* fall through to auto */
124
+ }
125
+ }
126
+ try {
127
+ const auto = hljs.highlightAuto(text);
128
+ return `<pre><code class="hljs">${auto.value}</code></pre>`;
129
+ } catch {
130
+ return `<pre><code>${escapeHtml(text)}</code></pre>`;
131
+ }
132
+ };
133
+
134
+ marked.use({ renderer, gfm: true, breaks: false, pedantic: false });
135
+
136
+ // Separate Marked instance for the editor's markdown preview. The chat
137
+ // renderer above does fancy SEARCH/REPLACE diff blocks and stamps every
138
+ // code fence through hljs — useful inside an assistant message, but
139
+ // disruptive when the user is just previewing a normal README and
140
+ // expects standard `<pre><code>` blocks. Vanilla rendering also avoids
141
+ // any chance our custom token-shape sniffing breaks on real markdown.
142
+ const previewMarked = new Marked({ gfm: true, breaks: false, pedantic: false });
143
+ previewMarked.use({
144
+ renderer: {
145
+ code(...args) {
146
+ const first = args[0];
147
+ const arg = first && typeof first === "object" ? first : { text: first, lang: args[1] };
148
+ const text = arg.text == null ? "" : String(arg.text);
149
+ const lang = typeof arg.lang === "string" ? arg.lang : "";
150
+ try {
151
+ const out =
152
+ lang && hljs.getLanguage(lang)
153
+ ? hljs.highlight(text, { language: lang, ignoreIllegals: true })
154
+ : hljs.highlightAuto(text);
155
+ const cls = lang ? `hljs language-${lang}` : "hljs";
156
+ return `<pre><code class="${cls}">${out.value}</code></pre>`;
157
+ } catch {
158
+ return `<pre><code>${escapeHtml(text)}</code></pre>`;
159
+ }
160
+ },
161
+ },
162
+ });
163
+
164
+ // ---------- bootstrapping ----------
165
+
166
+ const TOKEN = document.querySelector('meta[name="reasonix-token"]')?.getAttribute("content") ?? "";
167
+ const MODE =
168
+ document.querySelector('meta[name="reasonix-mode"]')?.getAttribute("content") ?? "standalone";
169
+
170
+ // Helper: every fetch tacks the token onto the URL (reads) and the
171
+ // header (mutations). Server logic in src/server/index.ts requires
172
+ // the header form for any non-GET.
173
+ async function api(path, opts = {}) {
174
+ const method = opts.method ?? "GET";
175
+ const url = `/api${path}${path.includes("?") ? "&" : "?"}token=${TOKEN}`;
176
+ const headers = { ...(opts.headers ?? {}) };
177
+ headers["X-Reasonix-Token"] = TOKEN;
178
+ if (opts.body !== undefined) headers["Content-Type"] = "application/json";
179
+ const res = await fetch(url, {
180
+ method,
181
+ headers,
182
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
183
+ });
184
+ const text = await res.text();
185
+ let parsed = null;
186
+ try {
187
+ parsed = text ? JSON.parse(text) : null;
188
+ } catch {
189
+ parsed = { error: text };
190
+ }
191
+ if (!res.ok) {
192
+ const err = new Error(parsed?.error ?? `${res.status} ${res.statusText}`);
193
+ err.status = res.status;
194
+ err.body = parsed;
195
+ throw err;
196
+ }
197
+ return parsed;
198
+ }
199
+
200
+ // usePoll: re-fetch a GET endpoint every `intervalMs`, returning
201
+ // `{ data, error, loading, refresh }`. v0.13 swaps this for SSE.
202
+ function usePoll(path, intervalMs = 2000) {
203
+ const [data, setData] = useState(null);
204
+ const [error, setError] = useState(null);
205
+ const [loading, setLoading] = useState(true);
206
+
207
+ const refresh = useCallback(async () => {
208
+ try {
209
+ const next = await api(path);
210
+ setData(next);
211
+ setError(null);
212
+ } catch (err) {
213
+ setError(err);
214
+ } finally {
215
+ setLoading(false);
216
+ }
217
+ }, [path]);
218
+
219
+ useEffect(() => {
220
+ let cancelled = false;
221
+ let timer = null;
222
+ const tick = async () => {
223
+ if (cancelled) return;
224
+ await refresh();
225
+ if (cancelled) return;
226
+ timer = setTimeout(tick, intervalMs);
227
+ };
228
+ tick();
229
+ return () => {
230
+ cancelled = true;
231
+ if (timer) clearTimeout(timer);
232
+ };
233
+ }, [refresh, intervalMs]);
234
+
235
+ return { data, error, loading, refresh };
236
+ }
237
+
238
+ // ---------- formatting helpers ----------
239
+
240
+ function fmtUsd(n) {
241
+ if (n === null || n === undefined) return "—";
242
+ if (n === 0) return "$0";
243
+ return `$${n.toFixed(n < 0.01 ? 6 : 4)}`;
244
+ }
245
+
246
+ function fmtPct(n) {
247
+ if (n === null || n === undefined) return "—";
248
+ return `${(n * 100).toFixed(1)}%`;
249
+ }
250
+
251
+ function fmtNum(n) {
252
+ if (n === null || n === undefined) return "—";
253
+ return n.toLocaleString();
254
+ }
255
+
256
+ function fmtBytes(n) {
257
+ if (n === null || n === undefined) return "—";
258
+ if (n < 1024) return `${n} B`;
259
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
260
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
261
+ return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
262
+ }
263
+
264
+ function fmtRelativeTime(iso) {
265
+ if (!iso) return "—";
266
+ const ms = typeof iso === "number" ? iso : Date.parse(iso);
267
+ if (!Number.isFinite(ms)) return "—";
268
+ const dSec = (Date.now() - ms) / 1000;
269
+ if (dSec < 60) return "just now";
270
+ if (dSec < 3600) return `${Math.floor(dSec / 60)}m ago`;
271
+ if (dSec < 86400) return `${Math.floor(dSec / 3600)}h ago`;
272
+ if (dSec < 30 * 86400) return `${Math.floor(dSec / 86400)}d ago`;
273
+ return new Date(ms).toISOString().slice(0, 10);
274
+ }
275
+
276
+ // ---------- panels ----------
277
+
278
+ function OverviewPanel() {
279
+ const { data, error, loading } = usePoll("/overview", 2000);
280
+ if (loading && !data) return html`<div class="boot">loading overview…</div>`;
281
+ if (error) return html`<div class="notice err">overview failed: ${error.message}</div>`;
282
+ const o = data;
283
+
284
+ return html`
285
+ <div>
286
+ <div class="panel-header">
287
+ <h2 class="panel-title">Live Cockpit</h2>
288
+ <span class="panel-subtitle">${o.mode === "attached" ? "attached to running session" : "standalone (read-only disk view)"}</span>
289
+ </div>
290
+
291
+ ${o.mode === "standalone" ? html`<div class="notice">Standalone mode — start <code>/dashboard</code> from inside <code>reasonix code</code> for live session state, MCP, and tools.</div>` : null}
292
+
293
+ <div class="metric-grid">
294
+ ${MetricCard("Reasonix", o.version, o.latestVersion && o.latestVersion !== o.version ? `latest: ${o.latestVersion}` : "current")}
295
+ ${MetricCard("Session", o.session ?? "—", o.session === null ? "ephemeral or disconnected" : null)}
296
+ ${MetricCard("Model", o.model ?? "—", o.model ? "active" : null)}
297
+ ${MetricCard("Edit mode", o.editMode ?? "—", o.editMode === "yolo" ? "all prompts bypassed" : null, o.editMode === "yolo" ? "warn" : null)}
298
+ ${MetricCard("Plan mode", o.planMode === null ? "—" : o.planMode ? "ON" : "off", o.planMode ? "writes gated" : null)}
299
+ ${MetricCard("Pending edits", fmtNum(o.pendingEdits), o.pendingEdits ? "awaiting /apply" : null)}
300
+ ${MetricCard("MCP servers", fmtNum(o.mcpServerCount), null)}
301
+ ${MetricCard("Tools", fmtNum(o.toolCount), null)}
302
+ </div>
303
+
304
+ <div class="section-title">Working directory</div>
305
+ <div class="card">
306
+ <div class="card-value mono" style="font-size: 14px;">${o.cwd ?? "—"}</div>
307
+ </div>
308
+ </div>
309
+ `;
310
+ }
311
+
312
+ function MetricCard(title, value, hint, pillVariant) {
313
+ const muted = value === "—" || value === null || value === undefined;
314
+ return html`
315
+ <div class="card">
316
+ <div class="card-title">${title}</div>
317
+ <div class="card-value ${muted ? "muted" : ""}">${value}</div>
318
+ ${
319
+ hint
320
+ ? pillVariant
321
+ ? html`<div class="card-hint"><span class="pill pill-${pillVariant}">${hint}</span></div>`
322
+ : html`<div class="card-hint">${hint}</div>`
323
+ : null
324
+ }
325
+ </div>
326
+ `;
327
+ }
328
+
329
+ function UsagePanel() {
330
+ const { data, error, loading } = usePoll("/usage", 5000);
331
+ if (loading && !data) return html`<div class="boot">loading usage…</div>`;
332
+ if (error) return html`<div class="notice err">usage failed: ${error.message}</div>`;
333
+ const u = data;
334
+
335
+ return html`
336
+ <div>
337
+ <div class="panel-header">
338
+ <h2 class="panel-title">Usage</h2>
339
+ <span class="panel-subtitle">${u.recordCount.toLocaleString()} records · ${u.logSize}</span>
340
+ </div>
341
+
342
+ ${
343
+ u.recordCount === 0
344
+ ? html`<div class="empty">No usage data yet — run a turn in <code>reasonix chat</code> / <code>code</code> / <code>run</code> and refresh.</div>`
345
+ : html`
346
+ <table>
347
+ <thead>
348
+ <tr>
349
+ <th></th>
350
+ <th class="numeric">turns</th>
351
+ <th class="numeric">cache hit</th>
352
+ <th class="numeric">cost (USD)</th>
353
+ <th class="numeric">cache saved</th>
354
+ <th class="numeric">vs Claude</th>
355
+ <th class="numeric">saved</th>
356
+ </tr>
357
+ </thead>
358
+ <tbody>
359
+ ${u.buckets.map((b) => {
360
+ const hitRatio =
361
+ b.cacheHitTokens + b.cacheMissTokens > 0
362
+ ? b.cacheHitTokens / (b.cacheHitTokens + b.cacheMissTokens)
363
+ : 0;
364
+ const claudeSavings = b.claudeEquivUsd > 0 ? 1 - b.costUsd / b.claudeEquivUsd : 0;
365
+ return html`
366
+ <tr>
367
+ <td>${b.label}</td>
368
+ <td class="numeric">${fmtNum(b.turns)}</td>
369
+ <td class="numeric">${b.turns > 0 ? fmtPct(hitRatio) : "—"}</td>
370
+ <td class="numeric">${b.turns > 0 ? fmtUsd(b.costUsd) : "—"}</td>
371
+ <td class="numeric">${b.turns > 0 && b.cacheSavingsUsd > 0 ? fmtUsd(b.cacheSavingsUsd) : "—"}</td>
372
+ <td class="numeric">${b.turns > 0 ? fmtUsd(b.claudeEquivUsd) : "—"}</td>
373
+ <td class="numeric">${b.turns > 0 && claudeSavings > 0 ? fmtPct(claudeSavings) : "—"}</td>
374
+ </tr>
375
+ `;
376
+ })}
377
+ </tbody>
378
+ </table>
379
+ `
380
+ }
381
+
382
+ ${
383
+ u.byModel.length > 0
384
+ ? html`
385
+ <div class="section-title">Most used models</div>
386
+ <table>
387
+ <thead><tr><th>model</th><th class="numeric">turns</th></tr></thead>
388
+ <tbody>
389
+ ${u.byModel.slice(0, 5).map(
390
+ (m) => html`
391
+ <tr><td><code>${m.model}</code></td><td class="numeric">${fmtNum(m.turns)}</td></tr>
392
+ `,
393
+ )}
394
+ </tbody>
395
+ </table>
396
+ `
397
+ : null
398
+ }
399
+
400
+ ${
401
+ u.subagents
402
+ ? html`
403
+ <div class="section-title">Subagent activity</div>
404
+ <div class="card">
405
+ <div class="card-title">Total runs</div>
406
+ <div class="card-value">${fmtNum(u.subagents.total)}</div>
407
+ <div class="card-hint">${fmtUsd(u.subagents.costUsd)} · ${(u.subagents.totalDurationMs / 1000).toFixed(1)}s total</div>
408
+ </div>
409
+ `
410
+ : null
411
+ }
412
+ </div>
413
+ `;
414
+ }
415
+
416
+ function ToolsPanel() {
417
+ const { data, error, loading } = usePoll("/tools", 4000);
418
+ if (loading && !data) return html`<div class="boot">loading tools…</div>`;
419
+ if (error?.status === 503) {
420
+ return html`<div class="notice">${error.body?.error ?? "live tools view requires an attached session"}</div>`;
421
+ }
422
+ if (error) return html`<div class="notice err">tools failed: ${error.message}</div>`;
423
+ const t = data;
424
+
425
+ return html`
426
+ <div>
427
+ <div class="panel-header">
428
+ <h2 class="panel-title">Tools</h2>
429
+ <span class="panel-subtitle">${t.total} registered ${t.planMode ? html`<span class="pill pill-warn">plan mode — writes gated</span>` : ""}</span>
430
+ </div>
431
+ ${
432
+ t.tools.length === 0
433
+ ? html`<div class="empty">No tools registered.</div>`
434
+ : html`
435
+ <table>
436
+ <thead>
437
+ <tr>
438
+ <th>name</th>
439
+ <th>flags</th>
440
+ <th>description</th>
441
+ </tr>
442
+ </thead>
443
+ <tbody>
444
+ ${t.tools.map(
445
+ (tool) => html`
446
+ <tr>
447
+ <td><code>${tool.name}</code></td>
448
+ <td>
449
+ ${tool.readOnly ? html`<span class="pill pill-ok">read-only</span>` : html`<span class="pill pill-accent">write</span>`}
450
+ ${tool.flattened ? html` <span class="pill pill-dim">flat</span>` : ""}
451
+ </td>
452
+ <td>${tool.description ?? ""}</td>
453
+ </tr>
454
+ `,
455
+ )}
456
+ </tbody>
457
+ </table>
458
+ `
459
+ }
460
+ </div>
461
+ `;
462
+ }
463
+
464
+ function PermissionsPanel() {
465
+ const { data, error, loading, refresh } = usePoll("/permissions", 5000);
466
+ const [draft, setDraft] = useState("");
467
+ const [busy, setBusy] = useState(false);
468
+ const [feedback, setFeedback] = useState(null);
469
+
470
+ const add = useCallback(async () => {
471
+ const prefix = draft.trim();
472
+ if (!prefix) return;
473
+ setBusy(true);
474
+ setFeedback(null);
475
+ try {
476
+ const res = await api("/permissions", { method: "POST", body: { prefix } });
477
+ if (res.alreadyPresent) setFeedback({ kind: "info", text: `${prefix} already in list` });
478
+ else setFeedback({ kind: "ok", text: `added: ${prefix}` });
479
+ setDraft("");
480
+ await refresh();
481
+ } catch (err) {
482
+ setFeedback({ kind: "err", text: err.message });
483
+ } finally {
484
+ setBusy(false);
485
+ }
486
+ }, [draft, refresh]);
487
+
488
+ const remove = useCallback(
489
+ async (prefix) => {
490
+ if (!confirm(`Remove "${prefix}" from this project's allowlist?`)) return;
491
+ setBusy(true);
492
+ setFeedback(null);
493
+ try {
494
+ await api("/permissions", { method: "DELETE", body: { prefix } });
495
+ setFeedback({ kind: "ok", text: `removed: ${prefix}` });
496
+ await refresh();
497
+ } catch (err) {
498
+ setFeedback({ kind: "err", text: err.message });
499
+ } finally {
500
+ setBusy(false);
501
+ }
502
+ },
503
+ [refresh],
504
+ );
505
+
506
+ const clearAll = useCallback(async () => {
507
+ if (!confirm("Wipe every project allowlist entry? Builtin entries are unaffected.")) return;
508
+ setBusy(true);
509
+ setFeedback(null);
510
+ try {
511
+ const res = await api("/permissions/clear", { method: "POST", body: { confirm: true } });
512
+ setFeedback({
513
+ kind: "ok",
514
+ text: `cleared ${res.dropped} entr${res.dropped === 1 ? "y" : "ies"}`,
515
+ });
516
+ await refresh();
517
+ } catch (err) {
518
+ setFeedback({ kind: "err", text: err.message });
519
+ } finally {
520
+ setBusy(false);
521
+ }
522
+ }, [refresh]);
523
+
524
+ if (loading && !data) return html`<div class="boot">loading permissions…</div>`;
525
+ if (error) return html`<div class="notice err">permissions failed: ${error.message}</div>`;
526
+ const p = data;
527
+
528
+ const banner =
529
+ p.editMode === "yolo"
530
+ ? html`<div class="notice warn">YOLO mode — every shell command auto-runs, allowlist bypassed. <code>/mode review</code> in TUI re-enables.</div>`
531
+ : null;
532
+
533
+ return html`
534
+ <div>
535
+ <div class="panel-header">
536
+ <h2 class="panel-title">Permissions</h2>
537
+ <span class="panel-subtitle">${p.currentCwd ? `project: ${p.currentCwd}` : "no active project"}</span>
538
+ </div>
539
+ ${banner}
540
+
541
+ ${
542
+ p.currentCwd
543
+ ? html`
544
+ <div class="row">
545
+ <input
546
+ type="text"
547
+ placeholder='add a prefix, e.g. "npm run build" or "deploy.sh"'
548
+ value=${draft}
549
+ onInput=${(e) => setDraft(e.target.value)}
550
+ onKeyDown=${(e) => {
551
+ if (e.key === "Enter") add();
552
+ }}
553
+ disabled=${busy}
554
+ />
555
+ <button class="primary" onClick=${add} disabled=${busy || !draft.trim()}>Add</button>
556
+ <button class="danger" onClick=${clearAll} disabled=${busy || p.project.length === 0}>Clear all</button>
557
+ </div>
558
+ ${feedback ? html`<div class="notice ${feedback.kind === "err" ? "err" : feedback.kind === "ok" ? "" : "warn"}">${feedback.text}</div>` : null}
559
+ `
560
+ : html`<div class="notice">Mutations require <code>/dashboard</code> from inside an active <code>reasonix code</code> session — standalone <code>reasonix dashboard</code> can't tell which project's allowlist to edit.</div>`
561
+ }
562
+
563
+ <div class="section-title">Project allowlist (${p.project.length})</div>
564
+ ${
565
+ p.project.length === 0
566
+ ? html`<div class="empty">Nothing stored yet for this project.</div>`
567
+ : html`
568
+ <table>
569
+ <thead><tr><th>#</th><th>prefix</th><th></th></tr></thead>
570
+ <tbody>
571
+ ${p.project.map(
572
+ (prefix, i) => html`
573
+ <tr>
574
+ <td class="muted">${i + 1}</td>
575
+ <td><code>${prefix}</code></td>
576
+ <td class="numeric">${p.currentCwd ? html`<button class="danger" onClick=${() => remove(prefix)} disabled=${busy}>remove</button>` : null}</td>
577
+ </tr>
578
+ `,
579
+ )}
580
+ </tbody>
581
+ </table>
582
+ `
583
+ }
584
+
585
+ <div class="section-title">Builtin allowlist (${p.builtin.length}) — read-only, baked in</div>
586
+ <div class="card mono" style="font-size: 12px; line-height: 1.7;">
587
+ ${groupByVerb(p.builtin).map(
588
+ ([verb, list]) => html`
589
+ <div><span class="pill pill-dim">${verb}</span> ${list.join(" · ")}</div>
590
+ `,
591
+ )}
592
+ </div>
593
+ </div>
594
+ `;
595
+ }
596
+
597
+ function groupByVerb(list) {
598
+ const groups = new Map();
599
+ for (const entry of list) {
600
+ const head = entry.split(" ")[0];
601
+ if (!groups.has(head)) groups.set(head, []);
602
+ const tail = entry.slice(head.length).trim();
603
+ groups.get(head).push(tail || "(bare)");
604
+ }
605
+ return [...groups.entries()];
606
+ }
607
+
608
+ // ---------- Chat panel ----------
609
+
610
+ const ROLE_GLYPH = {
611
+ user: "◇",
612
+ assistant: "◆",
613
+ tool: "▣",
614
+ info: "·",
615
+ warning: "▲",
616
+ error: "✦",
617
+ };
618
+
619
+ function renderMessageBody(text) {
620
+ if (!text) return null;
621
+ // marked.parse escapes raw HTML in source by default — so any `<script>`
622
+ // in model output gets rendered as literal text, not executed. We can
623
+ // safely hand the result straight to dangerouslySetInnerHTML.
624
+ const rendered = marked.parse(text);
625
+ return html`<div class="md" dangerouslySetInnerHTML=${{ __html: rendered }}></div>`;
626
+ }
627
+
628
+ // Map common file extensions to highlight.js languages.
629
+ const LANG_BY_EXT = {
630
+ ts: "typescript",
631
+ tsx: "typescript",
632
+ js: "javascript",
633
+ jsx: "javascript",
634
+ mjs: "javascript",
635
+ cjs: "javascript",
636
+ py: "python",
637
+ rs: "rust",
638
+ go: "go",
639
+ java: "java",
640
+ kt: "kotlin",
641
+ c: "c",
642
+ h: "c",
643
+ cpp: "cpp",
644
+ cc: "cpp",
645
+ hpp: "cpp",
646
+ cs: "csharp",
647
+ swift: "swift",
648
+ rb: "ruby",
649
+ php: "php",
650
+ sh: "bash",
651
+ bash: "bash",
652
+ zsh: "bash",
653
+ fish: "bash",
654
+ ps1: "powershell",
655
+ json: "json",
656
+ yaml: "yaml",
657
+ yml: "yaml",
658
+ toml: "ini",
659
+ xml: "xml",
660
+ html: "xml",
661
+ svg: "xml",
662
+ css: "css",
663
+ scss: "scss",
664
+ less: "less",
665
+ md: "markdown",
666
+ sql: "sql",
667
+ vue: "xml",
668
+ svelte: "xml",
669
+ tex: "latex",
670
+ proto: "protobuf",
671
+ dockerfile: "dockerfile",
672
+ };
673
+
674
+ function langFromPath(path) {
675
+ if (!path) return null;
676
+ const lower = path.toLowerCase();
677
+ if (lower.endsWith("dockerfile")) return "dockerfile";
678
+ const dot = lower.lastIndexOf(".");
679
+ if (dot < 0) return null;
680
+ const ext = lower.slice(dot + 1);
681
+ return LANG_BY_EXT[ext] ?? null;
682
+ }
683
+
684
+ function renderHighlightedBlock(text, lang) {
685
+ if (!text) return "";
686
+ const safeLang = lang && hljs.getLanguage(lang) ? lang : null;
687
+ try {
688
+ const out = safeLang
689
+ ? hljs.highlight(text, { language: safeLang, ignoreIllegals: true })
690
+ : hljs.highlightAuto(text);
691
+ return `<pre class="md"><code class="hljs ${safeLang ? `language-${safeLang}` : ""}">${out.value}</code></pre>`;
692
+ } catch {
693
+ return `<pre><code>${escapeHtml(text)}</code></pre>`;
694
+ }
695
+ }
696
+
697
+ function parseToolArgs(raw) {
698
+ if (!raw) return null;
699
+ try {
700
+ return JSON.parse(raw);
701
+ } catch {
702
+ return null;
703
+ }
704
+ }
705
+
706
+ function ToolCard({ msg }) {
707
+ const args = parseToolArgs(msg.toolArgs);
708
+ const name = msg.toolName ?? "tool";
709
+ // Reasonix's filesystem tools emit the path in args.path; MCP-bridged
710
+ // ones may differ but most expose a `path` field too. Normalize.
711
+ const path = args?.path ?? args?.file_path ?? args?.filename;
712
+
713
+ // edit_file (Reasonix) — search/replace pair → diff view.
714
+ if (
715
+ (name === "edit_file" || name.endsWith("_edit_file")) &&
716
+ args &&
717
+ typeof args.search === "string" &&
718
+ typeof args.replace === "string"
719
+ ) {
720
+ const diffHtml = renderSearchReplace(args.search, args.replace, path ?? "");
721
+ return html`
722
+ <div class="tool-card">
723
+ <div class="tool-card-head">
724
+ <span class="tool-card-icon">✎</span>
725
+ <span class="tool-card-name">edit_file</span>
726
+ ${path ? html`<code class="tool-card-path tool-card-path-link" onClick=${() => openFileInEditor(path)} title="open in editor">${path}</code>` : null}
727
+ </div>
728
+ <div dangerouslySetInnerHTML=${{ __html: diffHtml }}></div>
729
+ ${msg.text ? html`<div class="tool-card-result">${msg.text}</div>` : null}
730
+ </div>
731
+ `;
732
+ }
733
+
734
+ // write_file — show new content as a code block with path-derived lang.
735
+ if (
736
+ (name === "write_file" || name.endsWith("_write_file")) &&
737
+ args &&
738
+ typeof args.content === "string"
739
+ ) {
740
+ const lang = langFromPath(path);
741
+ return html`
742
+ <div class="tool-card">
743
+ <div class="tool-card-head">
744
+ <span class="tool-card-icon">+</span>
745
+ <span class="tool-card-name">write_file</span>
746
+ ${path ? html`<code class="tool-card-path tool-card-path-link" onClick=${() => openFileInEditor(path)} title="open in editor">${path}</code>` : null}
747
+ ${lang ? html`<span class="pill pill-dim">${lang}</span>` : null}
748
+ </div>
749
+ <div dangerouslySetInnerHTML=${{ __html: renderHighlightedBlock(args.content, lang) }}></div>
750
+ ${msg.text ? html`<div class="tool-card-result">${msg.text}</div>` : null}
751
+ </div>
752
+ `;
753
+ }
754
+
755
+ // read_file / list_files — content lands in msg.text.
756
+ if (name === "read_file" || name.endsWith("_read_file") || name === "filesystem_read_file") {
757
+ const lang = langFromPath(path);
758
+ return html`
759
+ <div class="tool-card">
760
+ <div class="tool-card-head">
761
+ <span class="tool-card-icon">▤</span>
762
+ <span class="tool-card-name">read_file</span>
763
+ ${path ? html`<code class="tool-card-path tool-card-path-link" onClick=${() => openFileInEditor(path)} title="open in editor">${path}</code>` : null}
764
+ ${lang ? html`<span class="pill pill-dim">${lang}</span>` : null}
765
+ </div>
766
+ <div dangerouslySetInnerHTML=${{ __html: renderHighlightedBlock(msg.text, lang) }}></div>
767
+ </div>
768
+ `;
769
+ }
770
+
771
+ // run_command / run_background — terminal-style.
772
+ if (name === "run_command" || name === "run_background") {
773
+ const cmd = args?.command;
774
+ return html`
775
+ <div class="tool-card">
776
+ <div class="tool-card-head">
777
+ <span class="tool-card-icon">⚡</span>
778
+ <span class="tool-card-name">${name === "run_background" ? "run_background" : "run_command"}</span>
779
+ </div>
780
+ ${
781
+ cmd
782
+ ? html`<pre class="tool-card-cmd"><span class="tool-card-prompt">$</span> <code>${cmd}</code></pre>`
783
+ : null
784
+ }
785
+ ${msg.text ? html`<pre class="tool-card-output">${msg.text}</pre>` : null}
786
+ </div>
787
+ `;
788
+ }
789
+
790
+ // list_files / file_exists / delete_file — show args + result inline.
791
+ if (
792
+ name === "list_files" ||
793
+ name === "file_exists" ||
794
+ name === "delete_file" ||
795
+ name === "create_directory" ||
796
+ name === "delete_directory" ||
797
+ name.endsWith("_list_files")
798
+ ) {
799
+ return html`
800
+ <div class="tool-card">
801
+ <div class="tool-card-head">
802
+ <span class="tool-card-icon">▣</span>
803
+ <span class="tool-card-name">${name}</span>
804
+ ${path ? html`<code class="tool-card-path tool-card-path-link" onClick=${() => openFileInEditor(path)} title="open in editor">${path}</code>` : null}
805
+ </div>
806
+ <pre class="tool-card-output">${msg.text}</pre>
807
+ </div>
808
+ `;
809
+ }
810
+
811
+ // Default — keep the legacy compact box but add an args preview when
812
+ // present so MCP-bridged tools still surface something readable.
813
+ return html`
814
+ <div class="tool-card">
815
+ <div class="tool-card-head">
816
+ <span class="tool-card-icon">▣</span>
817
+ <span class="tool-card-name">${name}</span>
818
+ </div>
819
+ ${
820
+ args
821
+ ? html`<details class="tool-card-args"><summary>arguments</summary><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></details>`
822
+ : null
823
+ }
824
+ <pre class="tool-card-output">${msg.text}</pre>
825
+ </div>
826
+ `;
827
+ }
828
+
829
+ function ChatMessage({ msg, streaming }) {
830
+ const role = msg.role;
831
+ const glyph = ROLE_GLYPH[role] ?? "·";
832
+ if (role === "tool") {
833
+ return html`
834
+ <div class="chat-msg tool">
835
+ <div class="glyph">${glyph}</div>
836
+ <${ToolCard} msg=${msg} />
837
+ </div>
838
+ `;
839
+ }
840
+ return html`
841
+ <div class="chat-msg ${role}">
842
+ <div class="glyph">${glyph}</div>
843
+ <div class="body">
844
+ ${msg.reasoning ? html`<div class="reasoning">${msg.reasoning}</div>` : null}
845
+ ${renderMessageBody(msg.text)}
846
+ ${streaming ? html`<span class="chat-streaming-cursor"></span>` : null}
847
+ </div>
848
+ </div>
849
+ `;
850
+ }
851
+
852
+ // ---------- Modal components mirroring the TUI ----------
853
+ //
854
+ // Each component renders a card matching the TUI's ModalCard accent
855
+ // palette: red for shell (run-now), magenta for choice (branching),
856
+ // cyan for plan (decision), green for edits. onResolve pushes to the
857
+ // server; the SSE channel will echo back a modal-down that clears the
858
+ // local state — both surfaces stay in lockstep without polling.
859
+
860
+ function ModalCard({ accent, icon, title, subtitle, children }) {
861
+ return html`
862
+ <div class="modal-card" style=${`border-left-color: ${accent};`}>
863
+ <div class="modal-card-head">
864
+ <span class="modal-card-icon" style=${`color: ${accent};`}>${icon}</span>
865
+ <div>
866
+ <div class="modal-card-title">${title}</div>
867
+ ${subtitle ? html`<div class="modal-card-subtitle">${subtitle}</div>` : null}
868
+ </div>
869
+ </div>
870
+ ${children}
871
+ </div>
872
+ `;
873
+ }
874
+
875
+ function ShellModal({ modal, onResolve }) {
876
+ const isBg = modal.shellKind === "run_background";
877
+ return html`
878
+ <${ModalCard}
879
+ accent="#f87171"
880
+ icon=${isBg ? "⏱" : "⚡"}
881
+ title=${isBg ? "background process" : "shell command"}
882
+ subtitle=${
883
+ isBg ? "long-running — keeps running after approval" : "model wants to run a shell command"
884
+ }
885
+ >
886
+ <div class="modal-cmd"><span class="modal-cmd-prompt">$</span> <code>${modal.command}</code></div>
887
+ <div class="modal-actions">
888
+ <button class="primary" onClick=${() => onResolve("shell", "run_once")}>Run once</button>
889
+ <button onClick=${() => onResolve("shell", "always_allow")}>Always allow "${modal.allowPrefix}"</button>
890
+ <button class="danger" onClick=${() => onResolve("shell", "deny")}>Deny</button>
891
+ </div>
892
+ <//>
893
+ `;
894
+ }
895
+
896
+ function ChoiceModal({ modal, onResolve }) {
897
+ const [custom, setCustom] = useState("");
898
+ const [showCustom, setShowCustom] = useState(false);
899
+ return html`
900
+ <${ModalCard} accent="#f0abfc" icon="🔀" title="model wants you to pick" subtitle=${modal.question}>
901
+ ${modal.options.map(
902
+ (opt) => html`
903
+ <button
904
+ key=${opt.id}
905
+ class="modal-choice-row"
906
+ onClick=${() => onResolve("choice", { kind: "pick", optionId: opt.id })}
907
+ >
908
+ <span class="modal-choice-id">${opt.id}</span>
909
+ <span class="modal-choice-title">${opt.title}</span>
910
+ ${opt.summary ? html`<span class="modal-choice-summary">${opt.summary}</span>` : null}
911
+ </button>
912
+ `,
913
+ )}
914
+ ${
915
+ modal.allowCustom
916
+ ? showCustom
917
+ ? html`
918
+ <div class="modal-custom">
919
+ <textarea
920
+ placeholder="Type a free-form answer…"
921
+ rows="2"
922
+ value=${custom}
923
+ onInput=${(e) => setCustom(e.target.value)}
924
+ ></textarea>
925
+ <div class="modal-actions">
926
+ <button class="primary" onClick=${() => onResolve("choice", { kind: "custom", text: custom })} disabled=${!custom.trim()}>Send</button>
927
+ <button onClick=${() => {
928
+ setShowCustom(false);
929
+ setCustom("");
930
+ }}>Back</button>
931
+ </div>
932
+ </div>
933
+ `
934
+ : html`
935
+ <button class="modal-choice-row" onClick=${() => setShowCustom(true)}>
936
+ <span class="modal-choice-id">·</span>
937
+ <span class="modal-choice-title">Type my own answer</span>
938
+ <span class="modal-choice-summary">None of the above fits — write a free-form reply.</span>
939
+ </button>
940
+ `
941
+ : null
942
+ }
943
+ <button class="modal-choice-row modal-choice-cancel" onClick=${() => onResolve("choice", { kind: "cancel" })}>
944
+ <span class="modal-choice-id">×</span>
945
+ <span class="modal-choice-title">Cancel</span>
946
+ <span class="modal-choice-summary">Drop the question. Model will ask what you actually want.</span>
947
+ </button>
948
+ <//>
949
+ `;
950
+ }
951
+
952
+ function PlanModal({ modal, onResolve }) {
953
+ const [feedback, setFeedback] = useState("");
954
+ const [stage, setStage] = useState(null); // null | "approve" | "refine"
955
+ const send = () => onResolve("plan", stage, feedback);
956
+ return html`
957
+ <${ModalCard} accent="#67e8f9" icon="◆" title="plan submitted" subtitle="model proposed a plan; review then pick">
958
+ <div class="md modal-plan-body" dangerouslySetInnerHTML=${{ __html: marked.parse(modal.body || "") }}></div>
959
+ ${
960
+ stage
961
+ ? html`
962
+ <textarea
963
+ placeholder=${
964
+ stage === "approve"
965
+ ? "Optional last instructions / answers to open questions (Enter to send blank)"
966
+ : "What needs to change? Be specific."
967
+ }
968
+ rows="3"
969
+ value=${feedback}
970
+ onInput=${(e) => setFeedback(e.target.value)}
971
+ ></textarea>
972
+ <div class="modal-actions">
973
+ <button class="primary" onClick=${send}>${stage === "approve" ? "Approve" : "Send refinement"}</button>
974
+ <button onClick=${() => {
975
+ setStage(null);
976
+ setFeedback("");
977
+ }}>Back</button>
978
+ </div>
979
+ `
980
+ : html`
981
+ <div class="modal-actions">
982
+ <button class="primary" onClick=${() => setStage("approve")}>Approve</button>
983
+ <button onClick=${() => setStage("refine")}>Refine</button>
984
+ <button class="danger" onClick=${() => onResolve("plan", "cancel")}>Cancel</button>
985
+ </div>
986
+ `
987
+ }
988
+ <//>
989
+ `;
990
+ }
991
+
992
+ // Line-level LCS diff. Returns an ordered list of rows; "context" rows
993
+ // appear on both sides, "del" only on the left (red), "ins" only on the
994
+ // right (green). Adjacent del/ins are paired into one row downstream so
995
+ // the change reads "old → new" left-to-right like a git side-by-side.
996
+ function lineDiff(aLines, bLines) {
997
+ const m = aLines.length;
998
+ const n = bLines.length;
999
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
1000
+ for (let i = 1; i <= m; i++) {
1001
+ for (let j = 1; j <= n; j++) {
1002
+ if (aLines[i - 1] === bLines[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
1003
+ else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
1004
+ }
1005
+ }
1006
+ const out = [];
1007
+ let i = m;
1008
+ let j = n;
1009
+ while (i > 0 || j > 0) {
1010
+ if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
1011
+ out.push({ kind: "context", text: aLines[i - 1] });
1012
+ i--;
1013
+ j--;
1014
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
1015
+ out.push({ kind: "ins", text: bLines[j - 1] });
1016
+ j--;
1017
+ } else {
1018
+ out.push({ kind: "del", text: aLines[i - 1] });
1019
+ i--;
1020
+ }
1021
+ }
1022
+ return out.reverse();
1023
+ }
1024
+
1025
+ // Pair del/ins runs into side-by-side rows. A run of consecutive dels
1026
+ // followed by a run of inss collapses into rows of (del[k], ins[k]) so
1027
+ // the modified line lines up across the gutter; surplus on either side
1028
+ // produces rows with the opposite cell empty.
1029
+ function pairDiffRows(diff) {
1030
+ const rows = [];
1031
+ let k = 0;
1032
+ while (k < diff.length) {
1033
+ if (diff[k].kind === "context") {
1034
+ rows.push({ left: diff[k].text, right: diff[k].text, kind: "context" });
1035
+ k++;
1036
+ continue;
1037
+ }
1038
+ const dels = [];
1039
+ const inss = [];
1040
+ while (k < diff.length && diff[k].kind === "del") {
1041
+ dels.push(diff[k].text);
1042
+ k++;
1043
+ }
1044
+ while (k < diff.length && diff[k].kind === "ins") {
1045
+ inss.push(diff[k].text);
1046
+ k++;
1047
+ }
1048
+ const pairs = Math.max(dels.length, inss.length);
1049
+ for (let p = 0; p < pairs; p++) {
1050
+ rows.push({
1051
+ left: dels[p] ?? null,
1052
+ right: inss[p] ?? null,
1053
+ kind: dels[p] != null && inss[p] != null ? "change" : dels[p] != null ? "del" : "ins",
1054
+ });
1055
+ }
1056
+ }
1057
+ return rows;
1058
+ }
1059
+
1060
+ // Highlight a single line via hljs in the file's language; falls back to
1061
+ // auto-detect, then escaped plain text. Always returns inline HTML safe
1062
+ // to drop into a span.
1063
+ function hlLine(text, lang) {
1064
+ if (text == null) return "";
1065
+ if (text === "") return "";
1066
+ try {
1067
+ if (lang && hljs.getLanguage(lang)) {
1068
+ return hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
1069
+ }
1070
+ return hljs.highlightAuto(text).value;
1071
+ } catch {
1072
+ return escapeHtml(text);
1073
+ }
1074
+ }
1075
+
1076
+ function EditReviewModal({ modal, onResolve }) {
1077
+ const search = modal.search ?? "";
1078
+ const replace = modal.replace ?? "";
1079
+ const lang = langFromPath(modal.path);
1080
+ const aLines = search.split("\n");
1081
+ const bLines = replace.split("\n");
1082
+ const rows = pairDiffRows(lineDiff(aLines, bLines));
1083
+
1084
+ return html`
1085
+ <${ModalCard}
1086
+ accent="#86efac"
1087
+ icon="◆"
1088
+ title="edit pending review"
1089
+ subtitle=${`${modal.path} · ${modal.remaining} of ${modal.total} blocks remaining`}
1090
+ >
1091
+ <div class="edit-diff-wrap">
1092
+ <div class="edit-diff-head">
1093
+ <div class="edit-diff-side edit-diff-side-old">
1094
+ <span class="edit-diff-marker">−</span> before
1095
+ </div>
1096
+ <div class="edit-diff-side edit-diff-side-new">
1097
+ <span class="edit-diff-marker">+</span> after
1098
+ </div>
1099
+ </div>
1100
+ <div class="edit-diff-body">
1101
+ ${rows.map(
1102
+ (row, i) => html`
1103
+ <div key=${i} class=${`edit-diff-row edit-diff-row-${row.kind}`}>
1104
+ <div class="edit-diff-cell edit-diff-cell-old">
1105
+ ${
1106
+ row.left != null
1107
+ ? html`<span
1108
+ class="edit-diff-line"
1109
+ dangerouslySetInnerHTML=${{ __html: hlLine(row.left, lang) || "&nbsp;" }}
1110
+ ></span>`
1111
+ : html`<span class="edit-diff-empty">&nbsp;</span>`
1112
+ }
1113
+ </div>
1114
+ <div class="edit-diff-cell edit-diff-cell-new">
1115
+ ${
1116
+ row.right != null
1117
+ ? html`<span
1118
+ class="edit-diff-line"
1119
+ dangerouslySetInnerHTML=${{ __html: hlLine(row.right, lang) || "&nbsp;" }}
1120
+ ></span>`
1121
+ : html`<span class="edit-diff-empty">&nbsp;</span>`
1122
+ }
1123
+ </div>
1124
+ </div>
1125
+ `,
1126
+ )}
1127
+ </div>
1128
+ </div>
1129
+ <div class="modal-actions">
1130
+ <button class="primary" onClick=${() => onResolve("edit-review", "apply")}>Apply (y)</button>
1131
+ <button onClick=${() => onResolve("edit-review", "reject")}>Reject (n)</button>
1132
+ <button onClick=${() => onResolve("edit-review", "apply-rest-of-turn")}>Apply rest (a)</button>
1133
+ <button onClick=${() => onResolve("edit-review", "flip-to-auto")}>Flip to AUTO (A)</button>
1134
+ </div>
1135
+ <//>
1136
+ `;
1137
+ }
1138
+
1139
+ function ChatPanel() {
1140
+ const [messages, setMessages] = useState([]);
1141
+ const [streaming, setStreaming] = useState(null); // { id, text, reasoning }
1142
+ const [busy, setBusy] = useState(false);
1143
+ const [input, setInput] = useState("");
1144
+ const [error, setError] = useState(null);
1145
+ const [bootError, setBootError] = useState(null);
1146
+ const [statusLine, setStatusLine] = useState(null);
1147
+ // Mirror of the active TUI modal: { kind, ...payload } | null. Set
1148
+ // by `modal-up` SSE events, cleared by `modal-down`. Web uses POST
1149
+ // /api/modal/resolve to drive resolution; either surface clears the
1150
+ // other's modal via the resulting modal-down event.
1151
+ const [modal, setModal] = useState(null);
1152
+ // Current edit gate (review / auto / yolo). null when not in code
1153
+ // mode. Refreshed via /api/overview poll because the mode also
1154
+ // flips from TUI Shift+Tab and we want the segmented control to
1155
+ // stay in sync without a dedicated event.
1156
+ const [editMode, setEditModeLocal] = useState(null);
1157
+ // Persisted preset + reasoning_effort, surfaced here so the user
1158
+ // can flip them mid-chat without leaving the tab. /api/overview
1159
+ // includes both since 0.14.x; the same poll covers all three.
1160
+ const [preset, setPresetLocal] = useState(null);
1161
+ const [effort, setEffortLocal] = useState(null);
1162
+ // Live session stats — cache hit, costs, tokens, balance — from the
1163
+ // same /api/overview poll. Renders into a compact status bar below
1164
+ // the input area.
1165
+ const [stats, setStats] = useState(null);
1166
+ const [overviewModel, setOverviewModel] = useState(null);
1167
+ // Sticks to bottom only while the user is already near the bottom.
1168
+ // Once they scroll up to read older content the streaming deltas no
1169
+ // longer yank the view back. Re-armed when they scroll back to the
1170
+ // bottom on their own. 80px threshold absorbs sub-pixel rounding.
1171
+ const shouldAutoScroll = useRef(true);
1172
+
1173
+ // Initial snapshot — messages + busy + any modal already up.
1174
+ useEffect(() => {
1175
+ let cancelled = false;
1176
+ (async () => {
1177
+ try {
1178
+ const data = await api("/messages");
1179
+ if (cancelled) return;
1180
+ setMessages(data.messages ?? []);
1181
+ setBusy(Boolean(data.busy));
1182
+ } catch (err) {
1183
+ if (!cancelled) setBootError(err.message);
1184
+ }
1185
+ try {
1186
+ const m = await api("/modal");
1187
+ if (!cancelled && m.modal) setModal(m.modal);
1188
+ } catch {
1189
+ /* skip — modal endpoint optional in standalone */
1190
+ }
1191
+ })();
1192
+ return () => {
1193
+ cancelled = true;
1194
+ };
1195
+ }, []);
1196
+
1197
+ // Live event stream.
1198
+ useEffect(() => {
1199
+ const es = new EventSource(`/api/events?token=${TOKEN}`);
1200
+ es.onmessage = (ev) => {
1201
+ let dash;
1202
+ try {
1203
+ dash = JSON.parse(ev.data);
1204
+ } catch {
1205
+ return;
1206
+ }
1207
+ if (dash.kind === "ping") return;
1208
+ if (dash.kind === "busy-change") {
1209
+ setBusy(dash.busy);
1210
+ return;
1211
+ }
1212
+ if (dash.kind === "user") {
1213
+ setMessages((prev) => [...prev, { id: dash.id, role: "user", text: dash.text }]);
1214
+ return;
1215
+ }
1216
+ if (dash.kind === "assistant_delta") {
1217
+ setStreaming((cur) => {
1218
+ const text = (cur?.text ?? "") + (dash.contentDelta ?? "");
1219
+ const reasoning = (cur?.reasoning ?? "") + (dash.reasoningDelta ?? "");
1220
+ return { id: dash.id, text, reasoning };
1221
+ });
1222
+ return;
1223
+ }
1224
+ if (dash.kind === "assistant_final") {
1225
+ setStreaming(null);
1226
+ setMessages((prev) => [
1227
+ ...prev,
1228
+ {
1229
+ id: dash.id,
1230
+ role: "assistant",
1231
+ text: dash.text,
1232
+ reasoning: dash.reasoning,
1233
+ },
1234
+ ]);
1235
+ return;
1236
+ }
1237
+ 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
+ ]);
1246
+ return;
1247
+ }
1248
+ if (dash.kind === "tool") {
1249
+ setMessages((prev) => [
1250
+ ...prev,
1251
+ {
1252
+ id: dash.id,
1253
+ role: "tool",
1254
+ text: dash.content,
1255
+ toolName: dash.toolName,
1256
+ toolArgs: dash.args,
1257
+ },
1258
+ ]);
1259
+ return;
1260
+ }
1261
+ if (dash.kind === "warning" || dash.kind === "error" || dash.kind === "info") {
1262
+ setMessages((prev) => [...prev, { id: dash.id, role: dash.kind, text: dash.text }]);
1263
+ return;
1264
+ }
1265
+ if (dash.kind === "status") {
1266
+ setStatusLine(dash.text);
1267
+ // Clear the status line shortly so old hints don't pile up.
1268
+ setTimeout(() => setStatusLine((cur) => (cur === dash.text ? null : cur)), 5000);
1269
+ return;
1270
+ }
1271
+ if (dash.kind === "modal-up") {
1272
+ setModal(dash.modal);
1273
+ return;
1274
+ }
1275
+ if (dash.kind === "modal-down") {
1276
+ setModal((cur) => (cur && cur.kind === dash.modalKind ? null : cur));
1277
+ return;
1278
+ }
1279
+ };
1280
+ es.onerror = () => {
1281
+ // Auto-reconnect by default; surface a brief banner on persistent
1282
+ // failure but don't tear down — EventSource retries in the background.
1283
+ setError("event stream interrupted — reconnecting…");
1284
+ setTimeout(() => setError(null), 3000);
1285
+ };
1286
+ return () => es.close();
1287
+ }, []);
1288
+
1289
+ const send = useCallback(async () => {
1290
+ const text = input.trim();
1291
+ if (!text || busy) return;
1292
+ setError(null);
1293
+ try {
1294
+ const res = await api("/submit", { method: "POST", body: { prompt: text } });
1295
+ if (!res.accepted) {
1296
+ setError(res.reason ?? "rejected");
1297
+ return;
1298
+ }
1299
+ setInput("");
1300
+ } catch (err) {
1301
+ setError(err.message);
1302
+ }
1303
+ }, [input, busy]);
1304
+
1305
+ const abort = useCallback(async () => {
1306
+ try {
1307
+ await api("/abort", { method: "POST" });
1308
+ } catch (err) {
1309
+ setError(err.message);
1310
+ }
1311
+ }, []);
1312
+
1313
+ // /new wipes context + scrollback (server-side); /clear keeps the
1314
+ // log but blanks the visible scroll. Both route through /api/submit
1315
+ // because handleSubmit on the TUI side already parses slashes — keeps
1316
+ // one source of truth, no special endpoint needed. Local messages
1317
+ // state is reset optimistically; an /api/messages refetch reconciles.
1318
+ const newConversation = useCallback(async () => {
1319
+ if (busy) {
1320
+ if (!confirm("A turn is in flight. Abort and start a new conversation?")) return;
1321
+ } else if (messages.length > 0 && !confirm("Clear current conversation and start fresh?")) {
1322
+ return;
1323
+ }
1324
+ try {
1325
+ await api("/submit", { method: "POST", body: { prompt: "/new" } });
1326
+ setMessages([]);
1327
+ setStreaming(null);
1328
+ showToast("new conversation", "info");
1329
+ // Refetch to reconcile in case the slash queued an info row.
1330
+ setTimeout(async () => {
1331
+ try {
1332
+ const r = await api("/messages");
1333
+ setMessages(r.messages ?? []);
1334
+ } catch {
1335
+ /* swallow */
1336
+ }
1337
+ }, 200);
1338
+ } catch (err) {
1339
+ setError(`/new failed: ${err.message}`);
1340
+ }
1341
+ }, [busy, messages.length]);
1342
+
1343
+ const clearScrollback = useCallback(async () => {
1344
+ try {
1345
+ await api("/submit", { method: "POST", body: { prompt: "/clear" } });
1346
+ setMessages([]);
1347
+ setStreaming(null);
1348
+ showToast("scrollback cleared", "info");
1349
+ setTimeout(async () => {
1350
+ try {
1351
+ const r = await api("/messages");
1352
+ setMessages(r.messages ?? []);
1353
+ } catch {
1354
+ /* swallow */
1355
+ }
1356
+ }, 200);
1357
+ } catch (err) {
1358
+ setError(`/clear failed: ${err.message}`);
1359
+ }
1360
+ }, []);
1361
+
1362
+ const onKeyDown = useCallback(
1363
+ (e) => {
1364
+ // Enter sends, Shift+Enter inserts newline.
1365
+ if (e.key === "Enter" && !e.shiftKey) {
1366
+ e.preventDefault();
1367
+ send();
1368
+ }
1369
+ },
1370
+ [send],
1371
+ );
1372
+
1373
+ if (bootError) {
1374
+ return html`<div class="notice err">chat unavailable: ${bootError}</div>`;
1375
+ }
1376
+
1377
+ // Track whether the user is parked at the bottom. Update on every
1378
+ // scroll event so a single wheel-up flips the auto-scroll guard
1379
+ // immediately. The threshold is generous enough that overshoot
1380
+ // (smooth-scroll rebound, sub-pixel rounding) doesn't accidentally
1381
+ // re-arm tracking when the user is barely above bottom.
1382
+ useEffect(() => {
1383
+ const el = document.querySelector(".chat-feed");
1384
+ if (!el) return;
1385
+ const onScroll = () => {
1386
+ const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
1387
+ shouldAutoScroll.current = distFromBottom < 80;
1388
+ };
1389
+ el.addEventListener("scroll", onScroll, { passive: true });
1390
+ return () => el.removeEventListener("scroll", onScroll);
1391
+ }, []);
1392
+
1393
+ // Auto-scroll only when the user hasn't scrolled away. Streaming
1394
+ // deltas no longer yank the view back; manual wheel/drag wins.
1395
+ useEffect(() => {
1396
+ if (!shouldAutoScroll.current) return;
1397
+ const el = document.querySelector(".chat-feed");
1398
+ if (el) el.scrollTop = el.scrollHeight;
1399
+ }, [messages, streaming]);
1400
+
1401
+ const allMessages = streaming
1402
+ ? [
1403
+ ...messages,
1404
+ {
1405
+ id: streaming.id,
1406
+ role: "assistant",
1407
+ text: streaming.text,
1408
+ reasoning: streaming.reasoning,
1409
+ },
1410
+ ]
1411
+ : messages;
1412
+
1413
+ // Resolve the active modal via POST /api/modal/resolve. The server
1414
+ // hands the choice straight to App.tsx's resolveXxx callback, which
1415
+ // calls the same handler the TUI button would. The local `modal`
1416
+ // state clears the moment the SSE channel echoes `modal-down`.
1417
+ const resolveModal = useCallback(async (kind, choice, text) => {
1418
+ try {
1419
+ await api("/modal/resolve", {
1420
+ method: "POST",
1421
+ body: text !== undefined ? { kind, choice, text } : { kind, choice },
1422
+ });
1423
+ } catch (err) {
1424
+ setError(`modal resolve failed: ${err.message}`);
1425
+ }
1426
+ }, []);
1427
+
1428
+ // Poll /api/overview for current edit mode. Polling (not SSE) is
1429
+ // fine — the gate flips from /mode, Shift+Tab, AND the web button;
1430
+ // a 4s poll is good enough to keep the segmented control visually
1431
+ // honest without piping yet another event kind.
1432
+ useEffect(() => {
1433
+ let cancelled = false;
1434
+ const tick = async () => {
1435
+ try {
1436
+ const o = await api("/overview");
1437
+ if (cancelled) return;
1438
+ setEditModeLocal(o.editMode ?? null);
1439
+ setPresetLocal(o.preset ?? null);
1440
+ setEffortLocal(o.reasoningEffort ?? null);
1441
+ setStats(o.stats ?? null);
1442
+ setOverviewModel(o.model ?? null);
1443
+ } catch {
1444
+ /* swallow */
1445
+ }
1446
+ };
1447
+ tick();
1448
+ const t = setInterval(tick, 2500);
1449
+ return () => {
1450
+ cancelled = true;
1451
+ clearInterval(t);
1452
+ };
1453
+ }, []);
1454
+
1455
+ const setEditMode = useCallback(async (next) => {
1456
+ setEditModeLocal(next); // optimistic
1457
+ try {
1458
+ await api("/edit-mode", { method: "POST", body: { mode: next } });
1459
+ } catch (err) {
1460
+ setError(`mode switch failed: ${err.message}`);
1461
+ try {
1462
+ const o = await api("/overview");
1463
+ setEditModeLocal(o.editMode ?? null);
1464
+ } catch {
1465
+ /* swallow */
1466
+ }
1467
+ }
1468
+ }, []);
1469
+
1470
+ // Generic settings flipper for preset + effort. Both go through
1471
+ // /api/settings (which writes to ~/.reasonix/config.json). preset
1472
+ // applies next session, effort applies next turn — the buttons'
1473
+ // tooltips remind the user.
1474
+ const setSetting = useCallback(async (key, value) => {
1475
+ if (key === "preset") setPresetLocal(value);
1476
+ if (key === "reasoningEffort") setEffortLocal(value);
1477
+ try {
1478
+ await api("/settings", { method: "POST", body: { [key]: value } });
1479
+ } catch (err) {
1480
+ setError(`${key} switch failed: ${err.message}`);
1481
+ try {
1482
+ const o = await api("/overview");
1483
+ setPresetLocal(o.preset ?? null);
1484
+ setEffortLocal(o.reasoningEffort ?? null);
1485
+ } catch {
1486
+ /* swallow */
1487
+ }
1488
+ }
1489
+ }, []);
1490
+
1491
+ return html`
1492
+ <div class="chat-shell">
1493
+ <div class="panel-header" style="margin-bottom: 12px;">
1494
+ <h2 class="panel-title">Chat</h2>
1495
+ <span class="panel-subtitle">
1496
+ mirrors the live ${MODE === "attached" ? "TUI" : "session"} — type here or in the terminal, both surfaces stay in sync
1497
+ </span>
1498
+ <div class="header-pickers" style="margin-left: auto;">
1499
+ ${
1500
+ effort
1501
+ ? html`
1502
+ <div class="mode-picker" title="reasoning_effort — applies next turn">
1503
+ ${["high", "max"].map(
1504
+ (e) => html`
1505
+ <button
1506
+ key=${e}
1507
+ class="mode-btn ${effort === e ? "active accent" : ""}"
1508
+ onClick=${() => setSetting("reasoningEffort", e)}
1509
+ title=${e === "max" ? "max (default — best quality)" : "high (cheaper / faster)"}
1510
+ >${e}</button>
1511
+ `,
1512
+ )}
1513
+ </div>
1514
+ `
1515
+ : null
1516
+ }
1517
+ ${
1518
+ preset
1519
+ ? html`
1520
+ <div class="mode-picker" title="preset — model commitment">
1521
+ ${(() => {
1522
+ // Anything that isn't one of the three new presets
1523
+ // (including legacy fast/smart/max from old configs)
1524
+ // highlights as `auto` — the safe default. User can
1525
+ // re-pick explicitly if they want flash or pro.
1526
+ const KNOWN = ["auto", "flash", "pro"];
1527
+ const canonical = KNOWN.includes(preset) ? preset : "auto";
1528
+ return ["auto", "flash", "pro"].map(
1529
+ (p) => html`
1530
+ <button
1531
+ key=${p}
1532
+ class="mode-btn ${canonical === p ? "active accent" : ""}"
1533
+ onClick=${() => setSetting("preset", p)}
1534
+ title=${
1535
+ p === "auto"
1536
+ ? "auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)"
1537
+ : p === "flash"
1538
+ ? "flash — always flash; no auto-escalate. /pro still works for one-shot manual"
1539
+ : "pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work."
1540
+ }
1541
+ >${p}</button>
1542
+ `,
1543
+ );
1544
+ })()}
1545
+ </div>
1546
+ `
1547
+ : null
1548
+ }
1549
+ ${
1550
+ editMode
1551
+ ? html`
1552
+ <div class="mode-picker" title="edit gate — Shift+Tab cycles in TUI">
1553
+ ${["review", "auto", "yolo"].map(
1554
+ (m) => html`
1555
+ <button
1556
+ key=${m}
1557
+ class="mode-btn ${editMode === m ? "active" : ""} ${m === "yolo" ? "yolo" : ""}"
1558
+ onClick=${() => setEditMode(m)}
1559
+ title=${
1560
+ m === "review"
1561
+ ? "review — both edits and non-allowlisted shell ask first"
1562
+ : m === "auto"
1563
+ ? "auto — edits auto-apply, shell still asks"
1564
+ : "yolo — edits AND shell auto-run, allowlist bypassed"
1565
+ }
1566
+ >${m}</button>
1567
+ `,
1568
+ )}
1569
+ </div>
1570
+ `
1571
+ : null
1572
+ }
1573
+ </div>
1574
+ </div>
1575
+
1576
+ ${
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
1582
+ }
1583
+ ${error ? html`<div class="notice err">${error}</div>` : null}
1584
+
1585
+ ${
1586
+ modal
1587
+ ? modal.kind === "shell"
1588
+ ? html`<${ShellModal} modal=${modal} onResolve=${resolveModal} />`
1589
+ : modal.kind === "choice"
1590
+ ? html`<${ChoiceModal} modal=${modal} onResolve=${resolveModal} />`
1591
+ : modal.kind === "plan"
1592
+ ? html`<${PlanModal} modal=${modal} onResolve=${resolveModal} />`
1593
+ : modal.kind === "edit-review"
1594
+ ? html`<${EditReviewModal} modal=${modal} onResolve=${resolveModal} />`
1595
+ : null
1596
+ : null
1597
+ }
1598
+
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
+ }
1616
+
1617
+ <div class="chat-input-area">
1618
+ <textarea
1619
+ placeholder=${busy ? "wait for the current turn to finish…" : "Type a prompt — Enter sends, Shift+Enter for a newline"}
1620
+ value=${input}
1621
+ onInput=${(e) => setInput(e.target.value)}
1622
+ onKeyDown=${onKeyDown}
1623
+ disabled=${busy}
1624
+ rows="2"
1625
+ ></textarea>
1626
+ <div style="display: flex; flex-direction: column; gap: 6px; align-self: stretch; justify-content: flex-end;">
1627
+ <button
1628
+ class="primary"
1629
+ onClick=${send}
1630
+ disabled=${busy || !input.trim()}
1631
+ >Send</button>
1632
+ <div style="display: flex; gap: 6px;">
1633
+ <button onClick=${newConversation} title="/new — wipe conversation context (loop log + scrollback)">New</button>
1634
+ <button onClick=${clearScrollback} title="/clear — wipe just visible scrollback (context kept)">Clear</button>
1635
+ </div>
1636
+ </div>
1637
+ </div>
1638
+
1639
+ <${ChatStatusBar} stats=${stats} model=${overviewModel} />
1640
+ </div>
1641
+ `;
1642
+ }
1643
+
1644
+ // ---------- Chat status bar ----------
1645
+ //
1646
+ // Mirrors the TUI's StatsPanel — turn / session cost, cache hit %,
1647
+ // ctx token gauge, balance. Sits beneath the input area as a compact
1648
+ // monospace strip. Renders as a placeholder ("· · ·") while stats
1649
+ // haven't arrived yet so the layout doesn't shift on first paint.
1650
+
1651
+ function ChatStatusBar({ stats, model }) {
1652
+ if (!stats) {
1653
+ return html`
1654
+ <div class="chat-statusbar">
1655
+ <span class="muted">· · · waiting for live stats</span>
1656
+ </div>
1657
+ `;
1658
+ }
1659
+ const ctxPct =
1660
+ stats.contextCapTokens > 0 ? (stats.lastPromptTokens / stats.contextCapTokens) * 100 : 0;
1661
+ const balance = stats.balance && stats.balance.length > 0 ? stats.balance[0] : null;
1662
+ return html`
1663
+ <div class="chat-statusbar">
1664
+ <span class="status-item">
1665
+ <span class="status-label">model</span>
1666
+ <code>${model ?? "—"}</code>
1667
+ </span>
1668
+ <span class="status-item">
1669
+ <span class="status-label">ctx</span>
1670
+ <span class="status-bar-mini">
1671
+ <span class="status-bar-mini-fill" style=${`width: ${Math.min(100, ctxPct).toFixed(1)}%;`}></span>
1672
+ </span>
1673
+ <span class="muted">${stats.lastPromptTokens.toLocaleString()} / ${(stats.contextCapTokens / 1000).toFixed(0)}K</span>
1674
+ </span>
1675
+ <span class="status-item">
1676
+ <span class="status-label">cache</span>
1677
+ <span class=${stats.cacheHitRatio >= 0.9 ? "status-ok" : stats.cacheHitRatio >= 0.6 ? "status-warn" : "status-err"}>
1678
+ ${(stats.cacheHitRatio * 100).toFixed(1)}%
1679
+ </span>
1680
+ </span>
1681
+ <span class="status-item">
1682
+ <span class="status-label">turn</span>
1683
+ <code>${fmtUsd(stats.lastTurnCostUsd)}</code>
1684
+ </span>
1685
+ <span class="status-item">
1686
+ <span class="status-label">session</span>
1687
+ <code>${fmtUsd(stats.totalCostUsd)}</code>
1688
+ <span class="muted" style="font-size: 10px;">
1689
+ (${stats.turns} turn${stats.turns === 1 ? "" : "s"})
1690
+ </span>
1691
+ </span>
1692
+ ${
1693
+ balance
1694
+ ? html`
1695
+ <span class="status-item">
1696
+ <span class="status-label">balance</span>
1697
+ <code>${balance.total_balance} ${balance.currency}</code>
1698
+ </span>
1699
+ `
1700
+ : null
1701
+ }
1702
+ </div>
1703
+ `;
1704
+ }
1705
+
1706
+ // ---------- System Health ----------
1707
+
1708
+ function SystemPanel() {
1709
+ const { data, error, loading } = usePoll("/health", 5000);
1710
+ if (loading && !data) return html`<div class="boot">loading health…</div>`;
1711
+ if (error) return html`<div class="notice err">health failed: ${error.message}</div>`;
1712
+ const h = data;
1713
+ const upToDate = h.latestVersion ? h.latestVersion === h.version : null;
1714
+ return html`
1715
+ <div>
1716
+ <div class="panel-header">
1717
+ <h2 class="panel-title">System Health</h2>
1718
+ <span class="panel-subtitle">disk · version · jobs</span>
1719
+ </div>
1720
+ <div class="metric-grid">
1721
+ ${MetricCard(
1722
+ "Reasonix",
1723
+ h.version,
1724
+ h.latestVersion === null
1725
+ ? "version check pending"
1726
+ : upToDate
1727
+ ? "up to date"
1728
+ : `latest: ${h.latestVersion}`,
1729
+ upToDate === false ? "warn" : null,
1730
+ )}
1731
+ ${MetricCard("Sessions", `${fmtNum(h.sessions.count)} files`, fmtBytes(h.sessions.totalBytes))}
1732
+ ${MetricCard("Memory", `${fmtNum(h.memory.fileCount)} files`, fmtBytes(h.memory.totalBytes))}
1733
+ ${MetricCard(
1734
+ "Semantic index",
1735
+ h.semantic.exists ? `${fmtNum(h.semantic.fileCount)} files` : "not built",
1736
+ h.semantic.exists ? fmtBytes(h.semantic.totalBytes) : "run `reasonix index` to build",
1737
+ )}
1738
+ ${MetricCard("Usage log", fmtBytes(h.usageLog.bytes), null)}
1739
+ ${MetricCard("Background jobs", h.jobs === null ? "—" : fmtNum(h.jobs), h.jobs === null ? "no live session" : null)}
1740
+ </div>
1741
+ <div class="section-title">Paths</div>
1742
+ <div class="card mono" style="font-size: 12px; line-height: 1.8;">
1743
+ <div><span class="pill pill-dim">home</span> ${h.reasonixHome}</div>
1744
+ <div><span class="pill pill-dim">sessions</span> ${h.sessions.path}</div>
1745
+ <div><span class="pill pill-dim">memory</span> ${h.memory.path}</div>
1746
+ <div><span class="pill pill-dim">semantic</span> ${h.semantic.path}</div>
1747
+ <div><span class="pill pill-dim">usage</span> ${h.usageLog.path}</div>
1748
+ </div>
1749
+ </div>
1750
+ `;
1751
+ }
1752
+
1753
+ // ---------- Sessions browser ----------
1754
+
1755
+ function SessionsPanel() {
1756
+ const { data, error, loading } = usePoll("/sessions", 5000);
1757
+ const [open, setOpen] = useState(null); // { name, messages } or null
1758
+ const [openLoading, setOpenLoading] = useState(false);
1759
+
1760
+ const view = useCallback(async (name) => {
1761
+ setOpen({ name, messages: null });
1762
+ setOpenLoading(true);
1763
+ try {
1764
+ const detail = await api(`/sessions/${encodeURIComponent(name)}`);
1765
+ setOpen({ name, messages: detail.messages });
1766
+ } catch (err) {
1767
+ setOpen({ name, messages: null, error: err.message });
1768
+ } finally {
1769
+ setOpenLoading(false);
1770
+ }
1771
+ }, []);
1772
+
1773
+ if (loading && !data) return html`<div class="boot">loading sessions…</div>`;
1774
+ if (error) return html`<div class="notice err">sessions failed: ${error.message}</div>`;
1775
+ const sessions = data.sessions ?? [];
1776
+
1777
+ if (open) {
1778
+ return html`
1779
+ <div>
1780
+ <div class="panel-header">
1781
+ <h2 class="panel-title">Session</h2>
1782
+ <span class="panel-subtitle">${open.name}</span>
1783
+ <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
1784
+ </div>
1785
+ ${
1786
+ openLoading
1787
+ ? html`<div class="boot">loading transcript…</div>`
1788
+ : open.error
1789
+ ? html`<div class="notice err">${open.error}</div>`
1790
+ : open.messages && open.messages.length > 0
1791
+ ? html`
1792
+ <div class="chat-feed" style="max-height: calc(100vh - 180px); overflow-y: auto;">
1793
+ ${open.messages.map(
1794
+ (m, i) => html`
1795
+ <${ChatMessage}
1796
+ key=${i}
1797
+ msg=${{
1798
+ id: `r-${i}`,
1799
+ role:
1800
+ m.role === "tool"
1801
+ ? "tool"
1802
+ : m.role === "assistant"
1803
+ ? "assistant"
1804
+ : m.role === "user"
1805
+ ? "user"
1806
+ : "info",
1807
+ text: m.content ?? "",
1808
+ toolName: m.toolName,
1809
+ }}
1810
+ streaming=${false}
1811
+ />
1812
+ `,
1813
+ )}
1814
+ </div>
1815
+ `
1816
+ : html`<div class="empty">empty transcript.</div>`
1817
+ }
1818
+ </div>
1819
+ `;
1820
+ }
1821
+
1822
+ return html`
1823
+ <div>
1824
+ <div class="panel-header">
1825
+ <h2 class="panel-title">Sessions</h2>
1826
+ <span class="panel-subtitle">${sessions.length} saved · click to read</span>
1827
+ </div>
1828
+ ${
1829
+ sessions.length === 0
1830
+ ? html`<div class="empty">No saved sessions yet.</div>`
1831
+ : html`
1832
+ <table>
1833
+ <thead>
1834
+ <tr>
1835
+ <th>name</th>
1836
+ <th class="numeric">messages</th>
1837
+ <th class="numeric">size</th>
1838
+ <th class="numeric">last touched</th>
1839
+ </tr>
1840
+ </thead>
1841
+ <tbody>
1842
+ ${sessions.map(
1843
+ (s) => html`
1844
+ <tr key=${s.name} onClick=${() => view(s.name)} style="cursor: pointer;">
1845
+ <td><code>${s.name}</code></td>
1846
+ <td class="numeric">${fmtNum(s.messageCount)}</td>
1847
+ <td class="numeric">${fmtBytes(s.size)}</td>
1848
+ <td class="numeric muted">${fmtRelativeTime(s.mtime)}</td>
1849
+ </tr>
1850
+ `,
1851
+ )}
1852
+ </tbody>
1853
+ </table>
1854
+ `
1855
+ }
1856
+ </div>
1857
+ `;
1858
+ }
1859
+
1860
+ // ---------- Plans archive ----------
1861
+
1862
+ function PlansPanel() {
1863
+ const { data, error, loading } = usePoll("/plans", 8000);
1864
+ const [open, setOpen] = useState(null);
1865
+ if (loading && !data) return html`<div class="boot">loading plans…</div>`;
1866
+ if (error) return html`<div class="notice err">plans failed: ${error.message}</div>`;
1867
+ const plans = data.plans ?? [];
1868
+
1869
+ if (open) {
1870
+ const completedSet = new Set(open.completedStepIds);
1871
+ return html`
1872
+ <div>
1873
+ <div class="panel-header">
1874
+ <h2 class="panel-title">Plan</h2>
1875
+ <span class="panel-subtitle">${open.session} · ${fmtRelativeTime(open.completedAt)}</span>
1876
+ <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
1877
+ </div>
1878
+ ${open.summary ? html`<div class="notice">${open.summary}</div>` : null}
1879
+ <div class="card">
1880
+ ${open.steps.map((step) => {
1881
+ const done = completedSet.has(step.id);
1882
+ return html`
1883
+ <div style="padding: 8px 0; border-bottom: 1px solid var(--border); display: flex; gap: 12px;">
1884
+ <div style="width: 16px; color: ${done ? "var(--ok)" : "var(--fg-3)"}; font-family: var(--mono);">
1885
+ ${done ? "✓" : "·"}
1886
+ </div>
1887
+ <div style="flex: 1;">
1888
+ <div style="color: ${done ? "var(--fg-2)" : "var(--fg-0)"}; font-weight: 500;">
1889
+ ${step.title}
1890
+ </div>
1891
+ ${step.action ? html`<div style="color: var(--fg-2); font-size: 12px; margin-top: 2px;">${step.action}</div>` : null}
1892
+ ${step.risk ? html`<span class="pill pill-${step.risk === "high" ? "err" : step.risk === "medium" ? "warn" : "dim"}" style="margin-top: 4px;">${step.risk}</span>` : null}
1893
+ </div>
1894
+ </div>
1895
+ `;
1896
+ })}
1897
+ </div>
1898
+ </div>
1899
+ `;
1900
+ }
1901
+
1902
+ return html`
1903
+ <div>
1904
+ <div class="panel-header">
1905
+ <h2 class="panel-title">Plans</h2>
1906
+ <span class="panel-subtitle">${plans.length} archived · click to view</span>
1907
+ </div>
1908
+ ${
1909
+ plans.length === 0
1910
+ ? html`<div class="empty">No archived plans yet — run a turn that calls <code>submit_plan</code> + <code>mark_step_complete</code>.</div>`
1911
+ : html`
1912
+ <table>
1913
+ <thead>
1914
+ <tr>
1915
+ <th>session</th>
1916
+ <th>title</th>
1917
+ <th class="numeric">progress</th>
1918
+ <th class="numeric">archived</th>
1919
+ </tr>
1920
+ </thead>
1921
+ <tbody>
1922
+ ${plans.map(
1923
+ (p, i) => html`
1924
+ <tr key=${i} onClick=${() => setOpen(p)} style="cursor: pointer;">
1925
+ <td><code>${p.session}</code></td>
1926
+ <td>${p.summary ?? html`<span class="muted">(no title)</span>`}</td>
1927
+ <td class="numeric">${p.completedSteps}/${p.totalSteps} · ${fmtPct(p.completionRatio)}</td>
1928
+ <td class="numeric muted">${fmtRelativeTime(p.completedAt)}</td>
1929
+ </tr>
1930
+ `,
1931
+ )}
1932
+ </tbody>
1933
+ </table>
1934
+ `
1935
+ }
1936
+ </div>
1937
+ `;
1938
+ }
1939
+
1940
+ // ---------- Usage time-series chart (uPlot) ----------
1941
+
1942
+ let uPlotPromise = null;
1943
+ function loadUPlot() {
1944
+ if (!uPlotPromise) {
1945
+ uPlotPromise = import("https://esm.sh/uplot@1.6.31").then((m) => m.default ?? m);
1946
+ }
1947
+ return uPlotPromise;
1948
+ }
1949
+
1950
+ function UsageChart({ days }) {
1951
+ const containerRef = useRef(null);
1952
+ const plotRef = useRef(null);
1953
+
1954
+ useEffect(() => {
1955
+ let cancelled = false;
1956
+ loadUPlot().then((uPlot) => {
1957
+ if (cancelled || !containerRef.current) return;
1958
+ // Destroy previous instance on data refresh.
1959
+ if (plotRef.current) {
1960
+ plotRef.current.destroy();
1961
+ plotRef.current = null;
1962
+ }
1963
+ // Don't render an empty chart — let the parent show a fallback.
1964
+ if (!days || days.length === 0) return;
1965
+ const xs = days.map((d) => Math.floor(Date.parse(d.day) / 1000));
1966
+ const cost = days.map((d) => d.costUsd);
1967
+ const saved = days.map((d) => d.cacheSavingsUsd);
1968
+ const turns = days.map((d) => d.turns);
1969
+ const data = [xs, cost, saved, turns];
1970
+ const opts = {
1971
+ width: containerRef.current.clientWidth,
1972
+ height: 280,
1973
+ cursor: { drag: { x: true, y: false } },
1974
+ scales: {
1975
+ x: { time: true },
1976
+ y: { auto: true },
1977
+ turns: { auto: true },
1978
+ },
1979
+ axes: [
1980
+ {
1981
+ stroke: "#94a3b8",
1982
+ grid: { stroke: "rgba(148, 163, 184, 0.08)" },
1983
+ },
1984
+ {
1985
+ scale: "y",
1986
+ label: "USD",
1987
+ stroke: "#94a3b8",
1988
+ grid: { stroke: "rgba(148, 163, 184, 0.08)" },
1989
+ values: (_u, v) => v.map((n) => `$${n.toFixed(4)}`),
1990
+ },
1991
+ {
1992
+ scale: "turns",
1993
+ side: 1,
1994
+ label: "turns",
1995
+ stroke: "#94a3b8",
1996
+ grid: { show: false },
1997
+ },
1998
+ ],
1999
+ series: [
2000
+ {},
2001
+ {
2002
+ label: "cost",
2003
+ stroke: "#67e8f9",
2004
+ width: 2,
2005
+ fill: "rgba(103, 232, 249, 0.10)",
2006
+ },
2007
+ {
2008
+ label: "cache saved",
2009
+ stroke: "#5eead4",
2010
+ width: 2,
2011
+ dash: [4, 4],
2012
+ },
2013
+ {
2014
+ label: "turns",
2015
+ stroke: "#c4b5fd",
2016
+ scale: "turns",
2017
+ width: 1.5,
2018
+ points: { show: true, size: 4 },
2019
+ },
2020
+ ],
2021
+ legend: { live: true },
2022
+ };
2023
+ plotRef.current = new uPlot(opts, data, containerRef.current);
2024
+ });
2025
+
2026
+ // Resize observer keeps the chart at full panel width.
2027
+ const ro = new ResizeObserver(() => {
2028
+ if (plotRef.current && containerRef.current) {
2029
+ plotRef.current.setSize({
2030
+ width: containerRef.current.clientWidth,
2031
+ height: 280,
2032
+ });
2033
+ }
2034
+ });
2035
+ if (containerRef.current) ro.observe(containerRef.current);
2036
+
2037
+ return () => {
2038
+ cancelled = true;
2039
+ ro.disconnect();
2040
+ if (plotRef.current) {
2041
+ plotRef.current.destroy();
2042
+ plotRef.current = null;
2043
+ }
2044
+ };
2045
+ }, [days]);
2046
+
2047
+ return html`<div ref=${containerRef} style="width: 100%; min-height: 280px;"></div>`;
2048
+ }
2049
+
2050
+ // ---------- existing UsagePanel rewrite — chart + table ----------
2051
+
2052
+ function UsageWithChart() {
2053
+ const { data: summary, error, loading } = usePoll("/usage", 5000);
2054
+ const [series, setSeries] = useState(null);
2055
+
2056
+ useEffect(() => {
2057
+ let cancelled = false;
2058
+ (async () => {
2059
+ try {
2060
+ const s = await api("/usage/series");
2061
+ if (!cancelled) setSeries(s.days ?? []);
2062
+ } catch {
2063
+ /* keep null; chart hides */
2064
+ }
2065
+ })();
2066
+ const t = setInterval(async () => {
2067
+ try {
2068
+ const s = await api("/usage/series");
2069
+ if (!cancelled) setSeries(s.days ?? []);
2070
+ } catch {
2071
+ /* swallow */
2072
+ }
2073
+ }, 30_000);
2074
+ return () => {
2075
+ cancelled = true;
2076
+ clearInterval(t);
2077
+ };
2078
+ }, []);
2079
+
2080
+ if (loading && !summary) return html`<div class="boot">loading usage…</div>`;
2081
+ if (error) return html`<div class="notice err">usage failed: ${error.message}</div>`;
2082
+ const u = summary;
2083
+
2084
+ return html`
2085
+ <div>
2086
+ <div class="panel-header">
2087
+ <h2 class="panel-title">Usage</h2>
2088
+ <span class="panel-subtitle">${u.recordCount.toLocaleString()} records · ${u.logSize}</span>
2089
+ </div>
2090
+
2091
+ ${
2092
+ series && series.length > 0
2093
+ ? html`
2094
+ <div class="card" style="padding: 18px;">
2095
+ <div class="card-title" style="margin-bottom: 12px;">Daily usage (cost · cache saved · turns)</div>
2096
+ <${UsageChart} days=${series} />
2097
+ </div>
2098
+ `
2099
+ : null
2100
+ }
2101
+
2102
+ ${
2103
+ u.recordCount === 0
2104
+ ? html`<div class="empty" style="margin-top: 16px;">No usage data yet — run a turn in <code>reasonix chat</code> / <code>code</code> / <code>run</code> and refresh.</div>`
2105
+ : html`
2106
+ <div class="section-title">Rolling windows</div>
2107
+ <table>
2108
+ <thead>
2109
+ <tr>
2110
+ <th></th>
2111
+ <th class="numeric">turns</th>
2112
+ <th class="numeric">cache hit</th>
2113
+ <th class="numeric">cost (USD)</th>
2114
+ <th class="numeric">cache saved</th>
2115
+ <th class="numeric">vs Claude</th>
2116
+ <th class="numeric">saved</th>
2117
+ </tr>
2118
+ </thead>
2119
+ <tbody>
2120
+ ${u.buckets.map((b) => {
2121
+ const hitRatio =
2122
+ b.cacheHitTokens + b.cacheMissTokens > 0
2123
+ ? b.cacheHitTokens / (b.cacheHitTokens + b.cacheMissTokens)
2124
+ : 0;
2125
+ const claudeSavings = b.claudeEquivUsd > 0 ? 1 - b.costUsd / b.claudeEquivUsd : 0;
2126
+ return html`
2127
+ <tr>
2128
+ <td>${b.label}</td>
2129
+ <td class="numeric">${fmtNum(b.turns)}</td>
2130
+ <td class="numeric">${b.turns > 0 ? fmtPct(hitRatio) : "—"}</td>
2131
+ <td class="numeric">${b.turns > 0 ? fmtUsd(b.costUsd) : "—"}</td>
2132
+ <td class="numeric">${b.turns > 0 && b.cacheSavingsUsd > 0 ? fmtUsd(b.cacheSavingsUsd) : "—"}</td>
2133
+ <td class="numeric">${b.turns > 0 ? fmtUsd(b.claudeEquivUsd) : "—"}</td>
2134
+ <td class="numeric">${b.turns > 0 && claudeSavings > 0 ? fmtPct(claudeSavings) : "—"}</td>
2135
+ </tr>
2136
+ `;
2137
+ })}
2138
+ </tbody>
2139
+ </table>
2140
+ `
2141
+ }
2142
+
2143
+ ${
2144
+ u.byModel.length > 0
2145
+ ? html`
2146
+ <div class="section-title">Most used models</div>
2147
+ <table>
2148
+ <thead><tr><th>model</th><th class="numeric">turns</th></tr></thead>
2149
+ <tbody>
2150
+ ${u.byModel.slice(0, 5).map(
2151
+ (m) => html`
2152
+ <tr><td><code>${m.model}</code></td><td class="numeric">${fmtNum(m.turns)}</td></tr>
2153
+ `,
2154
+ )}
2155
+ </tbody>
2156
+ </table>
2157
+ `
2158
+ : null
2159
+ }
2160
+ </div>
2161
+ `;
2162
+ }
2163
+
2164
+ // ---------- Settings ----------
2165
+
2166
+ function SettingsPanel() {
2167
+ const [data, setData] = useState(null);
2168
+ const [error, setError] = useState(null);
2169
+ const [saving, setSaving] = useState(false);
2170
+ const [saved, setSaved] = useState(null);
2171
+ const [draft, setDraft] = useState({});
2172
+
2173
+ const load = useCallback(async () => {
2174
+ try {
2175
+ const r = await api("/settings");
2176
+ setData(r);
2177
+ setDraft({});
2178
+ } catch (err) {
2179
+ setError(err.message);
2180
+ }
2181
+ }, []);
2182
+ useEffect(() => {
2183
+ load();
2184
+ }, [load]);
2185
+
2186
+ const save = useCallback(
2187
+ async (fields) => {
2188
+ setSaving(true);
2189
+ setError(null);
2190
+ try {
2191
+ await api("/settings", { method: "POST", body: fields });
2192
+ await load();
2193
+ setSaved(`saved: ${Object.keys(fields).join(", ")}`);
2194
+ setTimeout(() => setSaved(null), 3000);
2195
+ } catch (err) {
2196
+ setError(err.message);
2197
+ } finally {
2198
+ setSaving(false);
2199
+ }
2200
+ },
2201
+ [load],
2202
+ );
2203
+
2204
+ if (!data && !error) return html`<div class="boot">loading settings…</div>`;
2205
+ if (error && !data) return html`<div class="notice err">${error}</div>`;
2206
+ const v = data;
2207
+
2208
+ return html`
2209
+ <div>
2210
+ <div class="panel-header">
2211
+ <h2 class="panel-title">Settings</h2>
2212
+ <span class="panel-subtitle">~/.reasonix/config.json · most fields apply next session</span>
2213
+ </div>
2214
+ ${saved ? html`<div class="notice">${saved}</div>` : null}
2215
+ ${error ? html`<div class="notice err">${error}</div>` : null}
2216
+
2217
+ <div class="section-title">DeepSeek API</div>
2218
+ <div class="card">
2219
+ <div class="row">
2220
+ <span class="card-title" style="margin: 0;">API key</span>
2221
+ <code style="margin-left: auto;">${v.apiKey ?? "(not set)"}</code>
2222
+ </div>
2223
+ <div class="row" style="margin-top: 8px;">
2224
+ <input
2225
+ type="password"
2226
+ placeholder="paste a fresh sk-… token to replace"
2227
+ value=${draft.apiKey ?? ""}
2228
+ onInput=${(e) => setDraft({ ...draft, apiKey: e.target.value })}
2229
+ />
2230
+ <button
2231
+ class="primary"
2232
+ disabled=${saving || !(draft.apiKey ?? "").trim()}
2233
+ onClick=${() => save({ apiKey: draft.apiKey })}
2234
+ >Save key</button>
2235
+ </div>
2236
+ <div class="row" style="margin-top: 12px;">
2237
+ <span class="card-title" style="margin: 0;">Base URL</span>
2238
+ <input
2239
+ type="text"
2240
+ value=${draft.baseUrl ?? v.baseUrl ?? ""}
2241
+ placeholder="https://api.deepseek.com (default)"
2242
+ onInput=${(e) => setDraft({ ...draft, baseUrl: e.target.value })}
2243
+ />
2244
+ <button
2245
+ disabled=${saving || (draft.baseUrl ?? v.baseUrl ?? "") === (v.baseUrl ?? "")}
2246
+ onClick=${() => save({ baseUrl: draft.baseUrl })}
2247
+ >Save</button>
2248
+ </div>
2249
+ </div>
2250
+
2251
+ <div class="section-title">Defaults</div>
2252
+ <div class="card">
2253
+ <div class="row">
2254
+ <span class="card-title" style="margin: 0; flex: 0 0 110px;">Preset</span>
2255
+ <select
2256
+ value=${
2257
+ // Unknown values (legacy fast/smart/max, or anything
2258
+ // hand-edited into config.json) display as `auto`.
2259
+ ["auto", "flash", "pro"].includes(v.preset) ? v.preset : "auto"
2260
+ }
2261
+ onChange=${(e) => save({ preset: e.target.value })}
2262
+ disabled=${saving}
2263
+ >
2264
+ <option value="auto">auto — flash → pro on hard turns (default)</option>
2265
+ <option value="flash">flash — always flash, no auto-escalate</option>
2266
+ <option value="pro">pro — always pro</option>
2267
+ </select>
2268
+ <span class="muted" style="margin-left: auto; font-size: 12px;">applies next turn</span>
2269
+ </div>
2270
+ <div class="row" style="margin-top: 12px;">
2271
+ <span class="card-title" style="margin: 0; flex: 0 0 110px;">Effort</span>
2272
+ <select
2273
+ value=${v.reasoningEffort}
2274
+ onChange=${(e) => save({ reasoningEffort: e.target.value })}
2275
+ disabled=${saving}
2276
+ >
2277
+ <option value="max">max (default — best)</option>
2278
+ <option value="high">high (cheaper / faster)</option>
2279
+ </select>
2280
+ <span class="muted" style="margin-left: auto; font-size: 12px;">applies next turn</span>
2281
+ </div>
2282
+ <div class="row" style="margin-top: 12px;">
2283
+ <span class="card-title" style="margin: 0; flex: 0 0 110px;">Web search</span>
2284
+ <button
2285
+ class=${v.search ? "primary" : ""}
2286
+ onClick=${() => save({ search: !v.search })}
2287
+ disabled=${saving}
2288
+ >${v.search ? "ON" : "off"}</button>
2289
+ <span class="muted" style="margin-left: auto; font-size: 12px;">web_fetch + web_search tools</span>
2290
+ </div>
2291
+ </div>
2292
+
2293
+ <div class="section-title">Runtime</div>
2294
+ <div class="card">
2295
+ <div class="row">
2296
+ <span class="card-title" style="margin: 0; flex: 0 0 110px;">Active model</span>
2297
+ <code>${v.model ?? "—"}</code>
2298
+ </div>
2299
+ <div class="row" style="margin-top: 8px;">
2300
+ <span class="card-title" style="margin: 0; flex: 0 0 110px;">Edit mode</span>
2301
+ <code>${v.editMode}</code>
2302
+ <span class="muted" style="margin-left: auto; font-size: 12px;">switch from the Chat tab header</span>
2303
+ </div>
2304
+ </div>
2305
+ </div>
2306
+ `;
2307
+ }
2308
+
2309
+ // ---------- Hooks ----------
2310
+
2311
+ function HooksPanel() {
2312
+ const [data, setData] = useState(null);
2313
+ const [error, setError] = useState(null);
2314
+ const [drafts, setDrafts] = useState({}); // { project: jsonText, global: jsonText }
2315
+ const [busy, setBusy] = useState(false);
2316
+ const [info, setInfo] = useState(null);
2317
+
2318
+ const load = useCallback(async () => {
2319
+ try {
2320
+ const r = await api("/hooks");
2321
+ setData(r);
2322
+ setDrafts({
2323
+ project: JSON.stringify(r.project.hooks ?? {}, null, 2),
2324
+ global: JSON.stringify(r.global.hooks ?? {}, null, 2),
2325
+ });
2326
+ } catch (err) {
2327
+ setError(err.message);
2328
+ }
2329
+ }, []);
2330
+ useEffect(() => {
2331
+ load();
2332
+ }, [load]);
2333
+
2334
+ const saveScope = useCallback(
2335
+ async (scope) => {
2336
+ setBusy(true);
2337
+ setError(null);
2338
+ let parsed;
2339
+ try {
2340
+ parsed = JSON.parse(drafts[scope]);
2341
+ } catch (err) {
2342
+ setError(`${scope} JSON: ${err.message}`);
2343
+ setBusy(false);
2344
+ return;
2345
+ }
2346
+ try {
2347
+ await api("/hooks/save", { method: "POST", body: { scope, hooks: parsed } });
2348
+ await api("/hooks/reload", { method: "POST", body: {} });
2349
+ setInfo(`saved + reloaded ${scope}`);
2350
+ setTimeout(() => setInfo(null), 3000);
2351
+ await load();
2352
+ } catch (err) {
2353
+ setError(err.message);
2354
+ } finally {
2355
+ setBusy(false);
2356
+ }
2357
+ },
2358
+ [drafts, load],
2359
+ );
2360
+
2361
+ if (!data && !error) return html`<div class="boot">loading hooks…</div>`;
2362
+ if (error && !data) return html`<div class="notice err">${error}</div>`;
2363
+
2364
+ return html`
2365
+ <div>
2366
+ <div class="panel-header">
2367
+ <h2 class="panel-title">Hooks</h2>
2368
+ <span class="panel-subtitle">${data.resolved.length} resolved · events: ${data.events.join(", ")}</span>
2369
+ </div>
2370
+ ${info ? html`<div class="notice">${info}</div>` : null}
2371
+ ${error ? html`<div class="notice err">${error}</div>` : null}
2372
+ ${["project", "global"].map((scope) => {
2373
+ const meta = data[scope];
2374
+ return html`
2375
+ <div class="section-title">${scope} — <code>${meta.path ?? "(no project)"}</code></div>
2376
+ ${
2377
+ scope === "project" && !meta.path
2378
+ ? html`<div class="empty">No active project — open <code>/dashboard</code> from <code>reasonix code</code> to edit project hooks.</div>`
2379
+ : html`
2380
+ <textarea
2381
+ style="width: 100%; height: 240px; font-family: var(--mono); font-size: 12.5px; background: var(--bg-2); color: var(--fg-0); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 10px;"
2382
+ value=${drafts[scope] ?? ""}
2383
+ onInput=${(e) => setDrafts({ ...drafts, [scope]: e.target.value })}
2384
+ disabled=${busy}
2385
+ ></textarea>
2386
+ <div class="row" style="margin-top: 8px;">
2387
+ <button class="primary" disabled=${busy} onClick=${() => saveScope(scope)}>Save + Reload</button>
2388
+ <button disabled=${busy} onClick=${load}>Discard changes</button>
2389
+ </div>
2390
+ `
2391
+ }
2392
+ `;
2393
+ })}
2394
+ </div>
2395
+ `;
2396
+ }
2397
+
2398
+ // ---------- Memory ----------
2399
+
2400
+ function MemoryPanel() {
2401
+ const [tree, setTree] = useState(null);
2402
+ const [error, setError] = useState(null);
2403
+ const [open, setOpen] = useState(null); // { scope, name } | null
2404
+ const [body, setBody] = useState("");
2405
+ const [busy, setBusy] = useState(false);
2406
+ const [info, setInfo] = useState(null);
2407
+
2408
+ const load = useCallback(async () => {
2409
+ try {
2410
+ const r = await api("/memory");
2411
+ setTree(r);
2412
+ } catch (err) {
2413
+ setError(err.message);
2414
+ }
2415
+ }, []);
2416
+ useEffect(() => {
2417
+ load();
2418
+ }, [load]);
2419
+
2420
+ const openFile = useCallback(async (scope, name) => {
2421
+ setOpen({ scope, name });
2422
+ setBusy(true);
2423
+ try {
2424
+ const path =
2425
+ scope === "project" ? "/memory/project" : `/memory/${scope}/${encodeURIComponent(name)}`;
2426
+ const r = await api(path);
2427
+ setBody(r.body);
2428
+ } catch (err) {
2429
+ setError(err.message);
2430
+ } finally {
2431
+ setBusy(false);
2432
+ }
2433
+ }, []);
2434
+
2435
+ const save = useCallback(async () => {
2436
+ if (!open) return;
2437
+ setBusy(true);
2438
+ setError(null);
2439
+ try {
2440
+ const path =
2441
+ open.scope === "project"
2442
+ ? "/memory/project"
2443
+ : `/memory/${open.scope}/${encodeURIComponent(open.name)}`;
2444
+ await api(path, { method: "POST", body: { body } });
2445
+ setInfo(`saved ${open.scope}${open.name ? `/${open.name}` : ""}`);
2446
+ setTimeout(() => setInfo(null), 3000);
2447
+ await load();
2448
+ } catch (err) {
2449
+ setError(err.message);
2450
+ } finally {
2451
+ setBusy(false);
2452
+ }
2453
+ }, [open, body, load]);
2454
+
2455
+ if (!tree && !error) return html`<div class="boot">loading memory…</div>`;
2456
+ if (error && !tree) return html`<div class="notice err">${error}</div>`;
2457
+
2458
+ if (open) {
2459
+ return html`
2460
+ <div>
2461
+ <div class="panel-header">
2462
+ <h2 class="panel-title">Memory · ${open.scope}${open.name ? `/${open.name}` : ""}</h2>
2463
+ <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
2464
+ </div>
2465
+ ${info ? html`<div class="notice">${info}</div>` : null}
2466
+ ${error ? html`<div class="notice err">${error}</div>` : null}
2467
+ <textarea
2468
+ style="width: 100%; height: 480px; font-family: var(--mono); font-size: 13px; background: var(--bg-2); color: var(--fg-0); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px;"
2469
+ value=${body}
2470
+ onInput=${(e) => setBody(e.target.value)}
2471
+ disabled=${busy}
2472
+ ></textarea>
2473
+ <div class="row" style="margin-top: 8px;">
2474
+ <button class="primary" disabled=${busy} onClick=${save}>Save</button>
2475
+ <span class="muted" style="font-size: 12px;">${body.length.toLocaleString()} chars · re-applied on next /new or session restart</span>
2476
+ </div>
2477
+ </div>
2478
+ `;
2479
+ }
2480
+
2481
+ return html`
2482
+ <div>
2483
+ <div class="panel-header">
2484
+ <h2 class="panel-title">Memory</h2>
2485
+ <span class="panel-subtitle">REASONIX.md (committable) + private notes (~/.reasonix/memory)</span>
2486
+ </div>
2487
+ <div class="section-title">Project — REASONIX.md</div>
2488
+ ${
2489
+ tree.project.path
2490
+ ? html`
2491
+ <div class="card row" style="cursor: pointer;" onClick=${() => openFile("project")}>
2492
+ <span><code>${tree.project.path}</code></span>
2493
+ <span class="pill ${tree.project.exists ? "pill-ok" : "pill-dim"}" style="margin-left: auto;">
2494
+ ${tree.project.exists ? "exists" : "create"}
2495
+ </span>
2496
+ </div>
2497
+ `
2498
+ : html`<div class="empty">No active project.</div>`
2499
+ }
2500
+
2501
+ <div class="section-title">Global — ~/.reasonix/memory/global</div>
2502
+ ${
2503
+ tree.global.files.length === 0
2504
+ ? html`<div class="empty">No global memory files yet.</div>`
2505
+ : html`
2506
+ <table>
2507
+ <thead><tr><th>name</th><th class="numeric">size</th><th class="numeric">touched</th></tr></thead>
2508
+ <tbody>
2509
+ ${tree.global.files.map(
2510
+ (f) => html`
2511
+ <tr key=${f.name} style="cursor: pointer;" onClick=${() => openFile("global", f.name)}>
2512
+ <td><code>${f.name}</code></td>
2513
+ <td class="numeric">${fmtBytes(f.size)}</td>
2514
+ <td class="numeric muted">${fmtRelativeTime(f.mtime)}</td>
2515
+ </tr>
2516
+ `,
2517
+ )}
2518
+ </tbody>
2519
+ </table>
2520
+ `
2521
+ }
2522
+
2523
+ ${
2524
+ tree.projectMem.path
2525
+ ? html`
2526
+ <div class="section-title">Project private — ~/.reasonix/memory/&lt;hash&gt;</div>
2527
+ ${
2528
+ tree.projectMem.files.length === 0
2529
+ ? html`<div class="empty">No project-private memory yet.</div>`
2530
+ : html`
2531
+ <table>
2532
+ <thead><tr><th>name</th><th class="numeric">size</th><th class="numeric">touched</th></tr></thead>
2533
+ <tbody>
2534
+ ${tree.projectMem.files.map(
2535
+ (f) => html`
2536
+ <tr key=${f.name} style="cursor: pointer;" onClick=${() => openFile("project-mem", f.name)}>
2537
+ <td><code>${f.name}</code></td>
2538
+ <td class="numeric">${fmtBytes(f.size)}</td>
2539
+ <td class="numeric muted">${fmtRelativeTime(f.mtime)}</td>
2540
+ </tr>
2541
+ `,
2542
+ )}
2543
+ </tbody>
2544
+ </table>
2545
+ `
2546
+ }
2547
+ `
2548
+ : null
2549
+ }
2550
+ </div>
2551
+ `;
2552
+ }
2553
+
2554
+ // ---------- Skills ----------
2555
+
2556
+ function SkillsPanel() {
2557
+ const [data, setData] = useState(null);
2558
+ const [error, setError] = useState(null);
2559
+ const [open, setOpen] = useState(null);
2560
+ const [body, setBody] = useState("");
2561
+ const [busy, setBusy] = useState(false);
2562
+ const [info, setInfo] = useState(null);
2563
+ const [newName, setNewName] = useState("");
2564
+ const [newScope, setNewScope] = useState("global");
2565
+
2566
+ const load = useCallback(async () => {
2567
+ try {
2568
+ setData(await api("/skills"));
2569
+ } catch (err) {
2570
+ setError(err.message);
2571
+ }
2572
+ }, []);
2573
+ useEffect(() => {
2574
+ load();
2575
+ }, [load]);
2576
+
2577
+ const openSkill = useCallback(async (scope, name) => {
2578
+ setOpen({ scope, name });
2579
+ setBusy(true);
2580
+ try {
2581
+ const r = await api(`/skills/${scope}/${encodeURIComponent(name)}`);
2582
+ setBody(r.body);
2583
+ } catch (err) {
2584
+ setError(err.message);
2585
+ } finally {
2586
+ setBusy(false);
2587
+ }
2588
+ }, []);
2589
+
2590
+ const save = useCallback(async () => {
2591
+ if (!open) return;
2592
+ setBusy(true);
2593
+ try {
2594
+ await api(`/skills/${open.scope}/${encodeURIComponent(open.name)}`, {
2595
+ method: "POST",
2596
+ body: { body },
2597
+ });
2598
+ setInfo(`saved ${open.scope}/${open.name}`);
2599
+ setTimeout(() => setInfo(null), 3000);
2600
+ await load();
2601
+ } catch (err) {
2602
+ setError(err.message);
2603
+ } finally {
2604
+ setBusy(false);
2605
+ }
2606
+ }, [open, body, load]);
2607
+
2608
+ const remove = useCallback(async () => {
2609
+ if (!open) return;
2610
+ if (!confirm(`Delete skill ${open.scope}/${open.name}?`)) return;
2611
+ setBusy(true);
2612
+ try {
2613
+ await api(`/skills/${open.scope}/${encodeURIComponent(open.name)}`, { method: "DELETE" });
2614
+ setOpen(null);
2615
+ await load();
2616
+ } catch (err) {
2617
+ setError(err.message);
2618
+ } finally {
2619
+ setBusy(false);
2620
+ }
2621
+ }, [open, load]);
2622
+
2623
+ const create = useCallback(async () => {
2624
+ if (!newName.trim()) return;
2625
+ setBusy(true);
2626
+ const stub = `---\nname: ${newName.trim()}\ndescription: TODO — one-line description that helps the model match this skill\n---\n\n# ${newName.trim()}\n\n`;
2627
+ try {
2628
+ await api(`/skills/${newScope}/${encodeURIComponent(newName.trim())}`, {
2629
+ method: "POST",
2630
+ body: { body: stub },
2631
+ });
2632
+ setNewName("");
2633
+ await load();
2634
+ openSkill(newScope, newName.trim());
2635
+ } catch (err) {
2636
+ setError(err.message);
2637
+ } finally {
2638
+ setBusy(false);
2639
+ }
2640
+ }, [newName, newScope, load, openSkill]);
2641
+
2642
+ if (!data && !error) return html`<div class="boot">loading skills…</div>`;
2643
+ if (error && !data) return html`<div class="notice err">${error}</div>`;
2644
+
2645
+ if (open) {
2646
+ return html`
2647
+ <div>
2648
+ <div class="panel-header">
2649
+ <h2 class="panel-title">Skill · ${open.scope}/${open.name}</h2>
2650
+ <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
2651
+ </div>
2652
+ ${info ? html`<div class="notice">${info}</div>` : null}
2653
+ ${error ? html`<div class="notice err">${error}</div>` : null}
2654
+ <textarea
2655
+ style="width: 100%; height: 520px; font-family: var(--mono); font-size: 13px; background: var(--bg-2); color: var(--fg-0); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 12px;"
2656
+ value=${body}
2657
+ onInput=${(e) => setBody(e.target.value)}
2658
+ disabled=${busy}
2659
+ ></textarea>
2660
+ <div class="row" style="margin-top: 8px;">
2661
+ <button class="primary" disabled=${busy} onClick=${save}>Save</button>
2662
+ <button class="danger" disabled=${busy} onClick=${remove}>Delete</button>
2663
+ <span class="muted" style="font-size: 12px; margin-left: auto;">re-loaded on next /new or session restart</span>
2664
+ </div>
2665
+ </div>
2666
+ `;
2667
+ }
2668
+
2669
+ const renderList = (label, items, scope) => html`
2670
+ <div class="section-title">${label} (${items.length})</div>
2671
+ ${
2672
+ items.length === 0
2673
+ ? html`<div class="empty">none</div>`
2674
+ : html`
2675
+ <table>
2676
+ <thead><tr><th>name</th><th>description</th><th></th></tr></thead>
2677
+ <tbody>
2678
+ ${items.map(
2679
+ (s) => html`
2680
+ <tr key=${s.name} style="cursor: ${scope === "builtin" ? "default" : "pointer"};" onClick=${() => scope !== "builtin" && openSkill(scope, s.name)}>
2681
+ <td><code>${s.name}</code></td>
2682
+ <td>${s.description ?? html`<span class="muted">(no description)</span>`}</td>
2683
+ <td>${scope === "builtin" ? html`<span class="pill pill-dim">builtin</span>` : null}</td>
2684
+ </tr>
2685
+ `,
2686
+ )}
2687
+ </tbody>
2688
+ </table>
2689
+ `
2690
+ }
2691
+ `;
2692
+
2693
+ return html`
2694
+ <div>
2695
+ <div class="panel-header">
2696
+ <h2 class="panel-title">Skills</h2>
2697
+ <span class="panel-subtitle">click to edit · creates land in next /new</span>
2698
+ </div>
2699
+ ${error ? html`<div class="notice err">${error}</div>` : null}
2700
+
2701
+ <div class="section-title">Create new</div>
2702
+ <div class="card row">
2703
+ <select value=${newScope} onChange=${(e) => setNewScope(e.target.value)}>
2704
+ <option value="global">global</option>
2705
+ ${data.paths.project ? html`<option value="project">project</option>` : null}
2706
+ </select>
2707
+ <input
2708
+ type="text"
2709
+ placeholder="skill-name"
2710
+ value=${newName}
2711
+ onInput=${(e) => setNewName(e.target.value)}
2712
+ />
2713
+ <button class="primary" disabled=${busy || !newName.trim()} onClick=${create}>Create</button>
2714
+ </div>
2715
+
2716
+ ${renderList("Project", data.project, "project")}
2717
+ ${renderList("Global", data.global, "global")}
2718
+ ${renderList("Builtin (read-only)", data.builtin, "builtin")}
2719
+ </div>
2720
+ `;
2721
+ }
2722
+
2723
+ // ---------- MCP ----------
2724
+
2725
+ function McpPanel() {
2726
+ const [data, setData] = useState(null);
2727
+ const [specs, setSpecs] = useState(null);
2728
+ const [error, setError] = useState(null);
2729
+ const [info, setInfo] = useState(null);
2730
+ const [newSpec, setNewSpec] = useState("");
2731
+ const [busy, setBusy] = useState(false);
2732
+ const [open, setOpen] = useState(null); // server detail
2733
+
2734
+ const load = useCallback(async () => {
2735
+ try {
2736
+ setData(await api("/mcp"));
2737
+ setSpecs((await api("/mcp/specs")).specs);
2738
+ } catch (err) {
2739
+ setError(err.message);
2740
+ }
2741
+ }, []);
2742
+ useEffect(() => {
2743
+ load();
2744
+ }, [load]);
2745
+
2746
+ const addSpec = useCallback(async () => {
2747
+ if (!newSpec.trim()) return;
2748
+ setBusy(true);
2749
+ try {
2750
+ const r = await api("/mcp/specs", { method: "POST", body: { spec: newSpec.trim() } });
2751
+ setInfo(
2752
+ r.requiresRestart ? "saved — restart `reasonix code` to bridge this server" : "saved",
2753
+ );
2754
+ setTimeout(() => setInfo(null), 4000);
2755
+ setNewSpec("");
2756
+ await load();
2757
+ } catch (err) {
2758
+ setError(err.message);
2759
+ } finally {
2760
+ setBusy(false);
2761
+ }
2762
+ }, [newSpec, load]);
2763
+
2764
+ const removeSpec = useCallback(
2765
+ async (spec) => {
2766
+ if (!confirm(`Remove MCP spec from config?\n\n${spec}`)) return;
2767
+ setBusy(true);
2768
+ try {
2769
+ await api("/mcp/specs", { method: "DELETE", body: { spec } });
2770
+ setInfo("removed — restart to drop the live bridge");
2771
+ setTimeout(() => setInfo(null), 4000);
2772
+ await load();
2773
+ } catch (err) {
2774
+ setError(err.message);
2775
+ } finally {
2776
+ setBusy(false);
2777
+ }
2778
+ },
2779
+ [load],
2780
+ );
2781
+
2782
+ if (!data && !error) return html`<div class="boot">loading MCP…</div>`;
2783
+ if (error && !data) return html`<div class="notice err">${error}</div>`;
2784
+
2785
+ if (open) {
2786
+ return html`
2787
+ <div>
2788
+ <div class="panel-header">
2789
+ <h2 class="panel-title">MCP · ${open.label}</h2>
2790
+ <button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
2791
+ </div>
2792
+ <div class="card">
2793
+ <div class="row"><span class="card-title" style="margin: 0; flex: 0 0 110px;">spec</span><code>${open.spec}</code></div>
2794
+ <div class="row"><span class="card-title" style="margin: 0; flex: 0 0 110px;">server</span><span>${open.serverInfo?.name ?? "—"} ${open.serverInfo?.version ? `v${open.serverInfo.version}` : ""}</span></div>
2795
+ <div class="row"><span class="card-title" style="margin: 0; flex: 0 0 110px;">protocol</span><code>${open.protocolVersion}</code></div>
2796
+ </div>
2797
+ ${open.instructions ? html`<div class="notice">${open.instructions}</div>` : null}
2798
+ <div class="section-title">Tools (${open.tools.length})</div>
2799
+ <table>
2800
+ <thead><tr><th>name</th><th>description</th></tr></thead>
2801
+ <tbody>
2802
+ ${open.tools.map((t) => html`<tr><td><code>${t.name}</code></td><td>${t.description ?? ""}</td></tr>`)}
2803
+ </tbody>
2804
+ </table>
2805
+ ${
2806
+ open.resources.length > 0
2807
+ ? html`
2808
+ <div class="section-title">Resources (${open.resources.length})</div>
2809
+ <table>
2810
+ <thead><tr><th>name</th><th>uri</th></tr></thead>
2811
+ <tbody>
2812
+ ${open.resources.map((r) => html`<tr><td>${r.name}</td><td><code>${r.uri}</code></td></tr>`)}
2813
+ </tbody>
2814
+ </table>
2815
+ `
2816
+ : null
2817
+ }
2818
+ ${
2819
+ open.prompts.length > 0
2820
+ ? html`
2821
+ <div class="section-title">Prompts (${open.prompts.length})</div>
2822
+ <table>
2823
+ <thead><tr><th>name</th><th>description</th></tr></thead>
2824
+ <tbody>
2825
+ ${open.prompts.map((p) => html`<tr><td><code>${p.name}</code></td><td>${p.description ?? ""}</td></tr>`)}
2826
+ </tbody>
2827
+ </table>
2828
+ `
2829
+ : null
2830
+ }
2831
+ </div>
2832
+ `;
2833
+ }
2834
+
2835
+ return html`
2836
+ <div>
2837
+ <div class="panel-header">
2838
+ <h2 class="panel-title">MCP</h2>
2839
+ <span class="panel-subtitle">${data.servers.length} bridged · ${specs?.length ?? 0} in config</span>
2840
+ </div>
2841
+ ${info ? html`<div class="notice">${info}</div>` : null}
2842
+ ${error ? html`<div class="notice err">${error}</div>` : null}
2843
+
2844
+ <div class="section-title">Add server</div>
2845
+ <div class="card row">
2846
+ <input
2847
+ type="text"
2848
+ placeholder='spec — e.g. "fs=npx -y @modelcontextprotocol/server-filesystem /tmp/safe"'
2849
+ value=${newSpec}
2850
+ onInput=${(e) => setNewSpec(e.target.value)}
2851
+ />
2852
+ <button class="primary" disabled=${busy || !newSpec.trim()} onClick=${addSpec}>Add</button>
2853
+ </div>
2854
+
2855
+ <div class="section-title">Bridged (${data.servers.length})</div>
2856
+ ${
2857
+ data.servers.length === 0
2858
+ ? html`<div class="empty">No MCP servers in this session.</div>`
2859
+ : html`
2860
+ <table>
2861
+ <thead><tr><th>label</th><th>spec</th><th class="numeric">tools</th><th></th></tr></thead>
2862
+ <tbody>
2863
+ ${data.servers.map(
2864
+ (s) => html`
2865
+ <tr key=${s.label} style="cursor: pointer;" onClick=${() => setOpen(s)}>
2866
+ <td><code>${s.label}</code></td>
2867
+ <td><code style="font-size: 11px;">${s.spec}</code></td>
2868
+ <td class="numeric">${fmtNum(s.toolCount)}</td>
2869
+ <td></td>
2870
+ </tr>
2871
+ `,
2872
+ )}
2873
+ </tbody>
2874
+ </table>
2875
+ `
2876
+ }
2877
+
2878
+ <div class="section-title">Persisted specs (config.json)</div>
2879
+ ${
2880
+ (specs ?? []).length === 0
2881
+ ? html`<div class="empty">No MCP specs persisted in <code>~/.reasonix/config.json</code>.</div>`
2882
+ : html`
2883
+ <table>
2884
+ <thead><tr><th>spec</th><th></th></tr></thead>
2885
+ <tbody>
2886
+ ${specs.map(
2887
+ (spec) => html`
2888
+ <tr key=${spec}>
2889
+ <td><code>${spec}</code></td>
2890
+ <td class="numeric">
2891
+ <button class="danger" disabled=${busy} onClick=${(e) => {
2892
+ e.stopPropagation();
2893
+ removeSpec(spec);
2894
+ }}>remove</button>
2895
+ </td>
2896
+ </tr>
2897
+ `,
2898
+ )}
2899
+ </tbody>
2900
+ </table>
2901
+ `
2902
+ }
2903
+ </div>
2904
+ `;
2905
+ }
2906
+
2907
+ // ---------- Editor (CodeMirror 6, multi-tab) ----------
2908
+
2909
+ // Lazy-loaded CodeMirror modules — kept off the initial bundle so users
2910
+ // who never open Editor never pay the ~200KB cost. Cached after first
2911
+ // resolve so tab switches don't re-fetch.
2912
+ //
2913
+ // CodeMirror loads from a locally bundled file (`/assets/codemirror.js`,
2914
+ // produced by `scripts/bundle-codemirror.mjs`). One bundle = one copy
2915
+ // of every package = no Tag identity mismatch between oneDark and the
2916
+ // language parsers, no esm.sh round-trips on every cold load. The
2917
+ // previous esm.sh + ?deps= setup hit silent failure modes whenever the
2918
+ // CDN resolved a transitive @lezer/* to a different version than the
2919
+ // bundled cache thought it would.
2920
+ let cmModulesPromise = null;
2921
+ async function loadCodeMirror() {
2922
+ if (cmModulesPromise) return cmModulesPromise;
2923
+ cmModulesPromise = import(`/assets/codemirror.js?token=${TOKEN}`);
2924
+ return cmModulesPromise;
2925
+ }
2926
+
2927
+ // Map file path → CodeMirror language extension factory.
2928
+ function langExtensionFor(path, langs) {
2929
+ const lang = langFromPath(path);
2930
+ if (!lang) return null;
2931
+ // CodeMirror's javascript pack handles ts/tsx/jsx via options.
2932
+ if (lang === "typescript") return langs.typescript ? langs.typescript() : null;
2933
+ if (lang === "javascript") return langs.javascript ? langs.javascript({ jsx: true }) : null;
2934
+ const fn = langs[lang];
2935
+ return fn ? fn() : null;
2936
+ }
2937
+
2938
+ // Build a nested folder tree from a flat list of repo paths. Nodes use
2939
+ // Maps so insertion order is stable; sorting happens at render time.
2940
+ function buildFileTree(paths) {
2941
+ const root = { name: "", path: "", children: new Map(), isFile: false };
2942
+ for (const p of paths) {
2943
+ const parts = p.split("/").filter(Boolean);
2944
+ let node = root;
2945
+ for (let i = 0; i < parts.length; i++) {
2946
+ const isLast = i === parts.length - 1;
2947
+ const name = parts[i];
2948
+ const childPath = parts.slice(0, i + 1).join("/");
2949
+ let child = node.children.get(name);
2950
+ if (!child) {
2951
+ child = { name, path: childPath, children: new Map(), isFile: isLast };
2952
+ node.children.set(name, child);
2953
+ } else if (isLast && child.children.size === 0) {
2954
+ child.isFile = true;
2955
+ }
2956
+ node = child;
2957
+ }
2958
+ }
2959
+ return root;
2960
+ }
2961
+
2962
+ // Walk the tree honoring the expanded set; produce a flat row list the
2963
+ // renderer can map straight to JSX. Folders precede files; both sorted
2964
+ // case-insensitively.
2965
+ function flattenTree(node, expanded, depth, out) {
2966
+ const children = [...node.children.values()].sort((a, b) => {
2967
+ if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
2968
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
2969
+ });
2970
+ for (const child of children) {
2971
+ out.push({ name: child.name, path: child.path, depth, isFile: child.isFile });
2972
+ if (!child.isFile && expanded.has(child.path)) {
2973
+ flattenTree(child, expanded, depth + 1, out);
2974
+ }
2975
+ }
2976
+ return out;
2977
+ }
2978
+
2979
+ function EditorPanel({ onClose } = {}) {
2980
+ // tabs: { path, content, original, dirty, savedAt }
2981
+ const [tabs, setTabs] = useState([]);
2982
+ const [activeIdx, setActiveIdx] = useState(0);
2983
+ const [files, setFiles] = useState([]);
2984
+ const [filesError, setFilesError] = useState(null);
2985
+ const [openInput, setOpenInput] = useState("");
2986
+ const [filter, setFilter] = useState("");
2987
+ const [error, setError] = useState(null);
2988
+ const [busy, setBusy] = useState(false);
2989
+ const [cmReady, setCmReady] = useState(false);
2990
+ const [sideCollapsed, setSideCollapsed] = useState(false);
2991
+ const [expanded, setExpanded] = useState(() => new Set());
2992
+ // View mode for markdown tabs: "edit" (source only), "split" (source +
2993
+ // preview side-by-side), "preview" (rendered only). Non-md tabs always
2994
+ // render in edit mode regardless of this state.
2995
+ const [viewMode, setViewMode] = useState("edit");
2996
+ const editorContainerRef = useRef(null);
2997
+ const viewRef = useRef(null);
2998
+ const cmRef = useRef(null);
2999
+ const tabsRef = useRef(tabs);
3000
+ const activeIdxRef = useRef(activeIdx);
3001
+ useEffect(() => {
3002
+ tabsRef.current = tabs;
3003
+ }, [tabs]);
3004
+ useEffect(() => {
3005
+ activeIdxRef.current = activeIdx;
3006
+ }, [activeIdx]);
3007
+
3008
+ // Load file list (gitignore-aware) for the picker.
3009
+ const loadFiles = useCallback(async () => {
3010
+ try {
3011
+ const r = await api("/files");
3012
+ setFiles(r.files ?? []);
3013
+ } catch (err) {
3014
+ setFilesError(err.message);
3015
+ }
3016
+ }, []);
3017
+ useEffect(() => {
3018
+ loadFiles();
3019
+ }, [loadFiles]);
3020
+
3021
+ // Open a file → fetch + push tab + activate. If already open, just
3022
+ // switch to the existing tab so we don't lose unsaved edits.
3023
+ const openPath = useCallback(async (path) => {
3024
+ if (!path) return;
3025
+ const existing = tabsRef.current.findIndex((t) => t.path === path);
3026
+ if (existing >= 0) {
3027
+ setActiveIdx(existing);
3028
+ return;
3029
+ }
3030
+ setBusy(true);
3031
+ setError(null);
3032
+ try {
3033
+ const r = await api(`/file/${path.split("/").map(encodeURIComponent).join("/")}`);
3034
+ setTabs((prev) => [
3035
+ ...prev,
3036
+ { path, content: r.content, original: r.content, dirty: false, savedAt: r.mtime },
3037
+ ]);
3038
+ setActiveIdx(tabsRef.current.length);
3039
+ } catch (err) {
3040
+ setError(`open ${path}: ${err.message}`);
3041
+ } finally {
3042
+ setBusy(false);
3043
+ }
3044
+ }, []);
3045
+
3046
+ // Subscribe to "open-file" events fired from elsewhere (Chat panel
3047
+ // tool cards, file-mention links).
3048
+ useEffect(() => {
3049
+ const onOpen = (ev) => openPath(ev.detail.path);
3050
+ appBus.addEventListener("open-file", onOpen);
3051
+ return () => appBus.removeEventListener("open-file", onOpen);
3052
+ }, [openPath]);
3053
+
3054
+ // Mount CodeMirror lazily on first render.
3055
+ useEffect(() => {
3056
+ let cancelled = false;
3057
+ loadCodeMirror().then((cm) => {
3058
+ if (!cancelled) {
3059
+ cmRef.current = cm;
3060
+ setCmReady(true);
3061
+ }
3062
+ });
3063
+ return () => {
3064
+ cancelled = true;
3065
+ };
3066
+ }, []);
3067
+
3068
+ // Re-mount the editor view when active tab changes. Each tab's
3069
+ // content is held in React state — the view is just a presentation
3070
+ // layer over the current tab's string.
3071
+ useEffect(() => {
3072
+ if (!cmReady || !editorContainerRef.current) return;
3073
+ const cm = cmRef.current;
3074
+ if (!cm) return;
3075
+ const tab = tabs[activeIdx];
3076
+ if (!tab) {
3077
+ if (viewRef.current) {
3078
+ viewRef.current.destroy();
3079
+ viewRef.current = null;
3080
+ }
3081
+ return;
3082
+ }
3083
+
3084
+ if (viewRef.current) {
3085
+ viewRef.current.destroy();
3086
+ viewRef.current = null;
3087
+ }
3088
+
3089
+ const langExt = langExtensionFor(tab.path, cm.langs);
3090
+ const updateListener = cm.EditorView.updateListener.of((update) => {
3091
+ if (update.docChanged) {
3092
+ const text = update.state.doc.toString();
3093
+ // Mutate the tab's content + dirty flag without forcing a
3094
+ // full re-render of the editor (which would lose the cursor).
3095
+ // We DO setTabs so the tab bar's dirty dot updates.
3096
+ const idx = activeIdxRef.current;
3097
+ const live = tabsRef.current;
3098
+ if (live[idx]) {
3099
+ const next = [...live];
3100
+ next[idx] = { ...next[idx], content: text, dirty: text !== next[idx].original };
3101
+ tabsRef.current = next;
3102
+ setTabs(next);
3103
+ }
3104
+ }
3105
+ });
3106
+
3107
+ const extensions = [
3108
+ cm.lineNumbers(),
3109
+ cm.highlightActiveLineGutter ? cm.highlightActiveLineGutter() : [],
3110
+ cm.foldGutter ? cm.foldGutter() : [],
3111
+ cm.highlightActiveLine(),
3112
+ cm.drawSelection(),
3113
+ cm.history(),
3114
+ cm.bracketMatching(),
3115
+ cm.indentOnInput(),
3116
+ cm.closeBrackets ? cm.closeBrackets() : [],
3117
+ cm.autocompletion
3118
+ ? cm.autocompletion({
3119
+ activateOnTyping: true,
3120
+ closeOnBlur: true,
3121
+ maxRenderedOptions: 30,
3122
+ })
3123
+ : [],
3124
+ cm.highlightSelectionMatches ? cm.highlightSelectionMatches() : [],
3125
+ cm.keymap.of([
3126
+ ...cm.defaultKeymap,
3127
+ ...cm.historyKeymap,
3128
+ ...(cm.closeBracketsKeymap ?? []),
3129
+ ...(cm.searchKeymap ?? []),
3130
+ ...(cm.completionKeymap ?? []),
3131
+ ...(cm.foldKeymap ?? []),
3132
+ cm.indentWithTab,
3133
+ ]),
3134
+ // oneDark is an array of [theme, syntaxHighlighting(oneDarkHighlightStyle)] —
3135
+ // including it gives both the dark UI and the highlight tags. Keep
3136
+ // defaultHighlightStyle as a fallback only for languages oneDark omits.
3137
+ cm.oneDark,
3138
+ cm.syntaxHighlighting(cm.defaultHighlightStyle, { fallback: true }),
3139
+ cm.EditorView.lineWrapping,
3140
+ updateListener,
3141
+ ];
3142
+ if (langExt) extensions.push(langExt);
3143
+
3144
+ const state = cm.EditorState.create({ doc: tab.content, extensions });
3145
+ viewRef.current = new cm.EditorView({ state, parent: editorContainerRef.current });
3146
+
3147
+ return () => {
3148
+ if (viewRef.current) {
3149
+ viewRef.current.destroy();
3150
+ viewRef.current = null;
3151
+ }
3152
+ };
3153
+ }, [cmReady, activeIdx, tabs[activeIdx]?.path, viewMode]);
3154
+
3155
+ const closeTab = useCallback((idx) => {
3156
+ const tab = tabsRef.current[idx];
3157
+ if (tab?.dirty && !confirm(`${tab.path} has unsaved changes. Discard?`)) return;
3158
+ setTabs((prev) => prev.filter((_, i) => i !== idx));
3159
+ if (activeIdxRef.current >= idx) {
3160
+ setActiveIdx(Math.max(0, activeIdxRef.current - 1));
3161
+ }
3162
+ }, []);
3163
+
3164
+ const saveTab = useCallback(async (idx) => {
3165
+ const tab = tabsRef.current[idx];
3166
+ if (!tab) return;
3167
+ setBusy(true);
3168
+ setError(null);
3169
+ try {
3170
+ const r = await api(`/file/${tab.path.split("/").map(encodeURIComponent).join("/")}`, {
3171
+ method: "POST",
3172
+ body: { content: tab.content },
3173
+ });
3174
+ setTabs((prev) => {
3175
+ const next = [...prev];
3176
+ if (next[idx]) {
3177
+ next[idx] = { ...next[idx], original: tab.content, dirty: false, savedAt: r.mtime };
3178
+ }
3179
+ return next;
3180
+ });
3181
+ showToast(`saved ${tab.path}`, "info");
3182
+ } catch (err) {
3183
+ setError(`save ${tab.path}: ${err.message}`);
3184
+ } finally {
3185
+ setBusy(false);
3186
+ }
3187
+ }, []);
3188
+
3189
+ // Cmd/Ctrl+S — save active tab.
3190
+ useEffect(() => {
3191
+ const onKey = (e) => {
3192
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
3193
+ e.preventDefault();
3194
+ if (tabsRef.current[activeIdxRef.current]) {
3195
+ saveTab(activeIdxRef.current);
3196
+ }
3197
+ }
3198
+ };
3199
+ window.addEventListener("keydown", onKey);
3200
+ return () => window.removeEventListener("keydown", onKey);
3201
+ }, [saveTab]);
3202
+
3203
+ const tab = tabs[activeIdx];
3204
+
3205
+ const tree = useMemo(() => buildFileTree(files), [files]);
3206
+ const treeRows = useMemo(() => flattenTree(tree, expanded, 0, []), [tree, expanded]);
3207
+
3208
+ const toggleFolder = useCallback((path) => {
3209
+ setExpanded((prev) => {
3210
+ const next = new Set(prev);
3211
+ if (next.has(path)) next.delete(path);
3212
+ else next.add(path);
3213
+ return next;
3214
+ });
3215
+ }, []);
3216
+
3217
+ const filtering = filter.trim().length > 0;
3218
+ const filteredFiles = filtering
3219
+ ? files.filter((f) => f.toLowerCase().includes(filter.toLowerCase())).slice(0, 80)
3220
+ : null;
3221
+
3222
+ const openPaths = tabs.map((t) => t.path);
3223
+
3224
+ return html`
3225
+ <div class="editor-shell">
3226
+ ${
3227
+ onClose
3228
+ ? html`
3229
+ <div class="editor-drawer-head">
3230
+ <span class="editor-drawer-title">Editor</span>
3231
+ <button class="editor-drawer-close" onClick=${onClose} title="close editor (Esc)">×</button>
3232
+ </div>
3233
+ `
3234
+ : null
3235
+ }
3236
+ <div class="editor-tabs">
3237
+ ${
3238
+ tabs.length === 0
3239
+ ? html`<div class="editor-no-tabs">No files open. Pick from the list, paste a path above, or click a path in chat.</div>`
3240
+ : tabs.map(
3241
+ (t, i) => html`
3242
+ <div
3243
+ key=${t.path}
3244
+ class="editor-tab ${i === activeIdx ? "active" : ""}"
3245
+ onClick=${() => setActiveIdx(i)}
3246
+ >
3247
+ <span class="editor-tab-name" title=${t.path}>${t.path.split("/").pop()}</span>
3248
+ ${t.dirty ? html`<span class="editor-tab-dirty">●</span>` : null}
3249
+ <span class="editor-tab-close" onClick=${(e) => {
3250
+ e.stopPropagation();
3251
+ closeTab(i);
3252
+ }}>×</span>
3253
+ </div>
3254
+ `,
3255
+ )
3256
+ }
3257
+ </div>
3258
+ <div class="editor-body">
3259
+ ${
3260
+ sideCollapsed
3261
+ ? html`
3262
+ <div class="editor-side collapsed">
3263
+ <button
3264
+ class="editor-side-toggle"
3265
+ onClick=${() => setSideCollapsed(false)}
3266
+ title="show files"
3267
+ >▶</button>
3268
+ </div>
3269
+ `
3270
+ : html`
3271
+ <div class="editor-side">
3272
+ <div class="editor-side-head">
3273
+ <span class="editor-side-label">FILES</span>
3274
+ <button
3275
+ class="editor-side-toggle"
3276
+ onClick=${() => setSideCollapsed(true)}
3277
+ title="hide files"
3278
+ >◀</button>
3279
+ </div>
3280
+ <div class="row" style="margin-bottom: 8px;">
3281
+ <input
3282
+ type="text"
3283
+ placeholder="open by path…"
3284
+ value=${openInput}
3285
+ onInput=${(e) => setOpenInput(e.target.value)}
3286
+ onKeyDown=${(e) => {
3287
+ if (e.key === "Enter" && openInput.trim()) {
3288
+ openPath(openInput.trim());
3289
+ setOpenInput("");
3290
+ }
3291
+ }}
3292
+ />
3293
+ </div>
3294
+ <input
3295
+ type="search"
3296
+ placeholder=${`filter ${files.length} files…`}
3297
+ value=${filter}
3298
+ onInput=${(e) => setFilter(e.target.value)}
3299
+ style="margin-bottom: 8px;"
3300
+ />
3301
+ ${
3302
+ filesError
3303
+ ? html`<div class="notice err">${filesError}</div>`
3304
+ : filtering
3305
+ ? html`
3306
+ <div class="editor-files">
3307
+ ${filteredFiles.map(
3308
+ (f) => html`
3309
+ <div
3310
+ key=${f}
3311
+ class="editor-file ${openPaths.includes(f) ? "open" : ""}"
3312
+ onClick=${() => openPath(f)}
3313
+ title=${f}
3314
+ >${f}</div>
3315
+ `,
3316
+ )}
3317
+ ${files.length > 80 ? html`<div class="muted" style="padding: 8px; font-size: 11px;">narrow filter to see more</div>` : null}
3318
+ </div>
3319
+ `
3320
+ : html`
3321
+ <div class="editor-files">
3322
+ ${treeRows.map((row) =>
3323
+ row.isFile
3324
+ ? html`
3325
+ <div
3326
+ key=${row.path}
3327
+ class="editor-tree-file ${openPaths.includes(row.path) ? "open" : ""}"
3328
+ style=${`padding-left: ${row.depth * 12 + 22}px`}
3329
+ onClick=${() => openPath(row.path)}
3330
+ title=${row.path}
3331
+ >${row.name}</div>
3332
+ `
3333
+ : html`
3334
+ <div
3335
+ key=${row.path}
3336
+ class="editor-tree-folder"
3337
+ style=${`padding-left: ${row.depth * 12 + 4}px`}
3338
+ onClick=${() => toggleFolder(row.path)}
3339
+ >
3340
+ <span class="editor-tree-caret">${expanded.has(row.path) ? "▼" : "▶"}</span>
3341
+ <span class="editor-tree-name">${row.name}</span>
3342
+ </div>
3343
+ `,
3344
+ )}
3345
+ ${files.length === 0 ? html`<div class="muted" style="padding: 8px; font-size: 11px;">no files</div>` : null}
3346
+ </div>
3347
+ `
3348
+ }
3349
+ </div>
3350
+ `
3351
+ }
3352
+
3353
+ <div class="editor-main">
3354
+ ${
3355
+ tab
3356
+ ? html`
3357
+ <div class="editor-bar">
3358
+ <code style="font-size: 12px;">${tab.path}</code>
3359
+ <span class="muted" style="font-size: 12px;">${langFromPath(tab.path) ?? "plaintext"}</span>
3360
+ ${
3361
+ langFromPath(tab.path) === "markdown"
3362
+ ? html`
3363
+ <div class="view-mode-group" style="margin-left: auto;">
3364
+ <button
3365
+ class=${`view-mode ${viewMode === "edit" ? "active" : ""}`}
3366
+ onClick=${() => setViewMode("edit")}
3367
+ title="source only"
3368
+ >Edit</button>
3369
+ <button
3370
+ class=${`view-mode ${viewMode === "split" ? "active" : ""}`}
3371
+ onClick=${() => setViewMode("split")}
3372
+ title="source + preview side-by-side"
3373
+ >Split</button>
3374
+ <button
3375
+ class=${`view-mode ${viewMode === "preview" ? "active" : ""}`}
3376
+ onClick=${() => setViewMode("preview")}
3377
+ title="rendered only"
3378
+ >Preview</button>
3379
+ </div>
3380
+ `
3381
+ : null
3382
+ }
3383
+ <button
3384
+ class="primary"
3385
+ style=${langFromPath(tab.path) === "markdown" ? "" : "margin-left: auto;"}
3386
+ onClick=${() => saveTab(activeIdx)}
3387
+ disabled=${busy || !tab.dirty}
3388
+ >${tab.dirty ? "Save (⌘S)" : "Saved"}</button>
3389
+ </div>
3390
+ ${error ? html`<div class="notice err">${error}</div>` : null}
3391
+ ${(() => {
3392
+ const isMd = langFromPath(tab.path) === "markdown";
3393
+ const mode = isMd ? viewMode : "edit";
3394
+ if (mode === "preview") {
3395
+ return html`
3396
+ <div
3397
+ class="editor-host editor-md-preview md"
3398
+ dangerouslySetInnerHTML=${{ __html: previewMarked.parse(tab.content ?? "") }}
3399
+ ></div>
3400
+ `;
3401
+ }
3402
+ if (mode === "split") {
3403
+ return html`
3404
+ <div class="editor-split">
3405
+ <div ref=${editorContainerRef} class="editor-host editor-split-pane"></div>
3406
+ <div
3407
+ class="editor-host editor-md-preview md editor-split-pane"
3408
+ dangerouslySetInnerHTML=${{
3409
+ __html: previewMarked.parse(tab.content ?? ""),
3410
+ }}
3411
+ ></div>
3412
+ </div>
3413
+ `;
3414
+ }
3415
+ return html`<div ref=${editorContainerRef} class="editor-host"></div>`;
3416
+ })()}
3417
+ `
3418
+ : html`
3419
+ <div class="editor-empty">
3420
+ ${
3421
+ cmReady
3422
+ ? html`<div>Open a file to start editing.</div>`
3423
+ : html`<div>Loading editor (~200KB CodeMirror)…</div>`
3424
+ }
3425
+ </div>
3426
+ `
3427
+ }
3428
+ </div>
3429
+ </div>
3430
+ </div>
3431
+ `;
3432
+ }
3433
+
3434
+ function ComingSoonPanel({ name, milestone }) {
3435
+ return html`
3436
+ <div>
3437
+ <div class="panel-header">
3438
+ <h2 class="panel-title">${name}</h2>
3439
+ <span class="panel-subtitle">coming in ${milestone}</span>
3440
+ </div>
3441
+ <div class="empty">This panel lands in ${milestone} (see CHANGELOG).</div>
3442
+ </div>
3443
+ `;
3444
+ }
3445
+
3446
+ // ---------- shell ----------
3447
+
3448
+ const TABS = [
3449
+ {
3450
+ id: "chat",
3451
+ name: "Chat",
3452
+ glyph: "◆",
3453
+ panel: () => html`<${ChatPanel} />`,
3454
+ ready: true,
3455
+ badge: null,
3456
+ },
3457
+ {
3458
+ id: "editor",
3459
+ name: "Editor",
3460
+ glyph: "✎",
3461
+ panel: () => html`<${EditorPanel} />`,
3462
+ ready: true,
3463
+ badge: null,
3464
+ },
3465
+ {
3466
+ id: "overview",
3467
+ name: "Overview",
3468
+ glyph: "◈",
3469
+ panel: () => html`<${OverviewPanel} />`,
3470
+ ready: true,
3471
+ badge: null,
3472
+ },
3473
+ {
3474
+ id: "usage",
3475
+ name: "Usage",
3476
+ glyph: "$",
3477
+ panel: () => html`<${UsageWithChart} />`,
3478
+ ready: true,
3479
+ badge: null,
3480
+ },
3481
+ {
3482
+ id: "sessions",
3483
+ name: "Sessions",
3484
+ glyph: "›",
3485
+ panel: () => html`<${SessionsPanel} />`,
3486
+ ready: true,
3487
+ badge: null,
3488
+ },
3489
+ {
3490
+ id: "plans",
3491
+ name: "Plans",
3492
+ glyph: "P",
3493
+ panel: () => html`<${PlansPanel} />`,
3494
+ ready: true,
3495
+ badge: null,
3496
+ },
3497
+ {
3498
+ id: "tools",
3499
+ name: "Tools",
3500
+ glyph: "▣",
3501
+ panel: () => html`<${ToolsPanel} />`,
3502
+ ready: true,
3503
+ badge: null,
3504
+ },
3505
+ {
3506
+ id: "permissions",
3507
+ name: "Permissions",
3508
+ glyph: "▎",
3509
+ panel: () => html`<${PermissionsPanel} />`,
3510
+ ready: true,
3511
+ badge: null,
3512
+ },
3513
+ {
3514
+ id: "health",
3515
+ name: "System",
3516
+ glyph: "+",
3517
+ panel: () => html`<${SystemPanel} />`,
3518
+ ready: true,
3519
+ badge: null,
3520
+ },
3521
+ {
3522
+ id: "mcp",
3523
+ name: "MCP",
3524
+ glyph: "M",
3525
+ panel: () => html`<${McpPanel} />`,
3526
+ ready: true,
3527
+ badge: null,
3528
+ },
3529
+ {
3530
+ id: "skills",
3531
+ name: "Skills",
3532
+ glyph: "S",
3533
+ panel: () => html`<${SkillsPanel} />`,
3534
+ ready: true,
3535
+ badge: null,
3536
+ },
3537
+ {
3538
+ id: "memory",
3539
+ name: "Memory",
3540
+ glyph: "·",
3541
+ panel: () => html`<${MemoryPanel} />`,
3542
+ ready: true,
3543
+ badge: null,
3544
+ },
3545
+ {
3546
+ id: "hooks",
3547
+ name: "Hooks",
3548
+ glyph: "H",
3549
+ panel: () => html`<${HooksPanel} />`,
3550
+ ready: true,
3551
+ badge: null,
3552
+ },
3553
+ {
3554
+ id: "settings",
3555
+ name: "Settings",
3556
+ glyph: "⌘",
3557
+ panel: () => html`<${SettingsPanel} />`,
3558
+ ready: true,
3559
+ badge: null,
3560
+ },
3561
+ ];
3562
+
3563
+ // ---------- Toast system ----------
3564
+ //
3565
+ // One Set of currently-displayed toast objects, pushed via a custom
3566
+ // DOM event so any panel can fire a toast without prop-drilling. Auto-
3567
+ // dismiss after `ttl` ms (default 3000). The stack lives at the App
3568
+ // level so toasts persist across tab switches.
3569
+
3570
+ const toastBus = new EventTarget();
3571
+ function showToast(text, kind = "info", ttl = 3000) {
3572
+ toastBus.dispatchEvent(new CustomEvent("toast", { detail: { text, kind, ttl } }));
3573
+ }
3574
+
3575
+ // ---------- App-wide event bus ----------
3576
+ //
3577
+ // Three events:
3578
+ // - "open-file" { path } Editor panel opens the path in a tab
3579
+ // - "navigate-tab" { tabId } App switches active sidebar tab
3580
+ // - "error" { error, source } global ErrorOverlay shows it full-screen
3581
+ //
3582
+ // Used by Chat tool cards / file-mention links to deep-link into the
3583
+ // Editor without prop-drilling, and by global error handlers to surface
3584
+ // crashes in a full-screen modal with a "Report on GitHub" button.
3585
+
3586
+ const appBus = new EventTarget();
3587
+ function openFileInEditor(path) {
3588
+ if (!path) return;
3589
+ // Just signal "open this file" — the App-level editor drawer subscribes
3590
+ // and pops itself open. We don't navigate the sidebar; the drawer
3591
+ // sits over the current panel so the user can keep their place in
3592
+ // chat / overview / wherever they were.
3593
+ appBus.dispatchEvent(new CustomEvent("open-file", { detail: { path } }));
3594
+ }
3595
+
3596
+ // ---------- Global error capture ----------
3597
+ //
3598
+ // Three sources feed into one overlay:
3599
+ // 1. window.error — sync exceptions, script load failures
3600
+ // 2. window.unhandledrejection — async promise rejections
3601
+ // 3. Preact ErrorBoundary — render-time component exceptions
3602
+ //
3603
+ // All three normalize to `{ error, source, info? }` and dispatch via
3604
+ // appBus. ErrorOverlay queues the most recent and lets the user copy
3605
+ // the trace or open a pre-filled GitHub issue.
3606
+
3607
+ function reportAppError(error, source, info) {
3608
+ // Console-log so devtools still has the message even when the
3609
+ // overlay is dismissed; keeps "what just broke" debuggable.
3610
+ // eslint-disable-next-line no-console
3611
+ console.error(`[reasonix dashboard] ${source}:`, error, info);
3612
+ appBus.dispatchEvent(
3613
+ new CustomEvent("error", { detail: { error, source, info, ts: Date.now() } }),
3614
+ );
3615
+ }
3616
+
3617
+ window.addEventListener("error", (ev) => {
3618
+ // Resource-load errors (failing img/script) come through with no
3619
+ // `error` object and are noisy; only surface real exceptions.
3620
+ if (!ev.error) return;
3621
+ reportAppError(ev.error, "window", ev.message);
3622
+ });
3623
+
3624
+ window.addEventListener("unhandledrejection", (ev) => {
3625
+ reportAppError(ev.reason, "promise");
3626
+ });
3627
+
3628
+ function ToastStack() {
3629
+ const [toasts, setToasts] = useState([]);
3630
+ useEffect(() => {
3631
+ const onToast = (ev) => {
3632
+ const id = `${Date.now()}-${Math.random()}`;
3633
+ const t = { id, ...ev.detail };
3634
+ setToasts((prev) => [...prev, t]);
3635
+ setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== id)), t.ttl);
3636
+ };
3637
+ toastBus.addEventListener("toast", onToast);
3638
+ return () => toastBus.removeEventListener("toast", onToast);
3639
+ }, []);
3640
+ if (toasts.length === 0) return null;
3641
+ return html`
3642
+ <div class="toast-stack">
3643
+ ${toasts.map((t) => html`<div key=${t.id} class="toast ${t.kind}">${t.text}</div>`)}
3644
+ </div>
3645
+ `;
3646
+ }
3647
+
3648
+ // ---------- Error overlay ----------
3649
+ //
3650
+ // Renders a full-screen modal whenever a window error / promise
3651
+ // rejection / Preact render error fires through `appBus`. Includes a
3652
+ // "Copy details" button (clipboard) and "Report on GitHub" link with a
3653
+ // pre-filled body containing redacted environment info — the URL is
3654
+ // safe to surface (token is never embedded; just version + UA + the
3655
+ // trace itself).
3656
+
3657
+ const REPO_URL = "https://github.com/esengine/reasonix";
3658
+
3659
+ function buildIssueBody({ error, source, info }) {
3660
+ const ua = typeof navigator === "object" ? navigator.userAgent : "(unknown)";
3661
+ const errMsg = error?.message ?? String(error);
3662
+ const stack = error?.stack ?? "(no stack)";
3663
+ return [
3664
+ "**What happened**",
3665
+ "(describe what you were doing — typing, switching tabs, clicking a tool path, etc.)",
3666
+ "",
3667
+ "**Error**",
3668
+ "```",
3669
+ `${source}: ${errMsg}`,
3670
+ info ? `info: ${info}` : null,
3671
+ "",
3672
+ stack,
3673
+ "```",
3674
+ "",
3675
+ "**Environment**",
3676
+ `- Reasonix: ${MODE}`,
3677
+ `- Browser: ${ua}`,
3678
+ `- URL: ${location.pathname} (token redacted)`,
3679
+ "",
3680
+ "_Reported from the local dashboard's error overlay._",
3681
+ ]
3682
+ .filter((l) => l !== null)
3683
+ .join("\n");
3684
+ }
3685
+
3686
+ function ErrorOverlay() {
3687
+ const [err, setErr] = useState(null); // { error, source, info, ts }
3688
+ const [copied, setCopied] = useState(false);
3689
+
3690
+ useEffect(() => {
3691
+ const onError = (ev) => {
3692
+ // Show only the latest — if a second fires while overlay is up,
3693
+ // it replaces. Cumulative replay would be nice but for now the
3694
+ // user can copy / file the issue with the most recent.
3695
+ setErr(ev.detail);
3696
+ setCopied(false);
3697
+ };
3698
+ appBus.addEventListener("error", onError);
3699
+ return () => appBus.removeEventListener("error", onError);
3700
+ }, []);
3701
+
3702
+ // Esc dismisses (assuming non-fatal).
3703
+ useEffect(() => {
3704
+ if (!err) return;
3705
+ const onKey = (e) => {
3706
+ if (e.key === "Escape") setErr(null);
3707
+ };
3708
+ window.addEventListener("keydown", onKey);
3709
+ return () => window.removeEventListener("keydown", onKey);
3710
+ }, [err]);
3711
+
3712
+ if (!err) return null;
3713
+ const error = err.error;
3714
+ const errMsg = error?.message ?? String(error);
3715
+ const stack = error?.stack ?? "(no stack)";
3716
+
3717
+ const issueUrl = `${REPO_URL}/issues/new?title=${encodeURIComponent(`[dashboard] ${errMsg.slice(0, 80)}`)}&body=${encodeURIComponent(buildIssueBody(err))}`;
3718
+
3719
+ const copyDetails = async () => {
3720
+ const body = buildIssueBody(err);
3721
+ try {
3722
+ await navigator.clipboard.writeText(body);
3723
+ setCopied(true);
3724
+ setTimeout(() => setCopied(false), 2000);
3725
+ } catch {
3726
+ /* clipboard blocked — user can still hit "report on GitHub" */
3727
+ }
3728
+ };
3729
+
3730
+ return html`
3731
+ <div class="error-overlay">
3732
+ <div class="error-overlay-card">
3733
+ <div class="error-overlay-head">
3734
+ <span class="error-overlay-icon">✦</span>
3735
+ <div>
3736
+ <div class="error-overlay-title">Something broke in the dashboard</div>
3737
+ <div class="error-overlay-subtitle">${err.source} error · ${errMsg}</div>
3738
+ </div>
3739
+ </div>
3740
+
3741
+ <pre class="error-overlay-trace">${stack}</pre>
3742
+
3743
+ ${
3744
+ err.info
3745
+ ? html`<div class="error-overlay-info"><strong>info:</strong> ${err.info}</div>`
3746
+ : null
3747
+ }
3748
+
3749
+ <div class="error-overlay-help">
3750
+ The TUI is unaffected — only this browser tab tripped. You can
3751
+ dismiss and keep working, or report it so we can fix the
3752
+ underlying cause.
3753
+ </div>
3754
+
3755
+ <div class="error-overlay-actions">
3756
+ <button class="primary" onClick=${copyDetails}>
3757
+ ${copied ? "Copied ✓" : "Copy details"}
3758
+ </button>
3759
+ <a class="button" href=${issueUrl} target="_blank" rel="noopener noreferrer">
3760
+ Report on GitHub
3761
+ </a>
3762
+ <button onClick=${() => setErr(null)} style="margin-left: auto;">Dismiss (Esc)</button>
3763
+ </div>
3764
+ </div>
3765
+ </div>
3766
+ `;
3767
+ }
3768
+
3769
+ // Preact ErrorBoundary — catches render-time exceptions in the App
3770
+ // 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.
3773
+ class ErrorBoundary extends Component {
3774
+ constructor(props) {
3775
+ super(props);
3776
+ this.state = { caught: false };
3777
+ }
3778
+ static getDerivedStateFromError() {
3779
+ return { caught: true };
3780
+ }
3781
+ componentDidCatch(error, info) {
3782
+ reportAppError(error, "render", info?.componentStack ?? "");
3783
+ // Recover after a tick — overlay handles the user's next move.
3784
+ setTimeout(() => this.setState({ caught: false }), 100);
3785
+ }
3786
+ render() {
3787
+ if (this.state.caught) {
3788
+ return html`<div class="boot">recovering…</div>`;
3789
+ }
3790
+ return this.props.children;
3791
+ }
3792
+ }
3793
+
3794
+ function App() {
3795
+ const [activeId, setActiveId] = useState("chat");
3796
+ const [sidebarOpen, setSidebarOpen] = useState(false); // mobile drawer
3797
+ // Desktop "icon only" collapse — narrow sidebar that shows just the
3798
+ // glyphs. Persisted so the choice survives reload.
3799
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
3800
+ try {
3801
+ return localStorage.getItem("rx.sidebarCollapsed") === "1";
3802
+ } catch {
3803
+ return false;
3804
+ }
3805
+ });
3806
+ useEffect(() => {
3807
+ try {
3808
+ localStorage.setItem("rx.sidebarCollapsed", sidebarCollapsed ? "1" : "0");
3809
+ } catch {
3810
+ /* private mode / disabled storage — ignore */
3811
+ }
3812
+ }, [sidebarCollapsed]);
3813
+ // Editor drawer — opens whenever any panel fires "open-file" via
3814
+ // appBus. Lives at the App level so the editor's tab state persists
3815
+ // across sidebar-tab switches; you can open a file from Chat, switch
3816
+ // to Usage to glance at numbers, come back, and the editor's still
3817
+ // there. × on the drawer or Esc closes it.
3818
+ const [editorOpen, setEditorOpen] = useState(false);
3819
+ const active = TABS.find((t) => t.id === activeId) ?? TABS[0];
3820
+
3821
+ // Esc anywhere closes the mobile drawer (modals already handle their
3822
+ // own Esc). On desktop the drawer is always-open so this is a no-op.
3823
+ useEffect(() => {
3824
+ const onKey = (e) => {
3825
+ if (e.key === "Escape") setSidebarOpen(false);
3826
+ };
3827
+ window.addEventListener("keydown", onKey);
3828
+ return () => window.removeEventListener("keydown", onKey);
3829
+ }, []);
3830
+
3831
+ // Cross-component navigation — sidebar-tab switching when something
3832
+ // fires `navigate-tab` (kept in case other features want it; the
3833
+ // editor drawer no longer uses it).
3834
+ useEffect(() => {
3835
+ const onNav = (ev) => {
3836
+ const id = ev.detail?.tabId;
3837
+ if (id) setActiveId(id);
3838
+ };
3839
+ appBus.addEventListener("navigate-tab", onNav);
3840
+ return () => appBus.removeEventListener("navigate-tab", onNav);
3841
+ }, []);
3842
+
3843
+ // Open the editor drawer whenever any panel signals a file-open.
3844
+ // The drawer's <EditorPanel> is permanently mounted (with display:
3845
+ // none when closed) so its tab state survives toggling — opening
3846
+ // the same file twice from chat doesn't lose unsaved changes.
3847
+ useEffect(() => {
3848
+ const onOpenFile = () => setEditorOpen(true);
3849
+ appBus.addEventListener("open-file", onOpenFile);
3850
+ return () => appBus.removeEventListener("open-file", onOpenFile);
3851
+ }, []);
3852
+
3853
+ // Esc also closes the editor (in addition to the mobile drawer).
3854
+ useEffect(() => {
3855
+ if (!editorOpen) return;
3856
+ const onKey = (e) => {
3857
+ if (e.key === "Escape") setEditorOpen(false);
3858
+ };
3859
+ window.addEventListener("keydown", onKey);
3860
+ return () => window.removeEventListener("keydown", onKey);
3861
+ }, [editorOpen]);
3862
+
3863
+ const pickTab = useCallback((id) => {
3864
+ setActiveId(id);
3865
+ setSidebarOpen(false); // collapse drawer after pick on mobile
3866
+ }, []);
3867
+
3868
+ return html`
3869
+ <div class=${`sidebar ${sidebarOpen ? "open" : ""} ${sidebarCollapsed ? "collapsed" : ""}`}>
3870
+ <div class="sidebar-header">
3871
+ <div class="sidebar-brand" title="Reasonix"><span class="glyph">◈</span><span class="sidebar-label"> REASONIX</span></div>
3872
+ <div class="sidebar-version sidebar-label">dashboard</div>
3873
+ <div class="sidebar-mode sidebar-label">${MODE}</div>
3874
+ </div>
3875
+ <div class="gradient-rule"></div>
3876
+ <div class="sidebar-tabs">
3877
+ ${TABS.map(
3878
+ (tab) => html`
3879
+ <div
3880
+ class="tab ${tab.id === active.id ? "active" : ""} ${!tab.ready ? "tab-stub" : ""}"
3881
+ onClick=${() => tab.ready && pickTab(tab.id)}
3882
+ title=${tab.name}
3883
+ >
3884
+ <span class="glyph">${tab.glyph}</span>
3885
+ <span class="sidebar-label">${tab.name}</span>
3886
+ ${tab.badge ? html`<span class="badge sidebar-label">${tab.badge}</span>` : null}
3887
+ </div>
3888
+ `,
3889
+ )}
3890
+ </div>
3891
+ <button
3892
+ class="sidebar-collapse-toggle"
3893
+ onClick=${() => setSidebarCollapsed((c) => !c)}
3894
+ title=${sidebarCollapsed ? "expand sidebar" : "collapse to icons"}
3895
+ >${sidebarCollapsed ? "▶" : "◀"}<span class="sidebar-label"> ${sidebarCollapsed ? "expand" : "collapse"}</span></button>
3896
+ <div class="sidebar-footer sidebar-label">127.0.0.1 only · token-gated</div>
3897
+ </div>
3898
+ <div class="sidebar-backdrop" onClick=${() => setSidebarOpen(false)}></div>
3899
+ <button class="menu-toggle" onClick=${() => setSidebarOpen((s) => !s)} aria-label="Toggle sidebar">≡</button>
3900
+ <div class=${`main ${active.id === "editor" ? "main-editor" : ""}`}>
3901
+ <${ErrorBoundary}>${active.panel()}<//>
3902
+ </div>
3903
+ <div class=${`editor-drawer-host ${editorOpen ? "open" : ""}`}>
3904
+ <${ErrorBoundary}>
3905
+ <${EditorPanel} onClose=${() => setEditorOpen(false)} />
3906
+ <//>
3907
+ </div>
3908
+ <${ToastStack} />
3909
+ <${ErrorOverlay} />
3910
+ `;
3911
+ }
3912
+
3913
+ render(html`<${App} />`, document.getElementById("root"));