@xingjisoft/neutrino 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js ADDED
@@ -0,0 +1,1100 @@
1
+ import { createSettingsUI } from "./settings.js";
2
+ import { turnFolds, turnPreview, turnRanges } from "./turns.js";
3
+
4
+ // Neutrino web UI — a dependency-free single page app.
5
+ //
6
+ // It talks to the server API:
7
+ // GET /api/overview every saved conversation grouped by cwd
8
+ // GET /api/session/:id one saved transcript
9
+ // GET /api/live/:key/stream SSE stream of a running conversation
10
+ // POST /api/live/:key/{pause|resume|message|model}
11
+ // GET/POST /api/settings model defaults and conversation settings
12
+ //
13
+ // The sidebar is refreshed on a timer; the open live conversation is pushed
14
+ // over SSE so tokens appear as they are generated.
15
+
16
+ const MAX_VISIBLE = 5;
17
+ const REFRESH_MS = 4000;
18
+
19
+ // -------------------------------------------------------------------- access
20
+ //
21
+ // Login is remembered in an HttpOnly cookie shared by fetch and EventSource.
22
+ // Remove credentials left behind by older versions of the UI.
23
+ try { localStorage.removeItem("neutrino.token"); } catch { /* Storage may be blocked. */ }
24
+ async function loginWithPassword(password) {
25
+ const response = await fetch("/api/auth/login", {
26
+ method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({ password }),
28
+ });
29
+ const body = await response.json();
30
+ if (!response.ok) throw new Error(body.error || "Could not sign in.");
31
+ }
32
+
33
+ // Complete URL login before API requests start, so a slow password check cannot
34
+ // flash the login dialog or race the initial conversation/settings requests.
35
+ const initialLogin = (() => {
36
+ const params = new URLSearchParams(location.search);
37
+ const password = params.get("pwd");
38
+ if (params.has("pwd") || params.has("t") || params.has("token")) {
39
+ params.delete("pwd");
40
+ params.delete("t");
41
+ params.delete("token");
42
+ const query = params.toString();
43
+ try {
44
+ history.replaceState(null, "", location.pathname + (query ? `?${query}` : "") + location.hash);
45
+ } catch { /* Login still works when history updates are unavailable. */ }
46
+ }
47
+ if (password === null) return Promise.resolve(true);
48
+ return loginWithPassword(password).then(() => true).catch(error => {
49
+ showGate(error.message);
50
+ return false;
51
+ });
52
+ })();
53
+
54
+ async function apiFetch(path, init = {}) {
55
+ if (!await initialLogin) throw new Error("Password login required.");
56
+ const response = await fetch(path, { ...init, credentials: "same-origin" });
57
+ if (response.status === 401) {
58
+ showGate();
59
+ throw new Error("Password login required.");
60
+ }
61
+ return response;
62
+ }
63
+
64
+ let gateShown = false;
65
+
66
+ function showGate(message = "") {
67
+ if (gateShown) return;
68
+ gateShown = true;
69
+ closeStream();
70
+ setStatus("locked", "offline");
71
+ document.getElementById("settings-dialog").close();
72
+
73
+ // A modal dialog stays above streaming repaints and traps keyboard focus.
74
+ const gate = document.createElement("dialog");
75
+ gate.className = "gate";
76
+ gate.setAttribute("aria-labelledby", "login-title");
77
+ gate.innerHTML =
78
+ '<form class="gate-card">' +
79
+ '<h2 id="login-title">Sign in to Neutrino</h2>' +
80
+ '<p>Enter your Web password. The default password is 114514.</p>' +
81
+ '<label for="login-password">Password</label>' +
82
+ '<input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="256" required autofocus>' +
83
+ '<p class="form-feedback error" role="alert"></p>' +
84
+ '<button class="primary" type="submit">Sign in</button>' +
85
+ "</form>";
86
+ gate.querySelector(".form-feedback").textContent = message;
87
+ gate.addEventListener("cancel", event => event.preventDefault());
88
+ gate.querySelector("form").addEventListener("submit", async event => {
89
+ event.preventDefault();
90
+ const input = gate.querySelector("input");
91
+ const button = gate.querySelector("button");
92
+ const feedback = gate.querySelector(".form-feedback");
93
+ button.disabled = true;
94
+ feedback.textContent = "";
95
+ try {
96
+ await loginWithPassword(input.value);
97
+ location.reload();
98
+ } catch (error) {
99
+ feedback.textContent = error.message;
100
+ input.focus();
101
+ input.select();
102
+ } finally { button.disabled = false; }
103
+ });
104
+ document.body.appendChild(gate);
105
+ gate.showModal();
106
+ }
107
+
108
+ const el = {
109
+ app: document.getElementById("app"),
110
+ back: document.getElementById("back-to-menu"),
111
+ sessions: document.getElementById("sessions"),
112
+ status: document.getElementById("status"),
113
+ refresh: document.getElementById("refresh"),
114
+ title: document.getElementById("session-title"),
115
+ sub: document.getElementById("session-sub"),
116
+ controls: document.getElementById("controls"),
117
+ messages: document.getElementById("messages"),
118
+ composer: document.getElementById("composer"),
119
+ input: document.getElementById("input"),
120
+ send: document.getElementById("send"),
121
+ approval: document.getElementById("approval"),
122
+ };
123
+
124
+ const state = {
125
+ overview: null,
126
+ // { kind: "live", key } | { kind: "file", id } | null
127
+ selection: null,
128
+ snapshot: null,
129
+ eventSource: null,
130
+ lastSessionId: null,
131
+ collapsed: new Set(),
132
+ expanded: new Set(),
133
+ openTools: new Set(),
134
+ // Turns the reader expanded; a finished turn is folded unless listed here.
135
+ openTurns: new Set(),
136
+ openContext: null,
137
+ };
138
+
139
+ const preferences = createSettingsUI({
140
+ apiFetch,
141
+ async applyModel(selection) {
142
+ const current = state.selection;
143
+ if (!current) throw new Error("Select a conversation first.");
144
+ if (current.kind === "file") {
145
+ await continueSession(current.id, selection);
146
+ return;
147
+ }
148
+ const response = await apiFetch(`/api/live/${encodeURIComponent(current.key)}/model`, {
149
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(selection),
150
+ });
151
+ const body = await response.json();
152
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
153
+ if (state.selection?.kind === "live" && state.selection.key === current.key) {
154
+ state.snapshot = body.snapshot;
155
+ renderLive();
156
+ }
157
+ },
158
+ });
159
+
160
+ // ------------------------------------------------------------------- sounds
161
+ //
162
+ // A turn that ends while the tab is in the background is invisible, so finished
163
+ // turns and waiting approvals each get a short motif. Browsers refuse to start
164
+ // audio without a user gesture, so the context is created on the first
165
+ // interaction and reused; before that a cue is skipped rather than queued.
166
+
167
+ let audioContext = null;
168
+
169
+ function unlockAudio() {
170
+ try {
171
+ const Ctor = window.AudioContext || window.webkitAudioContext;
172
+ if (!Ctor) return;
173
+ if (!audioContext) audioContext = new Ctor();
174
+ if (audioContext.state === "suspended") audioContext.resume().catch(() => {});
175
+ } catch {
176
+ // No audio available (blocked, unsupported, mocked): stay silent.
177
+ audioContext = null;
178
+ }
179
+ }
180
+
181
+ // Feature-detected: some embedders (and the test harness) expose no events.
182
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
183
+ document.addEventListener("pointerdown", unlockAudio, true);
184
+ document.addEventListener("keydown", unlockAudio, true);
185
+ }
186
+
187
+ const SOUND_MOTIFS = {
188
+ // Turn finished: two short rising notes.
189
+ turn: [[660, 0], [988, 0.13]],
190
+ // Approval waiting: three insistent notes, because this blocks the agent.
191
+ approval: [[784, 0], [587, 0.16], [784, 0.32]],
192
+ };
193
+
194
+ function chime(kind) {
195
+ if (!preferences.soundAlerts()) return;
196
+ unlockAudio();
197
+ if (!audioContext || audioContext.state === "closed") return;
198
+ const start = audioContext.currentTime + 0.01;
199
+ for (const [frequency, offset] of SOUND_MOTIFS[kind] || SOUND_MOTIFS.turn) {
200
+ const oscillator = audioContext.createOscillator();
201
+ const gain = audioContext.createGain();
202
+ oscillator.type = "sine";
203
+ oscillator.frequency.value = frequency;
204
+ const at = start + offset;
205
+ gain.gain.setValueAtTime(0.0001, at);
206
+ gain.gain.exponentialRampToValueAtTime(0.16, at + 0.02);
207
+ gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.13);
208
+ oscillator.connect(gain);
209
+ gain.connect(audioContext.destination);
210
+ oscillator.start(at);
211
+ oscillator.stop(at + 0.15);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Cues fire on transitions, not on every frame: the stream re-sends a snapshot
217
+ * on every update, so comparing against the previous one keeps this from
218
+ * turning into a metronome. The first frame is skipped because it describes a
219
+ * state the page has just loaded.
220
+ */
221
+ function announce(previous, next) {
222
+ if (!previous || previous.key !== next.key) return;
223
+ if (!previous.approval && next.approval) chime("approval");
224
+ else if (previous.busy && !next.busy && !next.approval) chime("turn");
225
+ }
226
+
227
+ // ------------------------------------------------------------------ helpers
228
+
229
+ function escapeHtml(value) {
230
+ return String(value ?? "")
231
+ .replace(/&/g, "&amp;")
232
+ .replace(/</g, "&lt;")
233
+ .replace(/>/g, "&gt;")
234
+ .replace(/"/g, "&quot;")
235
+ .replace(/'/g, "&#39;");
236
+ }
237
+
238
+ function truncate(value, max) {
239
+ const text = String(value ?? "");
240
+ return text.length > max ? `${text.slice(0, max)}…` : text;
241
+ }
242
+
243
+ function safeJson(value) {
244
+ try {
245
+ return JSON.stringify(value ?? {});
246
+ } catch {
247
+ return "{}";
248
+ }
249
+ }
250
+
251
+ function formatTime(value) {
252
+ if (!value) return "";
253
+ const date = new Date(value);
254
+ if (Number.isNaN(date.getTime())) return "";
255
+ const now = Date.now();
256
+ const diff = now - date.getTime();
257
+ if (diff < 60_000) return "just now";
258
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
259
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
260
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
261
+ }
262
+
263
+ function formatDate(value) {
264
+ if (!value) return "";
265
+ const date = new Date(value);
266
+ return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
267
+ }
268
+
269
+ function setStatus(text, kind) {
270
+ el.status.textContent = text;
271
+ el.status.className = `status${kind ? ` ${kind}` : ""}`;
272
+ }
273
+
274
+ // A deliberately small markdown renderer: code fences, inline code, bold,
275
+ // italics and links. Everything else is escaped, so model output is inert.
276
+ function renderMarkdown(value) {
277
+ const placeholders = [];
278
+ let source = String(value ?? "");
279
+ source = source.replace(/```[\w-]*\n?([\s\S]*?)```/g, (_, code) => {
280
+ const id = `\u0000CODE${placeholders.length}\u0000`;
281
+ placeholders.push(`<pre class="code"><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
282
+ return id;
283
+ });
284
+ source = escapeHtml(source);
285
+ source = source.replace(/`([^`\n]+)`/g, "<code>$1</code>");
286
+ source = source.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
287
+ source = source.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
288
+ source = source.replace(
289
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
290
+ '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
291
+ );
292
+ source = source.replace(/\n/g, "<br>");
293
+ source = source.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => placeholders[Number(index)] ?? "");
294
+ return source;
295
+ }
296
+
297
+ // ------------------------------------------------------------------- status
298
+
299
+ async function loadOverview(options = {}) {
300
+ // Once the gate is up every poll would only produce another 401.
301
+ if (gateShown) return;
302
+ try {
303
+ const response = await apiFetch("/api/overview", { cache: "no-store" });
304
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
305
+ const overview = await response.json();
306
+ if (gateShown) return;
307
+ state.overview = overview;
308
+ setStatus(state.selection?.kind === "live" ? "live" : "online", state.selection?.kind === "live" ? "live" : "online");
309
+ renderSidebar();
310
+ syncSelection();
311
+ } catch (error) {
312
+ if (!gateShown) setStatus("offline", "offline");
313
+ if (!options.silent) console.error(error);
314
+ }
315
+ }
316
+
317
+ function syncSelection() {
318
+ const selection = state.selection;
319
+ if (!selection || selection.kind !== "live") return;
320
+ const live = state.overview?.live ?? [];
321
+ if (live.some((entry) => entry.key === selection.key)) return;
322
+
323
+ // The terminal session went away. Prefer the saved transcript when we have it.
324
+ const sessionId = state.lastSessionId;
325
+ closeStream();
326
+ if (sessionId) {
327
+ selectFile(sessionId);
328
+ return;
329
+ }
330
+ state.selection = null;
331
+ state.snapshot = null;
332
+ renderSidebar();
333
+ hideComposer();
334
+ el.controls.innerHTML = "";
335
+ el.title.textContent = "Session ended";
336
+ el.sub.textContent = "The terminal session was closed.";
337
+ el.messages.innerHTML = '<div class="empty">This live session has ended.</div>';
338
+ }
339
+
340
+ // ------------------------------------------------------------------ sidebar
341
+
342
+ function renderSidebar() {
343
+ const overview = state.overview;
344
+ el.sessions.innerHTML = "";
345
+
346
+ if (!overview || overview.groups.length === 0) {
347
+ const empty = document.createElement("div");
348
+ empty.className = "empty-side";
349
+ empty.textContent = "No conversations yet.";
350
+ el.sessions.appendChild(empty);
351
+ return;
352
+ }
353
+
354
+ for (const group of overview.groups) {
355
+ el.sessions.appendChild(renderGroup(group));
356
+ }
357
+ }
358
+
359
+ function renderGroup(group) {
360
+ const wrap = document.createElement("section");
361
+ wrap.className = "group";
362
+
363
+ const collapsed = state.collapsed.has(group.key);
364
+ const header = document.createElement("button");
365
+ header.className = "group-header";
366
+ header.innerHTML =
367
+ `<span class="chevron">${collapsed ? "▸" : "▾"}</span>` +
368
+ `<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(group.cwd)}</span>` +
369
+ `<span class="count">${group.sessions.length}</span>`;
370
+ header.addEventListener("click", () => {
371
+ if (state.collapsed.has(group.key)) state.collapsed.delete(group.key);
372
+ else state.collapsed.add(group.key);
373
+ renderSidebar();
374
+ });
375
+ wrap.appendChild(header);
376
+
377
+ if (collapsed) return wrap;
378
+
379
+ const list = document.createElement("div");
380
+ list.className = "group-items";
381
+ const expanded = state.expanded.has(group.key);
382
+ const visible = expanded ? group.sessions : group.sessions.slice(0, MAX_VISIBLE);
383
+ for (const session of visible) list.appendChild(renderSession(session));
384
+
385
+ const hiddenCount = group.sessions.length - MAX_VISIBLE;
386
+ if (hiddenCount > 0) {
387
+ const more = document.createElement("button");
388
+ more.className = "more";
389
+ more.textContent = expanded ? "Show less" : `Show ${hiddenCount} more`;
390
+ more.addEventListener("click", () => {
391
+ if (state.expanded.has(group.key)) state.expanded.delete(group.key);
392
+ else state.expanded.add(group.key);
393
+ renderSidebar();
394
+ });
395
+ list.appendChild(more);
396
+ }
397
+
398
+ wrap.appendChild(list);
399
+ return wrap;
400
+ }
401
+
402
+ function isSelected(session) {
403
+ const selection = state.selection;
404
+ if (!selection) return false;
405
+ if (selection.kind === "live") return Boolean(session.liveKey) && selection.key === session.liveKey;
406
+ return !session.liveKey && session.id === selection.id;
407
+ }
408
+
409
+ function renderSession(session) {
410
+ const item = document.createElement("button");
411
+ item.className = "session";
412
+ if (isSelected(session)) item.classList.add("selected");
413
+ if (session.live) item.classList.add("live");
414
+
415
+ const title = session.title || "New conversation";
416
+ item.innerHTML =
417
+ `<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
418
+ `${session.live ? '<span class="dot" title="Live"></span>' : ""}</div>` +
419
+ `<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
420
+ `<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
421
+ `<span class="count">${Number(session.message_count ?? 0)} msg</span></div>`;
422
+
423
+ item.addEventListener("click", () => {
424
+ if (session.liveKey) selectLive(session.liveKey);
425
+ else if (session.id) selectFile(session.id);
426
+ else return;
427
+ setConversationView(true);
428
+ });
429
+ return item;
430
+ }
431
+
432
+ // ------------------------------------------------------------------- select
433
+
434
+ // Only explicit navigation changes the mobile pane. Background refreshes and
435
+ // live-to-saved transitions must not take the user away from the menu.
436
+ function setConversationView(open) {
437
+ el.app.classList.toggle("conversation-open", open);
438
+ if (!window.matchMedia("(max-width: 760px)").matches) return;
439
+ if (open) el.back.focus();
440
+ else (el.sessions.querySelector(".session.selected") || el.refresh).focus();
441
+ }
442
+
443
+ function resetMessagePane(text) {
444
+ el.messages.innerHTML = "";
445
+ const empty = document.createElement("div");
446
+ empty.className = "empty";
447
+ empty.textContent = text;
448
+ el.messages.appendChild(empty);
449
+ }
450
+
451
+ async function selectLive(key) {
452
+ preferences.clear();
453
+ closeStream();
454
+ state.selection = { kind: "live", key };
455
+ state.snapshot = null;
456
+ state.lastSessionId = null;
457
+ state.openTools.clear();
458
+ state.openTurns.clear();
459
+ state.openContext = null;
460
+ renderSidebar();
461
+ el.controls.innerHTML = "";
462
+ el.title.textContent = "Connecting…";
463
+ el.sub.textContent = "";
464
+ setComposerMode("live");
465
+ resetMessagePane("Connecting…");
466
+ openStream(key);
467
+ }
468
+
469
+ function openStream(key) {
470
+ closeStream();
471
+ const source = new EventSource(`/api/live/${encodeURIComponent(key)}/stream`);
472
+ state.eventSource = source;
473
+
474
+ source.addEventListener("snapshot", (event) => {
475
+ let snapshot;
476
+ try {
477
+ snapshot = JSON.parse(event.data);
478
+ } catch {
479
+ return;
480
+ }
481
+ if (gateShown) return;
482
+ const previous = state.snapshot;
483
+ state.snapshot = snapshot;
484
+ if (snapshot.sessionId) state.lastSessionId = snapshot.sessionId;
485
+ announce(previous, snapshot);
486
+ setStatus("live", "live");
487
+ renderLive();
488
+ });
489
+
490
+ source.addEventListener("gone", () => {
491
+ closeStream();
492
+ syncSelection();
493
+ });
494
+
495
+ source.onopen = () => setStatus("live", "live");
496
+ source.onerror = () => {
497
+ // EventSource retries automatically; just surface the state.
498
+ setStatus("reconnecting…", "offline");
499
+ };
500
+ }
501
+
502
+ function closeStream() {
503
+ if (state.eventSource) {
504
+ state.eventSource.close();
505
+ state.eventSource = null;
506
+ }
507
+ }
508
+
509
+ async function selectFile(id) {
510
+ preferences.clear();
511
+ closeStream();
512
+ state.selection = { kind: "file", id };
513
+ state.snapshot = null;
514
+ state.openTools.clear();
515
+ state.openTurns.clear();
516
+ state.openContext = null;
517
+ renderSidebar();
518
+ el.controls.innerHTML = "";
519
+ setComposerMode("resume");
520
+ el.title.textContent = "Loading…";
521
+ el.sub.textContent = "";
522
+ resetMessagePane("Loading…");
523
+
524
+ try {
525
+ const response = await apiFetch(`/api/session/${encodeURIComponent(id)}`, { cache: "no-store" });
526
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
527
+ const data = await response.json();
528
+ renderFile(data);
529
+ } catch (error) {
530
+ el.title.textContent = "Conversation";
531
+ resetMessagePane(`Could not load this conversation: ${error.message}`);
532
+ }
533
+ }
534
+
535
+ // -------------------------------------------------------------------- render
536
+
537
+ function renderFile(data) {
538
+ const messages = Array.isArray(data.messages) ? data.messages : [];
539
+ el.title.textContent = firstUserMessage(messages) || "Conversation";
540
+ el.sub.textContent = [data.cwd, data.model, data.provider].filter(Boolean).join(" · ");
541
+ preferences.showConversation(data, `file:${data.id}`);
542
+ renderMessageList(messages, null, { readOnly: true, context: data.context, active: false });
543
+ renderFileControls(data);
544
+ }
545
+
546
+ /**
547
+ * A saved transcript is read-only by nature: nothing is running on the machine.
548
+ * Offer to pick it up in a browser-owned session, which is created on demand.
549
+ */
550
+ function renderFileControls(data) {
551
+ el.controls.innerHTML = "";
552
+
553
+ const badge = document.createElement("span");
554
+ badge.className = "badge readonly";
555
+ badge.textContent = "read-only";
556
+ el.controls.appendChild(badge);
557
+
558
+ const button = document.createElement("button");
559
+ button.className = "control primary";
560
+ button.textContent = "✎ Continue here";
561
+ button.title = "Resume this conversation in the browser and keep chatting";
562
+ button.addEventListener("click", async () => {
563
+ button.disabled = true;
564
+ button.textContent = "Starting…";
565
+ try {
566
+ await continueSession(data.id);
567
+ el.input.focus();
568
+ } catch (error) {
569
+ button.disabled = false;
570
+ button.textContent = "✎ Continue here";
571
+ el.sub.textContent = `Could not continue this conversation: ${error.message}`;
572
+ }
573
+ });
574
+ el.controls.appendChild(button);
575
+ renderApprovalModeControl(data.approval_mode, async (mode) => {
576
+ const key = await continueSession(data.id);
577
+ await changeApprovalMode(key, mode);
578
+ });
579
+ }
580
+
581
+ /** Attach to a resumed conversation, creating it server-side when needed. */
582
+ async function continueSession(id, selection = preferences.selectionForFile(id)) {
583
+ const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/continue`, {
584
+ method: "POST",
585
+ headers: selection ? { "Content-Type": "application/json" } : undefined,
586
+ body: selection ? JSON.stringify({ selection }) : undefined,
587
+ });
588
+ const body = await response.json().catch(() => ({}));
589
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
590
+ await selectLive(body.key);
591
+ return body.key;
592
+ }
593
+
594
+ function setComposerMode(mode) {
595
+ el.composer.classList.remove("hidden");
596
+ el.input.placeholder =
597
+ mode === "resume"
598
+ ? "Continue this conversation… (Enter to send)"
599
+ : "Message the agent… (Enter to send, Shift+Enter for a new line)";
600
+ }
601
+
602
+ function hideComposer() {
603
+ preferences.clear();
604
+ el.composer.classList.add("hidden");
605
+ renderApproval(null);
606
+ }
607
+
608
+ function renderApprovalDetails(request, fallbackCwd = "") {
609
+ const details = document.createElement("div");
610
+ details.className = "approval-details";
611
+ const tool = document.createElement("div");
612
+ tool.className = "approval-tool";
613
+ tool.textContent = request.tool || "Tool call";
614
+ details.appendChild(tool);
615
+
616
+ const cwd = request.cwd || fallbackCwd;
617
+ if (cwd) {
618
+ const location = document.createElement("div");
619
+ location.className = "approval-location";
620
+ location.textContent = `Working directory: ${cwd}`;
621
+ details.appendChild(location);
622
+ }
623
+
624
+ const args = request.arguments;
625
+ // `reason` is the agent's own justification for asking. It gets its own line so
626
+ // the command block below stays exactly what would run.
627
+ const stated = args && typeof args.reason === "string" ? args.reason.trim() : "";
628
+ const callArgs = args
629
+ ? Object.fromEntries(Object.entries(args).filter(([name]) => name !== "reason"))
630
+ : null;
631
+ const command = callArgs && typeof callArgs.command === "string" ? callArgs.command : null;
632
+ const code = document.createElement("pre");
633
+ code.className = "approval-code";
634
+ // Literal text preserves the complete command and prevents tool arguments
635
+ // from becoming markup. Risk summaries may be shortened; arguments are not.
636
+ code.textContent = command !== null ? command
637
+ : callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || "Command details unavailable.";
638
+ details.appendChild(code);
639
+
640
+ if (stated) {
641
+ const why = document.createElement("div");
642
+ why.className = "approval-why";
643
+ const label = document.createElement("strong");
644
+ label.textContent = "Why: ";
645
+ const claim = document.createElement("span");
646
+ claim.textContent = stated;
647
+ why.append(label, claim);
648
+ details.appendChild(why);
649
+ }
650
+
651
+ if (command !== null && Object.keys(callArgs).some(name => name !== "command")) {
652
+ const argumentsBox = document.createElement("details");
653
+ argumentsBox.className = "approval-arguments";
654
+ const label = document.createElement("summary");
655
+ label.textContent = "All arguments";
656
+ const parameters = document.createElement("pre");
657
+ parameters.className = "approval-code";
658
+ parameters.textContent = JSON.stringify(callArgs, null, 2);
659
+ argumentsBox.append(label, parameters);
660
+ details.appendChild(argumentsBox);
661
+ }
662
+
663
+ if (Array.isArray(request.reasons) && request.reasons.length) {
664
+ const reasons = document.createElement("ul");
665
+ reasons.className = "approval-reasons";
666
+ for (const reason of request.reasons) {
667
+ const item = document.createElement("li");
668
+ item.textContent = reason;
669
+ reasons.appendChild(item);
670
+ }
671
+ details.appendChild(reasons);
672
+ }
673
+ return details;
674
+ }
675
+
676
+ function renderApproval(snapshot) {
677
+ const pending = snapshot && snapshot.approval;
678
+ if (!pending) {
679
+ // An automated review runs before a card exists, so without this the tab
680
+ // just looks stuck for however long the reviewers take.
681
+ const review = snapshot && snapshot.reviewing;
682
+ if (review) {
683
+ el.approval.className = "approval high";
684
+ const head = document.createElement("div");
685
+ head.className = "approval-head";
686
+ const title = document.createElement("strong");
687
+ title.textContent = "Independent review";
688
+ const note = document.createElement("span");
689
+ note.className = "approval-risk";
690
+ // Delete-class calls report which of the three separate rounds is running.
691
+ const rounds = review.rounds || 1;
692
+ const which = rounds > 1 ? `round ${review.round || 1} of ${rounds}, ` : "";
693
+ note.textContent = `${which}${review.total} reviewer${review.total > 1 ? "s" : ""}`;
694
+ head.append(title, note);
695
+ if (review.request) {
696
+ el.approval.replaceChildren(head, renderApprovalDetails(review.request, snapshot.cwd));
697
+ } else {
698
+ el.approval.replaceChildren(head);
699
+ }
700
+ return;
701
+ }
702
+ el.approval.classList.add("hidden");
703
+ el.approval.replaceChildren();
704
+ return;
705
+ }
706
+ const extreme = pending.level === "extreme";
707
+ el.approval.className = "approval " + (extreme ? "extreme" : "high");
708
+
709
+ const head = document.createElement("div");
710
+ head.className = "approval-head";
711
+ const title = document.createElement("strong");
712
+ title.textContent = "Request Approval";
713
+ const risk = document.createElement("span");
714
+ risk.className = "approval-risk";
715
+ risk.textContent = extreme ? "EXTREME RISK" : "HIGH RISK";
716
+ head.append(title, risk);
717
+
718
+ const details = renderApprovalDetails(pending, snapshot.cwd);
719
+
720
+ const actions = document.createElement("div");
721
+ actions.className = "approval-actions";
722
+ const approve = document.createElement("button");
723
+ approve.className = "approve-btn";
724
+ approve.textContent = "Approve";
725
+ approve.addEventListener("click", () => decideApproval(true));
726
+ const deny = document.createElement("button");
727
+ deny.className = "deny-btn";
728
+ deny.textContent = "Deny";
729
+ deny.addEventListener("click", () => decideApproval(false));
730
+ actions.append(approve, deny);
731
+
732
+ el.approval.replaceChildren(head, details, actions);
733
+ }
734
+
735
+ function decideApproval(approved) {
736
+ const snapshot = state.snapshot;
737
+ if (!snapshot || !snapshot.approval) return;
738
+ postAction(snapshot.key, "approve", { approved });
739
+ }
740
+
741
+ function renderLive() {
742
+ const snapshot = state.snapshot;
743
+ if (!snapshot) return;
744
+ el.title.textContent = snapshot.title || "New conversation";
745
+ el.sub.textContent = [snapshot.cwd, snapshot.model, snapshot.provider].filter(Boolean).join(" · ");
746
+ preferences.showConversation(snapshot, `live:${snapshot.key}`);
747
+ renderControls(snapshot);
748
+ renderApproval(snapshot);
749
+ renderMessageList(snapshot.messages ?? [], snapshot.streamingContent, {
750
+ readOnly: false,
751
+ context: snapshot.context,
752
+ // The turn in flight must stay readable: only a finished turn folds. A
753
+ // paused turn is unfinished by the user's own choice, so it stays open too.
754
+ active: Boolean(snapshot.busy || snapshot.paused),
755
+ });
756
+ }
757
+
758
+ async function changeApprovalMode(key, mode) {
759
+ const response = await apiFetch(`/api/live/${encodeURIComponent(key)}/approval-mode`, {
760
+ method: "POST",
761
+ headers: { "Content-Type": "application/json" },
762
+ body: JSON.stringify({ mode }),
763
+ });
764
+ const body = await response.json();
765
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
766
+ if (state.selection?.kind === "live" && state.selection.key === key) {
767
+ state.snapshot = body.snapshot;
768
+ renderLive();
769
+ }
770
+ }
771
+
772
+ function renderApprovalModeControl(mode, change) {
773
+ const automatic = mode === "auto";
774
+ const button = document.createElement("button");
775
+ button.className = "control";
776
+ button.type = "button";
777
+ button.setAttribute("role", "switch");
778
+ button.setAttribute("aria-label", "Auto Approval");
779
+ button.setAttribute("aria-checked", String(automatic));
780
+ button.textContent = automatic ? "Auto Approval" : "Request Approval";
781
+ button.title = "Switch between requesting approval and automatic review";
782
+ button.addEventListener("click", async () => {
783
+ button.disabled = true;
784
+ try {
785
+ await change(automatic ? "request" : "auto");
786
+ } catch (error) {
787
+ el.sub.textContent = `Could not change approval mode: ${error.message}`;
788
+ } finally {
789
+ button.disabled = false;
790
+ }
791
+ });
792
+ el.controls.appendChild(button);
793
+ }
794
+
795
+ function renderControls(snapshot) {
796
+ el.controls.innerHTML = "";
797
+ renderApprovalModeControl(snapshot.approvalMode, mode =>
798
+ changeApprovalMode(snapshot.key, mode));
799
+
800
+ const badge = document.createElement("span");
801
+ const mode = snapshot.paused ? "paused" : snapshot.busy ? "busy" : "idle";
802
+ badge.className = `badge ${mode}`;
803
+ badge.textContent = snapshot.paused
804
+ ? "paused"
805
+ : snapshot.busy
806
+ ? snapshot.compressing
807
+ ? "compressing"
808
+ : "working"
809
+ : "idle";
810
+ el.controls.appendChild(badge);
811
+
812
+ if (snapshot.saveError) {
813
+ const warning = document.createElement("span");
814
+ warning.className = "badge paused";
815
+ warning.textContent = `History save failed: ${snapshot.saveError}`;
816
+ el.controls.appendChild(warning);
817
+ }
818
+
819
+ if (snapshot.busy || snapshot.paused) {
820
+ const button = document.createElement("button");
821
+ button.className = "control";
822
+ button.textContent = snapshot.paused ? "▶ Resume" : "❚❚ Pause";
823
+ button.addEventListener("click", () => {
824
+ postAction(snapshot.key, snapshot.paused ? "resume" : "pause");
825
+ });
826
+ el.controls.appendChild(button);
827
+ }
828
+
829
+ if (snapshot.queued) {
830
+ const queued = document.createElement("span");
831
+ queued.className = "queued";
832
+ queued.title = snapshot.queued;
833
+ queued.textContent = "queued";
834
+ el.controls.appendChild(queued);
835
+ }
836
+
837
+ if (snapshot.tokens && (snapshot.tokens.prompt || snapshot.tokens.completion)) {
838
+ const tokens = document.createElement("span");
839
+ tokens.className = "tokens";
840
+ tokens.textContent = `${snapshot.tokens.prompt}→${snapshot.tokens.completion} tok`;
841
+ el.controls.appendChild(tokens);
842
+ }
843
+ }
844
+
845
+ function renderMessageList(messages, streaming, options) {
846
+ // Re-rendering swaps the whole list, so remember where the reader was: if
847
+ // they scrolled up to read history, keep them there while tokens stream in.
848
+ const nearBottom = isNearBottom();
849
+ const previousTop = el.messages.scrollTop;
850
+ el.messages.innerHTML = "";
851
+
852
+ if (messages.length === 0 && !streaming) {
853
+ const empty = document.createElement("div");
854
+ empty.className = "empty";
855
+ empty.textContent = options?.readOnly ? "This conversation is empty." : "Say something to get started.";
856
+ el.messages.appendChild(empty);
857
+ return;
858
+ }
859
+
860
+ const original = messages.filter(message => message.role !== "system");
861
+ const context = options?.context;
862
+ const end = context && Number.isInteger(context.end) && context.end > 0 &&
863
+ context.end <= original.length && typeof context.summary === "string" && context.summary.trim()
864
+ ? context.end : 0;
865
+ if (end) {
866
+ const details = document.createElement("details");
867
+ details.className = "conversation-history";
868
+ const summary = document.createElement("summary");
869
+ summary.textContent = `Earlier conversation · ${end} messages`;
870
+ details.appendChild(summary);
871
+ const body = document.createElement("div");
872
+ body.className = "history-messages";
873
+ details.appendChild(body);
874
+ const populate = () => {
875
+ if (body.childNodes.length) return;
876
+ original.slice(0, end).forEach((message, index) => body.appendChild(renderMessage(message, index)));
877
+ };
878
+ details.open = state.openContext === end;
879
+ if (details.open) populate();
880
+ details.addEventListener("toggle", () => {
881
+ // Detached disclosures can emit a delayed toggle during a stream repaint.
882
+ if (!details.isConnected) return;
883
+ state.openContext = details.open ? end : null;
884
+ if (details.open) populate();
885
+ else body.replaceChildren();
886
+ });
887
+ el.messages.appendChild(details);
888
+ }
889
+ // Render the tail one turn at a time: a turn the agent has finished folds into
890
+ // a summary line, a running one stays open. The turn in flight is the last one,
891
+ // so `active` decides its state; a turn cut in half by the compressed prefix is
892
+ // rendered as plain messages instead.
893
+ const turns = turnRanges(original);
894
+ const running = options?.active && turns.length > 0 ? turns[turns.length - 1].start : -1;
895
+ for (const turn of turns) {
896
+ const from = Math.max(turn.start, end);
897
+ if (from >= turn.end) continue;
898
+ const body = original.slice(turn.start, turn.end);
899
+ if (from === turn.start && turn.start !== running && turnFolds(body)) {
900
+ el.messages.appendChild(renderTurnFold(turn.start, body));
901
+ continue;
902
+ }
903
+ for (let index = from; index < turn.end; index++) {
904
+ el.messages.appendChild(renderMessage(original[index], index));
905
+ }
906
+ }
907
+
908
+ if (streaming !== null && streaming !== undefined && streaming !== "") {
909
+ const block = renderMessage({ role: "assistant", content: streaming }, -1);
910
+ block.classList.add("streaming");
911
+ el.messages.appendChild(block);
912
+ }
913
+
914
+ if (nearBottom) scrollToBottom();
915
+ else el.messages.scrollTop = previousTop;
916
+ }
917
+
918
+ function renderMessage(message, index) {
919
+ const article = document.createElement("article");
920
+ article.className = `message ${message.role}`;
921
+
922
+ if (message.role === "tool") {
923
+ const details = document.createElement("details");
924
+ details.className = "tool";
925
+ details.dataset.index = String(index);
926
+ if (state.openTools.has(index)) details.open = true;
927
+ details.addEventListener("toggle", () => {
928
+ if (details.open) state.openTools.add(index);
929
+ else state.openTools.delete(index);
930
+ });
931
+
932
+ const summary = document.createElement("summary");
933
+ summary.innerHTML =
934
+ `<span class="tool-name">⚙ ${escapeHtml(message.name || "tool")}</span>` +
935
+ `<span class="tool-args">${escapeHtml(truncate(safeJson(message.arguments), 120))}</span>`;
936
+ details.appendChild(summary);
937
+
938
+ const result = document.createElement("pre");
939
+ result.className = "tool-result";
940
+ result.textContent = message.result || "(no output)";
941
+ details.appendChild(result);
942
+
943
+ article.appendChild(details);
944
+ return article;
945
+ }
946
+
947
+ if (message.role === "summary") {
948
+ article.classList.add("summary");
949
+ article.innerHTML =
950
+ `<div class="summary-label">Context summary · ${Number(message.count ?? 0)} messages</div>` +
951
+ `<div class="body">${renderMarkdown(message.content)}</div>`;
952
+ return article;
953
+ }
954
+
955
+ const who = message.role === "user" ? "You" : "Neutrino";
956
+ article.innerHTML = `<div class="who">${who}</div><div class="body">${renderMarkdown(message.content)}</div>`;
957
+ return article;
958
+ }
959
+
960
+ /**
961
+ * A finished turn, folded to one summary line. The messages are rendered on
962
+ * first open and dropped again on close, so a long transcript costs nothing
963
+ * until the reader asks for it. Nothing is lost either way: this is presentation
964
+ * only, and the saved transcript always holds every message.
965
+ */
966
+ function renderTurnFold(start, messages) {
967
+ const details = document.createElement("details");
968
+ details.className = "turn";
969
+ details.open = state.openTurns.has(start);
970
+
971
+ const count = messages.length;
972
+ const summary = document.createElement("summary");
973
+ const chevron = document.createElement("span");
974
+ chevron.className = "turn-chevron";
975
+ chevron.textContent = "▸";
976
+ const you = document.createElement("span");
977
+ you.className = "turn-you";
978
+ you.textContent = `You: ${truncate(turnPreview(messages, "user"), 110)}`;
979
+ const meta = document.createElement("span");
980
+ meta.className = "turn-meta";
981
+ meta.textContent =
982
+ `${count} message${count === 1 ? "" : "s"} · ` +
983
+ truncate(turnPreview(messages, "assistant", true), 90);
984
+ // The hint cannot live in the static text: it says the opposite once open.
985
+ const hint = document.createElement("span");
986
+ hint.className = "turn-hint";
987
+ meta.appendChild(hint);
988
+ summary.appendChild(chevron);
989
+ summary.appendChild(you);
990
+ summary.appendChild(meta);
991
+ details.appendChild(summary);
992
+
993
+ const syncHint = () => {
994
+ hint.textContent = details.open ? " — click to collapse" : " — click to expand";
995
+ };
996
+ syncHint();
997
+
998
+ const body = document.createElement("div");
999
+ body.className = "turn-messages";
1000
+ details.appendChild(body);
1001
+ const populate = () => {
1002
+ if (body.childNodes.length) return;
1003
+ messages.forEach((message, offset) => body.appendChild(renderMessage(message, start + offset)));
1004
+ };
1005
+ if (details.open) populate();
1006
+ details.addEventListener("toggle", () => {
1007
+ // Detached disclosures can emit a delayed toggle during a stream repaint.
1008
+ if (!details.isConnected) return;
1009
+ if (details.open) {
1010
+ state.openTurns.add(start);
1011
+ populate();
1012
+ } else {
1013
+ state.openTurns.delete(start);
1014
+ body.replaceChildren();
1015
+ }
1016
+ syncHint();
1017
+ });
1018
+ return details;
1019
+ }
1020
+
1021
+ function firstUserMessage(messages) {
1022
+ for (const message of messages) {
1023
+ if (message.role === "user" && message.content) {
1024
+ return truncate(message.content.replace(/\s+/g, " ").trim(), 120);
1025
+ }
1026
+ }
1027
+ return "";
1028
+ }
1029
+
1030
+ function isNearBottom() {
1031
+ return el.messages.scrollHeight - el.messages.scrollTop - el.messages.clientHeight < 90;
1032
+ }
1033
+
1034
+ function scrollToBottom() {
1035
+ el.messages.scrollTop = el.messages.scrollHeight;
1036
+ }
1037
+
1038
+ // ------------------------------------------------------------------- actions
1039
+
1040
+ async function postAction(key, action, body) {
1041
+ try {
1042
+ await apiFetch(`/api/live/${encodeURIComponent(key)}/${action}`, {
1043
+ method: "POST",
1044
+ headers: body ? { "Content-Type": "application/json" } : undefined,
1045
+ body: body ? JSON.stringify(body) : undefined,
1046
+ });
1047
+ } catch (error) {
1048
+ console.error(error);
1049
+ }
1050
+ }
1051
+
1052
+ async function sendMessage() {
1053
+ const selection = state.selection;
1054
+ if (!selection) return;
1055
+ const text = el.input.value;
1056
+ if (!text.trim()) return;
1057
+
1058
+ if (selection.kind === "live") {
1059
+ el.input.value = "";
1060
+ autosize();
1061
+ await postAction(selection.key, "message", { text });
1062
+ return;
1063
+ }
1064
+
1065
+ // Reading a saved transcript: resume it in the browser, then deliver the text.
1066
+ el.input.value = "";
1067
+ autosize();
1068
+ try {
1069
+ const key = await continueSession(selection.id);
1070
+ await postAction(key, "message", { text });
1071
+ } catch (error) {
1072
+ el.input.value = text;
1073
+ autosize();
1074
+ el.sub.textContent = `Could not continue this conversation: ${error.message}`;
1075
+ }
1076
+ }
1077
+
1078
+ function autosize() {
1079
+ el.input.style.height = "auto";
1080
+ el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
1081
+ }
1082
+
1083
+ // --------------------------------------------------------------------- init
1084
+
1085
+ el.refresh.addEventListener("click", () => loadOverview());
1086
+ el.back.addEventListener("click", () => setConversationView(false));
1087
+ el.send.addEventListener("click", sendMessage);
1088
+ el.input.addEventListener("input", autosize);
1089
+ el.input.addEventListener("keydown", (event) => {
1090
+ if (event.key === "Enter" && !event.shiftKey) {
1091
+ event.preventDefault();
1092
+ sendMessage();
1093
+ }
1094
+ });
1095
+
1096
+ window.addEventListener("beforeunload", closeStream);
1097
+
1098
+ preferences.load().catch(error => console.error(error));
1099
+ loadOverview();
1100
+ setInterval(() => loadOverview({ silent: true }), REFRESH_MS);