reasonix 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -884
- package/README.zh-CN.md +79 -804
- package/dashboard/app.css +1987 -2416
- package/dashboard/dist/app.js +24639 -0
- package/dashboard/dist/app.js.map +1 -0
- package/dashboard/dist/vendor-hljs.css +10 -0
- package/dashboard/dist/vendor-uplot.css +1 -0
- package/dashboard/index.html +2 -2
- package/dist/cli/index.js +5881 -3511
- package/dist/cli/index.js.map +1 -1
- package/package.json +10 -28
- package/dashboard/app.js +0 -4768
- package/dashboard/codemirror.js +0 -36
package/dashboard/app.js
DELETED
|
@@ -1,4768 +0,0 @@
|
|
|
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, "<")
|
|
41
|
-
.replace(/>/g, ">")
|
|
42
|
-
.replace(/"/g, """)
|
|
43
|
-
.replace(/'/g, "'");
|
|
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) || " " }}
|
|
1110
|
-
></span>`
|
|
1111
|
-
: html`<span class="edit-diff-empty"> </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) || " " }}
|
|
1120
|
-
></span>`
|
|
1121
|
-
: html`<span class="edit-diff-empty"> </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 WorkspaceModal({ modal, onResolve }) {
|
|
1140
|
-
return html`
|
|
1141
|
-
<${ModalCard}
|
|
1142
|
-
accent="#fbbf24"
|
|
1143
|
-
icon="◇"
|
|
1144
|
-
title="model wants to switch workspace"
|
|
1145
|
-
subtitle="every subsequent file / shell / memory tool resolves against the new root"
|
|
1146
|
-
>
|
|
1147
|
-
<div class="modal-cmd"><span class="modal-cmd-prompt">→</span> <code>${modal.path}</code></div>
|
|
1148
|
-
<div class="modal-actions">
|
|
1149
|
-
<button class="primary" onClick=${() => onResolve("workspace", "switch")}>Switch (Enter)</button>
|
|
1150
|
-
<button class="danger" onClick=${() => onResolve("workspace", "deny")}>Deny (Esc)</button>
|
|
1151
|
-
</div>
|
|
1152
|
-
<//>
|
|
1153
|
-
`;
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
function CheckpointModal({ modal, onResolve }) {
|
|
1157
|
-
const [reviseText, setReviseText] = useState("");
|
|
1158
|
-
const [staged, setStaged] = useState(false);
|
|
1159
|
-
const label = modal.title ? `${modal.stepId} · ${modal.title}` : modal.stepId;
|
|
1160
|
-
const counter = modal.total > 0 ? ` (${modal.completed}/${modal.total})` : "";
|
|
1161
|
-
return html`
|
|
1162
|
-
<${ModalCard}
|
|
1163
|
-
accent="#a5f3fc"
|
|
1164
|
-
icon="✓"
|
|
1165
|
-
title=${`step complete${counter}`}
|
|
1166
|
-
subtitle=${label}
|
|
1167
|
-
>
|
|
1168
|
-
${
|
|
1169
|
-
staged
|
|
1170
|
-
? html`
|
|
1171
|
-
<textarea
|
|
1172
|
-
placeholder="What needs to change before the next step? Leave blank to just continue."
|
|
1173
|
-
rows="3"
|
|
1174
|
-
value=${reviseText}
|
|
1175
|
-
onInput=${(e) => setReviseText(e.target.value)}
|
|
1176
|
-
></textarea>
|
|
1177
|
-
<div class="modal-actions">
|
|
1178
|
-
<button class="primary" onClick=${() => onResolve("checkpoint", "revise", reviseText)}>Send revision</button>
|
|
1179
|
-
<button onClick=${() => {
|
|
1180
|
-
setStaged(false);
|
|
1181
|
-
setReviseText("");
|
|
1182
|
-
}}>Back</button>
|
|
1183
|
-
</div>
|
|
1184
|
-
`
|
|
1185
|
-
: html`
|
|
1186
|
-
<div class="modal-actions">
|
|
1187
|
-
<button class="primary" onClick=${() => onResolve("checkpoint", "continue")}>Continue</button>
|
|
1188
|
-
<button onClick=${() => setStaged(true)}>Revise…</button>
|
|
1189
|
-
<button class="danger" onClick=${() => onResolve("checkpoint", "stop")}>Stop</button>
|
|
1190
|
-
</div>
|
|
1191
|
-
`
|
|
1192
|
-
}
|
|
1193
|
-
<//>
|
|
1194
|
-
`;
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
function RevisionModal({ modal, onResolve }) {
|
|
1198
|
-
const riskColor = (r) =>
|
|
1199
|
-
r === "high" ? "#f87171" : r === "med" ? "#fbbf24" : r === "low" ? "#86efac" : "#9ca3af";
|
|
1200
|
-
return html`
|
|
1201
|
-
<${ModalCard}
|
|
1202
|
-
accent="#c4b5fd"
|
|
1203
|
-
icon="✎"
|
|
1204
|
-
title="model proposed a plan revision"
|
|
1205
|
-
subtitle=${modal.summary || modal.reason}
|
|
1206
|
-
>
|
|
1207
|
-
<div class="modal-revise-reason">${modal.reason}</div>
|
|
1208
|
-
<ol class="modal-revise-steps">
|
|
1209
|
-
${modal.remainingSteps.map(
|
|
1210
|
-
(s) => html`
|
|
1211
|
-
<li key=${s.id}>
|
|
1212
|
-
<span class="modal-revise-dot" style=${`background:${riskColor(s.risk)}`}></span>
|
|
1213
|
-
<span class="modal-revise-id">${s.id}</span>
|
|
1214
|
-
<span class="modal-revise-title">${s.title}</span>
|
|
1215
|
-
<span class="modal-revise-action">${s.action}</span>
|
|
1216
|
-
</li>
|
|
1217
|
-
`,
|
|
1218
|
-
)}
|
|
1219
|
-
</ol>
|
|
1220
|
-
<div class="modal-actions">
|
|
1221
|
-
<button class="primary" onClick=${() => onResolve("revision", "accept")}>Accept</button>
|
|
1222
|
-
<button class="danger" onClick=${() => onResolve("revision", "reject")}>Reject</button>
|
|
1223
|
-
</div>
|
|
1224
|
-
<//>
|
|
1225
|
-
`;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
function ChatPanel() {
|
|
1229
|
-
const [messages, setMessages] = useState([]);
|
|
1230
|
-
const [streaming, setStreaming] = useState(null); // { id, text, reasoning }
|
|
1231
|
-
// Tool currently dispatched but not yet returning. Set on `tool_start`,
|
|
1232
|
-
// cleared on `tool` / `error`. Drives the in-flight row so the user
|
|
1233
|
-
// sees what's running (path, command, char counts) instead of a
|
|
1234
|
-
// generic "waiting" — file writes especially feel hung otherwise.
|
|
1235
|
-
const [activeTool, setActiveTool] = useState(null); // { id, toolName, args }
|
|
1236
|
-
const [busy, setBusy] = useState(false);
|
|
1237
|
-
const [input, setInput] = useState("");
|
|
1238
|
-
const [error, setError] = useState(null);
|
|
1239
|
-
const [bootError, setBootError] = useState(null);
|
|
1240
|
-
const [statusLine, setStatusLine] = useState(null);
|
|
1241
|
-
// Mirror of the active TUI modal: { kind, ...payload } | null. Set
|
|
1242
|
-
// by `modal-up` SSE events, cleared by `modal-down`. Web uses POST
|
|
1243
|
-
// /api/modal/resolve to drive resolution; either surface clears the
|
|
1244
|
-
// other's modal via the resulting modal-down event.
|
|
1245
|
-
const [modal, setModal] = useState(null);
|
|
1246
|
-
// Current edit gate (review / auto / yolo). null when not in code
|
|
1247
|
-
// mode. Refreshed via /api/overview poll because the mode also
|
|
1248
|
-
// flips from TUI Shift+Tab and we want the segmented control to
|
|
1249
|
-
// stay in sync without a dedicated event.
|
|
1250
|
-
const [editMode, setEditModeLocal] = useState(null);
|
|
1251
|
-
// Persisted preset + reasoning_effort, surfaced here so the user
|
|
1252
|
-
// can flip them mid-chat without leaving the tab. /api/overview
|
|
1253
|
-
// includes both since 0.14.x; the same poll covers all three.
|
|
1254
|
-
const [preset, setPresetLocal] = useState(null);
|
|
1255
|
-
const [effort, setEffortLocal] = useState(null);
|
|
1256
|
-
// Live session stats — cache hit, costs, tokens, balance — from the
|
|
1257
|
-
// same /api/overview poll. Renders into a compact status bar below
|
|
1258
|
-
// the input area.
|
|
1259
|
-
const [stats, setStats] = useState(null);
|
|
1260
|
-
const [overviewModel, setOverviewModel] = useState(null);
|
|
1261
|
-
// Whether the project has a built semantic index. Null = unknown
|
|
1262
|
-
// (poll hasn't landed) or non-attached. False = no index → show the
|
|
1263
|
-
// dismissible banner. True = index built → hide it.
|
|
1264
|
-
const [semanticIndex, setSemanticIndex] = useState(null);
|
|
1265
|
-
const [semanticBannerDismissed, setSemanticBannerDismissed] = useState(() => {
|
|
1266
|
-
try {
|
|
1267
|
-
return localStorage.getItem("rx.semanticBannerDismissed") === "1";
|
|
1268
|
-
} catch {
|
|
1269
|
-
return false;
|
|
1270
|
-
}
|
|
1271
|
-
});
|
|
1272
|
-
useEffect(() => {
|
|
1273
|
-
try {
|
|
1274
|
-
localStorage.setItem("rx.semanticBannerDismissed", semanticBannerDismissed ? "1" : "0");
|
|
1275
|
-
} catch {
|
|
1276
|
-
/* ignore */
|
|
1277
|
-
}
|
|
1278
|
-
}, [semanticBannerDismissed]);
|
|
1279
|
-
// Wall-clock timestamp the current turn started at — populated when
|
|
1280
|
-
// busy flips true, cleared when it flips false. Drives the "elapsed
|
|
1281
|
-
// Ns" readout in the in-flight indicator. Refreshed once per second
|
|
1282
|
-
// by `nowTick` so the seconds counter ticks visibly even between
|
|
1283
|
-
// SSE deltas.
|
|
1284
|
-
const [turnStartedAt, setTurnStartedAt] = useState(null);
|
|
1285
|
-
const [nowTick, setNowTick] = useState(0);
|
|
1286
|
-
useEffect(() => {
|
|
1287
|
-
if (!busy) return;
|
|
1288
|
-
const id = setInterval(() => setNowTick((n) => n + 1), 500);
|
|
1289
|
-
return () => clearInterval(id);
|
|
1290
|
-
}, [busy]);
|
|
1291
|
-
useEffect(() => {
|
|
1292
|
-
if (busy) {
|
|
1293
|
-
if (!turnStartedAt) setTurnStartedAt(Date.now());
|
|
1294
|
-
} else {
|
|
1295
|
-
setTurnStartedAt(null);
|
|
1296
|
-
}
|
|
1297
|
-
}, [busy, turnStartedAt]);
|
|
1298
|
-
// Sticks to bottom only while the user is already near the bottom.
|
|
1299
|
-
// Once they scroll up to read older content the streaming deltas no
|
|
1300
|
-
// longer yank the view back. Re-armed when they scroll back to the
|
|
1301
|
-
// bottom on their own. 80px threshold absorbs sub-pixel rounding.
|
|
1302
|
-
const shouldAutoScroll = useRef(true);
|
|
1303
|
-
// Ref to the scrollable feed container so we don't have to rely on
|
|
1304
|
-
// a global querySelector (which would race the conditional render
|
|
1305
|
-
// — `.chat-feed` only mounts when at least one message is present).
|
|
1306
|
-
// The feed is now always rendered, so `feedRef.current` is set on
|
|
1307
|
-
// first paint and the scroll listener attaches once.
|
|
1308
|
-
const feedRef = useRef(null);
|
|
1309
|
-
|
|
1310
|
-
// Initial snapshot — messages + busy + any modal already up.
|
|
1311
|
-
useEffect(() => {
|
|
1312
|
-
let cancelled = false;
|
|
1313
|
-
(async () => {
|
|
1314
|
-
try {
|
|
1315
|
-
const data = await api("/messages");
|
|
1316
|
-
if (cancelled) return;
|
|
1317
|
-
setMessages(data.messages ?? []);
|
|
1318
|
-
setBusy(Boolean(data.busy));
|
|
1319
|
-
} catch (err) {
|
|
1320
|
-
if (!cancelled) setBootError(err.message);
|
|
1321
|
-
}
|
|
1322
|
-
try {
|
|
1323
|
-
const m = await api("/modal");
|
|
1324
|
-
if (!cancelled && m.modal) setModal(m.modal);
|
|
1325
|
-
} catch {
|
|
1326
|
-
/* skip — modal endpoint optional in standalone */
|
|
1327
|
-
}
|
|
1328
|
-
})();
|
|
1329
|
-
return () => {
|
|
1330
|
-
cancelled = true;
|
|
1331
|
-
};
|
|
1332
|
-
}, []);
|
|
1333
|
-
|
|
1334
|
-
// Live event stream.
|
|
1335
|
-
useEffect(() => {
|
|
1336
|
-
const es = new EventSource(`/api/events?token=${TOKEN}`);
|
|
1337
|
-
es.onmessage = (ev) => {
|
|
1338
|
-
let dash;
|
|
1339
|
-
try {
|
|
1340
|
-
dash = JSON.parse(ev.data);
|
|
1341
|
-
} catch {
|
|
1342
|
-
return;
|
|
1343
|
-
}
|
|
1344
|
-
if (dash.kind === "ping") return;
|
|
1345
|
-
if (dash.kind === "busy-change") {
|
|
1346
|
-
setBusy(dash.busy);
|
|
1347
|
-
return;
|
|
1348
|
-
}
|
|
1349
|
-
if (dash.kind === "user") {
|
|
1350
|
-
setMessages((prev) => [...prev, { id: dash.id, role: "user", text: dash.text }]);
|
|
1351
|
-
return;
|
|
1352
|
-
}
|
|
1353
|
-
if (dash.kind === "assistant_delta") {
|
|
1354
|
-
setStreaming((cur) => {
|
|
1355
|
-
const text = (cur?.text ?? "") + (dash.contentDelta ?? "");
|
|
1356
|
-
const reasoning = (cur?.reasoning ?? "") + (dash.reasoningDelta ?? "");
|
|
1357
|
-
return { id: dash.id, text, reasoning };
|
|
1358
|
-
});
|
|
1359
|
-
return;
|
|
1360
|
-
}
|
|
1361
|
-
if (dash.kind === "assistant_final") {
|
|
1362
|
-
setStreaming(null);
|
|
1363
|
-
setMessages((prev) => [
|
|
1364
|
-
...prev,
|
|
1365
|
-
{
|
|
1366
|
-
id: dash.id,
|
|
1367
|
-
role: "assistant",
|
|
1368
|
-
text: dash.text,
|
|
1369
|
-
reasoning: dash.reasoning,
|
|
1370
|
-
},
|
|
1371
|
-
]);
|
|
1372
|
-
return;
|
|
1373
|
-
}
|
|
1374
|
-
if (dash.kind === "tool_start") {
|
|
1375
|
-
// Surface the dispatched tool + its args in the in-flight row.
|
|
1376
|
-
// No info-row placeholder: the InFlightRow now renders the
|
|
1377
|
-
// detail (path / command / char count) and the result card
|
|
1378
|
-
// appears when the `tool` event lands. Two rows for one tool
|
|
1379
|
-
// call was redundant noise.
|
|
1380
|
-
setActiveTool({ id: dash.id, toolName: dash.toolName, args: dash.args });
|
|
1381
|
-
return;
|
|
1382
|
-
}
|
|
1383
|
-
if (dash.kind === "tool") {
|
|
1384
|
-
setActiveTool((cur) => (cur && cur.id === dash.id ? null : cur));
|
|
1385
|
-
setMessages((prev) => [
|
|
1386
|
-
...prev,
|
|
1387
|
-
{
|
|
1388
|
-
id: dash.id,
|
|
1389
|
-
role: "tool",
|
|
1390
|
-
text: dash.content,
|
|
1391
|
-
toolName: dash.toolName,
|
|
1392
|
-
toolArgs: dash.args,
|
|
1393
|
-
},
|
|
1394
|
-
]);
|
|
1395
|
-
return;
|
|
1396
|
-
}
|
|
1397
|
-
if (dash.kind === "warning" || dash.kind === "error" || dash.kind === "info") {
|
|
1398
|
-
if (dash.kind === "error") {
|
|
1399
|
-
setActiveTool(null);
|
|
1400
|
-
}
|
|
1401
|
-
setMessages((prev) => [...prev, { id: dash.id, role: dash.kind, text: dash.text }]);
|
|
1402
|
-
return;
|
|
1403
|
-
}
|
|
1404
|
-
if (dash.kind === "status") {
|
|
1405
|
-
setStatusLine(dash.text);
|
|
1406
|
-
// Clear the status line shortly so old hints don't pile up.
|
|
1407
|
-
setTimeout(() => setStatusLine((cur) => (cur === dash.text ? null : cur)), 5000);
|
|
1408
|
-
return;
|
|
1409
|
-
}
|
|
1410
|
-
if (dash.kind === "modal-up") {
|
|
1411
|
-
setModal(dash.modal);
|
|
1412
|
-
return;
|
|
1413
|
-
}
|
|
1414
|
-
if (dash.kind === "modal-down") {
|
|
1415
|
-
setModal((cur) => (cur && cur.kind === dash.modalKind ? null : cur));
|
|
1416
|
-
return;
|
|
1417
|
-
}
|
|
1418
|
-
};
|
|
1419
|
-
es.onerror = () => {
|
|
1420
|
-
// Auto-reconnect by default; surface a brief banner on persistent
|
|
1421
|
-
// failure but don't tear down — EventSource retries in the background.
|
|
1422
|
-
setError("event stream interrupted — reconnecting…");
|
|
1423
|
-
setTimeout(() => setError(null), 3000);
|
|
1424
|
-
};
|
|
1425
|
-
return () => es.close();
|
|
1426
|
-
}, []);
|
|
1427
|
-
|
|
1428
|
-
const send = useCallback(async () => {
|
|
1429
|
-
const text = input.trim();
|
|
1430
|
-
if (!text || busy) return;
|
|
1431
|
-
setError(null);
|
|
1432
|
-
try {
|
|
1433
|
-
const res = await api("/submit", { method: "POST", body: { prompt: text } });
|
|
1434
|
-
if (!res.accepted) {
|
|
1435
|
-
setError(res.reason ?? "rejected");
|
|
1436
|
-
return;
|
|
1437
|
-
}
|
|
1438
|
-
setInput("");
|
|
1439
|
-
} catch (err) {
|
|
1440
|
-
setError(err.message);
|
|
1441
|
-
}
|
|
1442
|
-
}, [input, busy]);
|
|
1443
|
-
|
|
1444
|
-
const abort = useCallback(async () => {
|
|
1445
|
-
try {
|
|
1446
|
-
await api("/abort", { method: "POST" });
|
|
1447
|
-
} catch (err) {
|
|
1448
|
-
setError(err.message);
|
|
1449
|
-
}
|
|
1450
|
-
}, []);
|
|
1451
|
-
|
|
1452
|
-
// /new wipes context + scrollback (server-side); /clear keeps the
|
|
1453
|
-
// log but blanks the visible scroll. Both route through /api/submit
|
|
1454
|
-
// because handleSubmit on the TUI side already parses slashes — keeps
|
|
1455
|
-
// one source of truth, no special endpoint needed. Local messages
|
|
1456
|
-
// state is reset optimistically; an /api/messages refetch reconciles.
|
|
1457
|
-
const newConversation = useCallback(async () => {
|
|
1458
|
-
if (busy) {
|
|
1459
|
-
if (!confirm("A turn is in flight. Abort and start a new conversation?")) return;
|
|
1460
|
-
} else if (messages.length > 0 && !confirm("Clear current conversation and start fresh?")) {
|
|
1461
|
-
return;
|
|
1462
|
-
}
|
|
1463
|
-
try {
|
|
1464
|
-
await api("/submit", { method: "POST", body: { prompt: "/new" } });
|
|
1465
|
-
setMessages([]);
|
|
1466
|
-
setStreaming(null);
|
|
1467
|
-
setActiveTool(null);
|
|
1468
|
-
showToast("new conversation", "info");
|
|
1469
|
-
// Refetch to reconcile in case the slash queued an info row.
|
|
1470
|
-
setTimeout(async () => {
|
|
1471
|
-
try {
|
|
1472
|
-
const r = await api("/messages");
|
|
1473
|
-
setMessages(r.messages ?? []);
|
|
1474
|
-
} catch {
|
|
1475
|
-
/* swallow */
|
|
1476
|
-
}
|
|
1477
|
-
}, 200);
|
|
1478
|
-
} catch (err) {
|
|
1479
|
-
setError(`/new failed: ${err.message}`);
|
|
1480
|
-
}
|
|
1481
|
-
}, [busy, messages.length]);
|
|
1482
|
-
|
|
1483
|
-
const clearScrollback = useCallback(async () => {
|
|
1484
|
-
try {
|
|
1485
|
-
await api("/submit", { method: "POST", body: { prompt: "/clear" } });
|
|
1486
|
-
setMessages([]);
|
|
1487
|
-
setStreaming(null);
|
|
1488
|
-
setActiveTool(null);
|
|
1489
|
-
showToast("scrollback cleared", "info");
|
|
1490
|
-
setTimeout(async () => {
|
|
1491
|
-
try {
|
|
1492
|
-
const r = await api("/messages");
|
|
1493
|
-
setMessages(r.messages ?? []);
|
|
1494
|
-
} catch {
|
|
1495
|
-
/* swallow */
|
|
1496
|
-
}
|
|
1497
|
-
}, 200);
|
|
1498
|
-
} catch (err) {
|
|
1499
|
-
setError(`/clear failed: ${err.message}`);
|
|
1500
|
-
}
|
|
1501
|
-
}, []);
|
|
1502
|
-
|
|
1503
|
-
const onKeyDown = useCallback(
|
|
1504
|
-
(e) => {
|
|
1505
|
-
// Enter sends, Shift+Enter inserts newline.
|
|
1506
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
1507
|
-
e.preventDefault();
|
|
1508
|
-
send();
|
|
1509
|
-
}
|
|
1510
|
-
},
|
|
1511
|
-
[send],
|
|
1512
|
-
);
|
|
1513
|
-
|
|
1514
|
-
if (bootError) {
|
|
1515
|
-
return html`<div class="notice err">chat unavailable: ${bootError}</div>`;
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
// Track whether the user is parked at the bottom. Update on every
|
|
1519
|
-
// scroll event so a single wheel-up flips the auto-scroll guard
|
|
1520
|
-
// immediately. The threshold is generous enough that overshoot
|
|
1521
|
-
// (smooth-scroll rebound, sub-pixel rounding) doesn't accidentally
|
|
1522
|
-
// re-arm tracking when the user is barely above bottom.
|
|
1523
|
-
//
|
|
1524
|
-
// We also distinguish *user* scroll events from auto-scroll's own
|
|
1525
|
-
// programmatic `scrollTop = scrollHeight` writes. Without that gate
|
|
1526
|
-
// the auto-scroll effect would briefly snap to bottom, fire its
|
|
1527
|
-
// own scroll event, re-set shouldAutoScroll = true, then wonder
|
|
1528
|
-
// why the user complained that they couldn't scroll up — because
|
|
1529
|
-
// every wheel-up was racing against the next delta's auto-snap.
|
|
1530
|
-
// We mark the ref as `auto-scrolling` for one tick around the
|
|
1531
|
-
// programmatic write; the listener ignores events it sees during
|
|
1532
|
-
// that window.
|
|
1533
|
-
const autoScrollInFlight = useRef(false);
|
|
1534
|
-
useEffect(() => {
|
|
1535
|
-
const el = feedRef.current;
|
|
1536
|
-
if (!el) return;
|
|
1537
|
-
const onScroll = () => {
|
|
1538
|
-
if (autoScrollInFlight.current) return;
|
|
1539
|
-
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
1540
|
-
shouldAutoScroll.current = distFromBottom < 80;
|
|
1541
|
-
};
|
|
1542
|
-
el.addEventListener("scroll", onScroll, { passive: true });
|
|
1543
|
-
return () => el.removeEventListener("scroll", onScroll);
|
|
1544
|
-
}, []);
|
|
1545
|
-
|
|
1546
|
-
// Auto-scroll only when the user hasn't scrolled away. Streaming
|
|
1547
|
-
// deltas no longer yank the view back; manual wheel/drag wins.
|
|
1548
|
-
useEffect(() => {
|
|
1549
|
-
if (!shouldAutoScroll.current) return;
|
|
1550
|
-
const el = feedRef.current;
|
|
1551
|
-
if (!el) return;
|
|
1552
|
-
autoScrollInFlight.current = true;
|
|
1553
|
-
el.scrollTop = el.scrollHeight;
|
|
1554
|
-
// Clear the gate after the browser has had a chance to fire the
|
|
1555
|
-
// resulting scroll event (microtask-ish — rAF is overkill, a 0ms
|
|
1556
|
-
// setTimeout is enough to land after the synchronous handler).
|
|
1557
|
-
setTimeout(() => {
|
|
1558
|
-
autoScrollInFlight.current = false;
|
|
1559
|
-
}, 0);
|
|
1560
|
-
}, [messages, streaming]);
|
|
1561
|
-
|
|
1562
|
-
const allMessages = streaming
|
|
1563
|
-
? [
|
|
1564
|
-
...messages,
|
|
1565
|
-
{
|
|
1566
|
-
id: streaming.id,
|
|
1567
|
-
role: "assistant",
|
|
1568
|
-
text: streaming.text,
|
|
1569
|
-
reasoning: streaming.reasoning,
|
|
1570
|
-
},
|
|
1571
|
-
]
|
|
1572
|
-
: messages;
|
|
1573
|
-
|
|
1574
|
-
// Resolve the active modal via POST /api/modal/resolve. The server
|
|
1575
|
-
// hands the choice straight to App.tsx's resolveXxx callback, which
|
|
1576
|
-
// calls the same handler the TUI button would. The local `modal`
|
|
1577
|
-
// state clears the moment the SSE channel echoes `modal-down`.
|
|
1578
|
-
const resolveModal = useCallback(async (kind, choice, text) => {
|
|
1579
|
-
try {
|
|
1580
|
-
await api("/modal/resolve", {
|
|
1581
|
-
method: "POST",
|
|
1582
|
-
body: text !== undefined ? { kind, choice, text } : { kind, choice },
|
|
1583
|
-
});
|
|
1584
|
-
} catch (err) {
|
|
1585
|
-
setError(`modal resolve failed: ${err.message}`);
|
|
1586
|
-
}
|
|
1587
|
-
}, []);
|
|
1588
|
-
|
|
1589
|
-
// Poll /api/overview for current edit mode. Polling (not SSE) is
|
|
1590
|
-
// fine — the gate flips from /mode, Shift+Tab, AND the web button;
|
|
1591
|
-
// a 4s poll is good enough to keep the segmented control visually
|
|
1592
|
-
// honest without piping yet another event kind.
|
|
1593
|
-
useEffect(() => {
|
|
1594
|
-
let cancelled = false;
|
|
1595
|
-
const tick = async () => {
|
|
1596
|
-
try {
|
|
1597
|
-
const o = await api("/overview");
|
|
1598
|
-
if (cancelled) return;
|
|
1599
|
-
setEditModeLocal(o.editMode ?? null);
|
|
1600
|
-
setPresetLocal(o.preset ?? null);
|
|
1601
|
-
setEffortLocal(o.reasoningEffort ?? null);
|
|
1602
|
-
setStats(o.stats ?? null);
|
|
1603
|
-
setOverviewModel(o.model ?? null);
|
|
1604
|
-
setSemanticIndex(o.semanticIndexExists);
|
|
1605
|
-
} catch {
|
|
1606
|
-
/* swallow */
|
|
1607
|
-
}
|
|
1608
|
-
};
|
|
1609
|
-
tick();
|
|
1610
|
-
const t = setInterval(tick, 2500);
|
|
1611
|
-
return () => {
|
|
1612
|
-
cancelled = true;
|
|
1613
|
-
clearInterval(t);
|
|
1614
|
-
};
|
|
1615
|
-
}, []);
|
|
1616
|
-
|
|
1617
|
-
const setEditMode = useCallback(async (next) => {
|
|
1618
|
-
setEditModeLocal(next); // optimistic
|
|
1619
|
-
try {
|
|
1620
|
-
await api("/edit-mode", { method: "POST", body: { mode: next } });
|
|
1621
|
-
} catch (err) {
|
|
1622
|
-
setError(`mode switch failed: ${err.message}`);
|
|
1623
|
-
try {
|
|
1624
|
-
const o = await api("/overview");
|
|
1625
|
-
setEditModeLocal(o.editMode ?? null);
|
|
1626
|
-
} catch {
|
|
1627
|
-
/* swallow */
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
}, []);
|
|
1631
|
-
|
|
1632
|
-
// Generic settings flipper for preset + effort. Both go through
|
|
1633
|
-
// /api/settings (which writes to ~/.reasonix/config.json). preset
|
|
1634
|
-
// applies next session, effort applies next turn — the buttons'
|
|
1635
|
-
// tooltips remind the user.
|
|
1636
|
-
const setSetting = useCallback(async (key, value) => {
|
|
1637
|
-
if (key === "preset") setPresetLocal(value);
|
|
1638
|
-
if (key === "reasoningEffort") setEffortLocal(value);
|
|
1639
|
-
try {
|
|
1640
|
-
await api("/settings", { method: "POST", body: { [key]: value } });
|
|
1641
|
-
} catch (err) {
|
|
1642
|
-
setError(`${key} switch failed: ${err.message}`);
|
|
1643
|
-
try {
|
|
1644
|
-
const o = await api("/overview");
|
|
1645
|
-
setPresetLocal(o.preset ?? null);
|
|
1646
|
-
setEffortLocal(o.reasoningEffort ?? null);
|
|
1647
|
-
} catch {
|
|
1648
|
-
/* swallow */
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
}, []);
|
|
1652
|
-
|
|
1653
|
-
return html`
|
|
1654
|
-
<div class="chat-shell">
|
|
1655
|
-
<div class="panel-header" style="margin-bottom: 12px;">
|
|
1656
|
-
<h2 class="panel-title">Chat</h2>
|
|
1657
|
-
<span class="panel-subtitle">
|
|
1658
|
-
mirrors the live ${MODE === "attached" ? "TUI" : "session"} — type here or in the terminal, both surfaces stay in sync
|
|
1659
|
-
</span>
|
|
1660
|
-
<div class="header-pickers" style="margin-left: auto;">
|
|
1661
|
-
${
|
|
1662
|
-
effort
|
|
1663
|
-
? html`
|
|
1664
|
-
<div class="mode-picker" title="reasoning_effort — applies next turn">
|
|
1665
|
-
${["high", "max"].map(
|
|
1666
|
-
(e) => html`
|
|
1667
|
-
<button
|
|
1668
|
-
key=${e}
|
|
1669
|
-
class="mode-btn ${effort === e ? "active accent" : ""}"
|
|
1670
|
-
onClick=${() => setSetting("reasoningEffort", e)}
|
|
1671
|
-
title=${e === "max" ? "max (default — best quality)" : "high (cheaper / faster)"}
|
|
1672
|
-
>${e}</button>
|
|
1673
|
-
`,
|
|
1674
|
-
)}
|
|
1675
|
-
</div>
|
|
1676
|
-
`
|
|
1677
|
-
: null
|
|
1678
|
-
}
|
|
1679
|
-
${
|
|
1680
|
-
preset
|
|
1681
|
-
? html`
|
|
1682
|
-
<div class="mode-picker" title="preset — model commitment">
|
|
1683
|
-
${(() => {
|
|
1684
|
-
// Anything that isn't one of the three new presets
|
|
1685
|
-
// (including legacy fast/smart/max from old configs)
|
|
1686
|
-
// highlights as `auto` — the safe default. User can
|
|
1687
|
-
// re-pick explicitly if they want flash or pro.
|
|
1688
|
-
const KNOWN = ["auto", "flash", "pro"];
|
|
1689
|
-
const canonical = KNOWN.includes(preset) ? preset : "auto";
|
|
1690
|
-
return ["auto", "flash", "pro"].map(
|
|
1691
|
-
(p) => html`
|
|
1692
|
-
<button
|
|
1693
|
-
key=${p}
|
|
1694
|
-
class="mode-btn ${canonical === p ? "active accent" : ""}"
|
|
1695
|
-
onClick=${() => setSetting("preset", p)}
|
|
1696
|
-
title=${
|
|
1697
|
-
p === "auto"
|
|
1698
|
-
? "auto — flash baseline; auto-escalates to pro on hard turns (NEEDS_PRO / failure threshold)"
|
|
1699
|
-
: p === "flash"
|
|
1700
|
-
? "flash — always flash; no auto-escalate. /pro still works for one-shot manual"
|
|
1701
|
-
: "pro — always pro; ~3× flash cost (5/31 discount). Locks in on hard architecture work."
|
|
1702
|
-
}
|
|
1703
|
-
>${p}</button>
|
|
1704
|
-
`,
|
|
1705
|
-
);
|
|
1706
|
-
})()}
|
|
1707
|
-
</div>
|
|
1708
|
-
`
|
|
1709
|
-
: null
|
|
1710
|
-
}
|
|
1711
|
-
${
|
|
1712
|
-
editMode
|
|
1713
|
-
? html`
|
|
1714
|
-
<div class="mode-picker" title="edit gate — Shift+Tab cycles in TUI">
|
|
1715
|
-
${["review", "auto", "yolo"].map(
|
|
1716
|
-
(m) => html`
|
|
1717
|
-
<button
|
|
1718
|
-
key=${m}
|
|
1719
|
-
class="mode-btn ${editMode === m ? "active" : ""} ${m === "yolo" ? "yolo" : ""}"
|
|
1720
|
-
onClick=${() => setEditMode(m)}
|
|
1721
|
-
title=${
|
|
1722
|
-
m === "review"
|
|
1723
|
-
? "review — both edits and non-allowlisted shell ask first"
|
|
1724
|
-
: m === "auto"
|
|
1725
|
-
? "auto — edits auto-apply, shell still asks"
|
|
1726
|
-
: "yolo — edits AND shell auto-run, allowlist bypassed"
|
|
1727
|
-
}
|
|
1728
|
-
>${m}</button>
|
|
1729
|
-
`,
|
|
1730
|
-
)}
|
|
1731
|
-
</div>
|
|
1732
|
-
`
|
|
1733
|
-
: null
|
|
1734
|
-
}
|
|
1735
|
-
</div>
|
|
1736
|
-
</div>
|
|
1737
|
-
|
|
1738
|
-
${
|
|
1739
|
-
!busy && statusLine
|
|
1740
|
-
? html`<div class="chat-status"><span class="muted">${statusLine}</span></div>`
|
|
1741
|
-
: null
|
|
1742
|
-
}
|
|
1743
|
-
${
|
|
1744
|
-
semanticIndex === false && !semanticBannerDismissed
|
|
1745
|
-
? html`<div class="chat-banner">
|
|
1746
|
-
<span class="chat-banner-icon">≈</span>
|
|
1747
|
-
<span class="chat-banner-text">
|
|
1748
|
-
<strong>Semantic search isn't enabled for this project.</strong>
|
|
1749
|
-
<span class="muted">
|
|
1750
|
-
Build the index once and the model can find code by meaning ("where do we handle auth failures?") instead of grep on exact strings.
|
|
1751
|
-
</span>
|
|
1752
|
-
</span>
|
|
1753
|
-
<button
|
|
1754
|
-
class="primary"
|
|
1755
|
-
onClick=${() => appBus.dispatchEvent(new CustomEvent("navigate-tab", { detail: { tabId: "semantic" } }))}
|
|
1756
|
-
>Build it →</button>
|
|
1757
|
-
<button
|
|
1758
|
-
class="chat-banner-close"
|
|
1759
|
-
onClick=${() => setSemanticBannerDismissed(true)}
|
|
1760
|
-
title="dismiss (don't show again)"
|
|
1761
|
-
>×</button>
|
|
1762
|
-
</div>`
|
|
1763
|
-
: null
|
|
1764
|
-
}
|
|
1765
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
1766
|
-
|
|
1767
|
-
${
|
|
1768
|
-
modal
|
|
1769
|
-
? modal.kind === "shell"
|
|
1770
|
-
? html`<${ShellModal} modal=${modal} onResolve=${resolveModal} />`
|
|
1771
|
-
: modal.kind === "choice"
|
|
1772
|
-
? html`<${ChoiceModal} modal=${modal} onResolve=${resolveModal} />`
|
|
1773
|
-
: modal.kind === "plan"
|
|
1774
|
-
? html`<${PlanModal} modal=${modal} onResolve=${resolveModal} />`
|
|
1775
|
-
: modal.kind === "edit-review"
|
|
1776
|
-
? html`<${EditReviewModal} modal=${modal} onResolve=${resolveModal} />`
|
|
1777
|
-
: modal.kind === "workspace"
|
|
1778
|
-
? html`<${WorkspaceModal} modal=${modal} onResolve=${resolveModal} />`
|
|
1779
|
-
: modal.kind === "checkpoint"
|
|
1780
|
-
? html`<${CheckpointModal} modal=${modal} onResolve=${resolveModal} />`
|
|
1781
|
-
: modal.kind === "revision"
|
|
1782
|
-
? html`<${RevisionModal} modal=${modal} onResolve=${resolveModal} />`
|
|
1783
|
-
: null
|
|
1784
|
-
: null
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
<div class="chat-feed" ref=${feedRef}>
|
|
1788
|
-
${
|
|
1789
|
-
allMessages.length === 0
|
|
1790
|
-
? html`<div class="chat-empty">
|
|
1791
|
-
No conversation yet. Send a prompt below to begin.
|
|
1792
|
-
</div>`
|
|
1793
|
-
: allMessages.map(
|
|
1794
|
-
(m) => html`
|
|
1795
|
-
<${ChatMessage}
|
|
1796
|
-
key=${m.id}
|
|
1797
|
-
msg=${m}
|
|
1798
|
-
streaming=${streaming && streaming.id === m.id}
|
|
1799
|
-
/>
|
|
1800
|
-
`,
|
|
1801
|
-
)
|
|
1802
|
-
}
|
|
1803
|
-
</div>
|
|
1804
|
-
|
|
1805
|
-
<div class="chat-input-area">
|
|
1806
|
-
<textarea
|
|
1807
|
-
placeholder=${busy ? "wait for the current turn to finish…" : "Type a prompt — Enter sends, Shift+Enter for a newline"}
|
|
1808
|
-
value=${input}
|
|
1809
|
-
onInput=${(e) => setInput(e.target.value)}
|
|
1810
|
-
onKeyDown=${onKeyDown}
|
|
1811
|
-
disabled=${busy}
|
|
1812
|
-
rows="2"
|
|
1813
|
-
></textarea>
|
|
1814
|
-
<div style="display: flex; flex-direction: column; gap: 6px; align-self: stretch; justify-content: flex-end;">
|
|
1815
|
-
<button
|
|
1816
|
-
class="primary"
|
|
1817
|
-
onClick=${send}
|
|
1818
|
-
disabled=${busy || !input.trim()}
|
|
1819
|
-
>Send</button>
|
|
1820
|
-
<div style="display: flex; gap: 6px;">
|
|
1821
|
-
<button onClick=${newConversation} title="/new — wipe conversation context (loop log + scrollback)">New</button>
|
|
1822
|
-
<button onClick=${clearScrollback} title="/clear — wipe just visible scrollback (context kept)">Clear</button>
|
|
1823
|
-
</div>
|
|
1824
|
-
</div>
|
|
1825
|
-
</div>
|
|
1826
|
-
|
|
1827
|
-
${
|
|
1828
|
-
busy
|
|
1829
|
-
? html`<${InFlightRow}
|
|
1830
|
-
streaming=${streaming}
|
|
1831
|
-
activeTool=${activeTool}
|
|
1832
|
-
startedAt=${turnStartedAt}
|
|
1833
|
-
statusLine=${statusLine}
|
|
1834
|
-
onAbort=${abort}
|
|
1835
|
-
tick=${nowTick}
|
|
1836
|
-
/>`
|
|
1837
|
-
: null
|
|
1838
|
-
}
|
|
1839
|
-
<${ChatStatusBar} stats=${stats} model=${overviewModel} />
|
|
1840
|
-
</div>
|
|
1841
|
-
`;
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
// Summarize the dispatched tool in one line — what the user wants to
|
|
1845
|
-
// know is "is this hung or really doing X". Per-tool projection so a
|
|
1846
|
-
// write_file says "→ /path/foo (12,345 ch)" instead of just "tool is
|
|
1847
|
-
// running". Returns null for tools we don't have a custom shape for;
|
|
1848
|
-
// the row falls back to the bare tool name.
|
|
1849
|
-
function summarizeActiveTool(activeTool) {
|
|
1850
|
-
if (!activeTool) return null;
|
|
1851
|
-
const name = activeTool.toolName ?? "tool";
|
|
1852
|
-
const args = parseToolArgs(activeTool.args);
|
|
1853
|
-
const path = args?.path ?? args?.file_path ?? args?.filename;
|
|
1854
|
-
if (name === "write_file" && path) {
|
|
1855
|
-
const len = typeof args?.content === "string" ? args.content.length : null;
|
|
1856
|
-
return `${name} → ${path}${len != null ? ` (${len.toLocaleString()} ch)` : ""}`;
|
|
1857
|
-
}
|
|
1858
|
-
if ((name === "edit_file" || name.endsWith("_edit_file")) && path) {
|
|
1859
|
-
return `${name} → ${path}`;
|
|
1860
|
-
}
|
|
1861
|
-
if ((name === "run_command" || name === "run_background") && typeof args?.command === "string") {
|
|
1862
|
-
const c = args.command;
|
|
1863
|
-
return `${name} → $ ${c.length > 80 ? `${c.slice(0, 80)}…` : c}`;
|
|
1864
|
-
}
|
|
1865
|
-
if ((name === "read_file" || name === "list_files" || name === "search_files") && path) {
|
|
1866
|
-
return `${name} → ${path}`;
|
|
1867
|
-
}
|
|
1868
|
-
if (path) return `${name} → ${path}`;
|
|
1869
|
-
return name;
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
// Live "what's the model doing right now" strip. Lives just above the
|
|
1873
|
-
// ChatStatusBar so the user's eyes don't have to leave the input area
|
|
1874
|
-
// to see whether the turn is alive — ticks every 500ms via the parent's
|
|
1875
|
-
// nowTick so the seconds counter shows visible motion even when the
|
|
1876
|
-
// SSE stream is silent (model thinking, waiting on a tool, etc).
|
|
1877
|
-
function InFlightRow({ streaming, activeTool, startedAt, statusLine, onAbort, tick: _tick }) {
|
|
1878
|
-
const elapsedMs = startedAt ? Date.now() - startedAt : 0;
|
|
1879
|
-
const elapsed = (elapsedMs / 1000).toFixed(1);
|
|
1880
|
-
const reasoningLen = streaming?.reasoning?.length ?? 0;
|
|
1881
|
-
const textLen = streaming?.text?.length ?? 0;
|
|
1882
|
-
// Tool-running phase wins over text/reasoning since the model is
|
|
1883
|
-
// blocked on the tool — even if assistant_delta has fired we want
|
|
1884
|
-
// to show the active dispatch.
|
|
1885
|
-
const toolSummary = summarizeActiveTool(activeTool);
|
|
1886
|
-
const phase = toolSummary
|
|
1887
|
-
? "running"
|
|
1888
|
-
: reasoningLen > 0 && textLen === 0
|
|
1889
|
-
? "thinking"
|
|
1890
|
-
: textLen > 0
|
|
1891
|
-
? "streaming"
|
|
1892
|
-
: "waiting";
|
|
1893
|
-
return html`
|
|
1894
|
-
<div class="chat-inflight">
|
|
1895
|
-
<span class="spinner"></span>
|
|
1896
|
-
<span class="chat-inflight-phase">${phase}</span>
|
|
1897
|
-
<span class="chat-inflight-sep">·</span>
|
|
1898
|
-
<span class="muted">${elapsed}s</span>
|
|
1899
|
-
${
|
|
1900
|
-
toolSummary
|
|
1901
|
-
? html`
|
|
1902
|
-
<span class="chat-inflight-sep">·</span>
|
|
1903
|
-
<span class="chat-inflight-tool" title=${toolSummary}>${toolSummary}</span>
|
|
1904
|
-
`
|
|
1905
|
-
: null
|
|
1906
|
-
}
|
|
1907
|
-
${
|
|
1908
|
-
!toolSummary && (textLen > 0 || reasoningLen > 0)
|
|
1909
|
-
? html`
|
|
1910
|
-
<span class="chat-inflight-sep">·</span>
|
|
1911
|
-
<span class="muted">
|
|
1912
|
-
${reasoningLen > 0 ? html`reasoning ${reasoningLen.toLocaleString()} ch` : null}
|
|
1913
|
-
${reasoningLen > 0 && textLen > 0 ? html`<span> · </span>` : null}
|
|
1914
|
-
${textLen > 0 ? html`out ${textLen.toLocaleString()} ch` : null}
|
|
1915
|
-
</span>
|
|
1916
|
-
`
|
|
1917
|
-
: null
|
|
1918
|
-
}
|
|
1919
|
-
${
|
|
1920
|
-
statusLine
|
|
1921
|
-
? html`
|
|
1922
|
-
<span class="chat-inflight-sep">·</span>
|
|
1923
|
-
<span class="muted">${statusLine}</span>
|
|
1924
|
-
`
|
|
1925
|
-
: null
|
|
1926
|
-
}
|
|
1927
|
-
<button class="chat-inflight-abort" onClick=${onAbort}>Abort (Esc)</button>
|
|
1928
|
-
</div>
|
|
1929
|
-
`;
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
// ---------- Chat status bar ----------
|
|
1933
|
-
//
|
|
1934
|
-
// Mirrors the TUI's StatsPanel — turn / session cost, cache hit %,
|
|
1935
|
-
// ctx token gauge, balance. Sits beneath the input area as a compact
|
|
1936
|
-
// monospace strip. Renders as a placeholder ("· · ·") while stats
|
|
1937
|
-
// haven't arrived yet so the layout doesn't shift on first paint.
|
|
1938
|
-
|
|
1939
|
-
function ChatStatusBar({ stats, model }) {
|
|
1940
|
-
if (!stats) {
|
|
1941
|
-
return html`
|
|
1942
|
-
<div class="chat-statusbar">
|
|
1943
|
-
<span class="muted">· · · waiting for live stats</span>
|
|
1944
|
-
</div>
|
|
1945
|
-
`;
|
|
1946
|
-
}
|
|
1947
|
-
const ctxPct =
|
|
1948
|
-
stats.contextCapTokens > 0 ? (stats.lastPromptTokens / stats.contextCapTokens) * 100 : 0;
|
|
1949
|
-
const balance = stats.balance && stats.balance.length > 0 ? stats.balance[0] : null;
|
|
1950
|
-
return html`
|
|
1951
|
-
<div class="chat-statusbar">
|
|
1952
|
-
<span class="status-item">
|
|
1953
|
-
<span class="status-label">model</span>
|
|
1954
|
-
<code>${model ?? "—"}</code>
|
|
1955
|
-
</span>
|
|
1956
|
-
<span class="status-item">
|
|
1957
|
-
<span class="status-label">ctx</span>
|
|
1958
|
-
<span class="status-bar-mini">
|
|
1959
|
-
<span class="status-bar-mini-fill" style=${`width: ${Math.min(100, ctxPct).toFixed(1)}%;`}></span>
|
|
1960
|
-
</span>
|
|
1961
|
-
<span class="muted">${stats.lastPromptTokens.toLocaleString()} / ${(stats.contextCapTokens / 1000).toFixed(0)}K</span>
|
|
1962
|
-
</span>
|
|
1963
|
-
<span class="status-item">
|
|
1964
|
-
<span class="status-label">cache</span>
|
|
1965
|
-
<span class=${stats.cacheHitRatio >= 0.9 ? "status-ok" : stats.cacheHitRatio >= 0.6 ? "status-warn" : "status-err"}>
|
|
1966
|
-
${(stats.cacheHitRatio * 100).toFixed(1)}%
|
|
1967
|
-
</span>
|
|
1968
|
-
</span>
|
|
1969
|
-
<span class="status-item">
|
|
1970
|
-
<span class="status-label">turn</span>
|
|
1971
|
-
<code>${fmtUsd(stats.lastTurnCostUsd)}</code>
|
|
1972
|
-
</span>
|
|
1973
|
-
<span class="status-item">
|
|
1974
|
-
<span class="status-label">session</span>
|
|
1975
|
-
<code>${fmtUsd(stats.totalCostUsd)}</code>
|
|
1976
|
-
<span class="muted" style="font-size: 10px;">
|
|
1977
|
-
(${stats.turns} turn${stats.turns === 1 ? "" : "s"})
|
|
1978
|
-
</span>
|
|
1979
|
-
</span>
|
|
1980
|
-
${
|
|
1981
|
-
balance
|
|
1982
|
-
? html`
|
|
1983
|
-
<span class="status-item">
|
|
1984
|
-
<span class="status-label">balance</span>
|
|
1985
|
-
<code>${balance.total_balance} ${balance.currency}</code>
|
|
1986
|
-
</span>
|
|
1987
|
-
`
|
|
1988
|
-
: null
|
|
1989
|
-
}
|
|
1990
|
-
</div>
|
|
1991
|
-
`;
|
|
1992
|
-
}
|
|
1993
|
-
|
|
1994
|
-
// ---------- System Health ----------
|
|
1995
|
-
|
|
1996
|
-
function SystemPanel() {
|
|
1997
|
-
const { data, error, loading } = usePoll("/health", 5000);
|
|
1998
|
-
if (loading && !data) return html`<div class="boot">loading health…</div>`;
|
|
1999
|
-
if (error) return html`<div class="notice err">health failed: ${error.message}</div>`;
|
|
2000
|
-
const h = data;
|
|
2001
|
-
const upToDate = h.latestVersion ? h.latestVersion === h.version : null;
|
|
2002
|
-
return html`
|
|
2003
|
-
<div>
|
|
2004
|
-
<div class="panel-header">
|
|
2005
|
-
<h2 class="panel-title">System Health</h2>
|
|
2006
|
-
<span class="panel-subtitle">disk · version · jobs</span>
|
|
2007
|
-
</div>
|
|
2008
|
-
<div class="metric-grid">
|
|
2009
|
-
${MetricCard(
|
|
2010
|
-
"Reasonix",
|
|
2011
|
-
h.version,
|
|
2012
|
-
h.latestVersion === null
|
|
2013
|
-
? "version check pending"
|
|
2014
|
-
: upToDate
|
|
2015
|
-
? "up to date"
|
|
2016
|
-
: `latest: ${h.latestVersion}`,
|
|
2017
|
-
upToDate === false ? "warn" : null,
|
|
2018
|
-
)}
|
|
2019
|
-
${MetricCard("Sessions", `${fmtNum(h.sessions.count)} files`, fmtBytes(h.sessions.totalBytes))}
|
|
2020
|
-
${MetricCard("Memory", `${fmtNum(h.memory.fileCount)} files`, fmtBytes(h.memory.totalBytes))}
|
|
2021
|
-
${MetricCard(
|
|
2022
|
-
"Semantic index",
|
|
2023
|
-
h.semantic.exists ? `${fmtNum(h.semantic.fileCount)} files` : "not built",
|
|
2024
|
-
h.semantic.exists ? fmtBytes(h.semantic.totalBytes) : "run `reasonix index` to build",
|
|
2025
|
-
)}
|
|
2026
|
-
${MetricCard("Usage log", fmtBytes(h.usageLog.bytes), null)}
|
|
2027
|
-
${MetricCard("Background jobs", h.jobs === null ? "—" : fmtNum(h.jobs), h.jobs === null ? "no live session" : null)}
|
|
2028
|
-
</div>
|
|
2029
|
-
<div class="section-title">Paths</div>
|
|
2030
|
-
<div class="card mono" style="font-size: 12px; line-height: 1.8;">
|
|
2031
|
-
<div><span class="pill pill-dim">home</span> ${h.reasonixHome}</div>
|
|
2032
|
-
<div><span class="pill pill-dim">sessions</span> ${h.sessions.path}</div>
|
|
2033
|
-
<div><span class="pill pill-dim">memory</span> ${h.memory.path}</div>
|
|
2034
|
-
<div><span class="pill pill-dim">semantic</span> ${h.semantic.path}</div>
|
|
2035
|
-
<div><span class="pill pill-dim">usage</span> ${h.usageLog.path}</div>
|
|
2036
|
-
</div>
|
|
2037
|
-
</div>
|
|
2038
|
-
`;
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
// ---------- Sessions browser ----------
|
|
2042
|
-
|
|
2043
|
-
function SessionsPanel() {
|
|
2044
|
-
const { data, error, loading } = usePoll("/sessions", 5000);
|
|
2045
|
-
const [open, setOpen] = useState(null); // { name, messages } or null
|
|
2046
|
-
const [openLoading, setOpenLoading] = useState(false);
|
|
2047
|
-
|
|
2048
|
-
const view = useCallback(async (name) => {
|
|
2049
|
-
setOpen({ name, messages: null });
|
|
2050
|
-
setOpenLoading(true);
|
|
2051
|
-
try {
|
|
2052
|
-
const detail = await api(`/sessions/${encodeURIComponent(name)}`);
|
|
2053
|
-
setOpen({ name, messages: detail.messages });
|
|
2054
|
-
} catch (err) {
|
|
2055
|
-
setOpen({ name, messages: null, error: err.message });
|
|
2056
|
-
} finally {
|
|
2057
|
-
setOpenLoading(false);
|
|
2058
|
-
}
|
|
2059
|
-
}, []);
|
|
2060
|
-
|
|
2061
|
-
if (loading && !data) return html`<div class="boot">loading sessions…</div>`;
|
|
2062
|
-
if (error) return html`<div class="notice err">sessions failed: ${error.message}</div>`;
|
|
2063
|
-
const sessions = data.sessions ?? [];
|
|
2064
|
-
|
|
2065
|
-
if (open) {
|
|
2066
|
-
return html`
|
|
2067
|
-
<div>
|
|
2068
|
-
<div class="panel-header">
|
|
2069
|
-
<h2 class="panel-title">Session</h2>
|
|
2070
|
-
<span class="panel-subtitle">${open.name}</span>
|
|
2071
|
-
<button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
|
|
2072
|
-
</div>
|
|
2073
|
-
${
|
|
2074
|
-
openLoading
|
|
2075
|
-
? html`<div class="boot">loading transcript…</div>`
|
|
2076
|
-
: open.error
|
|
2077
|
-
? html`<div class="notice err">${open.error}</div>`
|
|
2078
|
-
: open.messages && open.messages.length > 0
|
|
2079
|
-
? html`
|
|
2080
|
-
<div class="chat-feed" style="max-height: calc(100vh - 180px); overflow-y: auto;">
|
|
2081
|
-
${open.messages.map(
|
|
2082
|
-
(m, i) => html`
|
|
2083
|
-
<${ChatMessage}
|
|
2084
|
-
key=${i}
|
|
2085
|
-
msg=${{
|
|
2086
|
-
id: `r-${i}`,
|
|
2087
|
-
role:
|
|
2088
|
-
m.role === "tool"
|
|
2089
|
-
? "tool"
|
|
2090
|
-
: m.role === "assistant"
|
|
2091
|
-
? "assistant"
|
|
2092
|
-
: m.role === "user"
|
|
2093
|
-
? "user"
|
|
2094
|
-
: "info",
|
|
2095
|
-
text: m.content ?? "",
|
|
2096
|
-
toolName: m.toolName,
|
|
2097
|
-
}}
|
|
2098
|
-
streaming=${false}
|
|
2099
|
-
/>
|
|
2100
|
-
`,
|
|
2101
|
-
)}
|
|
2102
|
-
</div>
|
|
2103
|
-
`
|
|
2104
|
-
: html`<div class="empty">empty transcript.</div>`
|
|
2105
|
-
}
|
|
2106
|
-
</div>
|
|
2107
|
-
`;
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
return html`
|
|
2111
|
-
<div>
|
|
2112
|
-
<div class="panel-header">
|
|
2113
|
-
<h2 class="panel-title">Sessions</h2>
|
|
2114
|
-
<span class="panel-subtitle">${sessions.length} saved · click to read</span>
|
|
2115
|
-
</div>
|
|
2116
|
-
${
|
|
2117
|
-
sessions.length === 0
|
|
2118
|
-
? html`<div class="empty">No saved sessions yet.</div>`
|
|
2119
|
-
: html`
|
|
2120
|
-
<table>
|
|
2121
|
-
<thead>
|
|
2122
|
-
<tr>
|
|
2123
|
-
<th>name</th>
|
|
2124
|
-
<th class="numeric">messages</th>
|
|
2125
|
-
<th class="numeric">size</th>
|
|
2126
|
-
<th class="numeric">last touched</th>
|
|
2127
|
-
</tr>
|
|
2128
|
-
</thead>
|
|
2129
|
-
<tbody>
|
|
2130
|
-
${sessions.map(
|
|
2131
|
-
(s) => html`
|
|
2132
|
-
<tr key=${s.name} onClick=${() => view(s.name)} style="cursor: pointer;">
|
|
2133
|
-
<td><code>${s.name}</code></td>
|
|
2134
|
-
<td class="numeric">${fmtNum(s.messageCount)}</td>
|
|
2135
|
-
<td class="numeric">${fmtBytes(s.size)}</td>
|
|
2136
|
-
<td class="numeric muted">${fmtRelativeTime(s.mtime)}</td>
|
|
2137
|
-
</tr>
|
|
2138
|
-
`,
|
|
2139
|
-
)}
|
|
2140
|
-
</tbody>
|
|
2141
|
-
</table>
|
|
2142
|
-
`
|
|
2143
|
-
}
|
|
2144
|
-
</div>
|
|
2145
|
-
`;
|
|
2146
|
-
}
|
|
2147
|
-
|
|
2148
|
-
// ---------- Plans archive ----------
|
|
2149
|
-
|
|
2150
|
-
function PlansPanel() {
|
|
2151
|
-
const { data, error, loading } = usePoll("/plans", 8000);
|
|
2152
|
-
const [open, setOpen] = useState(null);
|
|
2153
|
-
if (loading && !data) return html`<div class="boot">loading plans…</div>`;
|
|
2154
|
-
if (error) return html`<div class="notice err">plans failed: ${error.message}</div>`;
|
|
2155
|
-
const plans = data.plans ?? [];
|
|
2156
|
-
|
|
2157
|
-
if (open) {
|
|
2158
|
-
const completedSet = new Set(open.completedStepIds);
|
|
2159
|
-
return html`
|
|
2160
|
-
<div>
|
|
2161
|
-
<div class="panel-header">
|
|
2162
|
-
<h2 class="panel-title">Plan</h2>
|
|
2163
|
-
<span class="panel-subtitle">${open.session} · ${fmtRelativeTime(open.completedAt)}</span>
|
|
2164
|
-
<button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
|
|
2165
|
-
</div>
|
|
2166
|
-
${open.summary ? html`<div class="notice">${open.summary}</div>` : null}
|
|
2167
|
-
<div class="card">
|
|
2168
|
-
${open.steps.map((step) => {
|
|
2169
|
-
const done = completedSet.has(step.id);
|
|
2170
|
-
return html`
|
|
2171
|
-
<div style="padding: 8px 0; border-bottom: 1px solid var(--border); display: flex; gap: 12px;">
|
|
2172
|
-
<div style="width: 16px; color: ${done ? "var(--ok)" : "var(--fg-3)"}; font-family: var(--mono);">
|
|
2173
|
-
${done ? "✓" : "·"}
|
|
2174
|
-
</div>
|
|
2175
|
-
<div style="flex: 1;">
|
|
2176
|
-
<div style="color: ${done ? "var(--fg-2)" : "var(--fg-0)"}; font-weight: 500;">
|
|
2177
|
-
${step.title}
|
|
2178
|
-
</div>
|
|
2179
|
-
${step.action ? html`<div style="color: var(--fg-2); font-size: 12px; margin-top: 2px;">${step.action}</div>` : null}
|
|
2180
|
-
${step.risk ? html`<span class="pill pill-${step.risk === "high" ? "err" : step.risk === "medium" ? "warn" : "dim"}" style="margin-top: 4px;">${step.risk}</span>` : null}
|
|
2181
|
-
</div>
|
|
2182
|
-
</div>
|
|
2183
|
-
`;
|
|
2184
|
-
})}
|
|
2185
|
-
</div>
|
|
2186
|
-
</div>
|
|
2187
|
-
`;
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
return html`
|
|
2191
|
-
<div>
|
|
2192
|
-
<div class="panel-header">
|
|
2193
|
-
<h2 class="panel-title">Plans</h2>
|
|
2194
|
-
<span class="panel-subtitle">${plans.length} archived · click to view</span>
|
|
2195
|
-
</div>
|
|
2196
|
-
${
|
|
2197
|
-
plans.length === 0
|
|
2198
|
-
? html`<div class="empty">No archived plans yet — run a turn that calls <code>submit_plan</code> + <code>mark_step_complete</code>.</div>`
|
|
2199
|
-
: html`
|
|
2200
|
-
<table>
|
|
2201
|
-
<thead>
|
|
2202
|
-
<tr>
|
|
2203
|
-
<th>session</th>
|
|
2204
|
-
<th>title</th>
|
|
2205
|
-
<th class="numeric">progress</th>
|
|
2206
|
-
<th class="numeric">archived</th>
|
|
2207
|
-
</tr>
|
|
2208
|
-
</thead>
|
|
2209
|
-
<tbody>
|
|
2210
|
-
${plans.map(
|
|
2211
|
-
(p, i) => html`
|
|
2212
|
-
<tr key=${i} onClick=${() => setOpen(p)} style="cursor: pointer;">
|
|
2213
|
-
<td><code>${p.session}</code></td>
|
|
2214
|
-
<td>${p.summary ?? html`<span class="muted">(no title)</span>`}</td>
|
|
2215
|
-
<td class="numeric">${p.completedSteps}/${p.totalSteps} · ${fmtPct(p.completionRatio)}</td>
|
|
2216
|
-
<td class="numeric muted">${fmtRelativeTime(p.completedAt)}</td>
|
|
2217
|
-
</tr>
|
|
2218
|
-
`,
|
|
2219
|
-
)}
|
|
2220
|
-
</tbody>
|
|
2221
|
-
</table>
|
|
2222
|
-
`
|
|
2223
|
-
}
|
|
2224
|
-
</div>
|
|
2225
|
-
`;
|
|
2226
|
-
}
|
|
2227
|
-
|
|
2228
|
-
// ---------- Usage time-series chart (uPlot) ----------
|
|
2229
|
-
|
|
2230
|
-
let uPlotPromise = null;
|
|
2231
|
-
function loadUPlot() {
|
|
2232
|
-
if (!uPlotPromise) {
|
|
2233
|
-
uPlotPromise = import("https://esm.sh/uplot@1.6.31").then((m) => m.default ?? m);
|
|
2234
|
-
}
|
|
2235
|
-
return uPlotPromise;
|
|
2236
|
-
}
|
|
2237
|
-
|
|
2238
|
-
function UsageChart({ days }) {
|
|
2239
|
-
const containerRef = useRef(null);
|
|
2240
|
-
const plotRef = useRef(null);
|
|
2241
|
-
|
|
2242
|
-
useEffect(() => {
|
|
2243
|
-
let cancelled = false;
|
|
2244
|
-
loadUPlot().then((uPlot) => {
|
|
2245
|
-
if (cancelled || !containerRef.current) return;
|
|
2246
|
-
// Destroy previous instance on data refresh.
|
|
2247
|
-
if (plotRef.current) {
|
|
2248
|
-
plotRef.current.destroy();
|
|
2249
|
-
plotRef.current = null;
|
|
2250
|
-
}
|
|
2251
|
-
// Don't render an empty chart — let the parent show a fallback.
|
|
2252
|
-
if (!days || days.length === 0) return;
|
|
2253
|
-
const xs = days.map((d) => Math.floor(Date.parse(d.day) / 1000));
|
|
2254
|
-
const cost = days.map((d) => d.costUsd);
|
|
2255
|
-
const saved = days.map((d) => d.cacheSavingsUsd);
|
|
2256
|
-
const turns = days.map((d) => d.turns);
|
|
2257
|
-
const data = [xs, cost, saved, turns];
|
|
2258
|
-
const opts = {
|
|
2259
|
-
width: containerRef.current.clientWidth,
|
|
2260
|
-
height: 280,
|
|
2261
|
-
cursor: { drag: { x: true, y: false } },
|
|
2262
|
-
scales: {
|
|
2263
|
-
x: { time: true },
|
|
2264
|
-
y: { auto: true },
|
|
2265
|
-
turns: { auto: true },
|
|
2266
|
-
},
|
|
2267
|
-
axes: [
|
|
2268
|
-
{
|
|
2269
|
-
stroke: "#94a3b8",
|
|
2270
|
-
grid: { stroke: "rgba(148, 163, 184, 0.08)" },
|
|
2271
|
-
},
|
|
2272
|
-
{
|
|
2273
|
-
scale: "y",
|
|
2274
|
-
label: "USD",
|
|
2275
|
-
stroke: "#94a3b8",
|
|
2276
|
-
grid: { stroke: "rgba(148, 163, 184, 0.08)" },
|
|
2277
|
-
values: (_u, v) => v.map((n) => `$${n.toFixed(4)}`),
|
|
2278
|
-
},
|
|
2279
|
-
{
|
|
2280
|
-
scale: "turns",
|
|
2281
|
-
side: 1,
|
|
2282
|
-
label: "turns",
|
|
2283
|
-
stroke: "#94a3b8",
|
|
2284
|
-
grid: { show: false },
|
|
2285
|
-
},
|
|
2286
|
-
],
|
|
2287
|
-
series: [
|
|
2288
|
-
{},
|
|
2289
|
-
{
|
|
2290
|
-
label: "cost",
|
|
2291
|
-
stroke: "#67e8f9",
|
|
2292
|
-
width: 2,
|
|
2293
|
-
fill: "rgba(103, 232, 249, 0.10)",
|
|
2294
|
-
},
|
|
2295
|
-
{
|
|
2296
|
-
label: "cache saved",
|
|
2297
|
-
stroke: "#5eead4",
|
|
2298
|
-
width: 2,
|
|
2299
|
-
dash: [4, 4],
|
|
2300
|
-
},
|
|
2301
|
-
{
|
|
2302
|
-
label: "turns",
|
|
2303
|
-
stroke: "#c4b5fd",
|
|
2304
|
-
scale: "turns",
|
|
2305
|
-
width: 1.5,
|
|
2306
|
-
points: { show: true, size: 4 },
|
|
2307
|
-
},
|
|
2308
|
-
],
|
|
2309
|
-
legend: { live: true },
|
|
2310
|
-
};
|
|
2311
|
-
plotRef.current = new uPlot(opts, data, containerRef.current);
|
|
2312
|
-
});
|
|
2313
|
-
|
|
2314
|
-
// Resize observer keeps the chart at full panel width.
|
|
2315
|
-
const ro = new ResizeObserver(() => {
|
|
2316
|
-
if (plotRef.current && containerRef.current) {
|
|
2317
|
-
plotRef.current.setSize({
|
|
2318
|
-
width: containerRef.current.clientWidth,
|
|
2319
|
-
height: 280,
|
|
2320
|
-
});
|
|
2321
|
-
}
|
|
2322
|
-
});
|
|
2323
|
-
if (containerRef.current) ro.observe(containerRef.current);
|
|
2324
|
-
|
|
2325
|
-
return () => {
|
|
2326
|
-
cancelled = true;
|
|
2327
|
-
ro.disconnect();
|
|
2328
|
-
if (plotRef.current) {
|
|
2329
|
-
plotRef.current.destroy();
|
|
2330
|
-
plotRef.current = null;
|
|
2331
|
-
}
|
|
2332
|
-
};
|
|
2333
|
-
}, [days]);
|
|
2334
|
-
|
|
2335
|
-
return html`<div ref=${containerRef} style="width: 100%; min-height: 280px;"></div>`;
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
// ---------- existing UsagePanel rewrite — chart + table ----------
|
|
2339
|
-
|
|
2340
|
-
function UsageWithChart() {
|
|
2341
|
-
const { data: summary, error, loading } = usePoll("/usage", 5000);
|
|
2342
|
-
const [series, setSeries] = useState(null);
|
|
2343
|
-
|
|
2344
|
-
useEffect(() => {
|
|
2345
|
-
let cancelled = false;
|
|
2346
|
-
(async () => {
|
|
2347
|
-
try {
|
|
2348
|
-
const s = await api("/usage/series");
|
|
2349
|
-
if (!cancelled) setSeries(s.days ?? []);
|
|
2350
|
-
} catch {
|
|
2351
|
-
/* keep null; chart hides */
|
|
2352
|
-
}
|
|
2353
|
-
})();
|
|
2354
|
-
const t = setInterval(async () => {
|
|
2355
|
-
try {
|
|
2356
|
-
const s = await api("/usage/series");
|
|
2357
|
-
if (!cancelled) setSeries(s.days ?? []);
|
|
2358
|
-
} catch {
|
|
2359
|
-
/* swallow */
|
|
2360
|
-
}
|
|
2361
|
-
}, 30_000);
|
|
2362
|
-
return () => {
|
|
2363
|
-
cancelled = true;
|
|
2364
|
-
clearInterval(t);
|
|
2365
|
-
};
|
|
2366
|
-
}, []);
|
|
2367
|
-
|
|
2368
|
-
if (loading && !summary) return html`<div class="boot">loading usage…</div>`;
|
|
2369
|
-
if (error) return html`<div class="notice err">usage failed: ${error.message}</div>`;
|
|
2370
|
-
const u = summary;
|
|
2371
|
-
|
|
2372
|
-
return html`
|
|
2373
|
-
<div>
|
|
2374
|
-
<div class="panel-header">
|
|
2375
|
-
<h2 class="panel-title">Usage</h2>
|
|
2376
|
-
<span class="panel-subtitle">${u.recordCount.toLocaleString()} records · ${u.logSize}</span>
|
|
2377
|
-
</div>
|
|
2378
|
-
|
|
2379
|
-
${
|
|
2380
|
-
series && series.length > 0
|
|
2381
|
-
? html`
|
|
2382
|
-
<div class="card" style="padding: 18px;">
|
|
2383
|
-
<div class="card-title" style="margin-bottom: 12px;">Daily usage (cost · cache saved · turns)</div>
|
|
2384
|
-
<${UsageChart} days=${series} />
|
|
2385
|
-
</div>
|
|
2386
|
-
`
|
|
2387
|
-
: null
|
|
2388
|
-
}
|
|
2389
|
-
|
|
2390
|
-
${
|
|
2391
|
-
u.recordCount === 0
|
|
2392
|
-
? 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>`
|
|
2393
|
-
: html`
|
|
2394
|
-
<div class="section-title">Rolling windows</div>
|
|
2395
|
-
<table>
|
|
2396
|
-
<thead>
|
|
2397
|
-
<tr>
|
|
2398
|
-
<th></th>
|
|
2399
|
-
<th class="numeric">turns</th>
|
|
2400
|
-
<th class="numeric">cache hit</th>
|
|
2401
|
-
<th class="numeric">cost (USD)</th>
|
|
2402
|
-
<th class="numeric">cache saved</th>
|
|
2403
|
-
<th class="numeric">vs Claude</th>
|
|
2404
|
-
<th class="numeric">saved</th>
|
|
2405
|
-
</tr>
|
|
2406
|
-
</thead>
|
|
2407
|
-
<tbody>
|
|
2408
|
-
${u.buckets.map((b) => {
|
|
2409
|
-
const hitRatio =
|
|
2410
|
-
b.cacheHitTokens + b.cacheMissTokens > 0
|
|
2411
|
-
? b.cacheHitTokens / (b.cacheHitTokens + b.cacheMissTokens)
|
|
2412
|
-
: 0;
|
|
2413
|
-
const claudeSavings = b.claudeEquivUsd > 0 ? 1 - b.costUsd / b.claudeEquivUsd : 0;
|
|
2414
|
-
return html`
|
|
2415
|
-
<tr>
|
|
2416
|
-
<td>${b.label}</td>
|
|
2417
|
-
<td class="numeric">${fmtNum(b.turns)}</td>
|
|
2418
|
-
<td class="numeric">${b.turns > 0 ? fmtPct(hitRatio) : "—"}</td>
|
|
2419
|
-
<td class="numeric">${b.turns > 0 ? fmtUsd(b.costUsd) : "—"}</td>
|
|
2420
|
-
<td class="numeric">${b.turns > 0 && b.cacheSavingsUsd > 0 ? fmtUsd(b.cacheSavingsUsd) : "—"}</td>
|
|
2421
|
-
<td class="numeric">${b.turns > 0 ? fmtUsd(b.claudeEquivUsd) : "—"}</td>
|
|
2422
|
-
<td class="numeric">${b.turns > 0 && claudeSavings > 0 ? fmtPct(claudeSavings) : "—"}</td>
|
|
2423
|
-
</tr>
|
|
2424
|
-
`;
|
|
2425
|
-
})}
|
|
2426
|
-
</tbody>
|
|
2427
|
-
</table>
|
|
2428
|
-
`
|
|
2429
|
-
}
|
|
2430
|
-
|
|
2431
|
-
${
|
|
2432
|
-
u.byModel.length > 0
|
|
2433
|
-
? html`
|
|
2434
|
-
<div class="section-title">Most used models</div>
|
|
2435
|
-
<table>
|
|
2436
|
-
<thead><tr><th>model</th><th class="numeric">turns</th></tr></thead>
|
|
2437
|
-
<tbody>
|
|
2438
|
-
${u.byModel.slice(0, 5).map(
|
|
2439
|
-
(m) => html`
|
|
2440
|
-
<tr><td><code>${m.model}</code></td><td class="numeric">${fmtNum(m.turns)}</td></tr>
|
|
2441
|
-
`,
|
|
2442
|
-
)}
|
|
2443
|
-
</tbody>
|
|
2444
|
-
</table>
|
|
2445
|
-
`
|
|
2446
|
-
: null
|
|
2447
|
-
}
|
|
2448
|
-
</div>
|
|
2449
|
-
`;
|
|
2450
|
-
}
|
|
2451
|
-
|
|
2452
|
-
// ---------- Settings ----------
|
|
2453
|
-
|
|
2454
|
-
function SettingsPanel() {
|
|
2455
|
-
const [data, setData] = useState(null);
|
|
2456
|
-
const [error, setError] = useState(null);
|
|
2457
|
-
const [saving, setSaving] = useState(false);
|
|
2458
|
-
const [saved, setSaved] = useState(null);
|
|
2459
|
-
const [draft, setDraft] = useState({});
|
|
2460
|
-
|
|
2461
|
-
const load = useCallback(async () => {
|
|
2462
|
-
try {
|
|
2463
|
-
const r = await api("/settings");
|
|
2464
|
-
setData(r);
|
|
2465
|
-
setDraft({});
|
|
2466
|
-
} catch (err) {
|
|
2467
|
-
setError(err.message);
|
|
2468
|
-
}
|
|
2469
|
-
}, []);
|
|
2470
|
-
useEffect(() => {
|
|
2471
|
-
load();
|
|
2472
|
-
}, [load]);
|
|
2473
|
-
|
|
2474
|
-
const save = useCallback(
|
|
2475
|
-
async (fields) => {
|
|
2476
|
-
setSaving(true);
|
|
2477
|
-
setError(null);
|
|
2478
|
-
try {
|
|
2479
|
-
await api("/settings", { method: "POST", body: fields });
|
|
2480
|
-
await load();
|
|
2481
|
-
setSaved(`saved: ${Object.keys(fields).join(", ")}`);
|
|
2482
|
-
setTimeout(() => setSaved(null), 3000);
|
|
2483
|
-
} catch (err) {
|
|
2484
|
-
setError(err.message);
|
|
2485
|
-
} finally {
|
|
2486
|
-
setSaving(false);
|
|
2487
|
-
}
|
|
2488
|
-
},
|
|
2489
|
-
[load],
|
|
2490
|
-
);
|
|
2491
|
-
|
|
2492
|
-
if (!data && !error) return html`<div class="boot">loading settings…</div>`;
|
|
2493
|
-
if (error && !data) return html`<div class="notice err">${error}</div>`;
|
|
2494
|
-
const v = data;
|
|
2495
|
-
|
|
2496
|
-
return html`
|
|
2497
|
-
<div>
|
|
2498
|
-
<div class="panel-header">
|
|
2499
|
-
<h2 class="panel-title">Settings</h2>
|
|
2500
|
-
<span class="panel-subtitle">~/.reasonix/config.json · most fields apply next session</span>
|
|
2501
|
-
</div>
|
|
2502
|
-
${saved ? html`<div class="notice">${saved}</div>` : null}
|
|
2503
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
2504
|
-
|
|
2505
|
-
<div class="section-title">DeepSeek API</div>
|
|
2506
|
-
<div class="card">
|
|
2507
|
-
<div class="row">
|
|
2508
|
-
<span class="card-title" style="margin: 0;">API key</span>
|
|
2509
|
-
<code style="margin-left: auto;">${v.apiKey ?? "(not set)"}</code>
|
|
2510
|
-
</div>
|
|
2511
|
-
<div class="row" style="margin-top: 8px;">
|
|
2512
|
-
<input
|
|
2513
|
-
type="password"
|
|
2514
|
-
placeholder="paste a fresh sk-… token to replace"
|
|
2515
|
-
value=${draft.apiKey ?? ""}
|
|
2516
|
-
onInput=${(e) => setDraft({ ...draft, apiKey: e.target.value })}
|
|
2517
|
-
/>
|
|
2518
|
-
<button
|
|
2519
|
-
class="primary"
|
|
2520
|
-
disabled=${saving || !(draft.apiKey ?? "").trim()}
|
|
2521
|
-
onClick=${() => save({ apiKey: draft.apiKey })}
|
|
2522
|
-
>Save key</button>
|
|
2523
|
-
</div>
|
|
2524
|
-
<div class="row" style="margin-top: 12px;">
|
|
2525
|
-
<span class="card-title" style="margin: 0;">Base URL</span>
|
|
2526
|
-
<input
|
|
2527
|
-
type="text"
|
|
2528
|
-
value=${draft.baseUrl ?? v.baseUrl ?? ""}
|
|
2529
|
-
placeholder="https://api.deepseek.com (default)"
|
|
2530
|
-
onInput=${(e) => setDraft({ ...draft, baseUrl: e.target.value })}
|
|
2531
|
-
/>
|
|
2532
|
-
<button
|
|
2533
|
-
disabled=${saving || (draft.baseUrl ?? v.baseUrl ?? "") === (v.baseUrl ?? "")}
|
|
2534
|
-
onClick=${() => save({ baseUrl: draft.baseUrl })}
|
|
2535
|
-
>Save</button>
|
|
2536
|
-
</div>
|
|
2537
|
-
</div>
|
|
2538
|
-
|
|
2539
|
-
<div class="section-title">Defaults</div>
|
|
2540
|
-
<div class="card">
|
|
2541
|
-
<div class="row">
|
|
2542
|
-
<span class="card-title" style="margin: 0; flex: 0 0 110px;">Preset</span>
|
|
2543
|
-
<select
|
|
2544
|
-
value=${
|
|
2545
|
-
// Unknown values (legacy fast/smart/max, or anything
|
|
2546
|
-
// hand-edited into config.json) display as `auto`.
|
|
2547
|
-
["auto", "flash", "pro"].includes(v.preset) ? v.preset : "auto"
|
|
2548
|
-
}
|
|
2549
|
-
onChange=${(e) => save({ preset: e.target.value })}
|
|
2550
|
-
disabled=${saving}
|
|
2551
|
-
>
|
|
2552
|
-
<option value="auto">auto — flash → pro on hard turns (default)</option>
|
|
2553
|
-
<option value="flash">flash — always flash, no auto-escalate</option>
|
|
2554
|
-
<option value="pro">pro — always pro</option>
|
|
2555
|
-
</select>
|
|
2556
|
-
<span class="muted" style="margin-left: auto; font-size: 12px;">applies next turn</span>
|
|
2557
|
-
</div>
|
|
2558
|
-
<div class="row" style="margin-top: 12px;">
|
|
2559
|
-
<span class="card-title" style="margin: 0; flex: 0 0 110px;">Effort</span>
|
|
2560
|
-
<select
|
|
2561
|
-
value=${v.reasoningEffort}
|
|
2562
|
-
onChange=${(e) => save({ reasoningEffort: e.target.value })}
|
|
2563
|
-
disabled=${saving}
|
|
2564
|
-
>
|
|
2565
|
-
<option value="max">max (default — best)</option>
|
|
2566
|
-
<option value="high">high (cheaper / faster)</option>
|
|
2567
|
-
</select>
|
|
2568
|
-
<span class="muted" style="margin-left: auto; font-size: 12px;">applies next turn</span>
|
|
2569
|
-
</div>
|
|
2570
|
-
<div class="row" style="margin-top: 12px;">
|
|
2571
|
-
<span class="card-title" style="margin: 0; flex: 0 0 110px;">Web search</span>
|
|
2572
|
-
<button
|
|
2573
|
-
class=${v.search ? "primary" : ""}
|
|
2574
|
-
onClick=${() => save({ search: !v.search })}
|
|
2575
|
-
disabled=${saving}
|
|
2576
|
-
>${v.search ? "ON" : "off"}</button>
|
|
2577
|
-
<span class="muted" style="margin-left: auto; font-size: 12px;">web_fetch + web_search tools</span>
|
|
2578
|
-
</div>
|
|
2579
|
-
</div>
|
|
2580
|
-
|
|
2581
|
-
<div class="section-title">Runtime</div>
|
|
2582
|
-
<div class="card">
|
|
2583
|
-
<div class="row">
|
|
2584
|
-
<span class="card-title" style="margin: 0; flex: 0 0 110px;">Active model</span>
|
|
2585
|
-
<code>${v.model ?? "—"}</code>
|
|
2586
|
-
</div>
|
|
2587
|
-
<div class="row" style="margin-top: 8px;">
|
|
2588
|
-
<span class="card-title" style="margin: 0; flex: 0 0 110px;">Edit mode</span>
|
|
2589
|
-
<code>${v.editMode}</code>
|
|
2590
|
-
<span class="muted" style="margin-left: auto; font-size: 12px;">switch from the Chat tab header</span>
|
|
2591
|
-
</div>
|
|
2592
|
-
</div>
|
|
2593
|
-
</div>
|
|
2594
|
-
`;
|
|
2595
|
-
}
|
|
2596
|
-
|
|
2597
|
-
// ---------- Hooks ----------
|
|
2598
|
-
|
|
2599
|
-
function HooksPanel() {
|
|
2600
|
-
const [data, setData] = useState(null);
|
|
2601
|
-
const [error, setError] = useState(null);
|
|
2602
|
-
const [drafts, setDrafts] = useState({}); // { project: jsonText, global: jsonText }
|
|
2603
|
-
const [busy, setBusy] = useState(false);
|
|
2604
|
-
const [info, setInfo] = useState(null);
|
|
2605
|
-
|
|
2606
|
-
const load = useCallback(async () => {
|
|
2607
|
-
try {
|
|
2608
|
-
const r = await api("/hooks");
|
|
2609
|
-
setData(r);
|
|
2610
|
-
setDrafts({
|
|
2611
|
-
project: JSON.stringify(r.project.hooks ?? {}, null, 2),
|
|
2612
|
-
global: JSON.stringify(r.global.hooks ?? {}, null, 2),
|
|
2613
|
-
});
|
|
2614
|
-
} catch (err) {
|
|
2615
|
-
setError(err.message);
|
|
2616
|
-
}
|
|
2617
|
-
}, []);
|
|
2618
|
-
useEffect(() => {
|
|
2619
|
-
load();
|
|
2620
|
-
}, [load]);
|
|
2621
|
-
|
|
2622
|
-
const saveScope = useCallback(
|
|
2623
|
-
async (scope) => {
|
|
2624
|
-
setBusy(true);
|
|
2625
|
-
setError(null);
|
|
2626
|
-
let parsed;
|
|
2627
|
-
try {
|
|
2628
|
-
parsed = JSON.parse(drafts[scope]);
|
|
2629
|
-
} catch (err) {
|
|
2630
|
-
setError(`${scope} JSON: ${err.message}`);
|
|
2631
|
-
setBusy(false);
|
|
2632
|
-
return;
|
|
2633
|
-
}
|
|
2634
|
-
try {
|
|
2635
|
-
await api("/hooks/save", { method: "POST", body: { scope, hooks: parsed } });
|
|
2636
|
-
await api("/hooks/reload", { method: "POST", body: {} });
|
|
2637
|
-
setInfo(`saved + reloaded ${scope}`);
|
|
2638
|
-
setTimeout(() => setInfo(null), 3000);
|
|
2639
|
-
await load();
|
|
2640
|
-
} catch (err) {
|
|
2641
|
-
setError(err.message);
|
|
2642
|
-
} finally {
|
|
2643
|
-
setBusy(false);
|
|
2644
|
-
}
|
|
2645
|
-
},
|
|
2646
|
-
[drafts, load],
|
|
2647
|
-
);
|
|
2648
|
-
|
|
2649
|
-
if (!data && !error) return html`<div class="boot">loading hooks…</div>`;
|
|
2650
|
-
if (error && !data) return html`<div class="notice err">${error}</div>`;
|
|
2651
|
-
|
|
2652
|
-
return html`
|
|
2653
|
-
<div>
|
|
2654
|
-
<div class="panel-header">
|
|
2655
|
-
<h2 class="panel-title">Hooks</h2>
|
|
2656
|
-
<span class="panel-subtitle">${data.resolved.length} resolved · events: ${data.events.join(", ")}</span>
|
|
2657
|
-
</div>
|
|
2658
|
-
${info ? html`<div class="notice">${info}</div>` : null}
|
|
2659
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
2660
|
-
${["project", "global"].map((scope) => {
|
|
2661
|
-
const meta = data[scope];
|
|
2662
|
-
return html`
|
|
2663
|
-
<div class="section-title">${scope} — <code>${meta.path ?? "(no project)"}</code></div>
|
|
2664
|
-
${
|
|
2665
|
-
scope === "project" && !meta.path
|
|
2666
|
-
? html`<div class="empty">No active project — open <code>/dashboard</code> from <code>reasonix code</code> to edit project hooks.</div>`
|
|
2667
|
-
: html`
|
|
2668
|
-
<textarea
|
|
2669
|
-
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;"
|
|
2670
|
-
value=${drafts[scope] ?? ""}
|
|
2671
|
-
onInput=${(e) => setDrafts({ ...drafts, [scope]: e.target.value })}
|
|
2672
|
-
disabled=${busy}
|
|
2673
|
-
></textarea>
|
|
2674
|
-
<div class="row" style="margin-top: 8px;">
|
|
2675
|
-
<button class="primary" disabled=${busy} onClick=${() => saveScope(scope)}>Save + Reload</button>
|
|
2676
|
-
<button disabled=${busy} onClick=${load}>Discard changes</button>
|
|
2677
|
-
</div>
|
|
2678
|
-
`
|
|
2679
|
-
}
|
|
2680
|
-
`;
|
|
2681
|
-
})}
|
|
2682
|
-
</div>
|
|
2683
|
-
`;
|
|
2684
|
-
}
|
|
2685
|
-
|
|
2686
|
-
// ---------- Memory ----------
|
|
2687
|
-
|
|
2688
|
-
function MemoryPanel() {
|
|
2689
|
-
const [tree, setTree] = useState(null);
|
|
2690
|
-
const [error, setError] = useState(null);
|
|
2691
|
-
const [open, setOpen] = useState(null); // { scope, name } | null
|
|
2692
|
-
const [body, setBody] = useState("");
|
|
2693
|
-
const [busy, setBusy] = useState(false);
|
|
2694
|
-
const [info, setInfo] = useState(null);
|
|
2695
|
-
|
|
2696
|
-
const load = useCallback(async () => {
|
|
2697
|
-
try {
|
|
2698
|
-
const r = await api("/memory");
|
|
2699
|
-
setTree(r);
|
|
2700
|
-
} catch (err) {
|
|
2701
|
-
setError(err.message);
|
|
2702
|
-
}
|
|
2703
|
-
}, []);
|
|
2704
|
-
useEffect(() => {
|
|
2705
|
-
load();
|
|
2706
|
-
}, [load]);
|
|
2707
|
-
|
|
2708
|
-
const openFile = useCallback(async (scope, name) => {
|
|
2709
|
-
setOpen({ scope, name });
|
|
2710
|
-
setBusy(true);
|
|
2711
|
-
try {
|
|
2712
|
-
const path =
|
|
2713
|
-
scope === "project" ? "/memory/project" : `/memory/${scope}/${encodeURIComponent(name)}`;
|
|
2714
|
-
const r = await api(path);
|
|
2715
|
-
setBody(r.body);
|
|
2716
|
-
} catch (err) {
|
|
2717
|
-
setError(err.message);
|
|
2718
|
-
} finally {
|
|
2719
|
-
setBusy(false);
|
|
2720
|
-
}
|
|
2721
|
-
}, []);
|
|
2722
|
-
|
|
2723
|
-
const save = useCallback(async () => {
|
|
2724
|
-
if (!open) return;
|
|
2725
|
-
setBusy(true);
|
|
2726
|
-
setError(null);
|
|
2727
|
-
try {
|
|
2728
|
-
const path =
|
|
2729
|
-
open.scope === "project"
|
|
2730
|
-
? "/memory/project"
|
|
2731
|
-
: `/memory/${open.scope}/${encodeURIComponent(open.name)}`;
|
|
2732
|
-
await api(path, { method: "POST", body: { body } });
|
|
2733
|
-
setInfo(`saved ${open.scope}${open.name ? `/${open.name}` : ""}`);
|
|
2734
|
-
setTimeout(() => setInfo(null), 3000);
|
|
2735
|
-
await load();
|
|
2736
|
-
} catch (err) {
|
|
2737
|
-
setError(err.message);
|
|
2738
|
-
} finally {
|
|
2739
|
-
setBusy(false);
|
|
2740
|
-
}
|
|
2741
|
-
}, [open, body, load]);
|
|
2742
|
-
|
|
2743
|
-
if (!tree && !error) return html`<div class="boot">loading memory…</div>`;
|
|
2744
|
-
if (error && !tree) return html`<div class="notice err">${error}</div>`;
|
|
2745
|
-
|
|
2746
|
-
if (open) {
|
|
2747
|
-
return html`
|
|
2748
|
-
<div>
|
|
2749
|
-
<div class="panel-header">
|
|
2750
|
-
<h2 class="panel-title">Memory · ${open.scope}${open.name ? `/${open.name}` : ""}</h2>
|
|
2751
|
-
<button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
|
|
2752
|
-
</div>
|
|
2753
|
-
${info ? html`<div class="notice">${info}</div>` : null}
|
|
2754
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
2755
|
-
<textarea
|
|
2756
|
-
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;"
|
|
2757
|
-
value=${body}
|
|
2758
|
-
onInput=${(e) => setBody(e.target.value)}
|
|
2759
|
-
disabled=${busy}
|
|
2760
|
-
></textarea>
|
|
2761
|
-
<div class="row" style="margin-top: 8px;">
|
|
2762
|
-
<button class="primary" disabled=${busy} onClick=${save}>Save</button>
|
|
2763
|
-
<span class="muted" style="font-size: 12px;">${body.length.toLocaleString()} chars · re-applied on next /new or session restart</span>
|
|
2764
|
-
</div>
|
|
2765
|
-
</div>
|
|
2766
|
-
`;
|
|
2767
|
-
}
|
|
2768
|
-
|
|
2769
|
-
return html`
|
|
2770
|
-
<div>
|
|
2771
|
-
<div class="panel-header">
|
|
2772
|
-
<h2 class="panel-title">Memory</h2>
|
|
2773
|
-
<span class="panel-subtitle">REASONIX.md (committable) + private notes (~/.reasonix/memory)</span>
|
|
2774
|
-
</div>
|
|
2775
|
-
<div class="section-title">Project — REASONIX.md</div>
|
|
2776
|
-
${
|
|
2777
|
-
tree.project.path
|
|
2778
|
-
? html`
|
|
2779
|
-
<div class="card row" style="cursor: pointer;" onClick=${() => openFile("project")}>
|
|
2780
|
-
<span><code>${tree.project.path}</code></span>
|
|
2781
|
-
<span class="pill ${tree.project.exists ? "pill-ok" : "pill-dim"}" style="margin-left: auto;">
|
|
2782
|
-
${tree.project.exists ? "exists" : "create"}
|
|
2783
|
-
</span>
|
|
2784
|
-
</div>
|
|
2785
|
-
`
|
|
2786
|
-
: html`<div class="empty">No active project.</div>`
|
|
2787
|
-
}
|
|
2788
|
-
|
|
2789
|
-
<div class="section-title">Global — ~/.reasonix/memory/global</div>
|
|
2790
|
-
${
|
|
2791
|
-
tree.global.files.length === 0
|
|
2792
|
-
? html`<div class="empty">No global memory files yet.</div>`
|
|
2793
|
-
: html`
|
|
2794
|
-
<table>
|
|
2795
|
-
<thead><tr><th>name</th><th class="numeric">size</th><th class="numeric">touched</th></tr></thead>
|
|
2796
|
-
<tbody>
|
|
2797
|
-
${tree.global.files.map(
|
|
2798
|
-
(f) => html`
|
|
2799
|
-
<tr key=${f.name} style="cursor: pointer;" onClick=${() => openFile("global", f.name)}>
|
|
2800
|
-
<td><code>${f.name}</code></td>
|
|
2801
|
-
<td class="numeric">${fmtBytes(f.size)}</td>
|
|
2802
|
-
<td class="numeric muted">${fmtRelativeTime(f.mtime)}</td>
|
|
2803
|
-
</tr>
|
|
2804
|
-
`,
|
|
2805
|
-
)}
|
|
2806
|
-
</tbody>
|
|
2807
|
-
</table>
|
|
2808
|
-
`
|
|
2809
|
-
}
|
|
2810
|
-
|
|
2811
|
-
${
|
|
2812
|
-
tree.projectMem.path
|
|
2813
|
-
? html`
|
|
2814
|
-
<div class="section-title">Project private — ~/.reasonix/memory/<hash></div>
|
|
2815
|
-
${
|
|
2816
|
-
tree.projectMem.files.length === 0
|
|
2817
|
-
? html`<div class="empty">No project-private memory yet.</div>`
|
|
2818
|
-
: html`
|
|
2819
|
-
<table>
|
|
2820
|
-
<thead><tr><th>name</th><th class="numeric">size</th><th class="numeric">touched</th></tr></thead>
|
|
2821
|
-
<tbody>
|
|
2822
|
-
${tree.projectMem.files.map(
|
|
2823
|
-
(f) => html`
|
|
2824
|
-
<tr key=${f.name} style="cursor: pointer;" onClick=${() => openFile("project-mem", f.name)}>
|
|
2825
|
-
<td><code>${f.name}</code></td>
|
|
2826
|
-
<td class="numeric">${fmtBytes(f.size)}</td>
|
|
2827
|
-
<td class="numeric muted">${fmtRelativeTime(f.mtime)}</td>
|
|
2828
|
-
</tr>
|
|
2829
|
-
`,
|
|
2830
|
-
)}
|
|
2831
|
-
</tbody>
|
|
2832
|
-
</table>
|
|
2833
|
-
`
|
|
2834
|
-
}
|
|
2835
|
-
`
|
|
2836
|
-
: null
|
|
2837
|
-
}
|
|
2838
|
-
</div>
|
|
2839
|
-
`;
|
|
2840
|
-
}
|
|
2841
|
-
|
|
2842
|
-
// ---------- Skills ----------
|
|
2843
|
-
|
|
2844
|
-
function SkillsPanel() {
|
|
2845
|
-
const [data, setData] = useState(null);
|
|
2846
|
-
const [error, setError] = useState(null);
|
|
2847
|
-
const [open, setOpen] = useState(null);
|
|
2848
|
-
const [body, setBody] = useState("");
|
|
2849
|
-
const [busy, setBusy] = useState(false);
|
|
2850
|
-
const [info, setInfo] = useState(null);
|
|
2851
|
-
const [newName, setNewName] = useState("");
|
|
2852
|
-
const [newScope, setNewScope] = useState("global");
|
|
2853
|
-
|
|
2854
|
-
const load = useCallback(async () => {
|
|
2855
|
-
try {
|
|
2856
|
-
setData(await api("/skills"));
|
|
2857
|
-
} catch (err) {
|
|
2858
|
-
setError(err.message);
|
|
2859
|
-
}
|
|
2860
|
-
}, []);
|
|
2861
|
-
useEffect(() => {
|
|
2862
|
-
load();
|
|
2863
|
-
}, [load]);
|
|
2864
|
-
|
|
2865
|
-
const openSkill = useCallback(async (scope, name) => {
|
|
2866
|
-
setOpen({ scope, name });
|
|
2867
|
-
setBusy(true);
|
|
2868
|
-
try {
|
|
2869
|
-
const r = await api(`/skills/${scope}/${encodeURIComponent(name)}`);
|
|
2870
|
-
setBody(r.body);
|
|
2871
|
-
} catch (err) {
|
|
2872
|
-
setError(err.message);
|
|
2873
|
-
} finally {
|
|
2874
|
-
setBusy(false);
|
|
2875
|
-
}
|
|
2876
|
-
}, []);
|
|
2877
|
-
|
|
2878
|
-
const save = useCallback(async () => {
|
|
2879
|
-
if (!open) return;
|
|
2880
|
-
setBusy(true);
|
|
2881
|
-
try {
|
|
2882
|
-
await api(`/skills/${open.scope}/${encodeURIComponent(open.name)}`, {
|
|
2883
|
-
method: "POST",
|
|
2884
|
-
body: { body },
|
|
2885
|
-
});
|
|
2886
|
-
setInfo(`saved ${open.scope}/${open.name}`);
|
|
2887
|
-
setTimeout(() => setInfo(null), 3000);
|
|
2888
|
-
await load();
|
|
2889
|
-
} catch (err) {
|
|
2890
|
-
setError(err.message);
|
|
2891
|
-
} finally {
|
|
2892
|
-
setBusy(false);
|
|
2893
|
-
}
|
|
2894
|
-
}, [open, body, load]);
|
|
2895
|
-
|
|
2896
|
-
const remove = useCallback(async () => {
|
|
2897
|
-
if (!open) return;
|
|
2898
|
-
if (!confirm(`Delete skill ${open.scope}/${open.name}?`)) return;
|
|
2899
|
-
setBusy(true);
|
|
2900
|
-
try {
|
|
2901
|
-
await api(`/skills/${open.scope}/${encodeURIComponent(open.name)}`, { method: "DELETE" });
|
|
2902
|
-
setOpen(null);
|
|
2903
|
-
await load();
|
|
2904
|
-
} catch (err) {
|
|
2905
|
-
setError(err.message);
|
|
2906
|
-
} finally {
|
|
2907
|
-
setBusy(false);
|
|
2908
|
-
}
|
|
2909
|
-
}, [open, load]);
|
|
2910
|
-
|
|
2911
|
-
const create = useCallback(async () => {
|
|
2912
|
-
if (!newName.trim()) return;
|
|
2913
|
-
setBusy(true);
|
|
2914
|
-
const stub = `---\nname: ${newName.trim()}\ndescription: TODO — one-line description that helps the model match this skill\n---\n\n# ${newName.trim()}\n\n`;
|
|
2915
|
-
try {
|
|
2916
|
-
await api(`/skills/${newScope}/${encodeURIComponent(newName.trim())}`, {
|
|
2917
|
-
method: "POST",
|
|
2918
|
-
body: { body: stub },
|
|
2919
|
-
});
|
|
2920
|
-
setNewName("");
|
|
2921
|
-
await load();
|
|
2922
|
-
openSkill(newScope, newName.trim());
|
|
2923
|
-
} catch (err) {
|
|
2924
|
-
setError(err.message);
|
|
2925
|
-
} finally {
|
|
2926
|
-
setBusy(false);
|
|
2927
|
-
}
|
|
2928
|
-
}, [newName, newScope, load, openSkill]);
|
|
2929
|
-
|
|
2930
|
-
if (!data && !error) return html`<div class="boot">loading skills…</div>`;
|
|
2931
|
-
if (error && !data) return html`<div class="notice err">${error}</div>`;
|
|
2932
|
-
|
|
2933
|
-
if (open) {
|
|
2934
|
-
return html`
|
|
2935
|
-
<div>
|
|
2936
|
-
<div class="panel-header">
|
|
2937
|
-
<h2 class="panel-title">Skill · ${open.scope}/${open.name}</h2>
|
|
2938
|
-
<button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
|
|
2939
|
-
</div>
|
|
2940
|
-
${info ? html`<div class="notice">${info}</div>` : null}
|
|
2941
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
2942
|
-
<textarea
|
|
2943
|
-
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;"
|
|
2944
|
-
value=${body}
|
|
2945
|
-
onInput=${(e) => setBody(e.target.value)}
|
|
2946
|
-
disabled=${busy}
|
|
2947
|
-
></textarea>
|
|
2948
|
-
<div class="row" style="margin-top: 8px;">
|
|
2949
|
-
<button class="primary" disabled=${busy} onClick=${save}>Save</button>
|
|
2950
|
-
<button class="danger" disabled=${busy} onClick=${remove}>Delete</button>
|
|
2951
|
-
<span class="muted" style="font-size: 12px; margin-left: auto;">re-loaded on next /new or session restart</span>
|
|
2952
|
-
</div>
|
|
2953
|
-
</div>
|
|
2954
|
-
`;
|
|
2955
|
-
}
|
|
2956
|
-
|
|
2957
|
-
const renderList = (label, items, scope) => html`
|
|
2958
|
-
<div class="section-title">${label} (${items.length})</div>
|
|
2959
|
-
${
|
|
2960
|
-
items.length === 0
|
|
2961
|
-
? html`<div class="empty">none</div>`
|
|
2962
|
-
: html`
|
|
2963
|
-
<table>
|
|
2964
|
-
<thead><tr><th>name</th><th>description</th><th></th></tr></thead>
|
|
2965
|
-
<tbody>
|
|
2966
|
-
${items.map(
|
|
2967
|
-
(s) => html`
|
|
2968
|
-
<tr key=${s.name} style="cursor: ${scope === "builtin" ? "default" : "pointer"};" onClick=${() => scope !== "builtin" && openSkill(scope, s.name)}>
|
|
2969
|
-
<td><code>${s.name}</code></td>
|
|
2970
|
-
<td>${s.description ?? html`<span class="muted">(no description)</span>`}</td>
|
|
2971
|
-
<td>${scope === "builtin" ? html`<span class="pill pill-dim">builtin</span>` : null}</td>
|
|
2972
|
-
</tr>
|
|
2973
|
-
`,
|
|
2974
|
-
)}
|
|
2975
|
-
</tbody>
|
|
2976
|
-
</table>
|
|
2977
|
-
`
|
|
2978
|
-
}
|
|
2979
|
-
`;
|
|
2980
|
-
|
|
2981
|
-
return html`
|
|
2982
|
-
<div>
|
|
2983
|
-
<div class="panel-header">
|
|
2984
|
-
<h2 class="panel-title">Skills</h2>
|
|
2985
|
-
<span class="panel-subtitle">click to edit · creates land in next /new</span>
|
|
2986
|
-
</div>
|
|
2987
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
2988
|
-
|
|
2989
|
-
<div class="section-title">Create new</div>
|
|
2990
|
-
<div class="card row">
|
|
2991
|
-
<select value=${newScope} onChange=${(e) => setNewScope(e.target.value)}>
|
|
2992
|
-
<option value="global">global</option>
|
|
2993
|
-
${data.paths.project ? html`<option value="project">project</option>` : null}
|
|
2994
|
-
</select>
|
|
2995
|
-
<input
|
|
2996
|
-
type="text"
|
|
2997
|
-
placeholder="skill-name"
|
|
2998
|
-
value=${newName}
|
|
2999
|
-
onInput=${(e) => setNewName(e.target.value)}
|
|
3000
|
-
/>
|
|
3001
|
-
<button class="primary" disabled=${busy || !newName.trim()} onClick=${create}>Create</button>
|
|
3002
|
-
</div>
|
|
3003
|
-
|
|
3004
|
-
${renderList("Project", data.project, "project")}
|
|
3005
|
-
${renderList("Global", data.global, "global")}
|
|
3006
|
-
${renderList("Builtin (read-only)", data.builtin, "builtin")}
|
|
3007
|
-
</div>
|
|
3008
|
-
`;
|
|
3009
|
-
}
|
|
3010
|
-
|
|
3011
|
-
// ---------- MCP ----------
|
|
3012
|
-
|
|
3013
|
-
// ---------- Semantic index ----------
|
|
3014
|
-
|
|
3015
|
-
function SemanticPanel() {
|
|
3016
|
-
const [data, setData] = useState(null);
|
|
3017
|
-
const [error, setError] = useState(null);
|
|
3018
|
-
const [busy, setBusy] = useState(false);
|
|
3019
|
-
const [info, setInfo] = useState(null);
|
|
3020
|
-
|
|
3021
|
-
const load = useCallback(async () => {
|
|
3022
|
-
try {
|
|
3023
|
-
const r = await api("/semantic");
|
|
3024
|
-
setData(r);
|
|
3025
|
-
} catch (err) {
|
|
3026
|
-
setError(err.message);
|
|
3027
|
-
}
|
|
3028
|
-
}, []);
|
|
3029
|
-
|
|
3030
|
-
// Poll fast while a job is running OR while ollama is pulling a
|
|
3031
|
-
// model (the latest-line readout updates every few hundred ms during
|
|
3032
|
-
// a download). Slow when idle so the panel doesn't burn network just
|
|
3033
|
-
// sitting open in a tab.
|
|
3034
|
-
useEffect(() => {
|
|
3035
|
-
load();
|
|
3036
|
-
const phase = data?.job?.phase;
|
|
3037
|
-
const running = phase === "scan" || phase === "embed" || phase === "write";
|
|
3038
|
-
const pulling = data?.pull?.status === "pulling";
|
|
3039
|
-
const ms = running || pulling ? 1200 : 5000;
|
|
3040
|
-
const id = setInterval(load, ms);
|
|
3041
|
-
return () => clearInterval(id);
|
|
3042
|
-
}, [load, data?.job?.phase, data?.pull?.status]);
|
|
3043
|
-
|
|
3044
|
-
const start = useCallback(
|
|
3045
|
-
async (rebuild) => {
|
|
3046
|
-
setBusy(true);
|
|
3047
|
-
setError(null);
|
|
3048
|
-
setInfo(null);
|
|
3049
|
-
try {
|
|
3050
|
-
await api("/semantic/start", { method: "POST", body: { rebuild: !!rebuild } });
|
|
3051
|
-
setInfo(rebuild ? "rebuild started" : "incremental index started");
|
|
3052
|
-
await load();
|
|
3053
|
-
} catch (err) {
|
|
3054
|
-
setError(err.message);
|
|
3055
|
-
} finally {
|
|
3056
|
-
setBusy(false);
|
|
3057
|
-
}
|
|
3058
|
-
},
|
|
3059
|
-
[load],
|
|
3060
|
-
);
|
|
3061
|
-
|
|
3062
|
-
const stop = useCallback(async () => {
|
|
3063
|
-
setBusy(true);
|
|
3064
|
-
setError(null);
|
|
3065
|
-
try {
|
|
3066
|
-
await api("/semantic/stop", { method: "POST", body: {} });
|
|
3067
|
-
setInfo("stopping requested — current chunk batch will finish first");
|
|
3068
|
-
await load();
|
|
3069
|
-
} catch (err) {
|
|
3070
|
-
setError(err.message);
|
|
3071
|
-
} finally {
|
|
3072
|
-
setBusy(false);
|
|
3073
|
-
}
|
|
3074
|
-
}, [load]);
|
|
3075
|
-
|
|
3076
|
-
const startDaemon = useCallback(async () => {
|
|
3077
|
-
setBusy(true);
|
|
3078
|
-
setError(null);
|
|
3079
|
-
setInfo("starting ollama daemon (15s timeout)…");
|
|
3080
|
-
try {
|
|
3081
|
-
const r = await api("/semantic/ollama/start", { method: "POST", body: {} });
|
|
3082
|
-
setInfo(
|
|
3083
|
-
r.ready ? "daemon is up" : "daemon didn't come up in time — check `ollama serve` manually",
|
|
3084
|
-
);
|
|
3085
|
-
await load();
|
|
3086
|
-
} catch (err) {
|
|
3087
|
-
setError(err.message);
|
|
3088
|
-
} finally {
|
|
3089
|
-
setBusy(false);
|
|
3090
|
-
}
|
|
3091
|
-
}, [load]);
|
|
3092
|
-
|
|
3093
|
-
const pullModel = useCallback(
|
|
3094
|
-
async (model) => {
|
|
3095
|
-
setBusy(true);
|
|
3096
|
-
setError(null);
|
|
3097
|
-
setInfo(`pulling ${model} — this may take a few minutes on first install`);
|
|
3098
|
-
try {
|
|
3099
|
-
await api("/semantic/ollama/pull", { method: "POST", body: { model } });
|
|
3100
|
-
await load();
|
|
3101
|
-
} catch (err) {
|
|
3102
|
-
setError(err.message);
|
|
3103
|
-
} finally {
|
|
3104
|
-
setBusy(false);
|
|
3105
|
-
}
|
|
3106
|
-
},
|
|
3107
|
-
[load],
|
|
3108
|
-
);
|
|
3109
|
-
|
|
3110
|
-
if (!data && !error) return html`<div class="boot">loading semantic status…</div>`;
|
|
3111
|
-
if (error && !data) return html`<div class="notice err">${error}</div>`;
|
|
3112
|
-
|
|
3113
|
-
if (data && !data.attached) {
|
|
3114
|
-
return html`
|
|
3115
|
-
<div>
|
|
3116
|
-
<div class="panel-header">
|
|
3117
|
-
<h2 class="panel-title">Semantic</h2>
|
|
3118
|
-
<span class="panel-subtitle">code-mode required</span>
|
|
3119
|
-
</div>
|
|
3120
|
-
<div class="empty">${data.reason}</div>
|
|
3121
|
-
</div>
|
|
3122
|
-
`;
|
|
3123
|
-
}
|
|
3124
|
-
|
|
3125
|
-
const job = data.job;
|
|
3126
|
-
const phase = job?.phase;
|
|
3127
|
-
const running = phase === "scan" || phase === "embed" || phase === "write";
|
|
3128
|
-
const pull = data.pull;
|
|
3129
|
-
const pulling = pull?.status === "pulling";
|
|
3130
|
-
|
|
3131
|
-
// Tri-state Ollama check. Each level gates the next:
|
|
3132
|
-
// binary missing → user must install (we won't run a package
|
|
3133
|
-
// manager on their behalf).
|
|
3134
|
-
// daemon down → one-click start (`ollama serve`).
|
|
3135
|
-
// model missing → one-click pull.
|
|
3136
|
-
// all good → ready to index.
|
|
3137
|
-
const o = data.ollama ?? {};
|
|
3138
|
-
const binaryFound = o.binaryFound === true;
|
|
3139
|
-
const daemonRunning = o.daemonRunning === true;
|
|
3140
|
-
const modelPulled = o.modelPulled === true;
|
|
3141
|
-
const modelName = o.modelName ?? "nomic-embed-text";
|
|
3142
|
-
const installedModels = o.installedModels ?? [];
|
|
3143
|
-
const ready = binaryFound && daemonRunning && modelPulled;
|
|
3144
|
-
|
|
3145
|
-
return html`
|
|
3146
|
-
<div>
|
|
3147
|
-
<div class="panel-header">
|
|
3148
|
-
<h2 class="panel-title">Semantic</h2>
|
|
3149
|
-
<span class="panel-subtitle">${data.index.exists ? "index built" : "no index yet"}</span>
|
|
3150
|
-
</div>
|
|
3151
|
-
${info ? html`<div class="notice">${info}</div>` : null}
|
|
3152
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
3153
|
-
|
|
3154
|
-
<div class="section-title">Status</div>
|
|
3155
|
-
<div class="kv">
|
|
3156
|
-
<div><span class="kv-key">project</span><code>${data.root}</code></div>
|
|
3157
|
-
<div>
|
|
3158
|
-
<span class="kv-key">ollama</span>
|
|
3159
|
-
${
|
|
3160
|
-
binaryFound
|
|
3161
|
-
? daemonRunning
|
|
3162
|
-
? html`<span class="pill pill-ok">reachable</span><span class="muted" style="margin-left: 8px;">${installedModels.length} model(s)${
|
|
3163
|
-
installedModels.length > 0
|
|
3164
|
-
? ` · ${installedModels.slice(0, 3).join(", ")}${installedModels.length > 3 ? "…" : ""}`
|
|
3165
|
-
: ""
|
|
3166
|
-
}</span>`
|
|
3167
|
-
: html`<span class="pill pill-warn">daemon down</span><span class="muted" style="margin-left: 8px;">binary on PATH but not serving</span>`
|
|
3168
|
-
: html`<span class="pill pill-err">not installed</span><span class="muted" style="margin-left: 8px;">${o.error ?? "ollama binary not on PATH"}</span>`
|
|
3169
|
-
}
|
|
3170
|
-
</div>
|
|
3171
|
-
<div>
|
|
3172
|
-
<span class="kv-key">model</span>
|
|
3173
|
-
<code>${modelName}</code>
|
|
3174
|
-
${
|
|
3175
|
-
modelPulled
|
|
3176
|
-
? html`<span class="pill pill-ok" style="margin-left: 8px;">pulled</span>`
|
|
3177
|
-
: daemonRunning
|
|
3178
|
-
? html`<span class="pill pill-warn" style="margin-left: 8px;">not pulled</span>`
|
|
3179
|
-
: html`<span class="pill pill-dim" style="margin-left: 8px;">unknown (daemon down)</span>`
|
|
3180
|
-
}
|
|
3181
|
-
</div>
|
|
3182
|
-
<div>
|
|
3183
|
-
<span class="kv-key">index</span>
|
|
3184
|
-
${
|
|
3185
|
-
data.index.exists
|
|
3186
|
-
? html`<span class="muted">present at <code>.reasonix/semantic/</code></span>`
|
|
3187
|
-
: html`<span class="muted">none — run an index to enable <code>semantic_search</code></span>`
|
|
3188
|
-
}
|
|
3189
|
-
</div>
|
|
3190
|
-
</div>
|
|
3191
|
-
|
|
3192
|
-
${
|
|
3193
|
-
!binaryFound
|
|
3194
|
-
? html`
|
|
3195
|
-
<div class="section-title">Install Ollama</div>
|
|
3196
|
-
<div class="card" style="font-size: 13px;">
|
|
3197
|
-
Reasonix doesn't run package managers for you. Install Ollama
|
|
3198
|
-
first, then come back to this panel:
|
|
3199
|
-
<ul style="margin: 10px 0 4px 18px; padding: 0;">
|
|
3200
|
-
<li><strong>macOS / Windows:</strong> download from <a href="https://ollama.com/download" target="_blank" rel="noreferrer">ollama.com/download</a></li>
|
|
3201
|
-
<li><strong>Linux:</strong> <code>curl -fsSL https://ollama.com/install.sh | sh</code></li>
|
|
3202
|
-
</ul>
|
|
3203
|
-
<div class="muted" style="margin-top: 8px;">After install, this panel will offer to start the daemon and pull <code>${modelName}</code> for you. Refresh after installing.</div>
|
|
3204
|
-
</div>
|
|
3205
|
-
`
|
|
3206
|
-
: null
|
|
3207
|
-
}
|
|
3208
|
-
|
|
3209
|
-
${
|
|
3210
|
-
binaryFound && !daemonRunning
|
|
3211
|
-
? html`
|
|
3212
|
-
<div class="section-title">Daemon</div>
|
|
3213
|
-
<div class="card" style="font-size: 13px;">
|
|
3214
|
-
<code>ollama</code> is on your PATH but the HTTP daemon isn't reachable.
|
|
3215
|
-
<div class="row" style="margin-top: 10px;">
|
|
3216
|
-
<button class="primary" disabled=${busy} onClick=${startDaemon}>Start daemon</button>
|
|
3217
|
-
<span class="muted" style="font-size: 12px; align-self: center;">runs <code>ollama serve</code> detached — survives Reasonix exit</span>
|
|
3218
|
-
</div>
|
|
3219
|
-
</div>
|
|
3220
|
-
`
|
|
3221
|
-
: null
|
|
3222
|
-
}
|
|
3223
|
-
|
|
3224
|
-
${
|
|
3225
|
-
daemonRunning && !modelPulled
|
|
3226
|
-
? html`
|
|
3227
|
-
<div class="section-title">Model</div>
|
|
3228
|
-
<div class="card" style="font-size: 13px;">
|
|
3229
|
-
<code>${modelName}</code> isn't installed yet. ${pulling ? "" : "~270 MB download on first pull."}
|
|
3230
|
-
<div class="row" style="margin-top: 10px;">
|
|
3231
|
-
<button
|
|
3232
|
-
class="primary"
|
|
3233
|
-
disabled=${busy || pulling}
|
|
3234
|
-
onClick=${() => pullModel(modelName)}
|
|
3235
|
-
>${pulling ? "pulling…" : `Pull ${modelName}`}</button>
|
|
3236
|
-
</div>
|
|
3237
|
-
${
|
|
3238
|
-
pull
|
|
3239
|
-
? html`
|
|
3240
|
-
<div class="kv" style="margin-top: 10px;">
|
|
3241
|
-
<div>
|
|
3242
|
-
<span class="kv-key">status</span>
|
|
3243
|
-
<span class=${`pill ${pull.status === "done" ? "pill-ok" : pull.status === "error" ? "pill-err" : "pill-active"}`}>${pull.status}</span>
|
|
3244
|
-
<span class="muted" style="margin-left: 8px;">${((Date.now() - pull.startedAt) / 1000).toFixed(1)}s</span>
|
|
3245
|
-
</div>
|
|
3246
|
-
${
|
|
3247
|
-
pull.lastLine
|
|
3248
|
-
? html`<div><span class="kv-key">last</span><code style="font-size: 11.5px;">${pull.lastLine}</code></div>`
|
|
3249
|
-
: null
|
|
3250
|
-
}
|
|
3251
|
-
</div>
|
|
3252
|
-
`
|
|
3253
|
-
: null
|
|
3254
|
-
}
|
|
3255
|
-
</div>
|
|
3256
|
-
`
|
|
3257
|
-
: null
|
|
3258
|
-
}
|
|
3259
|
-
|
|
3260
|
-
<div class="section-title">Job</div>
|
|
3261
|
-
${job ? html`<${SemanticJobView} job=${job} running=${running} />` : html`<div class="muted">No job has run in this dashboard yet.</div>`}
|
|
3262
|
-
|
|
3263
|
-
<div class="row" style="margin-top: 14px;">
|
|
3264
|
-
<button class="primary" disabled=${busy || running || !ready} onClick=${() => start(false)}>Index (incremental)</button>
|
|
3265
|
-
<button disabled=${busy || running || !ready} onClick=${() => start(true)}>Rebuild (wipe + full)</button>
|
|
3266
|
-
<button disabled=${busy || !running} onClick=${stop}>Stop</button>
|
|
3267
|
-
</div>
|
|
3268
|
-
|
|
3269
|
-
<${SemanticExcludesCard} />
|
|
3270
|
-
</div>
|
|
3271
|
-
`;
|
|
3272
|
-
}
|
|
3273
|
-
|
|
3274
|
-
function SemanticExcludesCard() {
|
|
3275
|
-
const [data, setData] = useState(null);
|
|
3276
|
-
const [draft, setDraft] = useState(null);
|
|
3277
|
-
const [preview, setPreview] = useState(null);
|
|
3278
|
-
const [busy, setBusy] = useState(false);
|
|
3279
|
-
const [error, setError] = useState(null);
|
|
3280
|
-
const [info, setInfo] = useState(null);
|
|
3281
|
-
const [open, setOpen] = useState(false);
|
|
3282
|
-
|
|
3283
|
-
const load = useCallback(async () => {
|
|
3284
|
-
try {
|
|
3285
|
-
const r = await api("/index-config");
|
|
3286
|
-
setData(r);
|
|
3287
|
-
setDraft(toDraft(r.resolved));
|
|
3288
|
-
} catch (err) {
|
|
3289
|
-
setError(err.message);
|
|
3290
|
-
}
|
|
3291
|
-
}, []);
|
|
3292
|
-
|
|
3293
|
-
useEffect(() => {
|
|
3294
|
-
if (open && !data) load();
|
|
3295
|
-
}, [open, data, load]);
|
|
3296
|
-
|
|
3297
|
-
const reset = useCallback(() => {
|
|
3298
|
-
if (data) setDraft(toDraft(data.defaults));
|
|
3299
|
-
setPreview(null);
|
|
3300
|
-
}, [data]);
|
|
3301
|
-
|
|
3302
|
-
const save = useCallback(async () => {
|
|
3303
|
-
if (!draft) return;
|
|
3304
|
-
setBusy(true);
|
|
3305
|
-
setError(null);
|
|
3306
|
-
setInfo(null);
|
|
3307
|
-
try {
|
|
3308
|
-
const payload = fromDraft(draft);
|
|
3309
|
-
const r = await api("/index-config", { method: "POST", body: payload });
|
|
3310
|
-
setInfo(`saved · ${r.changed.length || 0} fields updated · re-run index to apply`);
|
|
3311
|
-
await load();
|
|
3312
|
-
} catch (err) {
|
|
3313
|
-
setError(err.message);
|
|
3314
|
-
} finally {
|
|
3315
|
-
setBusy(false);
|
|
3316
|
-
}
|
|
3317
|
-
}, [draft, load]);
|
|
3318
|
-
|
|
3319
|
-
const runPreview = useCallback(async () => {
|
|
3320
|
-
if (!draft) return;
|
|
3321
|
-
setBusy(true);
|
|
3322
|
-
setError(null);
|
|
3323
|
-
setInfo("running dry walk against project root…");
|
|
3324
|
-
try {
|
|
3325
|
-
const payload = fromDraft(draft);
|
|
3326
|
-
const r = await api("/index-config/preview", { method: "POST", body: payload });
|
|
3327
|
-
setPreview(r);
|
|
3328
|
-
setInfo(null);
|
|
3329
|
-
} catch (err) {
|
|
3330
|
-
setError(err.message);
|
|
3331
|
-
setInfo(null);
|
|
3332
|
-
} finally {
|
|
3333
|
-
setBusy(false);
|
|
3334
|
-
}
|
|
3335
|
-
}, [draft]);
|
|
3336
|
-
|
|
3337
|
-
return html`
|
|
3338
|
-
<div class="excludes-toggle" onClick=${() => setOpen(!open)}>
|
|
3339
|
-
<span class="caret">${open ? "▼" : "▶"}</span>
|
|
3340
|
-
<span class="label">Excludes</span>
|
|
3341
|
-
<span class="hint">config-driven skip rules applied during indexing</span>
|
|
3342
|
-
</div>
|
|
3343
|
-
${
|
|
3344
|
-
!open
|
|
3345
|
-
? null
|
|
3346
|
-
: !draft
|
|
3347
|
-
? html`<div class="empty">loading…</div>`
|
|
3348
|
-
: html`
|
|
3349
|
-
<div class="card excludes-card">
|
|
3350
|
-
${info ? html`<div class="notice">${info}</div>` : null}
|
|
3351
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
3352
|
-
<div class="lead">
|
|
3353
|
-
One value per line. Dirs / files match by basename. Patterns use picomatch syntax (e.g. <code>**/*.generated.ts</code>, <code>vendor/**</code>, <code>!keep-me</code>).
|
|
3354
|
-
</div>
|
|
3355
|
-
<div class="excludes-grid">
|
|
3356
|
-
<${ExcludesField} label="Exclude dirs" value=${draft.excludeDirs} onChange=${(v) => setDraft({ ...draft, excludeDirs: v })} />
|
|
3357
|
-
<${ExcludesField} label="Exclude files" value=${draft.excludeFiles} onChange=${(v) => setDraft({ ...draft, excludeFiles: v })} />
|
|
3358
|
-
<${ExcludesField} label="Exclude extensions" value=${draft.excludeExts} onChange=${(v) => setDraft({ ...draft, excludeExts: v })} />
|
|
3359
|
-
<${ExcludesField} label="Exclude patterns (glob)" value=${draft.excludePatterns} onChange=${(v) => setDraft({ ...draft, excludePatterns: v })} />
|
|
3360
|
-
</div>
|
|
3361
|
-
<div class="excludes-options">
|
|
3362
|
-
<label>
|
|
3363
|
-
<input type="checkbox" checked=${draft.respectGitignore} onChange=${(e) => setDraft({ ...draft, respectGitignore: e.target.checked })} />
|
|
3364
|
-
Respect <code>.gitignore</code>
|
|
3365
|
-
</label>
|
|
3366
|
-
<label>
|
|
3367
|
-
Max file size
|
|
3368
|
-
<input type="number" min="1024" step="1024" value=${draft.maxFileBytes} onChange=${(e) => setDraft({ ...draft, maxFileBytes: Number(e.target.value) || 0 })} />
|
|
3369
|
-
<span class="muted">bytes</span>
|
|
3370
|
-
</label>
|
|
3371
|
-
</div>
|
|
3372
|
-
<div class="excludes-actions">
|
|
3373
|
-
<button class="primary" disabled=${busy} onClick=${save}>Save</button>
|
|
3374
|
-
<button disabled=${busy} onClick=${runPreview}>Preview (dry-walk)</button>
|
|
3375
|
-
<button disabled=${busy} onClick=${reset}>Reset to defaults</button>
|
|
3376
|
-
</div>
|
|
3377
|
-
${preview ? html`<${ExcludesPreview} preview=${preview} />` : null}
|
|
3378
|
-
</div>
|
|
3379
|
-
`
|
|
3380
|
-
}
|
|
3381
|
-
`;
|
|
3382
|
-
}
|
|
3383
|
-
|
|
3384
|
-
function ExcludesPreview({ preview }) {
|
|
3385
|
-
const buckets = preview.skipBuckets || {};
|
|
3386
|
-
const samples = preview.skipSamples || {};
|
|
3387
|
-
const totalSkipped = Object.values(buckets).reduce((a, b) => a + (b || 0), 0);
|
|
3388
|
-
const reasons = [
|
|
3389
|
-
"gitignore",
|
|
3390
|
-
"pattern",
|
|
3391
|
-
"defaultDir",
|
|
3392
|
-
"defaultFile",
|
|
3393
|
-
"binaryExt",
|
|
3394
|
-
"binaryContent",
|
|
3395
|
-
"tooLarge",
|
|
3396
|
-
"readError",
|
|
3397
|
-
].filter((k) => (buckets[k] || 0) > 0);
|
|
3398
|
-
return html`
|
|
3399
|
-
<div class="excludes-preview">
|
|
3400
|
-
<div class="summary">
|
|
3401
|
-
Preview — would index <strong>${preview.filesIncluded}</strong> file(s), skip <strong>${totalSkipped}</strong>
|
|
3402
|
-
</div>
|
|
3403
|
-
${
|
|
3404
|
-
reasons.length === 0
|
|
3405
|
-
? html`<div class="muted">nothing skipped — all walked files would be indexed.</div>`
|
|
3406
|
-
: reasons.map(
|
|
3407
|
-
(r) => html`
|
|
3408
|
-
<details>
|
|
3409
|
-
<summary><strong>${r}: ${buckets[r]}</strong></summary>
|
|
3410
|
-
<ul>
|
|
3411
|
-
${(samples[r] || []).map((p) => html`<li><code>${p}</code></li>`)}
|
|
3412
|
-
${
|
|
3413
|
-
(buckets[r] || 0) > (samples[r] || []).length
|
|
3414
|
-
? html`<li class="muted">…${buckets[r] - samples[r].length} more</li>`
|
|
3415
|
-
: null
|
|
3416
|
-
}
|
|
3417
|
-
</ul>
|
|
3418
|
-
</details>
|
|
3419
|
-
`,
|
|
3420
|
-
)
|
|
3421
|
-
}
|
|
3422
|
-
${
|
|
3423
|
-
preview.sampleIncluded?.length
|
|
3424
|
-
? html`
|
|
3425
|
-
<details>
|
|
3426
|
-
<summary>first ${preview.sampleIncluded.length} included file(s)</summary>
|
|
3427
|
-
<ul>
|
|
3428
|
-
${preview.sampleIncluded.map((p) => html`<li><code>${p}</code></li>`)}
|
|
3429
|
-
</ul>
|
|
3430
|
-
</details>
|
|
3431
|
-
`
|
|
3432
|
-
: null
|
|
3433
|
-
}
|
|
3434
|
-
</div>
|
|
3435
|
-
`;
|
|
3436
|
-
}
|
|
3437
|
-
|
|
3438
|
-
function ExcludesField({ label, value, onChange }) {
|
|
3439
|
-
return html`
|
|
3440
|
-
<div class="excludes-field">
|
|
3441
|
-
<label>${label}</label>
|
|
3442
|
-
<textarea rows="5" value=${value} onChange=${(e) => onChange(e.target.value)}></textarea>
|
|
3443
|
-
</div>
|
|
3444
|
-
`;
|
|
3445
|
-
}
|
|
3446
|
-
|
|
3447
|
-
function toDraft(c) {
|
|
3448
|
-
return {
|
|
3449
|
-
excludeDirs: (c.excludeDirs ?? []).join("\n"),
|
|
3450
|
-
excludeFiles: (c.excludeFiles ?? []).join("\n"),
|
|
3451
|
-
excludeExts: (c.excludeExts ?? []).join("\n"),
|
|
3452
|
-
excludePatterns: (c.excludePatterns ?? []).join("\n"),
|
|
3453
|
-
respectGitignore: c.respectGitignore !== false,
|
|
3454
|
-
maxFileBytes: c.maxFileBytes ?? 262144,
|
|
3455
|
-
};
|
|
3456
|
-
}
|
|
3457
|
-
|
|
3458
|
-
function fromDraft(d) {
|
|
3459
|
-
const lines = (s) =>
|
|
3460
|
-
s
|
|
3461
|
-
.split(/\r?\n/)
|
|
3462
|
-
.map((x) => x.trim())
|
|
3463
|
-
.filter((x) => x.length > 0);
|
|
3464
|
-
return {
|
|
3465
|
-
excludeDirs: lines(d.excludeDirs),
|
|
3466
|
-
excludeFiles: lines(d.excludeFiles),
|
|
3467
|
-
excludeExts: lines(d.excludeExts),
|
|
3468
|
-
excludePatterns: lines(d.excludePatterns),
|
|
3469
|
-
respectGitignore: !!d.respectGitignore,
|
|
3470
|
-
maxFileBytes: d.maxFileBytes,
|
|
3471
|
-
};
|
|
3472
|
-
}
|
|
3473
|
-
|
|
3474
|
-
function SemanticJobView({ job, running }) {
|
|
3475
|
-
const phaseLabel =
|
|
3476
|
-
{
|
|
3477
|
-
scan: "scanning files",
|
|
3478
|
-
embed: "embedding chunks",
|
|
3479
|
-
write: "writing index",
|
|
3480
|
-
done: "done",
|
|
3481
|
-
error: "error",
|
|
3482
|
-
}[job.phase] ?? job.phase;
|
|
3483
|
-
const total = job.chunksTotal ?? 0;
|
|
3484
|
-
const doneN = job.chunksDone ?? 0;
|
|
3485
|
-
const ratio = total > 0 ? Math.min(1, doneN / total) : 0;
|
|
3486
|
-
const elapsed = ((Date.now() - job.startedAt) / 1000).toFixed(1);
|
|
3487
|
-
|
|
3488
|
-
return html`
|
|
3489
|
-
<div class="kv">
|
|
3490
|
-
<div><span class="kv-key">phase</span>
|
|
3491
|
-
<span class=${`pill ${job.phase === "error" ? "pill-err" : running ? "pill-active" : "pill-dim"}`}>${phaseLabel}</span>
|
|
3492
|
-
${job.aborted ? html`<span class="pill pill-warn" style="margin-left: 6px;">stopping</span>` : null}
|
|
3493
|
-
<span class="muted" style="margin-left: 8px;">${elapsed}s</span>
|
|
3494
|
-
</div>
|
|
3495
|
-
${
|
|
3496
|
-
job.filesScanned !== null && job.filesScanned !== undefined
|
|
3497
|
-
? html`<div><span class="kv-key">files</span>scanned ${job.filesScanned}${
|
|
3498
|
-
job.filesChanged != null ? ` · changed ${job.filesChanged}` : ""
|
|
3499
|
-
}${job.filesSkipped ? ` · skipped ${job.filesSkipped}` : ""}</div>`
|
|
3500
|
-
: null
|
|
3501
|
-
}
|
|
3502
|
-
${
|
|
3503
|
-
total > 0
|
|
3504
|
-
? html`
|
|
3505
|
-
<div>
|
|
3506
|
-
<span class="kv-key">chunks</span>${doneN} / ${total} (${(ratio * 100).toFixed(0)}%)
|
|
3507
|
-
</div>
|
|
3508
|
-
<div class="bar" style="margin-top: 4px;">
|
|
3509
|
-
<div class="fill" style=${`width: ${(ratio * 100).toFixed(1)}%; background: var(--primary);`}></div>
|
|
3510
|
-
</div>
|
|
3511
|
-
`
|
|
3512
|
-
: null
|
|
3513
|
-
}
|
|
3514
|
-
${
|
|
3515
|
-
job.error
|
|
3516
|
-
? html`<div><span class="kv-key">error</span><span class="err">${job.error}</span></div>`
|
|
3517
|
-
: null
|
|
3518
|
-
}
|
|
3519
|
-
${
|
|
3520
|
-
job.result
|
|
3521
|
-
? html`<div><span class="kv-key">result</span>added ${job.result.chunksAdded} · removed ${job.result.chunksRemoved}${
|
|
3522
|
-
job.result.chunksSkipped ? ` · failed ${job.result.chunksSkipped}` : ""
|
|
3523
|
-
} · ${(job.result.durationMs / 1000).toFixed(1)}s</div>`
|
|
3524
|
-
: null
|
|
3525
|
-
}
|
|
3526
|
-
${
|
|
3527
|
-
job.result?.skipBuckets
|
|
3528
|
-
? html`<${SkipBucketsView} buckets=${job.result.skipBuckets} />`
|
|
3529
|
-
: null
|
|
3530
|
-
}
|
|
3531
|
-
</div>
|
|
3532
|
-
`;
|
|
3533
|
-
}
|
|
3534
|
-
|
|
3535
|
-
function SkipBucketsView({ buckets }) {
|
|
3536
|
-
const order = [
|
|
3537
|
-
["gitignore", "gitignore"],
|
|
3538
|
-
["pattern", "pattern"],
|
|
3539
|
-
["defaultDir", "defaultDir"],
|
|
3540
|
-
["defaultFile", "defaultFile"],
|
|
3541
|
-
["binaryExt", "binaryExt"],
|
|
3542
|
-
["binaryContent", "binaryContent"],
|
|
3543
|
-
["tooLarge", "tooLarge"],
|
|
3544
|
-
["readError", "readError"],
|
|
3545
|
-
];
|
|
3546
|
-
const total = order.reduce((a, [k]) => a + (buckets[k] || 0), 0);
|
|
3547
|
-
if (total === 0) return null;
|
|
3548
|
-
const parts = order
|
|
3549
|
-
.filter(([k]) => (buckets[k] || 0) > 0)
|
|
3550
|
-
.map(([k, label]) => `${label}: ${buckets[k]}`);
|
|
3551
|
-
return html`<div><span class="kv-key">skipped</span>${total} files <span class="muted">(${parts.join(", ")})</span></div>`;
|
|
3552
|
-
}
|
|
3553
|
-
|
|
3554
|
-
function McpPanel() {
|
|
3555
|
-
const [data, setData] = useState(null);
|
|
3556
|
-
const [specs, setSpecs] = useState(null);
|
|
3557
|
-
const [error, setError] = useState(null);
|
|
3558
|
-
const [info, setInfo] = useState(null);
|
|
3559
|
-
const [newSpec, setNewSpec] = useState("");
|
|
3560
|
-
const [busy, setBusy] = useState(false);
|
|
3561
|
-
const [open, setOpen] = useState(null); // server detail
|
|
3562
|
-
|
|
3563
|
-
const load = useCallback(async () => {
|
|
3564
|
-
try {
|
|
3565
|
-
setData(await api("/mcp"));
|
|
3566
|
-
setSpecs((await api("/mcp/specs")).specs);
|
|
3567
|
-
} catch (err) {
|
|
3568
|
-
setError(err.message);
|
|
3569
|
-
}
|
|
3570
|
-
}, []);
|
|
3571
|
-
useEffect(() => {
|
|
3572
|
-
load();
|
|
3573
|
-
}, [load]);
|
|
3574
|
-
|
|
3575
|
-
const addSpec = useCallback(async () => {
|
|
3576
|
-
if (!newSpec.trim()) return;
|
|
3577
|
-
setBusy(true);
|
|
3578
|
-
try {
|
|
3579
|
-
const r = await api("/mcp/specs", { method: "POST", body: { spec: newSpec.trim() } });
|
|
3580
|
-
setInfo(
|
|
3581
|
-
r.requiresRestart ? "saved — restart `reasonix code` to bridge this server" : "saved",
|
|
3582
|
-
);
|
|
3583
|
-
setTimeout(() => setInfo(null), 4000);
|
|
3584
|
-
setNewSpec("");
|
|
3585
|
-
await load();
|
|
3586
|
-
} catch (err) {
|
|
3587
|
-
setError(err.message);
|
|
3588
|
-
} finally {
|
|
3589
|
-
setBusy(false);
|
|
3590
|
-
}
|
|
3591
|
-
}, [newSpec, load]);
|
|
3592
|
-
|
|
3593
|
-
const removeSpec = useCallback(
|
|
3594
|
-
async (spec) => {
|
|
3595
|
-
if (!confirm(`Remove MCP spec from config?\n\n${spec}`)) return;
|
|
3596
|
-
setBusy(true);
|
|
3597
|
-
try {
|
|
3598
|
-
await api("/mcp/specs", { method: "DELETE", body: { spec } });
|
|
3599
|
-
setInfo("removed — restart to drop the live bridge");
|
|
3600
|
-
setTimeout(() => setInfo(null), 4000);
|
|
3601
|
-
await load();
|
|
3602
|
-
} catch (err) {
|
|
3603
|
-
setError(err.message);
|
|
3604
|
-
} finally {
|
|
3605
|
-
setBusy(false);
|
|
3606
|
-
}
|
|
3607
|
-
},
|
|
3608
|
-
[load],
|
|
3609
|
-
);
|
|
3610
|
-
|
|
3611
|
-
if (!data && !error) return html`<div class="boot">loading MCP…</div>`;
|
|
3612
|
-
if (error && !data) return html`<div class="notice err">${error}</div>`;
|
|
3613
|
-
|
|
3614
|
-
if (open) {
|
|
3615
|
-
return html`
|
|
3616
|
-
<div>
|
|
3617
|
-
<div class="panel-header">
|
|
3618
|
-
<h2 class="panel-title">MCP · ${open.label}</h2>
|
|
3619
|
-
<button onClick=${() => setOpen(null)} style="margin-left: auto;">← back</button>
|
|
3620
|
-
</div>
|
|
3621
|
-
<div class="card">
|
|
3622
|
-
<div class="row"><span class="card-title" style="margin: 0; flex: 0 0 110px;">spec</span><code>${open.spec}</code></div>
|
|
3623
|
-
<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>
|
|
3624
|
-
<div class="row"><span class="card-title" style="margin: 0; flex: 0 0 110px;">protocol</span><code>${open.protocolVersion}</code></div>
|
|
3625
|
-
</div>
|
|
3626
|
-
${open.instructions ? html`<div class="notice">${open.instructions}</div>` : null}
|
|
3627
|
-
<div class="section-title">Tools (${open.tools.length})</div>
|
|
3628
|
-
<table>
|
|
3629
|
-
<thead><tr><th>name</th><th>description</th></tr></thead>
|
|
3630
|
-
<tbody>
|
|
3631
|
-
${open.tools.map((t) => html`<tr><td><code>${t.name}</code></td><td>${t.description ?? ""}</td></tr>`)}
|
|
3632
|
-
</tbody>
|
|
3633
|
-
</table>
|
|
3634
|
-
${
|
|
3635
|
-
open.resources.length > 0
|
|
3636
|
-
? html`
|
|
3637
|
-
<div class="section-title">Resources (${open.resources.length})</div>
|
|
3638
|
-
<table>
|
|
3639
|
-
<thead><tr><th>name</th><th>uri</th></tr></thead>
|
|
3640
|
-
<tbody>
|
|
3641
|
-
${open.resources.map((r) => html`<tr><td>${r.name}</td><td><code>${r.uri}</code></td></tr>`)}
|
|
3642
|
-
</tbody>
|
|
3643
|
-
</table>
|
|
3644
|
-
`
|
|
3645
|
-
: null
|
|
3646
|
-
}
|
|
3647
|
-
${
|
|
3648
|
-
open.prompts.length > 0
|
|
3649
|
-
? html`
|
|
3650
|
-
<div class="section-title">Prompts (${open.prompts.length})</div>
|
|
3651
|
-
<table>
|
|
3652
|
-
<thead><tr><th>name</th><th>description</th></tr></thead>
|
|
3653
|
-
<tbody>
|
|
3654
|
-
${open.prompts.map((p) => html`<tr><td><code>${p.name}</code></td><td>${p.description ?? ""}</td></tr>`)}
|
|
3655
|
-
</tbody>
|
|
3656
|
-
</table>
|
|
3657
|
-
`
|
|
3658
|
-
: null
|
|
3659
|
-
}
|
|
3660
|
-
</div>
|
|
3661
|
-
`;
|
|
3662
|
-
}
|
|
3663
|
-
|
|
3664
|
-
return html`
|
|
3665
|
-
<div>
|
|
3666
|
-
<div class="panel-header">
|
|
3667
|
-
<h2 class="panel-title">MCP</h2>
|
|
3668
|
-
<span class="panel-subtitle">${data.servers.length} bridged · ${specs?.length ?? 0} in config</span>
|
|
3669
|
-
</div>
|
|
3670
|
-
${info ? html`<div class="notice">${info}</div>` : null}
|
|
3671
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
3672
|
-
|
|
3673
|
-
<div class="section-title">Add server</div>
|
|
3674
|
-
<div class="card row">
|
|
3675
|
-
<input
|
|
3676
|
-
type="text"
|
|
3677
|
-
placeholder='spec — e.g. "fs=npx -y @modelcontextprotocol/server-filesystem /tmp/safe"'
|
|
3678
|
-
value=${newSpec}
|
|
3679
|
-
onInput=${(e) => setNewSpec(e.target.value)}
|
|
3680
|
-
/>
|
|
3681
|
-
<button class="primary" disabled=${busy || !newSpec.trim()} onClick=${addSpec}>Add</button>
|
|
3682
|
-
</div>
|
|
3683
|
-
|
|
3684
|
-
<div class="section-title">Bridged (${data.servers.length})</div>
|
|
3685
|
-
${
|
|
3686
|
-
data.servers.length === 0
|
|
3687
|
-
? html`<div class="empty">No MCP servers in this session.</div>`
|
|
3688
|
-
: html`
|
|
3689
|
-
<table>
|
|
3690
|
-
<thead><tr><th>label</th><th>spec</th><th class="numeric">tools</th><th></th></tr></thead>
|
|
3691
|
-
<tbody>
|
|
3692
|
-
${data.servers.map(
|
|
3693
|
-
(s) => html`
|
|
3694
|
-
<tr key=${s.label} style="cursor: pointer;" onClick=${() => setOpen(s)}>
|
|
3695
|
-
<td><code>${s.label}</code></td>
|
|
3696
|
-
<td><code style="font-size: 11px;">${s.spec}</code></td>
|
|
3697
|
-
<td class="numeric">${fmtNum(s.toolCount)}</td>
|
|
3698
|
-
<td></td>
|
|
3699
|
-
</tr>
|
|
3700
|
-
`,
|
|
3701
|
-
)}
|
|
3702
|
-
</tbody>
|
|
3703
|
-
</table>
|
|
3704
|
-
`
|
|
3705
|
-
}
|
|
3706
|
-
|
|
3707
|
-
<div class="section-title">Persisted specs (config.json)</div>
|
|
3708
|
-
${
|
|
3709
|
-
(specs ?? []).length === 0
|
|
3710
|
-
? html`<div class="empty">No MCP specs persisted in <code>~/.reasonix/config.json</code>.</div>`
|
|
3711
|
-
: html`
|
|
3712
|
-
<table>
|
|
3713
|
-
<thead><tr><th>spec</th><th></th></tr></thead>
|
|
3714
|
-
<tbody>
|
|
3715
|
-
${specs.map(
|
|
3716
|
-
(spec) => html`
|
|
3717
|
-
<tr key=${spec}>
|
|
3718
|
-
<td><code>${spec}</code></td>
|
|
3719
|
-
<td class="numeric">
|
|
3720
|
-
<button class="danger" disabled=${busy} onClick=${(e) => {
|
|
3721
|
-
e.stopPropagation();
|
|
3722
|
-
removeSpec(spec);
|
|
3723
|
-
}}>remove</button>
|
|
3724
|
-
</td>
|
|
3725
|
-
</tr>
|
|
3726
|
-
`,
|
|
3727
|
-
)}
|
|
3728
|
-
</tbody>
|
|
3729
|
-
</table>
|
|
3730
|
-
`
|
|
3731
|
-
}
|
|
3732
|
-
</div>
|
|
3733
|
-
`;
|
|
3734
|
-
}
|
|
3735
|
-
|
|
3736
|
-
// ---------- Editor (CodeMirror 6, multi-tab) ----------
|
|
3737
|
-
|
|
3738
|
-
// Lazy-loaded CodeMirror modules — kept off the initial bundle so users
|
|
3739
|
-
// who never open Editor never pay the ~200KB cost. Cached after first
|
|
3740
|
-
// resolve so tab switches don't re-fetch.
|
|
3741
|
-
//
|
|
3742
|
-
// CodeMirror loads from a locally bundled file (`/assets/codemirror.js`,
|
|
3743
|
-
// produced by `scripts/bundle-codemirror.mjs`). One bundle = one copy
|
|
3744
|
-
// of every package = no Tag identity mismatch between oneDark and the
|
|
3745
|
-
// language parsers, no esm.sh round-trips on every cold load. The
|
|
3746
|
-
// previous esm.sh + ?deps= setup hit silent failure modes whenever the
|
|
3747
|
-
// CDN resolved a transitive @lezer/* to a different version than the
|
|
3748
|
-
// bundled cache thought it would.
|
|
3749
|
-
let cmModulesPromise = null;
|
|
3750
|
-
async function loadCodeMirror() {
|
|
3751
|
-
if (cmModulesPromise) return cmModulesPromise;
|
|
3752
|
-
cmModulesPromise = import(`/assets/codemirror.js?token=${TOKEN}`);
|
|
3753
|
-
return cmModulesPromise;
|
|
3754
|
-
}
|
|
3755
|
-
|
|
3756
|
-
// Map file path → CodeMirror language extension factory.
|
|
3757
|
-
function langExtensionFor(path, langs) {
|
|
3758
|
-
const lang = langFromPath(path);
|
|
3759
|
-
if (!lang) return null;
|
|
3760
|
-
// CodeMirror's javascript pack handles ts/tsx/jsx via options.
|
|
3761
|
-
if (lang === "typescript") return langs.typescript ? langs.typescript() : null;
|
|
3762
|
-
if (lang === "javascript") return langs.javascript ? langs.javascript({ jsx: true }) : null;
|
|
3763
|
-
const fn = langs[lang];
|
|
3764
|
-
return fn ? fn() : null;
|
|
3765
|
-
}
|
|
3766
|
-
|
|
3767
|
-
// Build a nested folder tree from a flat list of repo paths. Nodes use
|
|
3768
|
-
// Maps so insertion order is stable; sorting happens at render time.
|
|
3769
|
-
function buildFileTree(paths) {
|
|
3770
|
-
const root = { name: "", path: "", children: new Map(), isFile: false };
|
|
3771
|
-
for (const p of paths) {
|
|
3772
|
-
const parts = p.split("/").filter(Boolean);
|
|
3773
|
-
let node = root;
|
|
3774
|
-
for (let i = 0; i < parts.length; i++) {
|
|
3775
|
-
const isLast = i === parts.length - 1;
|
|
3776
|
-
const name = parts[i];
|
|
3777
|
-
const childPath = parts.slice(0, i + 1).join("/");
|
|
3778
|
-
let child = node.children.get(name);
|
|
3779
|
-
if (!child) {
|
|
3780
|
-
child = { name, path: childPath, children: new Map(), isFile: isLast };
|
|
3781
|
-
node.children.set(name, child);
|
|
3782
|
-
} else if (isLast && child.children.size === 0) {
|
|
3783
|
-
child.isFile = true;
|
|
3784
|
-
}
|
|
3785
|
-
node = child;
|
|
3786
|
-
}
|
|
3787
|
-
}
|
|
3788
|
-
return root;
|
|
3789
|
-
}
|
|
3790
|
-
|
|
3791
|
-
// Walk the tree honoring the expanded set; produce a flat row list the
|
|
3792
|
-
// renderer can map straight to JSX. Folders precede files; both sorted
|
|
3793
|
-
// case-insensitively.
|
|
3794
|
-
function flattenTree(node, expanded, depth, out) {
|
|
3795
|
-
const children = [...node.children.values()].sort((a, b) => {
|
|
3796
|
-
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
|
3797
|
-
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
|
3798
|
-
});
|
|
3799
|
-
for (const child of children) {
|
|
3800
|
-
out.push({ name: child.name, path: child.path, depth, isFile: child.isFile });
|
|
3801
|
-
if (!child.isFile && expanded.has(child.path)) {
|
|
3802
|
-
flattenTree(child, expanded, depth + 1, out);
|
|
3803
|
-
}
|
|
3804
|
-
}
|
|
3805
|
-
return out;
|
|
3806
|
-
}
|
|
3807
|
-
|
|
3808
|
-
function EditorPanel({ onClose } = {}) {
|
|
3809
|
-
// tabs: { path, content, original, dirty, savedAt }
|
|
3810
|
-
const [tabs, setTabs] = useState([]);
|
|
3811
|
-
const [activeIdx, setActiveIdx] = useState(0);
|
|
3812
|
-
const [files, setFiles] = useState([]);
|
|
3813
|
-
const [filesError, setFilesError] = useState(null);
|
|
3814
|
-
const [openInput, setOpenInput] = useState("");
|
|
3815
|
-
const [filter, setFilter] = useState("");
|
|
3816
|
-
const [error, setError] = useState(null);
|
|
3817
|
-
const [busy, setBusy] = useState(false);
|
|
3818
|
-
const [cmReady, setCmReady] = useState(false);
|
|
3819
|
-
const [sideCollapsed, setSideCollapsed] = useState(false);
|
|
3820
|
-
const [expanded, setExpanded] = useState(() => new Set());
|
|
3821
|
-
// View mode for markdown tabs: "edit" (source only), "split" (source +
|
|
3822
|
-
// preview side-by-side), "preview" (rendered only). Non-md tabs always
|
|
3823
|
-
// render in edit mode regardless of this state.
|
|
3824
|
-
const [viewMode, setViewMode] = useState("edit");
|
|
3825
|
-
const editorContainerRef = useRef(null);
|
|
3826
|
-
const viewRef = useRef(null);
|
|
3827
|
-
const cmRef = useRef(null);
|
|
3828
|
-
const tabsRef = useRef(tabs);
|
|
3829
|
-
const activeIdxRef = useRef(activeIdx);
|
|
3830
|
-
useEffect(() => {
|
|
3831
|
-
tabsRef.current = tabs;
|
|
3832
|
-
}, [tabs]);
|
|
3833
|
-
useEffect(() => {
|
|
3834
|
-
activeIdxRef.current = activeIdx;
|
|
3835
|
-
}, [activeIdx]);
|
|
3836
|
-
|
|
3837
|
-
// Load file list (gitignore-aware) for the picker.
|
|
3838
|
-
const loadFiles = useCallback(async () => {
|
|
3839
|
-
try {
|
|
3840
|
-
const r = await api("/files");
|
|
3841
|
-
setFiles(r.files ?? []);
|
|
3842
|
-
} catch (err) {
|
|
3843
|
-
setFilesError(err.message);
|
|
3844
|
-
}
|
|
3845
|
-
}, []);
|
|
3846
|
-
useEffect(() => {
|
|
3847
|
-
loadFiles();
|
|
3848
|
-
}, [loadFiles]);
|
|
3849
|
-
|
|
3850
|
-
// Open a file → fetch + push tab + activate. If already open, just
|
|
3851
|
-
// switch to the existing tab so we don't lose unsaved edits.
|
|
3852
|
-
const openPath = useCallback(async (path) => {
|
|
3853
|
-
if (!path) return;
|
|
3854
|
-
const existing = tabsRef.current.findIndex((t) => t.path === path);
|
|
3855
|
-
if (existing >= 0) {
|
|
3856
|
-
setActiveIdx(existing);
|
|
3857
|
-
return;
|
|
3858
|
-
}
|
|
3859
|
-
setBusy(true);
|
|
3860
|
-
setError(null);
|
|
3861
|
-
try {
|
|
3862
|
-
const r = await api(`/file/${path.split("/").map(encodeURIComponent).join("/")}`);
|
|
3863
|
-
setTabs((prev) => [
|
|
3864
|
-
...prev,
|
|
3865
|
-
{ path, content: r.content, original: r.content, dirty: false, savedAt: r.mtime },
|
|
3866
|
-
]);
|
|
3867
|
-
setActiveIdx(tabsRef.current.length);
|
|
3868
|
-
} catch (err) {
|
|
3869
|
-
setError(`open ${path}: ${err.message}`);
|
|
3870
|
-
} finally {
|
|
3871
|
-
setBusy(false);
|
|
3872
|
-
}
|
|
3873
|
-
}, []);
|
|
3874
|
-
|
|
3875
|
-
// Subscribe to "open-file" events fired from elsewhere (Chat panel
|
|
3876
|
-
// tool cards, file-mention links).
|
|
3877
|
-
useEffect(() => {
|
|
3878
|
-
const onOpen = (ev) => openPath(ev.detail.path);
|
|
3879
|
-
appBus.addEventListener("open-file", onOpen);
|
|
3880
|
-
return () => appBus.removeEventListener("open-file", onOpen);
|
|
3881
|
-
}, [openPath]);
|
|
3882
|
-
|
|
3883
|
-
// Mount CodeMirror lazily on first render.
|
|
3884
|
-
useEffect(() => {
|
|
3885
|
-
let cancelled = false;
|
|
3886
|
-
loadCodeMirror().then((cm) => {
|
|
3887
|
-
if (!cancelled) {
|
|
3888
|
-
cmRef.current = cm;
|
|
3889
|
-
setCmReady(true);
|
|
3890
|
-
}
|
|
3891
|
-
});
|
|
3892
|
-
return () => {
|
|
3893
|
-
cancelled = true;
|
|
3894
|
-
};
|
|
3895
|
-
}, []);
|
|
3896
|
-
|
|
3897
|
-
// Re-mount the editor view when active tab changes. Each tab's
|
|
3898
|
-
// content is held in React state — the view is just a presentation
|
|
3899
|
-
// layer over the current tab's string.
|
|
3900
|
-
useEffect(() => {
|
|
3901
|
-
if (!cmReady || !editorContainerRef.current) return;
|
|
3902
|
-
const cm = cmRef.current;
|
|
3903
|
-
if (!cm) return;
|
|
3904
|
-
const tab = tabs[activeIdx];
|
|
3905
|
-
if (!tab) {
|
|
3906
|
-
if (viewRef.current) {
|
|
3907
|
-
viewRef.current.destroy();
|
|
3908
|
-
viewRef.current = null;
|
|
3909
|
-
}
|
|
3910
|
-
return;
|
|
3911
|
-
}
|
|
3912
|
-
|
|
3913
|
-
if (viewRef.current) {
|
|
3914
|
-
viewRef.current.destroy();
|
|
3915
|
-
viewRef.current = null;
|
|
3916
|
-
}
|
|
3917
|
-
|
|
3918
|
-
const langExt = langExtensionFor(tab.path, cm.langs);
|
|
3919
|
-
const updateListener = cm.EditorView.updateListener.of((update) => {
|
|
3920
|
-
if (update.docChanged) {
|
|
3921
|
-
const text = update.state.doc.toString();
|
|
3922
|
-
// Mutate the tab's content + dirty flag without forcing a
|
|
3923
|
-
// full re-render of the editor (which would lose the cursor).
|
|
3924
|
-
// We DO setTabs so the tab bar's dirty dot updates.
|
|
3925
|
-
const idx = activeIdxRef.current;
|
|
3926
|
-
const live = tabsRef.current;
|
|
3927
|
-
if (live[idx]) {
|
|
3928
|
-
const next = [...live];
|
|
3929
|
-
next[idx] = { ...next[idx], content: text, dirty: text !== next[idx].original };
|
|
3930
|
-
tabsRef.current = next;
|
|
3931
|
-
setTabs(next);
|
|
3932
|
-
}
|
|
3933
|
-
}
|
|
3934
|
-
});
|
|
3935
|
-
|
|
3936
|
-
const extensions = [
|
|
3937
|
-
cm.lineNumbers(),
|
|
3938
|
-
cm.highlightActiveLineGutter ? cm.highlightActiveLineGutter() : [],
|
|
3939
|
-
cm.foldGutter ? cm.foldGutter() : [],
|
|
3940
|
-
cm.highlightActiveLine(),
|
|
3941
|
-
cm.drawSelection(),
|
|
3942
|
-
cm.history(),
|
|
3943
|
-
cm.bracketMatching(),
|
|
3944
|
-
cm.indentOnInput(),
|
|
3945
|
-
cm.closeBrackets ? cm.closeBrackets() : [],
|
|
3946
|
-
cm.autocompletion
|
|
3947
|
-
? cm.autocompletion({
|
|
3948
|
-
activateOnTyping: true,
|
|
3949
|
-
closeOnBlur: true,
|
|
3950
|
-
maxRenderedOptions: 30,
|
|
3951
|
-
})
|
|
3952
|
-
: [],
|
|
3953
|
-
cm.highlightSelectionMatches ? cm.highlightSelectionMatches() : [],
|
|
3954
|
-
cm.keymap.of([
|
|
3955
|
-
...cm.defaultKeymap,
|
|
3956
|
-
...cm.historyKeymap,
|
|
3957
|
-
...(cm.closeBracketsKeymap ?? []),
|
|
3958
|
-
...(cm.searchKeymap ?? []),
|
|
3959
|
-
...(cm.completionKeymap ?? []),
|
|
3960
|
-
...(cm.foldKeymap ?? []),
|
|
3961
|
-
cm.indentWithTab,
|
|
3962
|
-
]),
|
|
3963
|
-
// oneDark is an array of [theme, syntaxHighlighting(oneDarkHighlightStyle)] —
|
|
3964
|
-
// including it gives both the dark UI and the highlight tags. Keep
|
|
3965
|
-
// defaultHighlightStyle as a fallback only for languages oneDark omits.
|
|
3966
|
-
cm.oneDark,
|
|
3967
|
-
cm.syntaxHighlighting(cm.defaultHighlightStyle, { fallback: true }),
|
|
3968
|
-
cm.EditorView.lineWrapping,
|
|
3969
|
-
updateListener,
|
|
3970
|
-
];
|
|
3971
|
-
if (langExt) extensions.push(langExt);
|
|
3972
|
-
|
|
3973
|
-
const state = cm.EditorState.create({ doc: tab.content, extensions });
|
|
3974
|
-
viewRef.current = new cm.EditorView({ state, parent: editorContainerRef.current });
|
|
3975
|
-
|
|
3976
|
-
return () => {
|
|
3977
|
-
if (viewRef.current) {
|
|
3978
|
-
viewRef.current.destroy();
|
|
3979
|
-
viewRef.current = null;
|
|
3980
|
-
}
|
|
3981
|
-
};
|
|
3982
|
-
}, [cmReady, activeIdx, tabs[activeIdx]?.path]);
|
|
3983
|
-
|
|
3984
|
-
// Becoming visible after display:none — CM6 measures lazily, force it.
|
|
3985
|
-
useEffect(() => {
|
|
3986
|
-
if (viewMode === "preview") return;
|
|
3987
|
-
viewRef.current?.requestMeasure?.();
|
|
3988
|
-
}, [viewMode]);
|
|
3989
|
-
|
|
3990
|
-
const closeTab = useCallback((idx) => {
|
|
3991
|
-
const tab = tabsRef.current[idx];
|
|
3992
|
-
if (tab?.dirty && !confirm(`${tab.path} has unsaved changes. Discard?`)) return;
|
|
3993
|
-
setTabs((prev) => prev.filter((_, i) => i !== idx));
|
|
3994
|
-
if (activeIdxRef.current >= idx) {
|
|
3995
|
-
setActiveIdx(Math.max(0, activeIdxRef.current - 1));
|
|
3996
|
-
}
|
|
3997
|
-
}, []);
|
|
3998
|
-
|
|
3999
|
-
const saveTab = useCallback(async (idx) => {
|
|
4000
|
-
const tab = tabsRef.current[idx];
|
|
4001
|
-
if (!tab) return;
|
|
4002
|
-
setBusy(true);
|
|
4003
|
-
setError(null);
|
|
4004
|
-
try {
|
|
4005
|
-
const r = await api(`/file/${tab.path.split("/").map(encodeURIComponent).join("/")}`, {
|
|
4006
|
-
method: "POST",
|
|
4007
|
-
body: { content: tab.content },
|
|
4008
|
-
});
|
|
4009
|
-
setTabs((prev) => {
|
|
4010
|
-
const next = [...prev];
|
|
4011
|
-
if (next[idx]) {
|
|
4012
|
-
next[idx] = { ...next[idx], original: tab.content, dirty: false, savedAt: r.mtime };
|
|
4013
|
-
}
|
|
4014
|
-
return next;
|
|
4015
|
-
});
|
|
4016
|
-
showToast(`saved ${tab.path}`, "info");
|
|
4017
|
-
} catch (err) {
|
|
4018
|
-
setError(`save ${tab.path}: ${err.message}`);
|
|
4019
|
-
} finally {
|
|
4020
|
-
setBusy(false);
|
|
4021
|
-
}
|
|
4022
|
-
}, []);
|
|
4023
|
-
|
|
4024
|
-
// Cmd/Ctrl+S — save active tab.
|
|
4025
|
-
useEffect(() => {
|
|
4026
|
-
const onKey = (e) => {
|
|
4027
|
-
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
|
4028
|
-
e.preventDefault();
|
|
4029
|
-
if (tabsRef.current[activeIdxRef.current]) {
|
|
4030
|
-
saveTab(activeIdxRef.current);
|
|
4031
|
-
}
|
|
4032
|
-
}
|
|
4033
|
-
};
|
|
4034
|
-
window.addEventListener("keydown", onKey);
|
|
4035
|
-
return () => window.removeEventListener("keydown", onKey);
|
|
4036
|
-
}, [saveTab]);
|
|
4037
|
-
|
|
4038
|
-
const tab = tabs[activeIdx];
|
|
4039
|
-
|
|
4040
|
-
const tree = useMemo(() => buildFileTree(files), [files]);
|
|
4041
|
-
const treeRows = useMemo(() => flattenTree(tree, expanded, 0, []), [tree, expanded]);
|
|
4042
|
-
|
|
4043
|
-
const toggleFolder = useCallback((path) => {
|
|
4044
|
-
setExpanded((prev) => {
|
|
4045
|
-
const next = new Set(prev);
|
|
4046
|
-
if (next.has(path)) next.delete(path);
|
|
4047
|
-
else next.add(path);
|
|
4048
|
-
return next;
|
|
4049
|
-
});
|
|
4050
|
-
}, []);
|
|
4051
|
-
|
|
4052
|
-
const filtering = filter.trim().length > 0;
|
|
4053
|
-
const filteredFiles = filtering
|
|
4054
|
-
? files.filter((f) => f.toLowerCase().includes(filter.toLowerCase())).slice(0, 80)
|
|
4055
|
-
: null;
|
|
4056
|
-
|
|
4057
|
-
const openPaths = tabs.map((t) => t.path);
|
|
4058
|
-
|
|
4059
|
-
return html`
|
|
4060
|
-
<div class="editor-shell">
|
|
4061
|
-
${
|
|
4062
|
-
onClose
|
|
4063
|
-
? html`
|
|
4064
|
-
<div class="editor-drawer-head">
|
|
4065
|
-
<span class="editor-drawer-title">Editor</span>
|
|
4066
|
-
<button class="editor-drawer-close" onClick=${onClose} title="close editor (Esc)">×</button>
|
|
4067
|
-
</div>
|
|
4068
|
-
`
|
|
4069
|
-
: null
|
|
4070
|
-
}
|
|
4071
|
-
<div class="editor-tabs">
|
|
4072
|
-
${
|
|
4073
|
-
tabs.length === 0
|
|
4074
|
-
? html`<div class="editor-no-tabs">No files open. Pick from the list, paste a path above, or click a path in chat.</div>`
|
|
4075
|
-
: tabs.map(
|
|
4076
|
-
(t, i) => html`
|
|
4077
|
-
<div
|
|
4078
|
-
key=${t.path}
|
|
4079
|
-
class="editor-tab ${i === activeIdx ? "active" : ""}"
|
|
4080
|
-
onClick=${() => setActiveIdx(i)}
|
|
4081
|
-
>
|
|
4082
|
-
<span class="editor-tab-name" title=${t.path}>${t.path.split("/").pop()}</span>
|
|
4083
|
-
${t.dirty ? html`<span class="editor-tab-dirty">●</span>` : null}
|
|
4084
|
-
<span class="editor-tab-close" onClick=${(e) => {
|
|
4085
|
-
e.stopPropagation();
|
|
4086
|
-
closeTab(i);
|
|
4087
|
-
}}>×</span>
|
|
4088
|
-
</div>
|
|
4089
|
-
`,
|
|
4090
|
-
)
|
|
4091
|
-
}
|
|
4092
|
-
</div>
|
|
4093
|
-
<div class="editor-body">
|
|
4094
|
-
${
|
|
4095
|
-
sideCollapsed
|
|
4096
|
-
? html`
|
|
4097
|
-
<div class="editor-side collapsed">
|
|
4098
|
-
<button
|
|
4099
|
-
class="editor-side-toggle"
|
|
4100
|
-
onClick=${() => setSideCollapsed(false)}
|
|
4101
|
-
title="show files"
|
|
4102
|
-
>▶</button>
|
|
4103
|
-
</div>
|
|
4104
|
-
`
|
|
4105
|
-
: html`
|
|
4106
|
-
<div class="editor-side">
|
|
4107
|
-
<div class="editor-side-head">
|
|
4108
|
-
<span class="editor-side-label">FILES</span>
|
|
4109
|
-
<button
|
|
4110
|
-
class="editor-side-toggle"
|
|
4111
|
-
onClick=${() => setSideCollapsed(true)}
|
|
4112
|
-
title="hide files"
|
|
4113
|
-
>◀</button>
|
|
4114
|
-
</div>
|
|
4115
|
-
<div class="row" style="margin-bottom: 8px;">
|
|
4116
|
-
<input
|
|
4117
|
-
type="text"
|
|
4118
|
-
placeholder="open by path…"
|
|
4119
|
-
value=${openInput}
|
|
4120
|
-
onInput=${(e) => setOpenInput(e.target.value)}
|
|
4121
|
-
onKeyDown=${(e) => {
|
|
4122
|
-
if (e.key === "Enter" && openInput.trim()) {
|
|
4123
|
-
openPath(openInput.trim());
|
|
4124
|
-
setOpenInput("");
|
|
4125
|
-
}
|
|
4126
|
-
}}
|
|
4127
|
-
/>
|
|
4128
|
-
</div>
|
|
4129
|
-
<input
|
|
4130
|
-
type="search"
|
|
4131
|
-
placeholder=${`filter ${files.length} files…`}
|
|
4132
|
-
value=${filter}
|
|
4133
|
-
onInput=${(e) => setFilter(e.target.value)}
|
|
4134
|
-
style="margin-bottom: 8px;"
|
|
4135
|
-
/>
|
|
4136
|
-
${
|
|
4137
|
-
filesError
|
|
4138
|
-
? html`<div class="notice err">${filesError}</div>`
|
|
4139
|
-
: filtering
|
|
4140
|
-
? html`
|
|
4141
|
-
<div class="editor-files">
|
|
4142
|
-
${filteredFiles.map(
|
|
4143
|
-
(f) => html`
|
|
4144
|
-
<div
|
|
4145
|
-
key=${f}
|
|
4146
|
-
class="editor-file ${openPaths.includes(f) ? "open" : ""}"
|
|
4147
|
-
onClick=${() => openPath(f)}
|
|
4148
|
-
title=${f}
|
|
4149
|
-
>${f}</div>
|
|
4150
|
-
`,
|
|
4151
|
-
)}
|
|
4152
|
-
${files.length > 80 ? html`<div class="muted" style="padding: 8px; font-size: 11px;">narrow filter to see more</div>` : null}
|
|
4153
|
-
</div>
|
|
4154
|
-
`
|
|
4155
|
-
: html`
|
|
4156
|
-
<div class="editor-files">
|
|
4157
|
-
${treeRows.map((row) =>
|
|
4158
|
-
row.isFile
|
|
4159
|
-
? html`
|
|
4160
|
-
<div
|
|
4161
|
-
key=${row.path}
|
|
4162
|
-
class="editor-tree-file ${openPaths.includes(row.path) ? "open" : ""}"
|
|
4163
|
-
style=${`padding-left: ${row.depth * 12 + 22}px`}
|
|
4164
|
-
onClick=${() => openPath(row.path)}
|
|
4165
|
-
title=${row.path}
|
|
4166
|
-
>${row.name}</div>
|
|
4167
|
-
`
|
|
4168
|
-
: html`
|
|
4169
|
-
<div
|
|
4170
|
-
key=${row.path}
|
|
4171
|
-
class="editor-tree-folder"
|
|
4172
|
-
style=${`padding-left: ${row.depth * 12 + 4}px`}
|
|
4173
|
-
onClick=${() => toggleFolder(row.path)}
|
|
4174
|
-
>
|
|
4175
|
-
<span class="editor-tree-caret">${expanded.has(row.path) ? "▼" : "▶"}</span>
|
|
4176
|
-
<span class="editor-tree-name">${row.name}</span>
|
|
4177
|
-
</div>
|
|
4178
|
-
`,
|
|
4179
|
-
)}
|
|
4180
|
-
${files.length === 0 ? html`<div class="muted" style="padding: 8px; font-size: 11px;">no files</div>` : null}
|
|
4181
|
-
</div>
|
|
4182
|
-
`
|
|
4183
|
-
}
|
|
4184
|
-
</div>
|
|
4185
|
-
`
|
|
4186
|
-
}
|
|
4187
|
-
|
|
4188
|
-
<div class="editor-main">
|
|
4189
|
-
${
|
|
4190
|
-
tab
|
|
4191
|
-
? html`
|
|
4192
|
-
<div class="editor-bar">
|
|
4193
|
-
<code style="font-size: 12px;">${tab.path}</code>
|
|
4194
|
-
<span class="muted" style="font-size: 12px;">${langFromPath(tab.path) ?? "plaintext"}</span>
|
|
4195
|
-
${
|
|
4196
|
-
langFromPath(tab.path) === "markdown"
|
|
4197
|
-
? html`
|
|
4198
|
-
<div class="view-mode-group" style="margin-left: auto;">
|
|
4199
|
-
<button
|
|
4200
|
-
class=${`view-mode ${viewMode === "edit" ? "active" : ""}`}
|
|
4201
|
-
onClick=${() => setViewMode("edit")}
|
|
4202
|
-
title="source only"
|
|
4203
|
-
>Edit</button>
|
|
4204
|
-
<button
|
|
4205
|
-
class=${`view-mode ${viewMode === "split" ? "active" : ""}`}
|
|
4206
|
-
onClick=${() => setViewMode("split")}
|
|
4207
|
-
title="source + preview side-by-side"
|
|
4208
|
-
>Split</button>
|
|
4209
|
-
<button
|
|
4210
|
-
class=${`view-mode ${viewMode === "preview" ? "active" : ""}`}
|
|
4211
|
-
onClick=${() => setViewMode("preview")}
|
|
4212
|
-
title="rendered only"
|
|
4213
|
-
>Preview</button>
|
|
4214
|
-
</div>
|
|
4215
|
-
`
|
|
4216
|
-
: null
|
|
4217
|
-
}
|
|
4218
|
-
<button
|
|
4219
|
-
class="primary"
|
|
4220
|
-
style=${langFromPath(tab.path) === "markdown" ? "" : "margin-left: auto;"}
|
|
4221
|
-
onClick=${() => saveTab(activeIdx)}
|
|
4222
|
-
disabled=${busy || !tab.dirty}
|
|
4223
|
-
>${tab.dirty ? "Save (⌘S)" : "Saved"}</button>
|
|
4224
|
-
</div>
|
|
4225
|
-
${error ? html`<div class="notice err">${error}</div>` : null}
|
|
4226
|
-
${(() => {
|
|
4227
|
-
const isMd = langFromPath(tab.path) === "markdown";
|
|
4228
|
-
const mode = isMd ? viewMode : "edit";
|
|
4229
|
-
// Stable DOM across mode toggles — CM-managed children + Preact reconciliation don't mix when the host moves.
|
|
4230
|
-
return html`
|
|
4231
|
-
<div class="editor-stage" data-mode=${mode}>
|
|
4232
|
-
<div ref=${editorContainerRef} class="editor-host"></div>
|
|
4233
|
-
${
|
|
4234
|
-
isMd
|
|
4235
|
-
? html`<div
|
|
4236
|
-
class="editor-md-preview md"
|
|
4237
|
-
dangerouslySetInnerHTML=${{
|
|
4238
|
-
__html: previewMarked.parse(tab.content ?? ""),
|
|
4239
|
-
}}
|
|
4240
|
-
></div>`
|
|
4241
|
-
: null
|
|
4242
|
-
}
|
|
4243
|
-
</div>
|
|
4244
|
-
`;
|
|
4245
|
-
})()}
|
|
4246
|
-
`
|
|
4247
|
-
: html`
|
|
4248
|
-
<div class="editor-empty">
|
|
4249
|
-
${
|
|
4250
|
-
cmReady
|
|
4251
|
-
? html`<div>Open a file to start editing.</div>`
|
|
4252
|
-
: html`<div>Loading editor (~200KB CodeMirror)…</div>`
|
|
4253
|
-
}
|
|
4254
|
-
</div>
|
|
4255
|
-
`
|
|
4256
|
-
}
|
|
4257
|
-
</div>
|
|
4258
|
-
</div>
|
|
4259
|
-
</div>
|
|
4260
|
-
`;
|
|
4261
|
-
}
|
|
4262
|
-
|
|
4263
|
-
function ComingSoonPanel({ name, milestone }) {
|
|
4264
|
-
return html`
|
|
4265
|
-
<div>
|
|
4266
|
-
<div class="panel-header">
|
|
4267
|
-
<h2 class="panel-title">${name}</h2>
|
|
4268
|
-
<span class="panel-subtitle">coming in ${milestone}</span>
|
|
4269
|
-
</div>
|
|
4270
|
-
<div class="empty">This panel lands in ${milestone} (see CHANGELOG).</div>
|
|
4271
|
-
</div>
|
|
4272
|
-
`;
|
|
4273
|
-
}
|
|
4274
|
-
|
|
4275
|
-
// ---------- shell ----------
|
|
4276
|
-
|
|
4277
|
-
const TABS = [
|
|
4278
|
-
{
|
|
4279
|
-
id: "chat",
|
|
4280
|
-
name: "Chat",
|
|
4281
|
-
glyph: "◆",
|
|
4282
|
-
panel: () => html`<${ChatPanel} />`,
|
|
4283
|
-
ready: true,
|
|
4284
|
-
badge: null,
|
|
4285
|
-
},
|
|
4286
|
-
{
|
|
4287
|
-
id: "editor",
|
|
4288
|
-
name: "Editor",
|
|
4289
|
-
glyph: "✎",
|
|
4290
|
-
panel: () => html`<${EditorPanel} />`,
|
|
4291
|
-
ready: true,
|
|
4292
|
-
badge: null,
|
|
4293
|
-
},
|
|
4294
|
-
{
|
|
4295
|
-
id: "overview",
|
|
4296
|
-
name: "Overview",
|
|
4297
|
-
glyph: "◈",
|
|
4298
|
-
panel: () => html`<${OverviewPanel} />`,
|
|
4299
|
-
ready: true,
|
|
4300
|
-
badge: null,
|
|
4301
|
-
},
|
|
4302
|
-
{
|
|
4303
|
-
id: "usage",
|
|
4304
|
-
name: "Usage",
|
|
4305
|
-
glyph: "$",
|
|
4306
|
-
panel: () => html`<${UsageWithChart} />`,
|
|
4307
|
-
ready: true,
|
|
4308
|
-
badge: null,
|
|
4309
|
-
},
|
|
4310
|
-
{
|
|
4311
|
-
id: "sessions",
|
|
4312
|
-
name: "Sessions",
|
|
4313
|
-
glyph: "›",
|
|
4314
|
-
panel: () => html`<${SessionsPanel} />`,
|
|
4315
|
-
ready: true,
|
|
4316
|
-
badge: null,
|
|
4317
|
-
},
|
|
4318
|
-
{
|
|
4319
|
-
id: "plans",
|
|
4320
|
-
name: "Plans",
|
|
4321
|
-
glyph: "P",
|
|
4322
|
-
panel: () => html`<${PlansPanel} />`,
|
|
4323
|
-
ready: true,
|
|
4324
|
-
badge: null,
|
|
4325
|
-
},
|
|
4326
|
-
{
|
|
4327
|
-
id: "tools",
|
|
4328
|
-
name: "Tools",
|
|
4329
|
-
glyph: "▣",
|
|
4330
|
-
panel: () => html`<${ToolsPanel} />`,
|
|
4331
|
-
ready: true,
|
|
4332
|
-
badge: null,
|
|
4333
|
-
},
|
|
4334
|
-
{
|
|
4335
|
-
id: "permissions",
|
|
4336
|
-
name: "Permissions",
|
|
4337
|
-
glyph: "▎",
|
|
4338
|
-
panel: () => html`<${PermissionsPanel} />`,
|
|
4339
|
-
ready: true,
|
|
4340
|
-
badge: null,
|
|
4341
|
-
},
|
|
4342
|
-
{
|
|
4343
|
-
id: "health",
|
|
4344
|
-
name: "System",
|
|
4345
|
-
glyph: "+",
|
|
4346
|
-
panel: () => html`<${SystemPanel} />`,
|
|
4347
|
-
ready: true,
|
|
4348
|
-
badge: null,
|
|
4349
|
-
},
|
|
4350
|
-
{
|
|
4351
|
-
id: "semantic",
|
|
4352
|
-
name: "Semantic",
|
|
4353
|
-
glyph: "≈",
|
|
4354
|
-
panel: () => html`<${SemanticPanel} />`,
|
|
4355
|
-
ready: true,
|
|
4356
|
-
badge: null,
|
|
4357
|
-
},
|
|
4358
|
-
{
|
|
4359
|
-
id: "mcp",
|
|
4360
|
-
name: "MCP",
|
|
4361
|
-
glyph: "M",
|
|
4362
|
-
panel: () => html`<${McpPanel} />`,
|
|
4363
|
-
ready: true,
|
|
4364
|
-
badge: null,
|
|
4365
|
-
},
|
|
4366
|
-
{
|
|
4367
|
-
id: "skills",
|
|
4368
|
-
name: "Skills",
|
|
4369
|
-
glyph: "S",
|
|
4370
|
-
panel: () => html`<${SkillsPanel} />`,
|
|
4371
|
-
ready: true,
|
|
4372
|
-
badge: null,
|
|
4373
|
-
},
|
|
4374
|
-
{
|
|
4375
|
-
id: "memory",
|
|
4376
|
-
name: "Memory",
|
|
4377
|
-
glyph: "·",
|
|
4378
|
-
panel: () => html`<${MemoryPanel} />`,
|
|
4379
|
-
ready: true,
|
|
4380
|
-
badge: null,
|
|
4381
|
-
},
|
|
4382
|
-
{
|
|
4383
|
-
id: "hooks",
|
|
4384
|
-
name: "Hooks",
|
|
4385
|
-
glyph: "H",
|
|
4386
|
-
panel: () => html`<${HooksPanel} />`,
|
|
4387
|
-
ready: true,
|
|
4388
|
-
badge: null,
|
|
4389
|
-
},
|
|
4390
|
-
{
|
|
4391
|
-
id: "settings",
|
|
4392
|
-
name: "Settings",
|
|
4393
|
-
glyph: "⌘",
|
|
4394
|
-
panel: () => html`<${SettingsPanel} />`,
|
|
4395
|
-
ready: true,
|
|
4396
|
-
badge: null,
|
|
4397
|
-
},
|
|
4398
|
-
];
|
|
4399
|
-
|
|
4400
|
-
// ---------- Toast system ----------
|
|
4401
|
-
//
|
|
4402
|
-
// One Set of currently-displayed toast objects, pushed via a custom
|
|
4403
|
-
// DOM event so any panel can fire a toast without prop-drilling. Auto-
|
|
4404
|
-
// dismiss after `ttl` ms (default 3000). The stack lives at the App
|
|
4405
|
-
// level so toasts persist across tab switches.
|
|
4406
|
-
|
|
4407
|
-
const toastBus = new EventTarget();
|
|
4408
|
-
function showToast(text, kind = "info", ttl = 3000) {
|
|
4409
|
-
toastBus.dispatchEvent(new CustomEvent("toast", { detail: { text, kind, ttl } }));
|
|
4410
|
-
}
|
|
4411
|
-
|
|
4412
|
-
// ---------- App-wide event bus ----------
|
|
4413
|
-
//
|
|
4414
|
-
// Three events:
|
|
4415
|
-
// - "open-file" { path } Editor panel opens the path in a tab
|
|
4416
|
-
// - "navigate-tab" { tabId } App switches active sidebar tab
|
|
4417
|
-
// - "error" { error, source } global ErrorOverlay shows it full-screen
|
|
4418
|
-
//
|
|
4419
|
-
// Used by Chat tool cards / file-mention links to deep-link into the
|
|
4420
|
-
// Editor without prop-drilling, and by global error handlers to surface
|
|
4421
|
-
// crashes in a full-screen modal with a "Report on GitHub" button.
|
|
4422
|
-
|
|
4423
|
-
const appBus = new EventTarget();
|
|
4424
|
-
function openFileInEditor(path) {
|
|
4425
|
-
if (!path) return;
|
|
4426
|
-
// Just signal "open this file" — the App-level editor drawer subscribes
|
|
4427
|
-
// and pops itself open. We don't navigate the sidebar; the drawer
|
|
4428
|
-
// sits over the current panel so the user can keep their place in
|
|
4429
|
-
// chat / overview / wherever they were.
|
|
4430
|
-
appBus.dispatchEvent(new CustomEvent("open-file", { detail: { path } }));
|
|
4431
|
-
}
|
|
4432
|
-
|
|
4433
|
-
// ---------- Global error capture ----------
|
|
4434
|
-
//
|
|
4435
|
-
// Three sources feed into one overlay:
|
|
4436
|
-
// 1. window.error — sync exceptions, script load failures
|
|
4437
|
-
// 2. window.unhandledrejection — async promise rejections
|
|
4438
|
-
// 3. Preact ErrorBoundary — render-time component exceptions
|
|
4439
|
-
//
|
|
4440
|
-
// All three normalize to `{ error, source, info? }` and dispatch via
|
|
4441
|
-
// appBus. ErrorOverlay queues the most recent and lets the user copy
|
|
4442
|
-
// the trace or open a pre-filled GitHub issue.
|
|
4443
|
-
|
|
4444
|
-
function reportAppError(error, source, info) {
|
|
4445
|
-
// Console-log so devtools still has the message even when the
|
|
4446
|
-
// overlay is dismissed; keeps "what just broke" debuggable.
|
|
4447
|
-
// eslint-disable-next-line no-console
|
|
4448
|
-
console.error(`[reasonix dashboard] ${source}:`, error, info);
|
|
4449
|
-
appBus.dispatchEvent(
|
|
4450
|
-
new CustomEvent("error", { detail: { error, source, info, ts: Date.now() } }),
|
|
4451
|
-
);
|
|
4452
|
-
}
|
|
4453
|
-
|
|
4454
|
-
window.addEventListener("error", (ev) => {
|
|
4455
|
-
// Resource-load errors (failing img/script) come through with no
|
|
4456
|
-
// `error` object and are noisy; only surface real exceptions.
|
|
4457
|
-
if (!ev.error) return;
|
|
4458
|
-
reportAppError(ev.error, "window", ev.message);
|
|
4459
|
-
});
|
|
4460
|
-
|
|
4461
|
-
window.addEventListener("unhandledrejection", (ev) => {
|
|
4462
|
-
reportAppError(ev.reason, "promise");
|
|
4463
|
-
});
|
|
4464
|
-
|
|
4465
|
-
function ToastStack() {
|
|
4466
|
-
const [toasts, setToasts] = useState([]);
|
|
4467
|
-
useEffect(() => {
|
|
4468
|
-
const onToast = (ev) => {
|
|
4469
|
-
const id = `${Date.now()}-${Math.random()}`;
|
|
4470
|
-
const t = { id, ...ev.detail };
|
|
4471
|
-
setToasts((prev) => [...prev, t]);
|
|
4472
|
-
setTimeout(() => setToasts((prev) => prev.filter((x) => x.id !== id)), t.ttl);
|
|
4473
|
-
};
|
|
4474
|
-
toastBus.addEventListener("toast", onToast);
|
|
4475
|
-
return () => toastBus.removeEventListener("toast", onToast);
|
|
4476
|
-
}, []);
|
|
4477
|
-
if (toasts.length === 0) return null;
|
|
4478
|
-
return html`
|
|
4479
|
-
<div class="toast-stack">
|
|
4480
|
-
${toasts.map((t) => html`<div key=${t.id} class="toast ${t.kind}">${t.text}</div>`)}
|
|
4481
|
-
</div>
|
|
4482
|
-
`;
|
|
4483
|
-
}
|
|
4484
|
-
|
|
4485
|
-
// ---------- Error overlay ----------
|
|
4486
|
-
//
|
|
4487
|
-
// Renders a full-screen modal whenever a window error / promise
|
|
4488
|
-
// rejection / Preact render error fires through `appBus`. Includes a
|
|
4489
|
-
// "Copy details" button (clipboard) and "Report on GitHub" link with a
|
|
4490
|
-
// pre-filled body containing redacted environment info — the URL is
|
|
4491
|
-
// safe to surface (token is never embedded; just version + UA + the
|
|
4492
|
-
// trace itself).
|
|
4493
|
-
|
|
4494
|
-
const REPO_URL = "https://github.com/esengine/reasonix";
|
|
4495
|
-
|
|
4496
|
-
function buildIssueBody({ error, source, info }) {
|
|
4497
|
-
const ua = typeof navigator === "object" ? navigator.userAgent : "(unknown)";
|
|
4498
|
-
const errMsg = error?.message ?? String(error);
|
|
4499
|
-
const stack = error?.stack ?? "(no stack)";
|
|
4500
|
-
return [
|
|
4501
|
-
"**What happened**",
|
|
4502
|
-
"(describe what you were doing — typing, switching tabs, clicking a tool path, etc.)",
|
|
4503
|
-
"",
|
|
4504
|
-
"**Error**",
|
|
4505
|
-
"```",
|
|
4506
|
-
`${source}: ${errMsg}`,
|
|
4507
|
-
info ? `info: ${info}` : null,
|
|
4508
|
-
"",
|
|
4509
|
-
stack,
|
|
4510
|
-
"```",
|
|
4511
|
-
"",
|
|
4512
|
-
"**Environment**",
|
|
4513
|
-
`- Reasonix: ${MODE}`,
|
|
4514
|
-
`- Browser: ${ua}`,
|
|
4515
|
-
`- URL: ${location.pathname} (token redacted)`,
|
|
4516
|
-
"",
|
|
4517
|
-
"_Reported from the local dashboard's error overlay._",
|
|
4518
|
-
]
|
|
4519
|
-
.filter((l) => l !== null)
|
|
4520
|
-
.join("\n");
|
|
4521
|
-
}
|
|
4522
|
-
|
|
4523
|
-
function ErrorOverlay() {
|
|
4524
|
-
const [err, setErr] = useState(null); // { error, source, info, ts }
|
|
4525
|
-
const [copied, setCopied] = useState(false);
|
|
4526
|
-
|
|
4527
|
-
useEffect(() => {
|
|
4528
|
-
const onError = (ev) => {
|
|
4529
|
-
// Show only the latest — if a second fires while overlay is up,
|
|
4530
|
-
// it replaces. Cumulative replay would be nice but for now the
|
|
4531
|
-
// user can copy / file the issue with the most recent.
|
|
4532
|
-
setErr(ev.detail);
|
|
4533
|
-
setCopied(false);
|
|
4534
|
-
};
|
|
4535
|
-
appBus.addEventListener("error", onError);
|
|
4536
|
-
return () => appBus.removeEventListener("error", onError);
|
|
4537
|
-
}, []);
|
|
4538
|
-
|
|
4539
|
-
// Esc dismisses (assuming non-fatal).
|
|
4540
|
-
useEffect(() => {
|
|
4541
|
-
if (!err) return;
|
|
4542
|
-
const onKey = (e) => {
|
|
4543
|
-
if (e.key === "Escape") setErr(null);
|
|
4544
|
-
};
|
|
4545
|
-
window.addEventListener("keydown", onKey);
|
|
4546
|
-
return () => window.removeEventListener("keydown", onKey);
|
|
4547
|
-
}, [err]);
|
|
4548
|
-
|
|
4549
|
-
if (!err) return null;
|
|
4550
|
-
const error = err.error;
|
|
4551
|
-
const errMsg = error?.message ?? String(error);
|
|
4552
|
-
const stack = error?.stack ?? "(no stack)";
|
|
4553
|
-
|
|
4554
|
-
const issueUrl = `${REPO_URL}/issues/new?title=${encodeURIComponent(`[dashboard] ${errMsg.slice(0, 80)}`)}&body=${encodeURIComponent(buildIssueBody(err))}`;
|
|
4555
|
-
|
|
4556
|
-
const copyDetails = async () => {
|
|
4557
|
-
const body = buildIssueBody(err);
|
|
4558
|
-
try {
|
|
4559
|
-
await navigator.clipboard.writeText(body);
|
|
4560
|
-
setCopied(true);
|
|
4561
|
-
setTimeout(() => setCopied(false), 2000);
|
|
4562
|
-
} catch {
|
|
4563
|
-
/* clipboard blocked — user can still hit "report on GitHub" */
|
|
4564
|
-
}
|
|
4565
|
-
};
|
|
4566
|
-
|
|
4567
|
-
return html`
|
|
4568
|
-
<div class="error-overlay">
|
|
4569
|
-
<div class="error-overlay-card">
|
|
4570
|
-
<div class="error-overlay-head">
|
|
4571
|
-
<span class="error-overlay-icon">✦</span>
|
|
4572
|
-
<div>
|
|
4573
|
-
<div class="error-overlay-title">Something broke in the dashboard</div>
|
|
4574
|
-
<div class="error-overlay-subtitle">${err.source} error · ${errMsg}</div>
|
|
4575
|
-
</div>
|
|
4576
|
-
</div>
|
|
4577
|
-
|
|
4578
|
-
<pre class="error-overlay-trace">${stack}</pre>
|
|
4579
|
-
|
|
4580
|
-
${
|
|
4581
|
-
err.info
|
|
4582
|
-
? html`<div class="error-overlay-info"><strong>info:</strong> ${err.info}</div>`
|
|
4583
|
-
: null
|
|
4584
|
-
}
|
|
4585
|
-
|
|
4586
|
-
<div class="error-overlay-help">
|
|
4587
|
-
The TUI is unaffected — only this browser tab tripped. You can
|
|
4588
|
-
dismiss and keep working, or report it so we can fix the
|
|
4589
|
-
underlying cause.
|
|
4590
|
-
</div>
|
|
4591
|
-
|
|
4592
|
-
<div class="error-overlay-actions">
|
|
4593
|
-
<button class="primary" onClick=${copyDetails}>
|
|
4594
|
-
${copied ? "Copied ✓" : "Copy details"}
|
|
4595
|
-
</button>
|
|
4596
|
-
<a class="button" href=${issueUrl} target="_blank" rel="noopener noreferrer">
|
|
4597
|
-
Report on GitHub
|
|
4598
|
-
</a>
|
|
4599
|
-
<button onClick=${() => setErr(null)} style="margin-left: auto;">Dismiss (Esc)</button>
|
|
4600
|
-
</div>
|
|
4601
|
-
</div>
|
|
4602
|
-
</div>
|
|
4603
|
-
`;
|
|
4604
|
-
}
|
|
4605
|
-
|
|
4606
|
-
// Preact ErrorBoundary — catches render-time exceptions in the App
|
|
4607
|
-
// subtree and dispatches them to the error overlay instead of leaving
|
|
4608
|
-
// the user with a blank white page. Recovers automatically the first
|
|
4609
|
-
// few times so transient hiccups don't strand the user; if a panel
|
|
4610
|
-
// throws repeatedly we stop the loop and render a manual "Try again"
|
|
4611
|
-
// fallback so the page never looks blank-but-ticking.
|
|
4612
|
-
class ErrorBoundary extends Component {
|
|
4613
|
-
constructor(props) {
|
|
4614
|
-
super(props);
|
|
4615
|
-
this.state = { caught: false, lastErr: null, attempts: 0 };
|
|
4616
|
-
}
|
|
4617
|
-
static getDerivedStateFromError(error) {
|
|
4618
|
-
return { caught: true, lastErr: error };
|
|
4619
|
-
}
|
|
4620
|
-
componentDidCatch(error, info) {
|
|
4621
|
-
reportAppError(error, "render", info?.componentStack ?? "");
|
|
4622
|
-
const attempts = (this.state.attempts ?? 0) + 1;
|
|
4623
|
-
if (attempts >= 3) {
|
|
4624
|
-
// Stop the auto-recover loop — the panel is genuinely broken,
|
|
4625
|
-
// surface a "Try again" button instead of flickering.
|
|
4626
|
-
this.setState({ attempts });
|
|
4627
|
-
return;
|
|
4628
|
-
}
|
|
4629
|
-
setTimeout(() => this.setState({ caught: false, attempts }), 100);
|
|
4630
|
-
}
|
|
4631
|
-
render() {
|
|
4632
|
-
if (this.state.caught) {
|
|
4633
|
-
if ((this.state.attempts ?? 0) >= 3) {
|
|
4634
|
-
return html`
|
|
4635
|
-
<div class="boot" style="flex-direction: column; gap: 12px;">
|
|
4636
|
-
<div>this panel keeps crashing — the error overlay has the trace.</div>
|
|
4637
|
-
<button onClick=${() => this.setState({ caught: false, attempts: 0 })}>
|
|
4638
|
-
Try again
|
|
4639
|
-
</button>
|
|
4640
|
-
</div>
|
|
4641
|
-
`;
|
|
4642
|
-
}
|
|
4643
|
-
return html`<div class="boot">recovering…</div>`;
|
|
4644
|
-
}
|
|
4645
|
-
return this.props.children;
|
|
4646
|
-
}
|
|
4647
|
-
}
|
|
4648
|
-
|
|
4649
|
-
function App() {
|
|
4650
|
-
const [activeId, setActiveId] = useState("chat");
|
|
4651
|
-
const [sidebarOpen, setSidebarOpen] = useState(false); // mobile drawer
|
|
4652
|
-
// Desktop "icon only" collapse — narrow sidebar that shows just the
|
|
4653
|
-
// glyphs. Persisted so the choice survives reload.
|
|
4654
|
-
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
|
4655
|
-
try {
|
|
4656
|
-
return localStorage.getItem("rx.sidebarCollapsed") === "1";
|
|
4657
|
-
} catch {
|
|
4658
|
-
return false;
|
|
4659
|
-
}
|
|
4660
|
-
});
|
|
4661
|
-
useEffect(() => {
|
|
4662
|
-
try {
|
|
4663
|
-
localStorage.setItem("rx.sidebarCollapsed", sidebarCollapsed ? "1" : "0");
|
|
4664
|
-
} catch {
|
|
4665
|
-
/* private mode / disabled storage — ignore */
|
|
4666
|
-
}
|
|
4667
|
-
}, [sidebarCollapsed]);
|
|
4668
|
-
// Editor drawer — opens whenever any panel fires "open-file" via
|
|
4669
|
-
// appBus. Lives at the App level so the editor's tab state persists
|
|
4670
|
-
// across sidebar-tab switches; you can open a file from Chat, switch
|
|
4671
|
-
// to Usage to glance at numbers, come back, and the editor's still
|
|
4672
|
-
// there. × on the drawer or Esc closes it.
|
|
4673
|
-
const [editorOpen, setEditorOpen] = useState(false);
|
|
4674
|
-
const active = TABS.find((t) => t.id === activeId) ?? TABS[0];
|
|
4675
|
-
|
|
4676
|
-
// Esc anywhere closes the mobile drawer (modals already handle their
|
|
4677
|
-
// own Esc). On desktop the drawer is always-open so this is a no-op.
|
|
4678
|
-
useEffect(() => {
|
|
4679
|
-
const onKey = (e) => {
|
|
4680
|
-
if (e.key === "Escape") setSidebarOpen(false);
|
|
4681
|
-
};
|
|
4682
|
-
window.addEventListener("keydown", onKey);
|
|
4683
|
-
return () => window.removeEventListener("keydown", onKey);
|
|
4684
|
-
}, []);
|
|
4685
|
-
|
|
4686
|
-
// Cross-component navigation — sidebar-tab switching when something
|
|
4687
|
-
// fires `navigate-tab` (kept in case other features want it; the
|
|
4688
|
-
// editor drawer no longer uses it).
|
|
4689
|
-
useEffect(() => {
|
|
4690
|
-
const onNav = (ev) => {
|
|
4691
|
-
const id = ev.detail?.tabId;
|
|
4692
|
-
if (id) setActiveId(id);
|
|
4693
|
-
};
|
|
4694
|
-
appBus.addEventListener("navigate-tab", onNav);
|
|
4695
|
-
return () => appBus.removeEventListener("navigate-tab", onNav);
|
|
4696
|
-
}, []);
|
|
4697
|
-
|
|
4698
|
-
// Open the editor drawer whenever any panel signals a file-open.
|
|
4699
|
-
// The drawer's <EditorPanel> is permanently mounted (with display:
|
|
4700
|
-
// none when closed) so its tab state survives toggling — opening
|
|
4701
|
-
// the same file twice from chat doesn't lose unsaved changes.
|
|
4702
|
-
useEffect(() => {
|
|
4703
|
-
const onOpenFile = () => setEditorOpen(true);
|
|
4704
|
-
appBus.addEventListener("open-file", onOpenFile);
|
|
4705
|
-
return () => appBus.removeEventListener("open-file", onOpenFile);
|
|
4706
|
-
}, []);
|
|
4707
|
-
|
|
4708
|
-
// Esc also closes the editor (in addition to the mobile drawer).
|
|
4709
|
-
useEffect(() => {
|
|
4710
|
-
if (!editorOpen) return;
|
|
4711
|
-
const onKey = (e) => {
|
|
4712
|
-
if (e.key === "Escape") setEditorOpen(false);
|
|
4713
|
-
};
|
|
4714
|
-
window.addEventListener("keydown", onKey);
|
|
4715
|
-
return () => window.removeEventListener("keydown", onKey);
|
|
4716
|
-
}, [editorOpen]);
|
|
4717
|
-
|
|
4718
|
-
const pickTab = useCallback((id) => {
|
|
4719
|
-
setActiveId(id);
|
|
4720
|
-
setSidebarOpen(false); // collapse drawer after pick on mobile
|
|
4721
|
-
}, []);
|
|
4722
|
-
|
|
4723
|
-
return html`
|
|
4724
|
-
<div class=${`sidebar ${sidebarOpen ? "open" : ""} ${sidebarCollapsed ? "collapsed" : ""}`}>
|
|
4725
|
-
<div class="sidebar-header">
|
|
4726
|
-
<div class="sidebar-brand" title="Reasonix"><span class="glyph">◈</span><span class="sidebar-label"> REASONIX</span></div>
|
|
4727
|
-
<div class="sidebar-version sidebar-label">dashboard</div>
|
|
4728
|
-
<div class="sidebar-mode sidebar-label">${MODE}</div>
|
|
4729
|
-
</div>
|
|
4730
|
-
<div class="gradient-rule"></div>
|
|
4731
|
-
<div class="sidebar-tabs">
|
|
4732
|
-
${TABS.map(
|
|
4733
|
-
(tab) => html`
|
|
4734
|
-
<div
|
|
4735
|
-
class="tab ${tab.id === active.id ? "active" : ""} ${!tab.ready ? "tab-stub" : ""}"
|
|
4736
|
-
onClick=${() => tab.ready && pickTab(tab.id)}
|
|
4737
|
-
title=${tab.name}
|
|
4738
|
-
>
|
|
4739
|
-
<span class="glyph">${tab.glyph}</span>
|
|
4740
|
-
<span class="sidebar-label">${tab.name}</span>
|
|
4741
|
-
${tab.badge ? html`<span class="badge sidebar-label">${tab.badge}</span>` : null}
|
|
4742
|
-
</div>
|
|
4743
|
-
`,
|
|
4744
|
-
)}
|
|
4745
|
-
</div>
|
|
4746
|
-
<button
|
|
4747
|
-
class="sidebar-collapse-toggle"
|
|
4748
|
-
onClick=${() => setSidebarCollapsed((c) => !c)}
|
|
4749
|
-
title=${sidebarCollapsed ? "expand sidebar" : "collapse to icons"}
|
|
4750
|
-
>${sidebarCollapsed ? "▶" : "◀"}<span class="sidebar-label"> ${sidebarCollapsed ? "expand" : "collapse"}</span></button>
|
|
4751
|
-
<div class="sidebar-footer sidebar-label">127.0.0.1 only · token-gated</div>
|
|
4752
|
-
</div>
|
|
4753
|
-
<div class="sidebar-backdrop" onClick=${() => setSidebarOpen(false)}></div>
|
|
4754
|
-
<button class="menu-toggle" onClick=${() => setSidebarOpen((s) => !s)} aria-label="Toggle sidebar">≡</button>
|
|
4755
|
-
<div class=${`main ${active.id === "editor" ? "main-editor" : ""}`}>
|
|
4756
|
-
<${ErrorBoundary}>${active.panel()}<//>
|
|
4757
|
-
</div>
|
|
4758
|
-
<div class=${`editor-drawer-host ${editorOpen ? "open" : ""}`}>
|
|
4759
|
-
<${ErrorBoundary}>
|
|
4760
|
-
<${EditorPanel} onClose=${() => setEditorOpen(false)} />
|
|
4761
|
-
<//>
|
|
4762
|
-
</div>
|
|
4763
|
-
<${ToastStack} />
|
|
4764
|
-
<${ErrorOverlay} />
|
|
4765
|
-
`;
|
|
4766
|
-
}
|
|
4767
|
-
|
|
4768
|
-
render(html`<${App} />`, document.getElementById("root"));
|