@xingjisoft/neutrino 0.2.1 → 0.3.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/web/app.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createSettingsUI } from "./settings.js";
2
+ import { renderMarkdown as renderMarkdownHtml } from "./markdown.js";
2
3
  import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
4
+ import { applyTranslations, getLanguage as currentLanguage, locale as currentLocale, setLanguage as setI18nLanguage, t as translate } from "./i18n.js";
3
5
 
4
6
  // Neutrino web UI — a dependency-free single page app.
5
7
  //
@@ -21,6 +23,43 @@ const REFRESH_MS = 4000;
21
23
  // Login is remembered in an HttpOnly cookie shared by fetch and EventSource.
22
24
  // Remove credentials left behind by older versions of the UI.
23
25
  try { localStorage.removeItem("neutrino.token"); } catch { /* Storage may be blocked. */ }
26
+
27
+ // Translation lookup. `translate` is an ES-module import; the browser tests
28
+ // that evaluate this file in a sandbox strip imports, so the helper degrades to
29
+ // the English source text instead of throwing. Interpolation is kept here too.
30
+ const tr = (text, values) => {
31
+ if (typeof translate === "function") return translate(text, values);
32
+ return String(text ?? "").replace(/\{(\w+)\}/g, (match, name) =>
33
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match);
34
+ };
35
+
36
+ function uiLanguage() {
37
+ return typeof currentLanguage === "function" ? currentLanguage() : "en";
38
+ }
39
+
40
+ function setUiLanguage(value) {
41
+ return typeof setI18nLanguage === "function" ? setI18nLanguage(value) : "en";
42
+ }
43
+
44
+ function localeTag() {
45
+ return typeof currentLocale === "function" ? currentLocale() : "en-US";
46
+ }
47
+
48
+ function applyStaticTranslations() {
49
+ if (typeof applyTranslations === "function") applyTranslations(document);
50
+ }
51
+
52
+ function readStorage(key) {
53
+ try {
54
+ return localStorage.getItem(key) || "";
55
+ } catch {
56
+ return "";
57
+ }
58
+ }
59
+
60
+ function writeStorage(key, value) {
61
+ try { localStorage.setItem(key, value); } catch { /* Private mode may block storage. */ }
62
+ }
24
63
  async function loginWithPassword(password) {
25
64
  const response = await fetch("/api/auth/login", {
26
65
  method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
@@ -84,12 +123,12 @@ function showGate(message = "") {
84
123
  gate.innerHTML =
85
124
  '<form class="gate-card">' +
86
125
  '<div class="gate-brand"><span class="logo">' + LOGO_MARK + "</span><strong>Neutrino</strong></div>" +
87
- '<h2 id="login-title">Sign in</h2>' +
88
- '<p>Enter your Web password to open this console. The default password is 114514.</p>' +
89
- '<label for="login-password">Password</label>' +
126
+ '<h2 id="login-title">' + tr("Sign in") + "</h2>" +
127
+ "<p>" + tr("Enter your Web password to open this console. The default password is 114514.") + "</p>" +
128
+ '<label for="login-password">' + tr("Password") + "</label>" +
90
129
  '<input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="256" required autofocus>' +
91
130
  '<p class="form-feedback error" role="alert"></p>' +
92
- '<button class="primary" type="submit">Sign in</button>' +
131
+ '<button class="primary" type="submit">' + tr("Sign in") + "</button>" +
93
132
  "</form>";
94
133
  gate.querySelector(".form-feedback").textContent = message;
95
134
  gate.addEventListener("cancel", event => event.preventDefault());
@@ -127,11 +166,61 @@ const el = {
127
166
  input: document.getElementById("input"),
128
167
  send: document.getElementById("send"),
129
168
  approval: document.getElementById("approval"),
169
+ emptyNewChat: document.getElementById("empty-new-chat"),
170
+ newChat: document.getElementById("new-chat"),
171
+ workspaceButton: document.getElementById("workspace-button"),
172
+ workspaceLabel: document.getElementById("workspace-label"),
173
+ languageSelect: document.getElementById("language-select"),
174
+ planCard: document.getElementById("plan-card"),
175
+ workspaceDialog: document.getElementById("workspace-dialog"),
176
+ workspaceList: document.getElementById("workspace-list"),
177
+ workspaceAll: document.getElementById("workspace-all"),
178
+ workspaceAdd: document.getElementById("workspace-add"),
179
+ workspaceClose: document.getElementById("workspace-close"),
180
+ workspaceFeedback: document.getElementById("workspace-feedback"),
181
+ newChatDialog: document.getElementById("new-chat-dialog"),
182
+ newChatForm: document.getElementById("new-chat-form"),
183
+ newChatTitle: document.getElementById("new-chat-title"),
184
+ newChatClose: document.getElementById("new-chat-close"),
185
+ newChatCancel: document.getElementById("new-chat-cancel"),
186
+ newChatStart: document.getElementById("new-chat-start"),
187
+ newChatCwd: document.getElementById("new-chat-cwd"),
188
+ newChatBrowse: document.getElementById("new-chat-browse"),
189
+ newChatRecents: document.getElementById("new-chat-recents"),
190
+ newChatProvider: document.getElementById("new-chat-provider"),
191
+ newChatModel: document.getElementById("new-chat-model"),
192
+ newChatIntensity: document.getElementById("new-chat-intensity"),
193
+ newChatApproval: document.getElementById("new-chat-approval"),
194
+ newChatNote: document.getElementById("new-chat-note"),
195
+ newChatFeedback: document.getElementById("new-chat-feedback"),
196
+ folderDialog: document.getElementById("folder-dialog"),
197
+ folderTitle: document.getElementById("folder-title"),
198
+ folderClose: document.getElementById("folder-close"),
199
+ folderCancel: document.getElementById("folder-cancel"),
200
+ folderUse: document.getElementById("folder-use"),
201
+ folderUp: document.getElementById("folder-up"),
202
+ folderPath: document.getElementById("folder-path"),
203
+ folderGo: document.getElementById("folder-go"),
204
+ folderRoots: document.getElementById("folder-roots"),
205
+ folderList: document.getElementById("folder-list"),
206
+ folderNew: document.getElementById("folder-new"),
207
+ folderNewName: document.getElementById("folder-new-name"),
208
+ folderNewCreate: document.getElementById("folder-new-create"),
209
+ folderNewCancel: document.getElementById("folder-new-cancel"),
210
+ folderMkdir: document.getElementById("folder-mkdir"),
211
+ folderFeedback: document.getElementById("folder-feedback"),
212
+ agentsOpen: document.getElementById("agents-open"),
213
+ agentsDialog: document.getElementById("agents-dialog"),
214
+ agentsClose: document.getElementById("agents-close"),
215
+ agentsList: document.getElementById("agents-list"),
216
+ agentsFeedback: document.getElementById("agents-feedback"),
217
+ agentsCwd: document.getElementById("agents-cwd"),
130
218
  };
131
219
 
132
220
  const state = {
133
221
  overview: null,
134
- // { kind: "live", key } | { kind: "file", id } | null
222
+ // { kind: "live", key } | { kind: "file", id } | { kind: "new", cwd } |
223
+ // { kind: "agent", agent, id, title } | null
135
224
  selection: null,
136
225
  snapshot: null,
137
226
  eventSource: null,
@@ -142,10 +231,32 @@ const state = {
142
231
  // Turns the reader expanded; a finished turn is folded unless listed here.
143
232
  openTurns: new Set(),
144
233
  openContext: null,
234
+ // Last loaded saved transcript, kept so a language switch can repaint it.
235
+ fileData: null,
236
+ // Last loaded read-only transcript from another agent.
237
+ agentData: null,
238
+ // { key, path } | null — the working directory the sidebar is scoped to.
239
+ workspace: storedWorkspace(),
240
+ // Plan / usage card visibility and the plan it was opened for.
241
+ planOpen: false,
242
+ planSeen: "",
243
+ composerMode: "live",
244
+ // Folder-picker state: where it is, what to do with the picked path.
245
+ folder: { path: "", parent: null, roots: [], onPick: null, mode: "workspace", loading: false },
246
+ workspaces: null,
247
+ // Translation key and colour class of the sidebar status line.
248
+ status: { key: "connecting…", kind: "" },
145
249
  };
146
250
 
251
+ function storedWorkspace() {
252
+ const path = readStorage("neutrino.workspace");
253
+ if (!path) return null;
254
+ return { key: pathKey(path), path };
255
+ }
256
+
147
257
  const preferences = createSettingsUI({
148
258
  apiFetch,
259
+ onLanguageChange: (language) => changeInterfaceLanguage(language),
149
260
  async applyModel(selection) {
150
261
  const current = state.selection;
151
262
  if (!current) throw new Error("Select a conversation first.");
@@ -258,6 +369,37 @@ function shortenPath(value, depth = 2) {
258
369
  return `…${separator}${parts.slice(-depth).join(separator)}`;
259
370
  }
260
371
 
372
+ /**
373
+ * Canonical form of a working directory, matching the server's `normalizeCwd`.
374
+ * It is only used to decide which sidebar group a path belongs to.
375
+ */
376
+ function pathKey(value) {
377
+ let text = String(value ?? "").trim();
378
+ if (!text) return "";
379
+ text = text.replace(/^\\\\\?\\/, "").replace(/\\/g, "/").replace(/\/{2,}/g, "/");
380
+ const wsl = /^\/(?:mnt|cygdrive)\/([a-zA-Z])(\/.*)?$/.exec(text);
381
+ const win = /^([a-zA-Z]):(\/.*)?$/.exec(text);
382
+ const drive = wsl ?? win;
383
+ if (drive) {
384
+ const rest = (drive[2] || "/").replace(/\/+$/, "") || "/";
385
+ return `${drive[1].toLowerCase()}:${rest.toLowerCase()}`;
386
+ }
387
+ return text.length > 1 ? text.replace(/\/+$/, "") : text;
388
+ }
389
+
390
+ /** Last path segment, used for compact workspace labels. */
391
+ function workspaceName(value) {
392
+ const parts = String(value ?? "").split(/[\\/]+/).filter(Boolean);
393
+ return parts.length ? parts[parts.length - 1] : String(value ?? "");
394
+ }
395
+
396
+ function setWorkspace(workspace, { persist = true } = {}) {
397
+ state.workspace = workspace ? { key: pathKey(workspace.path), path: workspace.path } : null;
398
+ if (persist) writeStorage("neutrino.workspace", state.workspace?.path ?? "");
399
+ renderWorkspaceChrome();
400
+ renderSidebar();
401
+ }
402
+
261
403
  function safeJson(value) {
262
404
  try {
263
405
  return JSON.stringify(value ?? {});
@@ -272,44 +414,33 @@ function formatTime(value) {
272
414
  if (Number.isNaN(date.getTime())) return "";
273
415
  const now = Date.now();
274
416
  const diff = now - date.getTime();
275
- if (diff < 60_000) return "just now";
276
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
277
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
278
- return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
417
+ if (diff < 60_000) return tr("just now");
418
+ if (diff < 3_600_000) return tr("{n}m ago", { n: Math.floor(diff / 60_000) });
419
+ if (diff < 86_400_000) return tr("{n}h ago", { n: Math.floor(diff / 3_600_000) });
420
+ return date.toLocaleDateString(localeTag(), { month: "short", day: "numeric" });
279
421
  }
280
422
 
281
423
  function formatDate(value) {
282
424
  if (!value) return "";
283
425
  const date = new Date(value);
284
- return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
426
+ return Number.isNaN(date.getTime()) ? "" : date.toLocaleString(localeTag());
285
427
  }
286
428
 
287
- function setStatus(text, kind) {
288
- el.status.textContent = text;
429
+ function setStatus(key, kind) {
430
+ // Store the translation key, not the rendered text: switching languages must
431
+ // not leave a stale word in the sidebar.
432
+ state.status = { key, kind };
433
+ el.status.textContent = tr(key);
289
434
  el.status.className = `status${kind ? ` ${kind}` : ""}`;
290
435
  }
291
436
 
292
- // A deliberately small markdown renderer: code fences, inline code, bold,
293
- // italics and links. Everything else is escaped, so model output is inert.
437
+ // Full markdown lives in web/markdown.js: headings, lists, tables, code,
438
+ // quotes, links, images and the inline emphasis family. The fallback keeps the
439
+ // sandboxed browser tests working when module imports are stripped; it still
440
+ // escapes all model output before adding line breaks.
294
441
  function renderMarkdown(value) {
295
- const placeholders = [];
296
- let source = String(value ?? "");
297
- source = source.replace(/```[\w-]*\n?([\s\S]*?)```/g, (_, code) => {
298
- const id = `\u0000CODE${placeholders.length}\u0000`;
299
- placeholders.push(`<pre class="code"><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
300
- return id;
301
- });
302
- source = escapeHtml(source);
303
- source = source.replace(/`([^`\n]+)`/g, "<code>$1</code>");
304
- source = source.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
305
- source = source.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
306
- source = source.replace(
307
- /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
308
- '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
309
- );
310
- source = source.replace(/\n/g, "<br>");
311
- source = source.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => placeholders[Number(index)] ?? "");
312
- return source;
442
+ if (typeof renderMarkdownHtml === "function") return renderMarkdownHtml(value);
443
+ return escapeHtml(String(value ?? "")).replace(/\n/g, "<br>");
313
444
  }
314
445
 
315
446
  // ------------------------------------------------------------------- status
@@ -324,6 +455,7 @@ async function loadOverview(options = {}) {
324
455
  if (gateShown) return;
325
456
  state.overview = overview;
326
457
  setStatus(state.selection?.kind === "live" ? "live" : "online", state.selection?.kind === "live" ? "live" : "online");
458
+ renderWorkspaceChrome();
327
459
  renderSidebar();
328
460
  syncSelection();
329
461
  } catch (error) {
@@ -350,35 +482,49 @@ function syncSelection() {
350
482
  renderSidebar();
351
483
  hideComposer();
352
484
  el.controls.innerHTML = "";
353
- el.title.textContent = "Session ended";
354
- el.sub.textContent = "The terminal session was closed.";
355
- el.messages.innerHTML = '<div class="empty">This live session has ended.</div>';
485
+ el.title.textContent = tr("Session ended");
486
+ el.sub.textContent = tr("The terminal session was closed.");
487
+ el.messages.innerHTML = `<div class="empty">${escapeHtml(tr("This live session has ended."))}</div>`;
488
+ renderPlanCard(null);
356
489
  }
357
490
 
358
491
  // ------------------------------------------------------------------ sidebar
359
492
 
493
+ /** Sidebar groups for the current view: every folder, or only the open workspace. */
494
+ function viewGroups() {
495
+ if (!state.overview) return null;
496
+ if (!state.workspace) return state.overview.groups;
497
+ const found = state.overview.groups.find((group) => group.key === state.workspace.key);
498
+ return found ? [found] : [{ key: state.workspace.key, cwd: state.workspace.path, updated_at: "", sessions: [] }];
499
+ }
500
+
360
501
  function renderSidebar() {
361
- const overview = state.overview;
362
502
  el.sessions.innerHTML = "";
503
+ const groups = viewGroups();
363
504
 
364
- if (!overview || overview.groups.length === 0) {
505
+ if (!groups || groups.length === 0) {
365
506
  const empty = document.createElement("div");
366
507
  empty.className = "empty-side";
367
- empty.textContent = "No conversations yet.";
508
+ empty.textContent = state.workspace ? tr("No conversations in this folder yet.") : tr("No conversations yet.");
368
509
  el.sessions.appendChild(empty);
369
510
  return;
370
511
  }
371
512
 
372
- for (const group of overview.groups) {
373
- el.sessions.appendChild(renderGroup(group));
513
+ for (const group of groups) el.sessions.appendChild(renderGroup(group, { forceOpen: Boolean(state.workspace) }));
514
+
515
+ if (state.workspace && groups[0] && groups[0].sessions.length === 0) {
516
+ const note = document.createElement("div");
517
+ note.className = "empty-side workspace-hint";
518
+ note.textContent = tr("Type a message below to start a conversation in this folder.");
519
+ el.sessions.appendChild(note);
374
520
  }
375
521
  }
376
522
 
377
- function renderGroup(group) {
523
+ function renderGroup(group, options = {}) {
378
524
  const wrap = document.createElement("section");
379
525
  wrap.className = "group";
380
526
 
381
- const collapsed = state.collapsed.has(group.key);
527
+ const collapsed = !options.forceOpen && state.collapsed.has(group.key);
382
528
  const header = document.createElement("button");
383
529
  header.className = "group-header";
384
530
  header.innerHTML =
@@ -402,11 +548,18 @@ function renderGroup(group) {
402
548
  const visible = expanded ? group.sessions : group.sessions.slice(0, MAX_VISIBLE);
403
549
  for (const session of visible) list.appendChild(renderSession(session));
404
550
 
551
+ if (group.sessions.length === 0) {
552
+ const empty = document.createElement("div");
553
+ empty.className = "empty-side";
554
+ empty.textContent = tr("No conversations in this folder yet.");
555
+ list.appendChild(empty);
556
+ }
557
+
405
558
  const hiddenCount = group.sessions.length - MAX_VISIBLE;
406
559
  if (hiddenCount > 0) {
407
560
  const more = document.createElement("button");
408
561
  more.className = "more";
409
- more.textContent = expanded ? "Show less" : `Show ${hiddenCount} more`;
562
+ more.textContent = expanded ? tr("Show less") : tr("Show {n} more", { n: hiddenCount });
410
563
  more.addEventListener("click", () => {
411
564
  if (state.expanded.has(group.key)) state.expanded.delete(group.key);
412
565
  else state.expanded.add(group.key);
@@ -432,15 +585,15 @@ function renderSession(session) {
432
585
  if (isSelected(session)) item.classList.add("selected");
433
586
  if (session.live) item.classList.add("live");
434
587
 
435
- const title = session.title || "New conversation";
588
+ const title = session.title || tr("New conversation");
436
589
  const count = Number(session.message_count ?? 0);
437
590
  item.title = title;
438
591
  item.innerHTML =
439
592
  `<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
440
- `${session.live ? '<span class="dot" title="Live"></span>' : ""}</div>` +
593
+ `${session.live ? `<span class="dot" title="${escapeHtml(tr("Live"))}"></span>` : ""}</div>` +
441
594
  `<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
442
595
  `<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
443
- `<span class="count">${count} message${count === 1 ? "" : "s"}</span></div>`;
596
+ `<span class="count">${escapeHtml(count === 1 ? tr("{n} message", { n: count }) : tr("{n} messages", { n: count }))}</span></div>`;
444
597
 
445
598
  item.addEventListener("click", () => {
446
599
  if (session.liveKey) selectLive(session.liveKey);
@@ -475,17 +628,22 @@ async function selectLive(key) {
475
628
  closeStream();
476
629
  state.selection = { kind: "live", key };
477
630
  state.snapshot = null;
631
+ state.fileData = null;
632
+ state.agentData = null;
478
633
  state.lastSessionId = null;
479
634
  state.openTools.clear();
480
635
  state.openTurns.clear();
481
636
  state.openContext = null;
637
+ state.planOpen = false;
638
+ state.planSeen = "";
482
639
  renderSidebar();
483
640
  el.controls.innerHTML = "";
484
641
  renderApproval(null);
485
- el.title.textContent = "Connecting…";
642
+ renderPlanCard(null);
643
+ el.title.textContent = tr("Connecting…");
486
644
  el.sub.textContent = "";
487
645
  setComposerMode("live");
488
- resetMessagePane("Connecting…");
646
+ resetMessagePane(tr("Connecting…"));
489
647
  openStream(key);
490
648
  }
491
649
 
@@ -534,16 +692,21 @@ async function selectFile(id) {
534
692
  closeStream();
535
693
  state.selection = { kind: "file", id };
536
694
  state.snapshot = null;
695
+ state.fileData = null;
696
+ state.agentData = null;
537
697
  state.openTools.clear();
538
698
  state.openTurns.clear();
539
699
  state.openContext = null;
700
+ state.planOpen = false;
701
+ state.planSeen = "";
540
702
  renderSidebar();
541
703
  el.controls.innerHTML = "";
542
704
  renderApproval(null);
705
+ renderPlanCard(null);
543
706
  setComposerMode("resume");
544
- el.title.textContent = "Loading…";
707
+ el.title.textContent = tr("Loading…");
545
708
  el.sub.textContent = "";
546
- resetMessagePane("Loading…");
709
+ resetMessagePane(tr("Loading…"));
547
710
 
548
711
  try {
549
712
  const response = await apiFetch(`/api/session/${encodeURIComponent(id)}`, { cache: "no-store" });
@@ -551,8 +714,8 @@ async function selectFile(id) {
551
714
  const data = await response.json();
552
715
  renderFile(data);
553
716
  } catch (error) {
554
- el.title.textContent = "Conversation";
555
- resetMessagePane(`Could not load this conversation: ${error.message}`);
717
+ el.title.textContent = tr("Conversation");
718
+ resetMessagePane(tr("Could not load this conversation: {error}", { error: error.message }));
556
719
  }
557
720
  }
558
721
 
@@ -560,11 +723,15 @@ async function selectFile(id) {
560
723
 
561
724
  function renderFile(data) {
562
725
  const messages = Array.isArray(data.messages) ? data.messages : [];
563
- el.title.textContent = firstUserMessage(messages) || "Conversation";
726
+ state.fileData = data;
727
+ state.agentData = null;
728
+ el.title.textContent = firstUserMessage(messages) || tr("Conversation");
564
729
  el.sub.textContent = [data.cwd, data.model, data.provider].filter(Boolean).join(" · ");
565
730
  preferences.showConversation(data, `file:${data.id}`);
566
731
  renderMessageList(messages, null, { readOnly: true, context: data.context, active: false });
567
732
  renderFileControls(data);
733
+ maybeOpenPlan(data.plan);
734
+ renderPlanCard(data);
568
735
  }
569
736
 
570
737
  /**
@@ -573,26 +740,27 @@ function renderFile(data) {
573
740
  */
574
741
  function renderFileControls(data) {
575
742
  el.controls.innerHTML = "";
743
+ el.controls.appendChild(planToggleButton());
576
744
 
577
745
  const badge = document.createElement("span");
578
746
  badge.className = "badge readonly";
579
- badge.textContent = "Read-only";
747
+ badge.textContent = tr("Read-only");
580
748
  el.controls.appendChild(badge);
581
749
 
582
750
  const button = document.createElement("button");
583
751
  button.className = "control primary";
584
- button.textContent = "Continue here";
585
- button.title = "Resume this conversation in the browser and keep chatting";
752
+ button.textContent = tr("Continue here");
753
+ button.title = tr("Resume this conversation in the browser and keep chatting");
586
754
  button.addEventListener("click", async () => {
587
755
  button.disabled = true;
588
- button.textContent = "Starting…";
756
+ button.textContent = tr("Starting…");
589
757
  try {
590
758
  await continueSession(data.id);
591
759
  el.input.focus();
592
760
  } catch (error) {
593
761
  button.disabled = false;
594
- button.textContent = "Continue here";
595
- el.sub.textContent = `Could not continue this conversation: ${error.message}`;
762
+ button.textContent = tr("Continue here");
763
+ el.sub.textContent = tr("Could not continue this conversation: {error}", { error: error.message });
596
764
  }
597
765
  });
598
766
  el.controls.appendChild(button);
@@ -600,6 +768,13 @@ function renderFileControls(data) {
600
768
  const key = await continueSession(data.id);
601
769
  await changeApprovalMode(key, mode);
602
770
  });
771
+
772
+ const remove = document.createElement("button");
773
+ remove.className = "control danger";
774
+ remove.textContent = tr("Delete");
775
+ remove.title = tr("Delete conversation");
776
+ remove.addEventListener("click", () => deleteConversation(data.id, remove));
777
+ el.controls.appendChild(remove);
603
778
  }
604
779
 
605
780
  /** Attach to a resumed conversation, creating it server-side when needed. */
@@ -616,11 +791,13 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
616
791
  }
617
792
 
618
793
  function setComposerMode(mode) {
794
+ state.composerMode = mode;
619
795
  el.composer.classList.remove("hidden");
620
- el.input.placeholder =
621
- mode === "resume"
622
- ? "Continue this conversation…"
623
- : "Message Neutrino…";
796
+ el.input.placeholder = mode === "resume"
797
+ ? tr("Continue this conversation…")
798
+ : mode === "new"
799
+ ? tr("Message Neutrino in this folder…")
800
+ : tr("Message Neutrino…");
624
801
  }
625
802
 
626
803
  function hideComposer() {
@@ -630,18 +807,24 @@ function hideComposer() {
630
807
  }
631
808
 
632
809
  function renderApprovalDetails(request, fallbackCwd = "") {
810
+ // The approval renderer is exercised on its own in the browser tests; fall
811
+ // back to the English source if the translation helper is out of scope.
812
+ const tx = (text, values) => (typeof tr === "function"
813
+ ? tr(text, values)
814
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
815
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
633
816
  const details = document.createElement("div");
634
817
  details.className = "approval-details";
635
818
  const tool = document.createElement("div");
636
819
  tool.className = "approval-tool";
637
- tool.textContent = request.tool || "Tool call";
820
+ tool.textContent = request.tool || tx("Tool call");
638
821
  details.appendChild(tool);
639
822
 
640
823
  const cwd = request.cwd || fallbackCwd;
641
824
  if (cwd) {
642
825
  const location = document.createElement("div");
643
826
  location.className = "approval-location";
644
- location.textContent = `Working directory: ${cwd}`;
827
+ location.textContent = tx("Working directory: {path}", { path: cwd });
645
828
  details.appendChild(location);
646
829
  }
647
830
 
@@ -658,14 +841,14 @@ function renderApprovalDetails(request, fallbackCwd = "") {
658
841
  // Literal text preserves the complete command and prevents tool arguments
659
842
  // from becoming markup. Risk summaries may be shortened; arguments are not.
660
843
  code.textContent = command !== null ? command
661
- : callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || "Command details unavailable.";
844
+ : callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || tx("Command details unavailable.");
662
845
  details.appendChild(code);
663
846
 
664
847
  if (stated) {
665
848
  const why = document.createElement("div");
666
849
  why.className = "approval-why";
667
850
  const label = document.createElement("strong");
668
- label.textContent = "Why: ";
851
+ label.textContent = tx("Why: ");
669
852
  const claim = document.createElement("span");
670
853
  claim.textContent = stated;
671
854
  why.append(label, claim);
@@ -676,7 +859,7 @@ function renderApprovalDetails(request, fallbackCwd = "") {
676
859
  const argumentsBox = document.createElement("details");
677
860
  argumentsBox.className = "approval-arguments";
678
861
  const label = document.createElement("summary");
679
- label.textContent = "All arguments";
862
+ label.textContent = tx("All arguments");
680
863
  const parameters = document.createElement("pre");
681
864
  parameters.className = "approval-code";
682
865
  parameters.textContent = JSON.stringify(callArgs, null, 2);
@@ -697,33 +880,39 @@ function renderApprovalDetails(request, fallbackCwd = "") {
697
880
  return details;
698
881
  }
699
882
 
883
+ /**
884
+ * Progress text for an automated review. It belongs in the status badge, not
885
+ * in the approval banner: automatic review needs no answer from the user, and
886
+ * reusing the approval card made the browser look like it was still asking for
887
+ * one while the reviewers were already deciding.
888
+ */
889
+ function reviewStatus(review) {
890
+ const tx = (text, values) => (typeof tr === "function"
891
+ ? tr(text, values)
892
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
893
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
894
+ const rounds = Number(review && review.rounds) || 1;
895
+ const total = Number(review && review.total) || 0;
896
+ if (rounds > 1) {
897
+ return tx("reviewing round {round}/{rounds}", { round: (review && review.round) || 1, rounds });
898
+ }
899
+ if (total === 1) return tx("reviewing · {n} reviewer", { n: total });
900
+ if (total > 1) return tx("reviewing · {n} reviewers", { n: total });
901
+ return tx("reviewing");
902
+ }
903
+
700
904
  function renderApproval(snapshot) {
905
+ const tx = (text, values) => (typeof tr === "function"
906
+ ? tr(text, values)
907
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
908
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
701
909
  const pending = snapshot && snapshot.approval;
702
910
  if (!pending) {
703
- // An automated review runs before a card exists, so without this the tab
704
- // just looks stuck for however long the reviewers take.
705
- const review = snapshot && snapshot.reviewing;
706
- if (review) {
707
- el.approval.className = "approval high";
708
- const head = document.createElement("div");
709
- head.className = "approval-head";
710
- const title = document.createElement("strong");
711
- title.textContent = "Independent review";
712
- const note = document.createElement("span");
713
- note.className = "approval-risk";
714
- // Delete-class calls report which of the three separate rounds is running.
715
- const rounds = review.rounds || 1;
716
- const which = rounds > 1 ? `round ${review.round || 1} of ${rounds}, ` : "";
717
- note.textContent = `${which}${review.total} reviewer${review.total > 1 ? "s" : ""}`;
718
- head.append(title, note);
719
- if (review.request) {
720
- el.approval.replaceChildren(head, renderApprovalDetails(review.request, snapshot.cwd));
721
- } else {
722
- el.approval.replaceChildren(head);
723
- }
724
- return;
725
- }
726
- el.approval.classList.add("hidden");
911
+ // The banner is reserved for a call that is really waiting for a human.
912
+ // Automatic review progress lives in the controls badge (see renderControls),
913
+ // because otherwise it reads as a second approval request that then
914
+ // disappears by itself.
915
+ el.approval.className = "approval hidden";
727
916
  el.approval.replaceChildren();
728
917
  return;
729
918
  }
@@ -733,10 +922,10 @@ function renderApproval(snapshot) {
733
922
  const head = document.createElement("div");
734
923
  head.className = "approval-head";
735
924
  const title = document.createElement("strong");
736
- title.textContent = "Approval required";
925
+ title.textContent = tx("Approval required");
737
926
  const risk = document.createElement("span");
738
927
  risk.className = "approval-risk";
739
- risk.textContent = extreme ? "EXTREME RISK" : "HIGH RISK";
928
+ risk.textContent = extreme ? tx("EXTREME RISK") : tx("HIGH RISK");
740
929
  head.append(title, risk);
741
930
 
742
931
  const details = renderApprovalDetails(pending, snapshot.cwd);
@@ -745,11 +934,11 @@ function renderApproval(snapshot) {
745
934
  actions.className = "approval-actions";
746
935
  const approve = document.createElement("button");
747
936
  approve.className = "approve-btn";
748
- approve.textContent = "Approve";
937
+ approve.textContent = tx("Approve");
749
938
  approve.addEventListener("click", () => decideApproval(true));
750
939
  const deny = document.createElement("button");
751
940
  deny.className = "deny-btn";
752
- deny.textContent = "Deny";
941
+ deny.textContent = tx("Deny");
753
942
  deny.addEventListener("click", () => decideApproval(false));
754
943
  actions.append(approve, deny);
755
944
 
@@ -765,7 +954,9 @@ function decideApproval(approved) {
765
954
  function renderLive() {
766
955
  const snapshot = state.snapshot;
767
956
  if (!snapshot) return;
768
- el.title.textContent = snapshot.title || "New conversation";
957
+ state.fileData = null;
958
+ state.agentData = null;
959
+ el.title.textContent = snapshot.title || tr("New conversation");
769
960
  el.sub.textContent = [snapshot.cwd, snapshot.model, snapshot.provider].filter(Boolean).join(" · ");
770
961
  preferences.showConversation(snapshot, `live:${snapshot.key}`);
771
962
  renderControls(snapshot);
@@ -777,6 +968,8 @@ function renderLive() {
777
968
  // paused turn is unfinished by the user's own choice, so it stays open too.
778
969
  active: Boolean(snapshot.busy || snapshot.paused),
779
970
  });
971
+ maybeOpenPlan(snapshot.plan);
972
+ renderPlanCard(snapshot);
780
973
  }
781
974
 
782
975
  async function changeApprovalMode(key, mode) {
@@ -794,21 +987,25 @@ async function changeApprovalMode(key, mode) {
794
987
  }
795
988
 
796
989
  function renderApprovalModeControl(mode, change) {
990
+ const tx = (text, values) => (typeof tr === "function"
991
+ ? tr(text, values)
992
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
993
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
797
994
  const automatic = mode === "auto";
798
995
  const button = document.createElement("button");
799
996
  button.className = "control";
800
997
  button.type = "button";
801
998
  button.setAttribute("role", "switch");
802
- button.setAttribute("aria-label", "Auto Approval");
999
+ button.setAttribute("aria-label", tx("Auto Approval"));
803
1000
  button.setAttribute("aria-checked", String(automatic));
804
- button.textContent = automatic ? "Auto Approval" : "Request Approval";
805
- button.title = "Switch between requesting approval and automatic review";
1001
+ button.textContent = automatic ? tx("Auto Approval") : tx("Request Approval");
1002
+ button.title = tx("Switch between requesting approval and automatic review");
806
1003
  button.addEventListener("click", async () => {
807
1004
  button.disabled = true;
808
1005
  try {
809
1006
  await change(automatic ? "request" : "auto");
810
1007
  } catch (error) {
811
- el.sub.textContent = `Could not change approval mode: ${error.message}`;
1008
+ el.sub.textContent = tx("Could not change approval mode: {error}", { error: error.message });
812
1009
  } finally {
813
1010
  button.disabled = false;
814
1011
  }
@@ -818,6 +1015,7 @@ function renderApprovalModeControl(mode, change) {
818
1015
 
819
1016
  function renderControls(snapshot) {
820
1017
  el.controls.innerHTML = "";
1018
+ el.controls.appendChild(planToggleButton());
821
1019
  renderApprovalModeControl(snapshot.approvalMode, mode =>
822
1020
  changeApprovalMode(snapshot.key, mode));
823
1021
 
@@ -826,51 +1024,66 @@ function renderControls(snapshot) {
826
1024
  badge.className = `badge ${mode}`;
827
1025
  // A retried request leaves the session busy but silent: name the retry so the
828
1026
  // badge does not just say "working" for minutes. The same goes for a
829
- // sub-agent, whose own messages never reach this transcript.
1027
+ // sub-agent, whose own messages never reach this transcript. An automated
1028
+ // review is reported here too, so it never needs the approval banner.
830
1029
  const retrying = snapshot.retrying;
1030
+ // A pending human decision is an approval, not a review, even if an earlier
1031
+ // review callback is still winding down.
1032
+ const reviewing = snapshot.approval ? null : snapshot.reviewing;
831
1033
  const subagent = snapshot.subagent;
832
1034
  badge.textContent = snapshot.paused
833
- ? "paused"
1035
+ ? tr("paused")
834
1036
  : snapshot.busy
835
1037
  ? snapshot.compressing
836
- ? "compressing"
1038
+ ? tr("compressing")
837
1039
  : retrying
838
- ? `retrying ${retrying.attempt}/${retrying.attempts}`
839
- : subagent
840
- ? `sub-agent: ${subagent}`
841
- : "working"
842
- : "idle";
1040
+ ? tr("retrying {attempt}/{attempts}", { attempt: retrying.attempt, attempts: retrying.attempts })
1041
+ : reviewing
1042
+ ? reviewStatus(reviewing)
1043
+ : subagent
1044
+ ? tr("sub-agent: {label}", { label: subagent })
1045
+ : tr("working")
1046
+ : tr("idle");
843
1047
  el.controls.appendChild(badge);
844
1048
 
845
1049
  if (snapshot.saveError) {
846
1050
  const warning = document.createElement("span");
847
1051
  warning.className = "badge paused";
848
- warning.textContent = `History save failed: ${snapshot.saveError}`;
1052
+ warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError });
849
1053
  el.controls.appendChild(warning);
850
1054
  }
851
1055
 
852
1056
  if (snapshot.busy || snapshot.paused) {
853
1057
  const button = document.createElement("button");
854
1058
  button.className = "control";
855
- button.textContent = snapshot.paused ? "Resume" : "Pause";
1059
+ button.textContent = snapshot.paused ? tr("Resume") : tr("Pause");
856
1060
  button.addEventListener("click", () => {
857
1061
  postAction(snapshot.key, snapshot.paused ? "resume" : "pause");
858
1062
  });
859
1063
  el.controls.appendChild(button);
860
1064
  }
861
1065
 
1066
+ if ((snapshot.messages?.length ?? 0) > 0 || snapshot.busy) {
1067
+ const clear = document.createElement("button");
1068
+ clear.className = "control danger";
1069
+ clear.textContent = tr("Clear");
1070
+ clear.title = tr("Clear conversation");
1071
+ clear.addEventListener("click", () => clearConversation(snapshot.key));
1072
+ el.controls.appendChild(clear);
1073
+ }
1074
+
862
1075
  if (snapshot.queued) {
863
1076
  const queued = document.createElement("span");
864
1077
  queued.className = "queued";
865
1078
  queued.title = snapshot.queued;
866
- queued.textContent = "queued";
1079
+ queued.textContent = tr("queued");
867
1080
  el.controls.appendChild(queued);
868
1081
  }
869
1082
 
870
1083
  if (snapshot.tokens && (snapshot.tokens.prompt || snapshot.tokens.completion)) {
871
1084
  const tokens = document.createElement("span");
872
1085
  tokens.className = "tokens";
873
- tokens.textContent = `${snapshot.tokens.prompt}→${snapshot.tokens.completion} tok`;
1086
+ tokens.textContent = tr("{prompt}→{completion} tok", { prompt: snapshot.tokens.prompt, completion: snapshot.tokens.completion });
874
1087
  el.controls.appendChild(tokens);
875
1088
  }
876
1089
  }
@@ -885,7 +1098,8 @@ function renderMessageList(messages, streaming, options) {
885
1098
  if (messages.length === 0 && !streaming) {
886
1099
  const empty = document.createElement("div");
887
1100
  empty.className = "empty";
888
- empty.textContent = options?.readOnly ? "This conversation is empty." : "Send a message to get started.";
1101
+ const emptyText = options?.readOnly ? "This conversation is empty." : "Send a message to get started.";
1102
+ empty.textContent = typeof tr === "function" ? tr(emptyText) : emptyText;
889
1103
  el.messages.appendChild(empty);
890
1104
  return;
891
1105
  }
@@ -899,7 +1113,9 @@ function renderMessageList(messages, streaming, options) {
899
1113
  const details = document.createElement("details");
900
1114
  details.className = "conversation-history";
901
1115
  const summary = document.createElement("summary");
902
- summary.textContent = `Earlier conversation · ${end} messages`;
1116
+ summary.textContent = typeof tr === "function"
1117
+ ? tr("Earlier conversation · {n} messages", { n: end })
1118
+ : `Earlier conversation · ${end} messages`;
903
1119
  details.appendChild(summary);
904
1120
  const body = document.createElement("div");
905
1121
  body.className = "history-messages";
@@ -982,13 +1198,13 @@ function renderMessage(message, index) {
982
1198
  if (message.role === "summary") {
983
1199
  article.classList.add("summary");
984
1200
  article.innerHTML =
985
- `<div class="summary-label">Context summary · ${Number(message.count ?? 0)} messages</div>` +
1201
+ `<div class="summary-label">${escapeHtml(tr("Context summary · {n} messages", { n: Number(message.count ?? 0) }))}</div>` +
986
1202
  `<div class="body">${renderMarkdown(message.content)}</div>`;
987
1203
  return article;
988
1204
  }
989
1205
 
990
- const who = message.role === "user" ? "You" : "Neutrino";
991
- article.innerHTML = `<div class="who">${who}</div><div class="body">${renderMarkdown(message.content)}</div>`;
1206
+ const who = message.role === "user" ? tr("You") : "Neutrino";
1207
+ article.innerHTML = `<div class="who">${escapeHtml(who)}</div><div class="body">${renderMarkdown(message.content)}</div>`;
992
1208
  return article;
993
1209
  }
994
1210
 
@@ -1000,6 +1216,12 @@ function renderMessage(message, index) {
1000
1216
  * transcript always holds every message.
1001
1217
  */
1002
1218
  function renderTurnFold(start, messages) {
1219
+ // This renderer is also exercised standalone by the web tests, so it carries
1220
+ // an English fallback for the translation helper.
1221
+ const tx = (text, values) => (typeof tr === "function"
1222
+ ? tr(text, values)
1223
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
1224
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
1003
1225
  // The reply is never folded away, so a turn renders as a group: the fold (the
1004
1226
  // prompt line plus the work it hides) and, right below it, the answer itself.
1005
1227
  const reply = turnAnswerIndex(messages);
@@ -1018,10 +1240,10 @@ function renderTurnFold(start, messages) {
1018
1240
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>';
1019
1241
  const you = document.createElement("span");
1020
1242
  you.className = "turn-you";
1021
- you.textContent = `You: ${truncate(turnPreview(messages, "user"), 110)}`;
1243
+ you.textContent = tx("You: {text}", { text: truncate(turnPreview(messages, "user"), 110) });
1022
1244
  const meta = document.createElement("span");
1023
1245
  meta.className = "turn-meta";
1024
- meta.textContent = `${hidden} of ${messages.length} message${messages.length === 1 ? "" : "s"} folded`;
1246
+ meta.textContent = tx("{n} of {m} messages folded", { n: hidden, m: messages.length });
1025
1247
  // The hint cannot live in the static text: it says the opposite once open.
1026
1248
  const hint = document.createElement("span");
1027
1249
  hint.className = "turn-hint";
@@ -1033,7 +1255,7 @@ function renderTurnFold(start, messages) {
1033
1255
  group.appendChild(details);
1034
1256
 
1035
1257
  const syncHint = () => {
1036
- hint.textContent = details.open ? " — click to collapse" : " — click to expand";
1258
+ hint.textContent = details.open ? tx(" — click to collapse") : tx(" — click to expand");
1037
1259
  };
1038
1260
  syncHint();
1039
1261
 
@@ -1110,6 +1332,24 @@ async function sendMessage() {
1110
1332
  return;
1111
1333
  }
1112
1334
 
1335
+ if (selection.kind === "new") {
1336
+ // A workspace landing page: typing starts a fresh conversation there, just
1337
+ // like typing on the terminal's start screen.
1338
+ el.input.value = "";
1339
+ autosize();
1340
+ try {
1341
+ const created = await createConversation(selection.cwd, preferences.defaultSelection?.());
1342
+ await selectLive(created.key);
1343
+ setConversationView(true);
1344
+ await postAction(created.key, "message", { text });
1345
+ } catch (error) {
1346
+ el.input.value = text;
1347
+ autosize();
1348
+ el.sub.textContent = tr("Could not start the conversation: {error}", { error: error.message });
1349
+ }
1350
+ return;
1351
+ }
1352
+
1113
1353
  // Reading a saved transcript: resume it in the browser, then deliver the text.
1114
1354
  el.input.value = "";
1115
1355
  autosize();
@@ -1119,7 +1359,7 @@ async function sendMessage() {
1119
1359
  } catch (error) {
1120
1360
  el.input.value = text;
1121
1361
  autosize();
1122
- el.sub.textContent = `Could not continue this conversation: ${error.message}`;
1362
+ el.sub.textContent = tr("Could not continue this conversation: {error}", { error: error.message });
1123
1363
  }
1124
1364
  }
1125
1365
 
@@ -1128,6 +1368,854 @@ function autosize() {
1128
1368
  el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
1129
1369
  }
1130
1370
 
1371
+ // ------------------------------------------------------- workspaces & dialogs
1372
+
1373
+ function renderWorkspaceChrome() {
1374
+ if (!el.workspaceLabel) return;
1375
+ const workspace = state.workspace;
1376
+ el.workspaceLabel.textContent = workspace ? workspaceName(workspace.path) : tr("All directories");
1377
+ if (el.workspaceButton) {
1378
+ el.workspaceButton.title = workspace ? workspace.path : tr("Working directory");
1379
+ el.workspaceButton.classList.toggle("active", Boolean(workspace));
1380
+ }
1381
+ }
1382
+
1383
+ function joinPath(parent, name) {
1384
+ const separator = String(parent ?? "").includes("\\") ? "\\" : "/";
1385
+ return `${String(parent ?? "").replace(/[\\/]+$/, "")}${separator}${name}`;
1386
+ }
1387
+
1388
+ function selectionCwd() {
1389
+ const selection = state.selection;
1390
+ if (!selection) return "";
1391
+ if (selection.kind === "live") return state.snapshot?.cwd || "";
1392
+ if (selection.kind === "file") return state.fileData?.cwd || "";
1393
+ if (selection.kind === "new") return selection.cwd || "";
1394
+ if (selection.kind === "agent") return state.agentData?.cwd || state.workspace?.path || "";
1395
+ return "";
1396
+ }
1397
+
1398
+ function confirmAction(message) {
1399
+ try {
1400
+ return typeof window === "undefined" || typeof window.confirm !== "function" ? true : window.confirm(message);
1401
+ } catch {
1402
+ return true;
1403
+ }
1404
+ }
1405
+
1406
+ function apiError(data, fallback) {
1407
+ return tr(data && typeof data.error === "string" ? data.error : fallback);
1408
+ }
1409
+
1410
+ function renderHomeEmpty() {
1411
+ el.messages.innerHTML = "";
1412
+ const empty = document.createElement("div");
1413
+ empty.className = "empty";
1414
+ empty.innerHTML =
1415
+ '<span class="empty-mark" aria-hidden="true">' +
1416
+ '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1417
+ '<ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />' +
1418
+ '<circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />' +
1419
+ "</svg></span>" +
1420
+ `<strong>${escapeHtml(tr("No conversation open"))}</strong>` +
1421
+ `<span>${escapeHtml(tr("Choose a conversation from the sidebar to view it."))}</span>`;
1422
+ const button = document.createElement("button");
1423
+ button.className = "control primary";
1424
+ button.type = "button";
1425
+ button.textContent = tr("New conversation");
1426
+ button.addEventListener("click", () => openNewChatDialog());
1427
+ empty.appendChild(button);
1428
+ el.messages.appendChild(empty);
1429
+ }
1430
+
1431
+ /** Back to the neutral landing view, with no conversation selected. */
1432
+ function showHome({ menu = true } = {}) {
1433
+ closeStream();
1434
+ state.selection = null;
1435
+ state.snapshot = null;
1436
+ state.fileData = null;
1437
+ state.agentData = null;
1438
+ state.planOpen = false;
1439
+ state.planSeen = "";
1440
+ preferences.clear();
1441
+ renderSidebar();
1442
+ renderWorkspaceChrome();
1443
+ el.controls.innerHTML = "";
1444
+ renderApproval(null);
1445
+ renderPlanCard(null);
1446
+ hideComposer();
1447
+ el.title.textContent = tr("Select a conversation");
1448
+ el.sub.textContent = "";
1449
+ renderHomeEmpty();
1450
+ if (menu) setConversationView(false);
1451
+ }
1452
+
1453
+ /** Show a folder as an empty workspace ready for its first message. */
1454
+ function renderWorkspaceLanding(path) {
1455
+ state.selection = { kind: "new", cwd: path };
1456
+ state.snapshot = null;
1457
+ state.fileData = null;
1458
+ state.planOpen = false;
1459
+ state.planSeen = "";
1460
+ closeStream();
1461
+ preferences.clear();
1462
+ renderSidebar();
1463
+ renderWorkspaceChrome();
1464
+ el.controls.innerHTML = "";
1465
+ renderApproval(null);
1466
+ renderPlanCard(null);
1467
+ el.title.textContent = workspaceName(path) || tr("Working directory");
1468
+ el.sub.textContent = path;
1469
+ setComposerMode("new");
1470
+ el.messages.innerHTML = "";
1471
+
1472
+ const empty = document.createElement("div");
1473
+ empty.className = "empty workspace-empty";
1474
+ empty.innerHTML =
1475
+ '<span class="empty-mark" aria-hidden="true">' +
1476
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
1477
+ '<path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.5h7.5A2.5 2.5 0 0 1 21 10v7a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17z" />' +
1478
+ "</svg></span>" +
1479
+ `<strong>${escapeHtml(workspaceName(path) || path)}</strong>` +
1480
+ `<span class="workspace-path">${escapeHtml(path)}</span>` +
1481
+ `<p class="workspace-prompt">${escapeHtml(tr("Type a message below to start a conversation in this folder."))}</p>`;
1482
+ const button = document.createElement("button");
1483
+ button.className = "control primary";
1484
+ button.type = "button";
1485
+ button.textContent = tr("New conversation");
1486
+ button.addEventListener("click", () => openNewChatDialog({ cwd: path }));
1487
+ empty.appendChild(button);
1488
+ el.messages.appendChild(empty);
1489
+ }
1490
+
1491
+ /** Open a working directory and make it the active sidebar scope. */
1492
+ async function openWorkspace(path, { record = true } = {}) {
1493
+ const target = String(path ?? "").trim();
1494
+ if (!target) return;
1495
+ setWorkspace({ path: target });
1496
+ renderWorkspaceLanding(target);
1497
+ setConversationView(true);
1498
+ if (record) {
1499
+ try {
1500
+ const response = await apiFetch("/api/workspaces", {
1501
+ method: "POST",
1502
+ headers: { "Content-Type": "application/json" },
1503
+ body: JSON.stringify({ path: target }),
1504
+ });
1505
+ const data = await response.json().catch(() => ({}));
1506
+ if (response.ok && Array.isArray(data.workspaces)) {
1507
+ state.workspaces = { ...(state.workspaces ?? {}), workspaces: data.workspaces };
1508
+ }
1509
+ } catch {
1510
+ // Opening the folder still works when remembering it does not.
1511
+ }
1512
+ }
1513
+ await loadOverview({ silent: true });
1514
+ }
1515
+
1516
+ function renderWorkspaceList() {
1517
+ if (!el.workspaceList) return;
1518
+ el.workspaceList.innerHTML = "";
1519
+ const folders = state.workspaces?.workspaces ?? [];
1520
+ if (!folders.length) {
1521
+ const empty = document.createElement("div");
1522
+ empty.className = "empty-side";
1523
+ empty.textContent = tr("No recent folders.");
1524
+ el.workspaceList.appendChild(empty);
1525
+ return;
1526
+ }
1527
+ for (const folder of folders) {
1528
+ const item = document.createElement("button");
1529
+ item.className = "workspace-item";
1530
+ item.type = "button";
1531
+ item.title = folder.path;
1532
+ if (state.workspace && pathKey(state.workspace.path) === pathKey(folder.path)) {
1533
+ item.classList.add("selected");
1534
+ }
1535
+ if (!folder.exists) item.classList.add("missing");
1536
+ const count = Number(folder.sessions ?? 0);
1537
+ item.innerHTML =
1538
+ '<span class="workspace-icon" aria-hidden="true">' +
1539
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.5h7.5A2.5 2.5 0 0 1 21 10v7a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17z" /></svg>' +
1540
+ "</span>" +
1541
+ `<span class="workspace-info"><strong>${escapeHtml(folder.name || workspaceName(folder.path))}</strong>` +
1542
+ `<small>${escapeHtml(shortenPath(folder.path, 3))}</small></span>` +
1543
+ `<span class="workspace-count">${escapeHtml(tr("{n} conversations", { n: count }))}</span>`;
1544
+ item.addEventListener("click", async () => {
1545
+ if (!folder.exists) {
1546
+ el.workspaceFeedback.textContent = tr("This folder no longer exists.");
1547
+ return;
1548
+ }
1549
+ el.workspaceDialog.close();
1550
+ await openWorkspace(folder.path, { record: false });
1551
+ });
1552
+ el.workspaceList.appendChild(item);
1553
+ }
1554
+ }
1555
+
1556
+ async function openWorkspaceDialog() {
1557
+ el.workspaceFeedback.textContent = "";
1558
+ if (!el.workspaceDialog.open) el.workspaceDialog.showModal();
1559
+ renderWorkspaceList();
1560
+ try {
1561
+ const response = await apiFetch("/api/workspaces", { cache: "no-store" });
1562
+ const data = await response.json();
1563
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
1564
+ state.workspaces = data;
1565
+ renderWorkspaceList();
1566
+ } catch (error) {
1567
+ el.workspaceFeedback.textContent = tr("Could not open this folder: {error}", { error: error.message });
1568
+ }
1569
+ }
1570
+
1571
+ function renderNewChatRecents() {
1572
+ if (!el.newChatRecents) return;
1573
+ el.newChatRecents.innerHTML = "";
1574
+ const folders = (state.workspaces?.workspaces ?? []).filter((folder) => folder.exists !== false).slice(0, 8);
1575
+ if (!folders.length) {
1576
+ const empty = document.createElement("span");
1577
+ empty.className = "recents-empty";
1578
+ empty.textContent = tr("No recent folders.");
1579
+ el.newChatRecents.appendChild(empty);
1580
+ return;
1581
+ }
1582
+ for (const folder of folders) {
1583
+ const chip = document.createElement("button");
1584
+ chip.className = "path-chip";
1585
+ chip.type = "button";
1586
+ chip.title = folder.path;
1587
+ chip.textContent = workspaceName(folder.path) || folder.path;
1588
+ if (el.newChatCwd.value.trim() && pathKey(el.newChatCwd.value) === pathKey(folder.path)) {
1589
+ chip.classList.add("selected");
1590
+ }
1591
+ chip.addEventListener("click", () => {
1592
+ el.newChatCwd.value = folder.path;
1593
+ renderNewChatRecents();
1594
+ });
1595
+ el.newChatRecents.appendChild(chip);
1596
+ }
1597
+ }
1598
+
1599
+ function fillSelect(select, entries, selected) {
1600
+ select.replaceChildren();
1601
+ for (const entry of entries) {
1602
+ const option = document.createElement("option");
1603
+ option.value = entry.value;
1604
+ option.textContent = entry.label;
1605
+ select.appendChild(option);
1606
+ }
1607
+ select.value = entries.some((entry) => entry.value === selected) ? selected : entries[0]?.value ?? "";
1608
+ }
1609
+
1610
+ function fillNewChatModels(catalog, selectedModel) {
1611
+ const provider = catalog.providers.find((entry) => entry.name === el.newChatProvider.value) ?? catalog.providers[0];
1612
+ const models = provider?.models ?? [];
1613
+ fillSelect(el.newChatModel, models.map((model) => ({
1614
+ value: model.name,
1615
+ label: model.name + (model.configured ? "" : ` ${tr("(API key missing)")}`),
1616
+ })), selectedModel);
1617
+ }
1618
+
1619
+ function updateNewChatNote(catalog) {
1620
+ const provider = catalog?.providers.find((entry) => entry.name === el.newChatProvider.value);
1621
+ const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
1622
+ if (model && !model.configured) {
1623
+ el.newChatNote.textContent = tr("This model has no API key configured. Add one in the terminal model screen, or choose another model.");
1624
+ } else {
1625
+ el.newChatNote.textContent = "";
1626
+ }
1627
+ }
1628
+
1629
+ /** Relabel the open new-conversation sheet without losing its selections. */
1630
+ function refreshNewChatTexts() {
1631
+ if (!el.newChatDialog?.open) return;
1632
+ const catalog = preferences.catalog?.();
1633
+ if (!catalog) return;
1634
+ const provider = el.newChatProvider.value;
1635
+ const model = el.newChatModel.value;
1636
+ const intensity = el.newChatIntensity.value;
1637
+ const approval = el.newChatApproval.value;
1638
+ fillSelect(el.newChatProvider, catalog.providers.map((entry) => ({ value: entry.name, label: entry.name })), provider);
1639
+ fillNewChatModels(catalog, model);
1640
+ fillSelect(el.newChatIntensity, catalog.intensities.map((value) => ({ value, label: value })), intensity);
1641
+ el.newChatApproval.value = approval;
1642
+ updateNewChatNote(catalog);
1643
+ renderNewChatRecents();
1644
+ }
1645
+
1646
+ async function openNewChatDialog(options = {}) {
1647
+ el.newChatFeedback.textContent = "";
1648
+ if (!el.newChatDialog.open) el.newChatDialog.showModal();
1649
+ let catalog;
1650
+ try {
1651
+ catalog = await preferences.load();
1652
+ const response = await apiFetch("/api/workspaces", { cache: "no-store" });
1653
+ if (response.ok) state.workspaces = await response.json();
1654
+ } catch (error) {
1655
+ el.newChatFeedback.textContent = tr(error.message);
1656
+ }
1657
+ if (!catalog) return;
1658
+
1659
+ const defaults = catalog.selection;
1660
+ fillSelect(el.newChatProvider, catalog.providers.map((provider) => ({ value: provider.name, label: provider.name })), defaults.provider);
1661
+ fillNewChatModels(catalog, defaults.model);
1662
+ fillSelect(el.newChatIntensity, catalog.intensities.map((value) => ({ value, label: value })), defaults.intensity);
1663
+ el.newChatApproval.value = catalog.settings.approval_mode === "auto" ? "auto" : "request";
1664
+
1665
+ const start = String(options.cwd || selectionCwd() || state.workspace?.path || state.workspaces?.cwd || "").trim();
1666
+ el.newChatCwd.value = start;
1667
+ renderNewChatRecents();
1668
+ updateNewChatNote(catalog);
1669
+ if (typeof el.newChatCwd.focus === "function") el.newChatCwd.focus();
1670
+ }
1671
+
1672
+ async function submitNewChat() {
1673
+ if (el.newChatStart.disabled) return;
1674
+ const cwd = el.newChatCwd.value.trim();
1675
+ if (!cwd) {
1676
+ el.newChatFeedback.textContent = tr("working directory is required");
1677
+ el.newChatCwd.focus();
1678
+ return;
1679
+ }
1680
+ const selection = {
1681
+ provider: el.newChatProvider.value,
1682
+ model: el.newChatModel.value,
1683
+ intensity: el.newChatIntensity.value,
1684
+ };
1685
+ el.newChatStart.disabled = true;
1686
+ el.newChatStart.textContent = tr("Creating…");
1687
+ el.newChatFeedback.textContent = "";
1688
+ try {
1689
+ const created = await createConversation(cwd, selection, el.newChatApproval.value);
1690
+ el.newChatDialog.close();
1691
+ await selectLive(created.key);
1692
+ setConversationView(true);
1693
+ el.input.focus();
1694
+ } catch (error) {
1695
+ el.newChatFeedback.textContent = tr("Could not start the conversation: {error}", { error: error.message });
1696
+ } finally {
1697
+ el.newChatStart.disabled = false;
1698
+ el.newChatStart.textContent = tr("Start conversation");
1699
+ }
1700
+ }
1701
+
1702
+ /** Create a browser-owned conversation in `cwd` and reveal its folder. */
1703
+ async function createConversation(cwd, selection, approvalMode) {
1704
+ const payload = { cwd };
1705
+ if (selection && selection.provider && selection.model) payload.selection = selection;
1706
+ if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
1707
+ const response = await apiFetch("/api/conversations", {
1708
+ method: "POST",
1709
+ headers: { "Content-Type": "application/json" },
1710
+ body: JSON.stringify(payload),
1711
+ });
1712
+ const data = await response.json().catch(() => ({}));
1713
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
1714
+ setWorkspace({ path: data.cwd || cwd });
1715
+ await loadOverview({ silent: true });
1716
+ return data;
1717
+ }
1718
+
1719
+ // ------------------------------------------------------------- folder picker
1720
+
1721
+ function renderFolder() {
1722
+ if (!el.folderList) return;
1723
+ const folder = state.folder;
1724
+ el.folderPath.value = folder.path || "";
1725
+ el.folderUp.disabled = !folder.parent;
1726
+ el.folderUse.disabled = !folder.path || folder.loading;
1727
+
1728
+ el.folderRoots.innerHTML = "";
1729
+ for (const root of folder.roots ?? []) {
1730
+ const button = document.createElement("button");
1731
+ button.className = "root-chip";
1732
+ button.type = "button";
1733
+ button.textContent = root;
1734
+ button.addEventListener("click", () => loadFolder(root));
1735
+ el.folderRoots.appendChild(button);
1736
+ }
1737
+
1738
+ el.folderList.innerHTML = "";
1739
+ const entries = folder.entries ?? [];
1740
+ if (!entries.length) {
1741
+ const empty = document.createElement("div");
1742
+ empty.className = "empty-side";
1743
+ empty.textContent = folder.loading ? tr("Loading folders…") : tr("No subfolders.");
1744
+ el.folderList.appendChild(empty);
1745
+ return;
1746
+ }
1747
+ for (const entry of entries) {
1748
+ const item = document.createElement("button");
1749
+ item.className = "folder-item";
1750
+ item.type = "button";
1751
+ item.title = entry.path;
1752
+ item.innerHTML =
1753
+ '<span class="folder-item-icon" aria-hidden="true">' +
1754
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.5h7.5A2.5 2.5 0 0 1 21 10v7a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17z" /></svg>' +
1755
+ `</span><span>${escapeHtml(entry.name)}</span>`;
1756
+ item.addEventListener("click", () => loadFolder(entry.path));
1757
+ if (typeof item.addEventListener === "function") {
1758
+ item.addEventListener("dblclick", () => useCurrentFolder());
1759
+ }
1760
+ el.folderList.appendChild(item);
1761
+ }
1762
+ }
1763
+
1764
+ let folderLoadSeq = 0;
1765
+
1766
+ async function loadFolder(path) {
1767
+ const folder = state.folder;
1768
+ const request = ++folderLoadSeq;
1769
+ folder.loading = true;
1770
+ el.folderFeedback.textContent = tr("Loading folders…");
1771
+ el.folderUse.disabled = true;
1772
+ try {
1773
+ const query = path ? `?path=${encodeURIComponent(path)}` : "";
1774
+ const response = await apiFetch(`/api/fs${query}`, { cache: "no-store" });
1775
+ const data = await response.json().catch(() => ({}));
1776
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
1777
+ // A slower, older answer must not replace the folder the user is on now.
1778
+ if (request !== folderLoadSeq) return;
1779
+ state.folder = {
1780
+ ...folder,
1781
+ loading: false,
1782
+ path: data.path,
1783
+ parent: data.parent,
1784
+ roots: data.roots ?? [],
1785
+ entries: data.entries ?? [],
1786
+ };
1787
+ el.folderFeedback.textContent = "";
1788
+ renderFolder();
1789
+ if (typeof el.folderPath.focus === "function") el.folderPath.focus();
1790
+ } catch (error) {
1791
+ if (request !== folderLoadSeq) return;
1792
+ folder.loading = false;
1793
+ el.folderFeedback.textContent = tr("Could not open this folder: {error}", { error: error.message });
1794
+ el.folderUse.disabled = !state.folder.path;
1795
+ }
1796
+ }
1797
+
1798
+ function openFolderDialog({ start = "", onPick = null } = {}) {
1799
+ state.folder = {
1800
+ path: "",
1801
+ parent: null,
1802
+ roots: [],
1803
+ entries: [],
1804
+ loading: false,
1805
+ onPick,
1806
+ };
1807
+ el.folderNew.classList.add("hidden");
1808
+ el.folderNewName.value = "";
1809
+ el.folderFeedback.textContent = "";
1810
+ if (!el.folderDialog.open) el.folderDialog.showModal();
1811
+ renderFolder();
1812
+ const from = String(start || state.workspace?.path || state.workspaces?.cwd || "").trim();
1813
+ void loadFolder(from);
1814
+ }
1815
+
1816
+ function useCurrentFolder() {
1817
+ const path = state.folder.path;
1818
+ if (!path) return;
1819
+ const onPick = state.folder.onPick;
1820
+ el.folderDialog.close();
1821
+ if (typeof onPick === "function") onPick(path);
1822
+ }
1823
+
1824
+ async function createFolderFromDialog() {
1825
+ const name = el.folderNewName.value.trim();
1826
+ if (!name) {
1827
+ el.folderFeedback.textContent = tr("Enter a folder name.");
1828
+ el.folderNewName.focus();
1829
+ return;
1830
+ }
1831
+ if (/[\\/]/.test(name)) {
1832
+ el.folderFeedback.textContent = tr("folder name cannot contain a path separator");
1833
+ return;
1834
+ }
1835
+ const target = joinPath(state.folder.path, name);
1836
+ el.folderNewCreate.disabled = true;
1837
+ try {
1838
+ const response = await apiFetch("/api/fs/mkdir", {
1839
+ method: "POST",
1840
+ headers: { "Content-Type": "application/json" },
1841
+ body: JSON.stringify({ path: target }),
1842
+ });
1843
+ const data = await response.json().catch(() => ({}));
1844
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
1845
+ el.folderNew.classList.add("hidden");
1846
+ el.folderNewName.value = "";
1847
+ await loadFolder(data.path || target);
1848
+ el.folderFeedback.textContent = tr("Folder created.");
1849
+ } catch (error) {
1850
+ el.folderFeedback.textContent = apiError({ error: error.message }, error.message);
1851
+ } finally {
1852
+ el.folderNewCreate.disabled = false;
1853
+ }
1854
+ }
1855
+
1856
+ // -------------------------------------------------------------- plan / usage
1857
+
1858
+ function planSignature(plan) {
1859
+ if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return "";
1860
+ return `${plan.goal ?? ""}|${plan.steps.map((step) => step.title ?? "").join("|")}`;
1861
+ }
1862
+
1863
+ function maybeOpenPlan(plan) {
1864
+ const signature = planSignature(plan);
1865
+ if (signature && signature !== state.planSeen) {
1866
+ state.planSeen = signature;
1867
+ state.planOpen = true;
1868
+ }
1869
+ }
1870
+
1871
+ function planToggleButton() {
1872
+ const button = document.createElement("button");
1873
+ button.className = "icon-btn plan-toggle";
1874
+ button.type = "button";
1875
+ button.setAttribute("aria-expanded", String(state.planOpen));
1876
+ button.title = state.planOpen ? tr("Hide plan") : tr("Show plan");
1877
+ button.setAttribute("aria-label", button.title);
1878
+ button.innerHTML =
1879
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg>';
1880
+ button.addEventListener("click", () => {
1881
+ state.planOpen = !state.planOpen;
1882
+ button.classList.toggle("active", state.planOpen);
1883
+ button.setAttribute("aria-expanded", String(state.planOpen));
1884
+ button.title = state.planOpen ? tr("Hide plan") : tr("Show plan");
1885
+ renderPlanCard(state.snapshot ?? state.fileData ?? null);
1886
+ });
1887
+ button.classList.toggle("active", state.planOpen);
1888
+ return button;
1889
+ }
1890
+
1891
+ function planStatusLabel(status) {
1892
+ if (status === "done") return tr("Done");
1893
+ if (status === "active") return tr("Active");
1894
+ if (status === "failed") return tr("Failed");
1895
+ return tr("Pending");
1896
+ }
1897
+
1898
+ function renderPlanCard(source) {
1899
+ if (!el.planCard) return;
1900
+ const plan = source && source.plan && Array.isArray(source.plan.steps) && source.plan.steps.length ? source.plan : null;
1901
+ const tokens = source && source.tokens ? source.tokens : null;
1902
+ const hasUsage = tokens && (Number(tokens.prompt) > 0 || Number(tokens.completion) > 0);
1903
+ if (!state.planOpen || (!plan && !hasUsage)) {
1904
+ el.planCard.classList.add("hidden");
1905
+ el.planCard.replaceChildren();
1906
+ return;
1907
+ }
1908
+
1909
+ const card = document.createElement("div");
1910
+ card.className = "plan-card-inner";
1911
+ const head = document.createElement("div");
1912
+ head.className = "plan-card-head";
1913
+ const title = document.createElement("strong");
1914
+ title.textContent = plan ? tr("Plan") : tr("Usage");
1915
+ const close = document.createElement("button");
1916
+ close.className = "icon-btn";
1917
+ close.type = "button";
1918
+ close.setAttribute("aria-label", tr("Close"));
1919
+ close.innerHTML =
1920
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>';
1921
+ close.addEventListener("click", () => {
1922
+ state.planOpen = false;
1923
+ renderPlanCard(source);
1924
+ });
1925
+ head.append(title, close);
1926
+ card.appendChild(head);
1927
+
1928
+ if (plan) {
1929
+ if (plan.goal) {
1930
+ const goal = document.createElement("div");
1931
+ goal.className = "plan-goal";
1932
+ goal.textContent = plan.goal;
1933
+ card.appendChild(goal);
1934
+ }
1935
+ const steps = document.createElement("div");
1936
+ steps.className = "plan-steps";
1937
+ let done = 0;
1938
+ for (const step of plan.steps) {
1939
+ const status = ["done", "active", "failed", "pending"].includes(step.status) ? step.status : "pending";
1940
+ if (status === "done") done += 1;
1941
+ const row = document.createElement("div");
1942
+ row.className = `plan-step ${status}`;
1943
+ const node = document.createElement("span");
1944
+ node.className = "plan-node";
1945
+ node.setAttribute("aria-hidden", "true");
1946
+ const body = document.createElement("div");
1947
+ body.className = "plan-step-body";
1948
+ const stepTitle = document.createElement("strong");
1949
+ stepTitle.textContent = step.title || "";
1950
+ const stateLabel = document.createElement("span");
1951
+ stateLabel.className = "plan-step-state";
1952
+ stateLabel.textContent = planStatusLabel(status);
1953
+ const titleRow = document.createElement("div");
1954
+ titleRow.className = "plan-step-title";
1955
+ titleRow.append(stepTitle, stateLabel);
1956
+ body.appendChild(titleRow);
1957
+ if (step.detail) {
1958
+ const detail = document.createElement("div");
1959
+ detail.className = "plan-step-detail";
1960
+ detail.textContent = step.detail;
1961
+ body.appendChild(detail);
1962
+ }
1963
+ row.append(node, body);
1964
+ steps.appendChild(row);
1965
+ }
1966
+ card.appendChild(steps);
1967
+ const meta = document.createElement("div");
1968
+ meta.className = "plan-meta";
1969
+ const stopped = plan.stopped ? ` · ${tr("Stopped")}` : "";
1970
+ meta.textContent = tr("{done} of {total} steps done", { done, total: plan.steps.length }) + stopped;
1971
+ card.appendChild(meta);
1972
+ }
1973
+
1974
+ if (hasUsage) {
1975
+ const usage = document.createElement("div");
1976
+ usage.className = "plan-usage";
1977
+ const rows = [
1978
+ [tr("Prompt"), Number(tokens.prompt)],
1979
+ [tr("Completion"), Number(tokens.completion)],
1980
+ [tr("Total"), Number(tokens.prompt) + Number(tokens.completion)],
1981
+ ];
1982
+ for (const [label, value] of rows) {
1983
+ const row = document.createElement("div");
1984
+ row.className = "plan-usage-row";
1985
+ const name = document.createElement("span");
1986
+ name.textContent = label;
1987
+ const count = document.createElement("strong");
1988
+ count.textContent = value.toLocaleString(localeTag());
1989
+ row.append(name, count);
1990
+ usage.appendChild(row);
1991
+ }
1992
+ card.appendChild(usage);
1993
+ }
1994
+
1995
+ el.planCard.replaceChildren(card);
1996
+ el.planCard.classList.remove("hidden");
1997
+ }
1998
+
1999
+ // ------------------------------------------------------------------- actions
2000
+
2001
+ async function clearConversation(key) {
2002
+ try {
2003
+ const response = await apiFetch(`/api/live/${encodeURIComponent(key)}/clear`, { method: "POST" });
2004
+ const data = await response.json().catch(() => ({}));
2005
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2006
+ if (state.selection?.kind === "live" && state.selection.key === key) {
2007
+ state.snapshot = data.snapshot ?? state.snapshot;
2008
+ // The cleared conversation has no transcript file until its next message.
2009
+ state.lastSessionId = null;
2010
+ state.planOpen = false;
2011
+ state.planSeen = "";
2012
+ if (state.snapshot) renderLive();
2013
+ if (state.snapshot) renderPlanCard(null);
2014
+ el.sub.textContent = tr("Conversation cleared.");
2015
+ }
2016
+ } catch (error) {
2017
+ el.sub.textContent = tr("Could not clear this conversation: {error}", { error: error.message });
2018
+ }
2019
+ }
2020
+
2021
+ async function deleteConversation(id, button) {
2022
+ if (!confirmAction(tr("Delete this conversation? Its saved history will be removed."))) return;
2023
+ if (button) button.disabled = true;
2024
+ try {
2025
+ const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/delete`, { method: "POST" });
2026
+ const data = await response.json().catch(() => ({}));
2027
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2028
+ showHome();
2029
+ await loadOverview();
2030
+ } catch (error) {
2031
+ if (button) button.disabled = false;
2032
+ el.sub.textContent = tr("Could not delete this conversation: {error}", { error: error.message });
2033
+ }
2034
+ }
2035
+
2036
+ // -------------------------------------------------------------- other agents
2037
+
2038
+ const OTHER_AGENTS = [
2039
+ { id: "claude", label: "Claude Code" },
2040
+ { id: "codex", label: "Codex" },
2041
+ { id: "opencode", label: "OpenCode" },
2042
+ ];
2043
+
2044
+ function agentLabel(agent) {
2045
+ return OTHER_AGENTS.find((entry) => entry.id === agent)?.label ?? agent;
2046
+ }
2047
+
2048
+ /** Working directory the other-agent lists are scoped to. */
2049
+ async function agentsScopeCwd() {
2050
+ if (!state.workspaces) {
2051
+ try {
2052
+ const response = await apiFetch("/api/workspaces", { cache: "no-store" });
2053
+ if (response.ok) state.workspaces = await response.json();
2054
+ } catch {
2055
+ // The dialog can still show a readable error below.
2056
+ }
2057
+ }
2058
+ return state.workspace?.path || selectionCwd() || state.agentData?.cwd || state.workspaces?.cwd || "";
2059
+ }
2060
+
2061
+ function renderAgentList(groups) {
2062
+ el.agentsList.innerHTML = "";
2063
+ const total = groups.reduce((count, group) => count + group.sessions.length, 0);
2064
+ if (total === 0) {
2065
+ const empty = document.createElement("div");
2066
+ empty.className = "empty-side";
2067
+ empty.textContent = tr("No saved sessions from other agents in this folder.");
2068
+ el.agentsList.appendChild(empty);
2069
+ return;
2070
+ }
2071
+ for (const group of groups) {
2072
+ if (!group.sessions.length) continue;
2073
+ const section = document.createElement("section");
2074
+ section.className = "agent-section";
2075
+ const title = document.createElement("h3");
2076
+ title.textContent = group.label;
2077
+ section.appendChild(title);
2078
+ for (const session of group.sessions) {
2079
+ const item = document.createElement("button");
2080
+ item.className = "agent-session";
2081
+ item.type = "button";
2082
+ item.title = session.title || session.id;
2083
+ item.innerHTML =
2084
+ '<span class="agent-session-body">' +
2085
+ `<span class="agent-session-title">${escapeHtml(session.title || tr("Conversation"))}</span>` +
2086
+ `<span class="agent-session-meta">${escapeHtml([session.model, formatTime(session.updated_at)].filter(Boolean).join(" · "))}</span>` +
2087
+ "</span>";
2088
+ item.addEventListener("click", () => {
2089
+ el.agentsDialog.close();
2090
+ void selectAgentSession(group.id, session.id, session.title || "");
2091
+ });
2092
+ section.appendChild(item);
2093
+ }
2094
+ el.agentsList.appendChild(section);
2095
+ }
2096
+ }
2097
+
2098
+ async function openAgentsDialog() {
2099
+ el.agentsFeedback.textContent = tr("Loading…");
2100
+ el.agentsList.innerHTML = "";
2101
+ if (!el.agentsDialog.open) el.agentsDialog.showModal();
2102
+ const cwd = await agentsScopeCwd();
2103
+ el.agentsCwd.textContent = cwd;
2104
+ try {
2105
+ const groups = await Promise.all(OTHER_AGENTS.map(async (agent) => {
2106
+ const response = await apiFetch(`/api/agents/${agent.id}/sessions?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" });
2107
+ const data = await response.json().catch(() => ({}));
2108
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2109
+ return { ...agent, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
2110
+ }));
2111
+ el.agentsFeedback.textContent = "";
2112
+ renderAgentList(groups);
2113
+ } catch (error) {
2114
+ el.agentsFeedback.textContent = tr("Could not load other agents' history: {error}", { error: error.message });
2115
+ }
2116
+ }
2117
+
2118
+ function renderAgentView(data) {
2119
+ const messages = Array.isArray(data.messages) ? data.messages : [];
2120
+ const label = agentLabel(state.selection?.agent ?? data.agent ?? "");
2121
+ el.title.textContent = state.selection?.title || firstUserMessage(messages) || tr("Conversation");
2122
+ el.sub.textContent = [label, data.cwd || state.agentData?.cwd].filter(Boolean).join(" · ");
2123
+ renderMessageList(messages, null, { readOnly: true, active: false });
2124
+ el.controls.innerHTML = "";
2125
+ const badge = document.createElement("span");
2126
+ badge.className = "badge readonly";
2127
+ badge.textContent = tr("Read-only");
2128
+ el.controls.appendChild(badge);
2129
+ const source = document.createElement("span");
2130
+ source.className = "badge readonly";
2131
+ source.textContent = label;
2132
+ el.controls.appendChild(source);
2133
+ renderApproval(null);
2134
+ renderPlanCard(null);
2135
+ hideComposer();
2136
+ }
2137
+
2138
+ async function selectAgentSession(agent, id, title) {
2139
+ closeStream();
2140
+ state.selection = { kind: "agent", agent, id, title };
2141
+ state.snapshot = null;
2142
+ state.fileData = null;
2143
+ state.agentData = null;
2144
+ state.planOpen = false;
2145
+ state.planSeen = "";
2146
+ preferences.clear();
2147
+ renderSidebar();
2148
+ el.controls.innerHTML = "";
2149
+ renderApproval(null);
2150
+ renderPlanCard(null);
2151
+ hideComposer();
2152
+ el.title.textContent = tr("Loading…");
2153
+ el.sub.textContent = title || "";
2154
+ resetMessagePane(tr("Loading…"));
2155
+ setConversationView(true);
2156
+ try {
2157
+ const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}`, { cache: "no-store" });
2158
+ const data = await response.json().catch(() => ({}));
2159
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2160
+ state.agentData = { ...data, agent, cwd: state.agentData?.cwd || state.workspace?.path || "" };
2161
+ renderAgentView(state.agentData);
2162
+ } catch (error) {
2163
+ el.title.textContent = title || tr("Conversation");
2164
+ el.sub.textContent = tr("Could not load this session: {error}", { error: error.message });
2165
+ resetMessagePane(tr("Could not load this session: {error}", { error: error.message }));
2166
+ }
2167
+ }
2168
+
2169
+ // ------------------------------------------------------------------ language
2170
+
2171
+ function syncLanguageSelects(language) {
2172
+ if (el.languageSelect) el.languageSelect.value = language;
2173
+ const settingsLanguage = document.getElementById("settings-language");
2174
+ if (settingsLanguage) settingsLanguage.value = language;
2175
+ }
2176
+
2177
+ async function changeInterfaceLanguage(language, { persist = true } = {}) {
2178
+ const normalized = String(language ?? "").toLowerCase().startsWith("zh") ? "zh" : "en";
2179
+ setUiLanguage(normalized);
2180
+ writeStorage("neutrino.language", normalized);
2181
+ applyStaticTranslations();
2182
+ syncLanguageSelects(normalized);
2183
+ if (state.status) setStatus(state.status.key, state.status.kind);
2184
+ preferences.refreshTexts?.();
2185
+ refreshNewChatTexts();
2186
+ renderWorkspaceChrome();
2187
+ renderSidebar();
2188
+ const kind = state.selection?.kind;
2189
+ if (kind === "live" && state.snapshot) renderLive();
2190
+ else if (kind === "file" && state.fileData) renderFile(state.fileData);
2191
+ else if (kind === "agent" && state.agentData) renderAgentView(state.agentData);
2192
+ else if (kind === "new" && state.selection.cwd) renderWorkspaceLanding(state.selection.cwd);
2193
+ else if (!state.selection) renderHomeEmpty();
2194
+ if (kind === "live") setComposerMode("live");
2195
+ else if (kind === "file") setComposerMode("resume");
2196
+ else if (kind === "new") setComposerMode("new");
2197
+ else if (!state.selection) hideComposer();
2198
+ if (persist) {
2199
+ try {
2200
+ await apiFetch("/api/settings", {
2201
+ method: "POST",
2202
+ headers: { "Content-Type": "application/json" },
2203
+ body: JSON.stringify({ settings: { language: normalized } }),
2204
+ });
2205
+ } catch (error) {
2206
+ console.error(error);
2207
+ }
2208
+ }
2209
+ }
2210
+
2211
+ function initializeLanguage() {
2212
+ const stored = readStorage("neutrino.language");
2213
+ if (stored) setUiLanguage(stored);
2214
+ applyStaticTranslations();
2215
+ syncLanguageSelects(uiLanguage());
2216
+ if (state.status) setStatus(state.status.key, state.status.kind);
2217
+ }
2218
+
1131
2219
  // --------------------------------------------------------------------- init
1132
2220
 
1133
2221
  el.refresh.addEventListener("click", () => loadOverview());
@@ -1141,8 +2229,105 @@ el.input.addEventListener("keydown", (event) => {
1141
2229
  }
1142
2230
  });
1143
2231
 
2232
+ el.newChat.addEventListener("click", () => openNewChatDialog());
2233
+ if (el.emptyNewChat) el.emptyNewChat.addEventListener("click", () => openNewChatDialog());
2234
+ el.workspaceButton.addEventListener("click", openWorkspaceDialog);
2235
+ el.workspaceClose.addEventListener("click", () => el.workspaceDialog.close());
2236
+ el.workspaceAll.addEventListener("click", () => {
2237
+ el.workspaceDialog.close();
2238
+ setWorkspace(null);
2239
+ showHome();
2240
+ });
2241
+ el.workspaceAdd.addEventListener("click", () => {
2242
+ el.workspaceDialog.close();
2243
+ openFolderDialog({ start: state.workspace?.path || state.workspaces?.cwd || "", onPick: (path) => openWorkspace(path) });
2244
+ });
2245
+ el.agentsOpen.addEventListener("click", () => openAgentsDialog());
2246
+ el.agentsClose.addEventListener("click", () => el.agentsDialog.close());
2247
+ el.agentsDialog.addEventListener("cancel", (event) => {
2248
+ event.preventDefault();
2249
+ el.agentsDialog.close();
2250
+ });
2251
+ el.languageSelect?.addEventListener("change", () => changeInterfaceLanguage(el.languageSelect.value));
2252
+
2253
+ el.newChatClose.addEventListener("click", () => el.newChatDialog.close());
2254
+ el.newChatCancel.addEventListener("click", () => el.newChatDialog.close());
2255
+ el.newChatDialog.addEventListener("cancel", (event) => {
2256
+ event.preventDefault();
2257
+ el.newChatDialog.close();
2258
+ });
2259
+ el.newChatForm.addEventListener("submit", (event) => {
2260
+ event.preventDefault();
2261
+ submitNewChat();
2262
+ });
2263
+ el.newChatBrowse.addEventListener("click", () => {
2264
+ openFolderDialog({
2265
+ start: el.newChatCwd.value.trim() || state.workspace?.path || "",
2266
+ onPick: (path) => {
2267
+ el.newChatCwd.value = path;
2268
+ renderNewChatRecents();
2269
+ },
2270
+ });
2271
+ });
2272
+ el.newChatCwd.addEventListener("input", renderNewChatRecents);
2273
+ el.newChatProvider.addEventListener("change", () => {
2274
+ const catalog = preferences.catalog?.();
2275
+ if (catalog) {
2276
+ fillNewChatModels(catalog, "");
2277
+ updateNewChatNote(catalog);
2278
+ }
2279
+ });
2280
+ el.newChatModel.addEventListener("change", () => {
2281
+ const catalog = preferences.catalog?.();
2282
+ if (catalog) updateNewChatNote(catalog);
2283
+ });
2284
+
2285
+ el.folderClose.addEventListener("click", () => el.folderDialog.close());
2286
+ el.folderCancel.addEventListener("click", () => el.folderDialog.close());
2287
+ el.folderUse.addEventListener("click", useCurrentFolder);
2288
+ el.folderUp.addEventListener("click", () => {
2289
+ if (state.folder.parent) void loadFolder(state.folder.parent);
2290
+ });
2291
+ el.folderGo.addEventListener("click", () => void loadFolder(el.folderPath.value.trim()));
2292
+ el.folderPath.addEventListener("keydown", (event) => {
2293
+ if (event.key === "Enter") {
2294
+ event.preventDefault();
2295
+ void loadFolder(el.folderPath.value.trim());
2296
+ }
2297
+ });
2298
+ el.folderMkdir.addEventListener("click", () => {
2299
+ el.folderNew.classList.remove("hidden");
2300
+ el.folderFeedback.textContent = "";
2301
+ el.folderNewName.value = "";
2302
+ el.folderNewName.focus();
2303
+ });
2304
+ el.folderNewCreate.addEventListener("click", () => void createFolderFromDialog());
2305
+ el.folderNewCancel.addEventListener("click", () => el.folderNew.classList.add("hidden"));
2306
+ el.folderNewName.addEventListener("keydown", (event) => {
2307
+ if (event.key === "Enter") {
2308
+ event.preventDefault();
2309
+ void createFolderFromDialog();
2310
+ }
2311
+ if (event.key === "Escape") el.folderNew.classList.add("hidden");
2312
+ });
2313
+ el.folderDialog.addEventListener("cancel", (event) => {
2314
+ event.preventDefault();
2315
+ el.folderDialog.close();
2316
+ });
2317
+
1144
2318
  window.addEventListener("beforeunload", closeStream);
1145
2319
 
1146
- preferences.load().catch(error => console.error(error));
1147
- loadOverview();
2320
+ initializeLanguage();
2321
+ preferences.load()
2322
+ .then((config) => {
2323
+ const language = config?.settings?.language;
2324
+ if (language && language !== uiLanguage()) return changeInterfaceLanguage(language, { persist: false });
2325
+ return undefined;
2326
+ })
2327
+ .catch(error => console.error(error));
2328
+ loadOverview().then(() => {
2329
+ // Reopen the folder the user had open last time, without stealing a click
2330
+ // that landed on a saved conversation while the overview was loading.
2331
+ if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);
2332
+ });
1148
2333
  setInterval(() => loadOverview({ silent: true }), REFRESH_MS);