@xingjisoft/neutrino 0.2.1 → 0.4.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.
Files changed (43) hide show
  1. package/README.md +312 -436
  2. package/assets/pro/backend_output_model.txt +5 -0
  3. package/assets/pro/claude-module-prompt.txt +65 -0
  4. package/assets/pro/common.txt +9 -0
  5. package/assets/pro/confirm_model.txt +5 -0
  6. package/assets/pro/front_output_model.txt +9 -0
  7. package/assets/pro/genimage_model.txt +1 -0
  8. package/assets/pro/main_model.txt +5 -0
  9. package/assets/pro/model-template.json +376 -0
  10. package/assets/pro/modeling_model.txt +5 -0
  11. package/assets/pro/plan_model.txt +8 -0
  12. package/assets/pro/thinking_model.txt +3 -0
  13. package/dist/app.js +43 -7
  14. package/dist/bin.js +5 -2
  15. package/dist/browser.js +57 -0
  16. package/dist/config.js +179 -26
  17. package/dist/index.js +1 -1
  18. package/dist/llm/approval.js +6 -2
  19. package/dist/llm/client.js +197 -28
  20. package/dist/llm/subagent.js +37 -1
  21. package/dist/pro/agents.js +104 -0
  22. package/dist/pro/config.js +88 -0
  23. package/dist/pro/preflight.js +172 -0
  24. package/dist/screens/chat.js +54 -38
  25. package/dist/screens/modelSelect.js +15 -4
  26. package/dist/screens/settings.js +9 -7
  27. package/dist/screens/start.js +48 -15
  28. package/dist/ui.js +58 -0
  29. package/dist/web/hub.js +31 -4
  30. package/dist/web/server.js +611 -50
  31. package/dist/web/session.js +320 -20
  32. package/dist/web/settings.js +117 -24
  33. package/package.json +37 -37
  34. package/web/README.md +158 -16
  35. package/web/app.js +1603 -128
  36. package/web/i18n.js +382 -0
  37. package/web/index.html +289 -37
  38. package/web/markdown.js +710 -0
  39. package/web/neutrino-pro.gif +0 -0
  40. package/web/neutrino.png +0 -0
  41. package/web/pro.js +184 -0
  42. package/web/settings.js +322 -193
  43. package/web/style.css +1378 -42
package/web/app.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { createSettingsUI } from "./settings.js";
2
+ import { createProUI } from "./pro.js";
3
+ import { renderMarkdown as renderMarkdownHtml } from "./markdown.js";
2
4
  import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
5
+ import { applyTranslations, getLanguage as currentLanguage, locale as currentLocale, setLanguage as setI18nLanguage, t as translate } from "./i18n.js";
3
6
 
4
7
  // Neutrino web UI — a dependency-free single page app.
5
8
  //
@@ -21,6 +24,74 @@ const REFRESH_MS = 4000;
21
24
  // Login is remembered in an HttpOnly cookie shared by fetch and EventSource.
22
25
  // Remove credentials left behind by older versions of the UI.
23
26
  try { localStorage.removeItem("neutrino.token"); } catch { /* Storage may be blocked. */ }
27
+
28
+ // ------------------------------------------------------------------ pro mode
29
+ //
30
+ // The "Pro" workspace is served at /pro and owns an isolated API under
31
+ // /pro/api, while this page and the login cookie are shared. Detect the mode
32
+ // before the request helpers below so no call can leak between the workspaces.
33
+ const PRO_MODE = (() => {
34
+ try {
35
+ const pathname = String(location?.pathname ?? "");
36
+ return pathname === "/pro" || pathname.startsWith("/pro/");
37
+ } catch {
38
+ return false; // Test sandboxes and unusual embedders have no location.
39
+ }
40
+ })();
41
+
42
+ const API_BASE = PRO_MODE ? "/pro/api" : "/api";
43
+ // Lets Pro-specific chrome (the wider sidebar needed by the longer brand name)
44
+ // apply before the first paint of this module.
45
+ if (PRO_MODE) document.body?.classList.add("pro-mode");
46
+
47
+ /** Point an /api request at the active workspace; other paths are unchanged. */
48
+ function apiPath(path) {
49
+ if (!PRO_MODE) return path;
50
+ const text = String(path);
51
+ return text.startsWith("/api/") ? `${API_BASE}${text.slice(4)}` : path;
52
+ }
53
+
54
+ /** Namespace a remembered preference so both workspaces keep their own copy. */
55
+ function modeStorageKey(key) {
56
+ return PRO_MODE ? `neutrino.pro.${key}` : key;
57
+ }
58
+
59
+ // Translation lookup. `translate` is an ES-module import; the browser tests
60
+ // that evaluate this file in a sandbox strip imports, so the helper degrades to
61
+ // the English source text instead of throwing. Interpolation is kept here too.
62
+ const tr = (text, values) => {
63
+ if (typeof translate === "function") return translate(text, values);
64
+ return String(text ?? "").replace(/\{(\w+)\}/g, (match, name) =>
65
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match);
66
+ };
67
+
68
+ function uiLanguage() {
69
+ return typeof currentLanguage === "function" ? currentLanguage() : "en";
70
+ }
71
+
72
+ function setUiLanguage(value) {
73
+ return typeof setI18nLanguage === "function" ? setI18nLanguage(value) : "en";
74
+ }
75
+
76
+ function localeTag() {
77
+ return typeof currentLocale === "function" ? currentLocale() : "en-US";
78
+ }
79
+
80
+ function applyStaticTranslations() {
81
+ if (typeof applyTranslations === "function") applyTranslations(document);
82
+ }
83
+
84
+ function readStorage(key) {
85
+ try {
86
+ return localStorage.getItem(key) || "";
87
+ } catch {
88
+ return "";
89
+ }
90
+ }
91
+
92
+ function writeStorage(key, value) {
93
+ try { localStorage.setItem(key, value); } catch { /* Private mode may block storage. */ }
94
+ }
24
95
  async function loginWithPassword(password) {
25
96
  const response = await fetch("/api/auth/login", {
26
97
  method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
@@ -53,7 +124,7 @@ const initialLogin = (() => {
53
124
 
54
125
  async function apiFetch(path, init = {}) {
55
126
  if (!await initialLogin) throw new Error("Password login required.");
56
- const response = await fetch(path, { ...init, credentials: "same-origin" });
127
+ const response = await fetch(apiPath(path), { ...init, credentials: "same-origin" });
57
128
  if (response.status === 401) {
58
129
  showGate();
59
130
  throw new Error("Password login required.");
@@ -84,12 +155,12 @@ function showGate(message = "") {
84
155
  gate.innerHTML =
85
156
  '<form class="gate-card">' +
86
157
  '<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>' +
158
+ '<h2 id="login-title">' + tr("Sign in") + "</h2>" +
159
+ "<p>" + tr("Enter your Web password to open this console. The default password is 114514.") + "</p>" +
160
+ '<label for="login-password">' + tr("Password") + "</label>" +
90
161
  '<input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="256" required autofocus>' +
91
162
  '<p class="form-feedback error" role="alert"></p>' +
92
- '<button class="primary" type="submit">Sign in</button>' +
163
+ '<button class="primary" type="submit">' + tr("Sign in") + "</button>" +
93
164
  "</form>";
94
165
  gate.querySelector(".form-feedback").textContent = message;
95
166
  gate.addEventListener("cancel", event => event.preventDefault());
@@ -119,6 +190,11 @@ const el = {
119
190
  sessions: document.getElementById("sessions"),
120
191
  status: document.getElementById("status"),
121
192
  refresh: document.getElementById("refresh"),
193
+ brandLogo: document.getElementById("brand-logo"),
194
+ brandName: document.getElementById("brand-name"),
195
+ proTip: document.getElementById("pro-tip"),
196
+ proTipClose: document.getElementById("pro-tip-close"),
197
+ proExit: document.getElementById("pro-exit"),
122
198
  title: document.getElementById("session-title"),
123
199
  sub: document.getElementById("session-sub"),
124
200
  controls: document.getElementById("controls"),
@@ -127,11 +203,61 @@ const el = {
127
203
  input: document.getElementById("input"),
128
204
  send: document.getElementById("send"),
129
205
  approval: document.getElementById("approval"),
206
+ emptyNewChat: document.getElementById("empty-new-chat"),
207
+ newChat: document.getElementById("new-chat"),
208
+ workspaceButton: document.getElementById("workspace-button"),
209
+ workspaceLabel: document.getElementById("workspace-label"),
210
+ languageSelect: document.getElementById("language-select"),
211
+ planCard: document.getElementById("plan-card"),
212
+ workspaceDialog: document.getElementById("workspace-dialog"),
213
+ workspaceList: document.getElementById("workspace-list"),
214
+ workspaceAll: document.getElementById("workspace-all"),
215
+ workspaceAdd: document.getElementById("workspace-add"),
216
+ workspaceClose: document.getElementById("workspace-close"),
217
+ workspaceFeedback: document.getElementById("workspace-feedback"),
218
+ newChatDialog: document.getElementById("new-chat-dialog"),
219
+ newChatForm: document.getElementById("new-chat-form"),
220
+ newChatTitle: document.getElementById("new-chat-title"),
221
+ newChatClose: document.getElementById("new-chat-close"),
222
+ newChatCancel: document.getElementById("new-chat-cancel"),
223
+ newChatStart: document.getElementById("new-chat-start"),
224
+ newChatCwd: document.getElementById("new-chat-cwd"),
225
+ newChatBrowse: document.getElementById("new-chat-browse"),
226
+ newChatRecents: document.getElementById("new-chat-recents"),
227
+ newChatProvider: document.getElementById("new-chat-provider"),
228
+ newChatModel: document.getElementById("new-chat-model"),
229
+ newChatIntensity: document.getElementById("new-chat-intensity"),
230
+ newChatApproval: document.getElementById("new-chat-approval"),
231
+ newChatNote: document.getElementById("new-chat-note"),
232
+ newChatFeedback: document.getElementById("new-chat-feedback"),
233
+ folderDialog: document.getElementById("folder-dialog"),
234
+ folderTitle: document.getElementById("folder-title"),
235
+ folderClose: document.getElementById("folder-close"),
236
+ folderCancel: document.getElementById("folder-cancel"),
237
+ folderUse: document.getElementById("folder-use"),
238
+ folderUp: document.getElementById("folder-up"),
239
+ folderPath: document.getElementById("folder-path"),
240
+ folderGo: document.getElementById("folder-go"),
241
+ folderRoots: document.getElementById("folder-roots"),
242
+ folderList: document.getElementById("folder-list"),
243
+ folderNew: document.getElementById("folder-new"),
244
+ folderNewName: document.getElementById("folder-new-name"),
245
+ folderNewCreate: document.getElementById("folder-new-create"),
246
+ folderNewCancel: document.getElementById("folder-new-cancel"),
247
+ folderMkdir: document.getElementById("folder-mkdir"),
248
+ folderFeedback: document.getElementById("folder-feedback"),
249
+ agentsOpen: document.getElementById("agents-open"),
250
+ agentsDialog: document.getElementById("agents-dialog"),
251
+ agentsClose: document.getElementById("agents-close"),
252
+ agentsList: document.getElementById("agents-list"),
253
+ agentsFeedback: document.getElementById("agents-feedback"),
254
+ agentsCwd: document.getElementById("agents-cwd"),
130
255
  };
131
256
 
132
257
  const state = {
133
258
  overview: null,
134
- // { kind: "live", key } | { kind: "file", id } | null
259
+ // { kind: "live", key } | { kind: "file", id } | { kind: "new", cwd } |
260
+ // { kind: "agent", agent, id, title } | null
135
261
  selection: null,
136
262
  snapshot: null,
137
263
  eventSource: null,
@@ -142,10 +268,35 @@ const state = {
142
268
  // Turns the reader expanded; a finished turn is folded unless listed here.
143
269
  openTurns: new Set(),
144
270
  openContext: null,
271
+ // Last loaded saved transcript, kept so a language switch can repaint it.
272
+ fileData: null,
273
+ // Last loaded read-only transcript from another agent.
274
+ agentData: null,
275
+ // { key, path } | null — the working directory the sidebar is scoped to.
276
+ workspace: storedWorkspace(),
277
+ // Plan / usage card visibility and the plan it was opened for.
278
+ planOpen: false,
279
+ planSeen: "",
280
+ composerMode: "live",
281
+ // Folder-picker state: where it is, what to do with the picked path.
282
+ folder: { path: "", parent: null, roots: [], onPick: null, mode: "workspace", loading: false },
283
+ workspaces: null,
284
+ // Translation key and colour class of the sidebar status line.
285
+ status: { key: "connecting…", kind: "" },
145
286
  };
146
287
 
288
+ function storedWorkspace() {
289
+ const path = readStorage(modeStorageKey("neutrino.workspace"));
290
+ if (!path) return null;
291
+ return { key: pathKey(path), path };
292
+ }
293
+
294
+ let proUI = null;
147
295
  const preferences = createSettingsUI({
148
296
  apiFetch,
297
+ proMode: PRO_MODE,
298
+ onCatalogChange: (catalog) => proUI?.load(catalog),
299
+ onLanguageChange: (language) => changeInterfaceLanguage(language),
149
300
  async applyModel(selection) {
150
301
  const current = state.selection;
151
302
  if (!current) throw new Error("Select a conversation first.");
@@ -165,6 +316,37 @@ const preferences = createSettingsUI({
165
316
  },
166
317
  });
167
318
 
319
+ if (PRO_MODE && typeof createProUI === "function") {
320
+ proUI = createProUI({
321
+ async changePreset(preset) {
322
+ const current = state.selection;
323
+ if (current?.kind === "live") {
324
+ const response = await apiFetch(`/api/live/${encodeURIComponent(current.key)}/pro-preset`, {
325
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ preset }),
326
+ });
327
+ const body = await response.json();
328
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
329
+ if (state.selection?.kind === "live" && state.selection.key === current.key) {
330
+ state.snapshot = body.snapshot;
331
+ renderLive();
332
+ }
333
+ } else if (current?.kind !== "file") {
334
+ const response = await apiFetch("/api/settings", {
335
+ method: "POST", headers: { "Content-Type": "application/json" },
336
+ body: JSON.stringify({ settings: { pro_preset: preset } }),
337
+ });
338
+ const body = await response.json();
339
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
340
+ await preferences.load();
341
+ }
342
+ },
343
+ });
344
+ }
345
+
346
+ function currentProPreset() {
347
+ return proUI?.preset() || preferences.catalog?.()?.pro?.preset;
348
+ }
349
+
168
350
  // ------------------------------------------------------------------- sounds
169
351
  //
170
352
  // A turn that ends while the tab is in the background is invisible, so finished
@@ -258,6 +440,37 @@ function shortenPath(value, depth = 2) {
258
440
  return `…${separator}${parts.slice(-depth).join(separator)}`;
259
441
  }
260
442
 
443
+ /**
444
+ * Canonical form of a working directory, matching the server's `normalizeCwd`.
445
+ * It is only used to decide which sidebar group a path belongs to.
446
+ */
447
+ function pathKey(value) {
448
+ let text = String(value ?? "").trim();
449
+ if (!text) return "";
450
+ text = text.replace(/^\\\\\?\\/, "").replace(/\\/g, "/").replace(/\/{2,}/g, "/");
451
+ const wsl = /^\/(?:mnt|cygdrive)\/([a-zA-Z])(\/.*)?$/.exec(text);
452
+ const win = /^([a-zA-Z]):(\/.*)?$/.exec(text);
453
+ const drive = wsl ?? win;
454
+ if (drive) {
455
+ const rest = (drive[2] || "/").replace(/\/+$/, "") || "/";
456
+ return `${drive[1].toLowerCase()}:${rest.toLowerCase()}`;
457
+ }
458
+ return text.length > 1 ? text.replace(/\/+$/, "") : text;
459
+ }
460
+
461
+ /** Last path segment, used for compact workspace labels. */
462
+ function workspaceName(value) {
463
+ const parts = String(value ?? "").split(/[\\/]+/).filter(Boolean);
464
+ return parts.length ? parts[parts.length - 1] : String(value ?? "");
465
+ }
466
+
467
+ function setWorkspace(workspace, { persist = true } = {}) {
468
+ state.workspace = workspace ? { key: pathKey(workspace.path), path: workspace.path } : null;
469
+ if (persist) writeStorage(modeStorageKey("neutrino.workspace"), state.workspace?.path ?? "");
470
+ renderWorkspaceChrome();
471
+ renderSidebar();
472
+ }
473
+
261
474
  function safeJson(value) {
262
475
  try {
263
476
  return JSON.stringify(value ?? {});
@@ -272,44 +485,33 @@ function formatTime(value) {
272
485
  if (Number.isNaN(date.getTime())) return "";
273
486
  const now = Date.now();
274
487
  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" });
488
+ if (diff < 60_000) return tr("just now");
489
+ if (diff < 3_600_000) return tr("{n}m ago", { n: Math.floor(diff / 60_000) });
490
+ if (diff < 86_400_000) return tr("{n}h ago", { n: Math.floor(diff / 3_600_000) });
491
+ return date.toLocaleDateString(localeTag(), { month: "short", day: "numeric" });
279
492
  }
280
493
 
281
494
  function formatDate(value) {
282
495
  if (!value) return "";
283
496
  const date = new Date(value);
284
- return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
497
+ return Number.isNaN(date.getTime()) ? "" : date.toLocaleString(localeTag());
285
498
  }
286
499
 
287
- function setStatus(text, kind) {
288
- el.status.textContent = text;
500
+ function setStatus(key, kind) {
501
+ // Store the translation key, not the rendered text: switching languages must
502
+ // not leave a stale word in the sidebar.
503
+ state.status = { key, kind };
504
+ el.status.textContent = tr(key);
289
505
  el.status.className = `status${kind ? ` ${kind}` : ""}`;
290
506
  }
291
507
 
292
- // A deliberately small markdown renderer: code fences, inline code, bold,
293
- // italics and links. Everything else is escaped, so model output is inert.
508
+ // Full markdown lives in web/markdown.js: headings, lists, tables, code,
509
+ // quotes, links, images and the inline emphasis family. The fallback keeps the
510
+ // sandboxed browser tests working when module imports are stripped; it still
511
+ // escapes all model output before adding line breaks.
294
512
  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;
513
+ if (typeof renderMarkdownHtml === "function") return renderMarkdownHtml(value);
514
+ return escapeHtml(String(value ?? "")).replace(/\n/g, "<br>");
313
515
  }
314
516
 
315
517
  // ------------------------------------------------------------------- status
@@ -324,6 +526,7 @@ async function loadOverview(options = {}) {
324
526
  if (gateShown) return;
325
527
  state.overview = overview;
326
528
  setStatus(state.selection?.kind === "live" ? "live" : "online", state.selection?.kind === "live" ? "live" : "online");
529
+ renderWorkspaceChrome();
327
530
  renderSidebar();
328
531
  syncSelection();
329
532
  } catch (error) {
@@ -350,35 +553,49 @@ function syncSelection() {
350
553
  renderSidebar();
351
554
  hideComposer();
352
555
  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>';
556
+ el.title.textContent = tr("Session ended");
557
+ el.sub.textContent = tr("The terminal session was closed.");
558
+ el.messages.innerHTML = `<div class="empty">${escapeHtml(tr("This live session has ended."))}</div>`;
559
+ renderPlanCard(null);
356
560
  }
357
561
 
358
562
  // ------------------------------------------------------------------ sidebar
359
563
 
564
+ /** Sidebar groups for the current view: every folder, or only the open workspace. */
565
+ function viewGroups() {
566
+ if (!state.overview) return null;
567
+ if (!state.workspace) return state.overview.groups;
568
+ const found = state.overview.groups.find((group) => group.key === state.workspace.key);
569
+ return found ? [found] : [{ key: state.workspace.key, cwd: state.workspace.path, updated_at: "", sessions: [] }];
570
+ }
571
+
360
572
  function renderSidebar() {
361
- const overview = state.overview;
362
573
  el.sessions.innerHTML = "";
574
+ const groups = viewGroups();
363
575
 
364
- if (!overview || overview.groups.length === 0) {
576
+ if (!groups || groups.length === 0) {
365
577
  const empty = document.createElement("div");
366
578
  empty.className = "empty-side";
367
- empty.textContent = "No conversations yet.";
579
+ empty.textContent = state.workspace ? tr("No conversations in this folder yet.") : tr("No conversations yet.");
368
580
  el.sessions.appendChild(empty);
369
581
  return;
370
582
  }
371
583
 
372
- for (const group of overview.groups) {
373
- el.sessions.appendChild(renderGroup(group));
584
+ for (const group of groups) el.sessions.appendChild(renderGroup(group, { forceOpen: Boolean(state.workspace) }));
585
+
586
+ if (state.workspace && groups[0] && groups[0].sessions.length === 0) {
587
+ const note = document.createElement("div");
588
+ note.className = "empty-side workspace-hint";
589
+ note.textContent = tr("Type a message below to start a conversation in this folder.");
590
+ el.sessions.appendChild(note);
374
591
  }
375
592
  }
376
593
 
377
- function renderGroup(group) {
594
+ function renderGroup(group, options = {}) {
378
595
  const wrap = document.createElement("section");
379
596
  wrap.className = "group";
380
597
 
381
- const collapsed = state.collapsed.has(group.key);
598
+ const collapsed = !options.forceOpen && state.collapsed.has(group.key);
382
599
  const header = document.createElement("button");
383
600
  header.className = "group-header";
384
601
  header.innerHTML =
@@ -402,11 +619,18 @@ function renderGroup(group) {
402
619
  const visible = expanded ? group.sessions : group.sessions.slice(0, MAX_VISIBLE);
403
620
  for (const session of visible) list.appendChild(renderSession(session));
404
621
 
622
+ if (group.sessions.length === 0) {
623
+ const empty = document.createElement("div");
624
+ empty.className = "empty-side";
625
+ empty.textContent = tr("No conversations in this folder yet.");
626
+ list.appendChild(empty);
627
+ }
628
+
405
629
  const hiddenCount = group.sessions.length - MAX_VISIBLE;
406
630
  if (hiddenCount > 0) {
407
631
  const more = document.createElement("button");
408
632
  more.className = "more";
409
- more.textContent = expanded ? "Show less" : `Show ${hiddenCount} more`;
633
+ more.textContent = expanded ? tr("Show less") : tr("Show {n} more", { n: hiddenCount });
410
634
  more.addEventListener("click", () => {
411
635
  if (state.expanded.has(group.key)) state.expanded.delete(group.key);
412
636
  else state.expanded.add(group.key);
@@ -432,15 +656,15 @@ function renderSession(session) {
432
656
  if (isSelected(session)) item.classList.add("selected");
433
657
  if (session.live) item.classList.add("live");
434
658
 
435
- const title = session.title || "New conversation";
659
+ const title = session.title || tr("New conversation");
436
660
  const count = Number(session.message_count ?? 0);
437
661
  item.title = title;
438
662
  item.innerHTML =
439
663
  `<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
440
- `${session.live ? '<span class="dot" title="Live"></span>' : ""}</div>` +
664
+ `${session.live ? `<span class="dot" title="${escapeHtml(tr("Live"))}"></span>` : ""}</div>` +
441
665
  `<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
442
666
  `<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
443
- `<span class="count">${count} message${count === 1 ? "" : "s"}</span></div>`;
667
+ `<span class="count">${escapeHtml(count === 1 ? tr("{n} message", { n: count }) : tr("{n} messages", { n: count }))}</span></div>`;
444
668
 
445
669
  item.addEventListener("click", () => {
446
670
  if (session.liveKey) selectLive(session.liveKey);
@@ -475,23 +699,28 @@ async function selectLive(key) {
475
699
  closeStream();
476
700
  state.selection = { kind: "live", key };
477
701
  state.snapshot = null;
702
+ state.fileData = null;
703
+ state.agentData = null;
478
704
  state.lastSessionId = null;
479
705
  state.openTools.clear();
480
706
  state.openTurns.clear();
481
707
  state.openContext = null;
708
+ state.planOpen = false;
709
+ state.planSeen = "";
482
710
  renderSidebar();
483
711
  el.controls.innerHTML = "";
484
712
  renderApproval(null);
485
- el.title.textContent = "Connecting…";
713
+ renderPlanCard(null);
714
+ el.title.textContent = tr("Connecting…");
486
715
  el.sub.textContent = "";
487
716
  setComposerMode("live");
488
- resetMessagePane("Connecting…");
717
+ resetMessagePane(tr("Connecting…"));
489
718
  openStream(key);
490
719
  }
491
720
 
492
721
  function openStream(key) {
493
722
  closeStream();
494
- const source = new EventSource(`/api/live/${encodeURIComponent(key)}/stream`);
723
+ const source = new EventSource(apiPath(`/api/live/${encodeURIComponent(key)}/stream`));
495
724
  state.eventSource = source;
496
725
 
497
726
  source.addEventListener("snapshot", (event) => {
@@ -534,16 +763,21 @@ async function selectFile(id) {
534
763
  closeStream();
535
764
  state.selection = { kind: "file", id };
536
765
  state.snapshot = null;
766
+ state.fileData = null;
767
+ state.agentData = null;
537
768
  state.openTools.clear();
538
769
  state.openTurns.clear();
539
770
  state.openContext = null;
771
+ state.planOpen = false;
772
+ state.planSeen = "";
540
773
  renderSidebar();
541
774
  el.controls.innerHTML = "";
542
775
  renderApproval(null);
776
+ renderPlanCard(null);
543
777
  setComposerMode("resume");
544
- el.title.textContent = "Loading…";
778
+ el.title.textContent = tr("Loading…");
545
779
  el.sub.textContent = "";
546
- resetMessagePane("Loading…");
780
+ resetMessagePane(tr("Loading…"));
547
781
 
548
782
  try {
549
783
  const response = await apiFetch(`/api/session/${encodeURIComponent(id)}`, { cache: "no-store" });
@@ -551,8 +785,8 @@ async function selectFile(id) {
551
785
  const data = await response.json();
552
786
  renderFile(data);
553
787
  } catch (error) {
554
- el.title.textContent = "Conversation";
555
- resetMessagePane(`Could not load this conversation: ${error.message}`);
788
+ el.title.textContent = tr("Conversation");
789
+ resetMessagePane(tr("Could not load this conversation: {error}", { error: error.message }));
556
790
  }
557
791
  }
558
792
 
@@ -560,11 +794,16 @@ async function selectFile(id) {
560
794
 
561
795
  function renderFile(data) {
562
796
  const messages = Array.isArray(data.messages) ? data.messages : [];
563
- el.title.textContent = firstUserMessage(messages) || "Conversation";
797
+ state.fileData = data;
798
+ state.agentData = null;
799
+ el.title.textContent = firstUserMessage(messages) || tr("Conversation");
564
800
  el.sub.textContent = [data.cwd, data.model, data.provider].filter(Boolean).join(" · ");
565
801
  preferences.showConversation(data, `file:${data.id}`);
802
+ proUI?.show(data, `file:${data.id}`);
566
803
  renderMessageList(messages, null, { readOnly: true, context: data.context, active: false });
567
804
  renderFileControls(data);
805
+ maybeOpenPlan(data.plan);
806
+ renderPlanCard(data);
568
807
  }
569
808
 
570
809
  /**
@@ -573,26 +812,27 @@ function renderFile(data) {
573
812
  */
574
813
  function renderFileControls(data) {
575
814
  el.controls.innerHTML = "";
815
+ el.controls.appendChild(planToggleButton());
576
816
 
577
817
  const badge = document.createElement("span");
578
818
  badge.className = "badge readonly";
579
- badge.textContent = "Read-only";
819
+ badge.textContent = tr("Read-only");
580
820
  el.controls.appendChild(badge);
581
821
 
582
822
  const button = document.createElement("button");
583
823
  button.className = "control primary";
584
- button.textContent = "Continue here";
585
- button.title = "Resume this conversation in the browser and keep chatting";
824
+ button.textContent = tr("Continue here");
825
+ button.title = tr("Resume this conversation in the browser and keep chatting");
586
826
  button.addEventListener("click", async () => {
587
827
  button.disabled = true;
588
- button.textContent = "Starting…";
828
+ button.textContent = tr("Starting…");
589
829
  try {
590
830
  await continueSession(data.id);
591
831
  el.input.focus();
592
832
  } catch (error) {
593
833
  button.disabled = false;
594
- button.textContent = "Continue here";
595
- el.sub.textContent = `Could not continue this conversation: ${error.message}`;
834
+ button.textContent = tr("Continue here");
835
+ el.sub.textContent = tr("Could not continue this conversation: {error}", { error: error.message });
596
836
  }
597
837
  });
598
838
  el.controls.appendChild(button);
@@ -600,14 +840,22 @@ function renderFileControls(data) {
600
840
  const key = await continueSession(data.id);
601
841
  await changeApprovalMode(key, mode);
602
842
  });
843
+
844
+ const remove = document.createElement("button");
845
+ remove.className = "control danger";
846
+ remove.textContent = tr("Delete");
847
+ remove.title = tr("Delete conversation");
848
+ remove.addEventListener("click", () => deleteConversation(data.id, remove));
849
+ el.controls.appendChild(remove);
603
850
  }
604
851
 
605
852
  /** Attach to a resumed conversation, creating it server-side when needed. */
606
853
  async function continueSession(id, selection = preferences.selectionForFile(id)) {
854
+ const payload = PRO_MODE ? { pro_preset: currentProPreset() } : selection ? { selection } : undefined;
607
855
  const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/continue`, {
608
856
  method: "POST",
609
- headers: selection ? { "Content-Type": "application/json" } : undefined,
610
- body: selection ? JSON.stringify({ selection }) : undefined,
857
+ headers: payload ? { "Content-Type": "application/json" } : undefined,
858
+ body: payload ? JSON.stringify(payload) : undefined,
611
859
  });
612
860
  const body = await response.json().catch(() => ({}));
613
861
  if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
@@ -616,11 +864,13 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
616
864
  }
617
865
 
618
866
  function setComposerMode(mode) {
867
+ state.composerMode = mode;
619
868
  el.composer.classList.remove("hidden");
620
- el.input.placeholder =
621
- mode === "resume"
622
- ? "Continue this conversation…"
623
- : "Message Neutrino…";
869
+ el.input.placeholder = mode === "resume"
870
+ ? tr("Continue this conversation…")
871
+ : mode === "new"
872
+ ? tr("Message Neutrino in this folder…")
873
+ : tr("Message Neutrino…");
624
874
  }
625
875
 
626
876
  function hideComposer() {
@@ -630,18 +880,24 @@ function hideComposer() {
630
880
  }
631
881
 
632
882
  function renderApprovalDetails(request, fallbackCwd = "") {
883
+ // The approval renderer is exercised on its own in the browser tests; fall
884
+ // back to the English source if the translation helper is out of scope.
885
+ const tx = (text, values) => (typeof tr === "function"
886
+ ? tr(text, values)
887
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
888
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
633
889
  const details = document.createElement("div");
634
890
  details.className = "approval-details";
635
891
  const tool = document.createElement("div");
636
892
  tool.className = "approval-tool";
637
- tool.textContent = request.tool || "Tool call";
893
+ tool.textContent = request.tool || tx("Tool call");
638
894
  details.appendChild(tool);
639
895
 
640
896
  const cwd = request.cwd || fallbackCwd;
641
897
  if (cwd) {
642
898
  const location = document.createElement("div");
643
899
  location.className = "approval-location";
644
- location.textContent = `Working directory: ${cwd}`;
900
+ location.textContent = tx("Working directory: {path}", { path: cwd });
645
901
  details.appendChild(location);
646
902
  }
647
903
 
@@ -658,14 +914,14 @@ function renderApprovalDetails(request, fallbackCwd = "") {
658
914
  // Literal text preserves the complete command and prevents tool arguments
659
915
  // from becoming markup. Risk summaries may be shortened; arguments are not.
660
916
  code.textContent = command !== null ? command
661
- : callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || "Command details unavailable.";
917
+ : callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || tx("Command details unavailable.");
662
918
  details.appendChild(code);
663
919
 
664
920
  if (stated) {
665
921
  const why = document.createElement("div");
666
922
  why.className = "approval-why";
667
923
  const label = document.createElement("strong");
668
- label.textContent = "Why: ";
924
+ label.textContent = tx("Why: ");
669
925
  const claim = document.createElement("span");
670
926
  claim.textContent = stated;
671
927
  why.append(label, claim);
@@ -676,7 +932,7 @@ function renderApprovalDetails(request, fallbackCwd = "") {
676
932
  const argumentsBox = document.createElement("details");
677
933
  argumentsBox.className = "approval-arguments";
678
934
  const label = document.createElement("summary");
679
- label.textContent = "All arguments";
935
+ label.textContent = tx("All arguments");
680
936
  const parameters = document.createElement("pre");
681
937
  parameters.className = "approval-code";
682
938
  parameters.textContent = JSON.stringify(callArgs, null, 2);
@@ -697,33 +953,39 @@ function renderApprovalDetails(request, fallbackCwd = "") {
697
953
  return details;
698
954
  }
699
955
 
956
+ /**
957
+ * Progress text for an automated review. It belongs in the status badge, not
958
+ * in the approval banner: automatic review needs no answer from the user, and
959
+ * reusing the approval card made the browser look like it was still asking for
960
+ * one while the reviewers were already deciding.
961
+ */
962
+ function reviewStatus(review) {
963
+ const tx = (text, values) => (typeof tr === "function"
964
+ ? tr(text, values)
965
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
966
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
967
+ const rounds = Number(review && review.rounds) || 1;
968
+ const total = Number(review && review.total) || 0;
969
+ if (rounds > 1) {
970
+ return tx("reviewing round {round}/{rounds}", { round: (review && review.round) || 1, rounds });
971
+ }
972
+ if (total === 1) return tx("reviewing · {n} reviewer", { n: total });
973
+ if (total > 1) return tx("reviewing · {n} reviewers", { n: total });
974
+ return tx("reviewing");
975
+ }
976
+
700
977
  function renderApproval(snapshot) {
978
+ const tx = (text, values) => (typeof tr === "function"
979
+ ? tr(text, values)
980
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
981
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
701
982
  const pending = snapshot && snapshot.approval;
702
983
  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");
984
+ // The banner is reserved for a call that is really waiting for a human.
985
+ // Automatic review progress lives in the controls badge (see renderControls),
986
+ // because otherwise it reads as a second approval request that then
987
+ // disappears by itself.
988
+ el.approval.className = "approval hidden";
727
989
  el.approval.replaceChildren();
728
990
  return;
729
991
  }
@@ -733,10 +995,10 @@ function renderApproval(snapshot) {
733
995
  const head = document.createElement("div");
734
996
  head.className = "approval-head";
735
997
  const title = document.createElement("strong");
736
- title.textContent = "Approval required";
998
+ title.textContent = tx("Approval required");
737
999
  const risk = document.createElement("span");
738
1000
  risk.className = "approval-risk";
739
- risk.textContent = extreme ? "EXTREME RISK" : "HIGH RISK";
1001
+ risk.textContent = extreme ? tx("EXTREME RISK") : tx("HIGH RISK");
740
1002
  head.append(title, risk);
741
1003
 
742
1004
  const details = renderApprovalDetails(pending, snapshot.cwd);
@@ -745,11 +1007,11 @@ function renderApproval(snapshot) {
745
1007
  actions.className = "approval-actions";
746
1008
  const approve = document.createElement("button");
747
1009
  approve.className = "approve-btn";
748
- approve.textContent = "Approve";
1010
+ approve.textContent = tx("Approve");
749
1011
  approve.addEventListener("click", () => decideApproval(true));
750
1012
  const deny = document.createElement("button");
751
1013
  deny.className = "deny-btn";
752
- deny.textContent = "Deny";
1014
+ deny.textContent = tx("Deny");
753
1015
  deny.addEventListener("click", () => decideApproval(false));
754
1016
  actions.append(approve, deny);
755
1017
 
@@ -765,9 +1027,12 @@ function decideApproval(approved) {
765
1027
  function renderLive() {
766
1028
  const snapshot = state.snapshot;
767
1029
  if (!snapshot) return;
768
- el.title.textContent = snapshot.title || "New conversation";
1030
+ state.fileData = null;
1031
+ state.agentData = null;
1032
+ el.title.textContent = snapshot.title || tr("New conversation");
769
1033
  el.sub.textContent = [snapshot.cwd, snapshot.model, snapshot.provider].filter(Boolean).join(" · ");
770
1034
  preferences.showConversation(snapshot, `live:${snapshot.key}`);
1035
+ proUI?.show(snapshot, `live:${snapshot.key}`);
771
1036
  renderControls(snapshot);
772
1037
  renderApproval(snapshot);
773
1038
  renderMessageList(snapshot.messages ?? [], snapshot.streamingContent, {
@@ -777,6 +1042,8 @@ function renderLive() {
777
1042
  // paused turn is unfinished by the user's own choice, so it stays open too.
778
1043
  active: Boolean(snapshot.busy || snapshot.paused),
779
1044
  });
1045
+ maybeOpenPlan(snapshot.plan);
1046
+ renderPlanCard(snapshot);
780
1047
  }
781
1048
 
782
1049
  async function changeApprovalMode(key, mode) {
@@ -794,21 +1061,25 @@ async function changeApprovalMode(key, mode) {
794
1061
  }
795
1062
 
796
1063
  function renderApprovalModeControl(mode, change) {
1064
+ const tx = (text, values) => (typeof tr === "function"
1065
+ ? tr(text, values)
1066
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
1067
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
797
1068
  const automatic = mode === "auto";
798
1069
  const button = document.createElement("button");
799
1070
  button.className = "control";
800
1071
  button.type = "button";
801
1072
  button.setAttribute("role", "switch");
802
- button.setAttribute("aria-label", "Auto Approval");
1073
+ button.setAttribute("aria-label", tx("Auto Approval"));
803
1074
  button.setAttribute("aria-checked", String(automatic));
804
- button.textContent = automatic ? "Auto Approval" : "Request Approval";
805
- button.title = "Switch between requesting approval and automatic review";
1075
+ button.textContent = automatic ? tx("Auto Approval") : tx("Request Approval");
1076
+ button.title = tx("Switch between requesting approval and automatic review");
806
1077
  button.addEventListener("click", async () => {
807
1078
  button.disabled = true;
808
1079
  try {
809
1080
  await change(automatic ? "request" : "auto");
810
1081
  } catch (error) {
811
- el.sub.textContent = `Could not change approval mode: ${error.message}`;
1082
+ el.sub.textContent = tx("Could not change approval mode: {error}", { error: error.message });
812
1083
  } finally {
813
1084
  button.disabled = false;
814
1085
  }
@@ -818,6 +1089,7 @@ function renderApprovalModeControl(mode, change) {
818
1089
 
819
1090
  function renderControls(snapshot) {
820
1091
  el.controls.innerHTML = "";
1092
+ el.controls.appendChild(planToggleButton());
821
1093
  renderApprovalModeControl(snapshot.approvalMode, mode =>
822
1094
  changeApprovalMode(snapshot.key, mode));
823
1095
 
@@ -826,51 +1098,66 @@ function renderControls(snapshot) {
826
1098
  badge.className = `badge ${mode}`;
827
1099
  // A retried request leaves the session busy but silent: name the retry so the
828
1100
  // badge does not just say "working" for minutes. The same goes for a
829
- // sub-agent, whose own messages never reach this transcript.
1101
+ // sub-agent, whose own messages never reach this transcript. An automated
1102
+ // review is reported here too, so it never needs the approval banner.
830
1103
  const retrying = snapshot.retrying;
1104
+ // A pending human decision is an approval, not a review, even if an earlier
1105
+ // review callback is still winding down.
1106
+ const reviewing = snapshot.approval ? null : snapshot.reviewing;
831
1107
  const subagent = snapshot.subagent;
832
1108
  badge.textContent = snapshot.paused
833
- ? "paused"
1109
+ ? tr("paused")
834
1110
  : snapshot.busy
835
1111
  ? snapshot.compressing
836
- ? "compressing"
1112
+ ? tr("compressing")
837
1113
  : retrying
838
- ? `retrying ${retrying.attempt}/${retrying.attempts}`
839
- : subagent
840
- ? `sub-agent: ${subagent}`
841
- : "working"
842
- : "idle";
1114
+ ? tr("retrying {attempt}/{attempts}", { attempt: retrying.attempt, attempts: retrying.attempts })
1115
+ : reviewing
1116
+ ? reviewStatus(reviewing)
1117
+ : subagent
1118
+ ? tr("sub-agent: {label}", { label: subagent })
1119
+ : tr("working")
1120
+ : tr("idle");
843
1121
  el.controls.appendChild(badge);
844
1122
 
845
1123
  if (snapshot.saveError) {
846
1124
  const warning = document.createElement("span");
847
1125
  warning.className = "badge paused";
848
- warning.textContent = `History save failed: ${snapshot.saveError}`;
1126
+ warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError });
849
1127
  el.controls.appendChild(warning);
850
1128
  }
851
1129
 
852
1130
  if (snapshot.busy || snapshot.paused) {
853
1131
  const button = document.createElement("button");
854
1132
  button.className = "control";
855
- button.textContent = snapshot.paused ? "Resume" : "Pause";
1133
+ button.textContent = snapshot.paused ? tr("Resume") : tr("Pause");
856
1134
  button.addEventListener("click", () => {
857
1135
  postAction(snapshot.key, snapshot.paused ? "resume" : "pause");
858
1136
  });
859
1137
  el.controls.appendChild(button);
860
1138
  }
861
1139
 
1140
+ if ((snapshot.messages?.length ?? 0) > 0 || snapshot.busy) {
1141
+ const clear = document.createElement("button");
1142
+ clear.className = "control danger";
1143
+ clear.textContent = tr("Clear");
1144
+ clear.title = tr("Clear conversation");
1145
+ clear.addEventListener("click", () => clearConversation(snapshot.key));
1146
+ el.controls.appendChild(clear);
1147
+ }
1148
+
862
1149
  if (snapshot.queued) {
863
1150
  const queued = document.createElement("span");
864
1151
  queued.className = "queued";
865
1152
  queued.title = snapshot.queued;
866
- queued.textContent = "queued";
1153
+ queued.textContent = tr("queued");
867
1154
  el.controls.appendChild(queued);
868
1155
  }
869
1156
 
870
1157
  if (snapshot.tokens && (snapshot.tokens.prompt || snapshot.tokens.completion)) {
871
1158
  const tokens = document.createElement("span");
872
1159
  tokens.className = "tokens";
873
- tokens.textContent = `${snapshot.tokens.prompt}→${snapshot.tokens.completion} tok`;
1160
+ tokens.textContent = tr("{prompt}→{completion} tok", { prompt: snapshot.tokens.prompt, completion: snapshot.tokens.completion });
874
1161
  el.controls.appendChild(tokens);
875
1162
  }
876
1163
  }
@@ -885,7 +1172,8 @@ function renderMessageList(messages, streaming, options) {
885
1172
  if (messages.length === 0 && !streaming) {
886
1173
  const empty = document.createElement("div");
887
1174
  empty.className = "empty";
888
- empty.textContent = options?.readOnly ? "This conversation is empty." : "Send a message to get started.";
1175
+ const emptyText = options?.readOnly ? "This conversation is empty." : "Send a message to get started.";
1176
+ empty.textContent = typeof tr === "function" ? tr(emptyText) : emptyText;
889
1177
  el.messages.appendChild(empty);
890
1178
  return;
891
1179
  }
@@ -899,7 +1187,9 @@ function renderMessageList(messages, streaming, options) {
899
1187
  const details = document.createElement("details");
900
1188
  details.className = "conversation-history";
901
1189
  const summary = document.createElement("summary");
902
- summary.textContent = `Earlier conversation · ${end} messages`;
1190
+ summary.textContent = typeof tr === "function"
1191
+ ? tr("Earlier conversation · {n} messages", { n: end })
1192
+ : `Earlier conversation · ${end} messages`;
903
1193
  details.appendChild(summary);
904
1194
  const body = document.createElement("div");
905
1195
  body.className = "history-messages";
@@ -982,13 +1272,13 @@ function renderMessage(message, index) {
982
1272
  if (message.role === "summary") {
983
1273
  article.classList.add("summary");
984
1274
  article.innerHTML =
985
- `<div class="summary-label">Context summary · ${Number(message.count ?? 0)} messages</div>` +
1275
+ `<div class="summary-label">${escapeHtml(tr("Context summary · {n} messages", { n: Number(message.count ?? 0) }))}</div>` +
986
1276
  `<div class="body">${renderMarkdown(message.content)}</div>`;
987
1277
  return article;
988
1278
  }
989
1279
 
990
- const who = message.role === "user" ? "You" : "Neutrino";
991
- article.innerHTML = `<div class="who">${who}</div><div class="body">${renderMarkdown(message.content)}</div>`;
1280
+ const who = message.role === "user" ? tr("You") : "Neutrino";
1281
+ article.innerHTML = `<div class="who">${escapeHtml(who)}</div><div class="body">${renderMarkdown(message.content)}</div>`;
992
1282
  return article;
993
1283
  }
994
1284
 
@@ -1000,6 +1290,12 @@ function renderMessage(message, index) {
1000
1290
  * transcript always holds every message.
1001
1291
  */
1002
1292
  function renderTurnFold(start, messages) {
1293
+ // This renderer is also exercised standalone by the web tests, so it carries
1294
+ // an English fallback for the translation helper.
1295
+ const tx = (text, values) => (typeof tr === "function"
1296
+ ? tr(text, values)
1297
+ : String(text).replace(/\{(\w+)\}/g, (match, name) =>
1298
+ values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
1003
1299
  // The reply is never folded away, so a turn renders as a group: the fold (the
1004
1300
  // prompt line plus the work it hides) and, right below it, the answer itself.
1005
1301
  const reply = turnAnswerIndex(messages);
@@ -1018,10 +1314,10 @@ function renderTurnFold(start, messages) {
1018
1314
  '<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
1315
  const you = document.createElement("span");
1020
1316
  you.className = "turn-you";
1021
- you.textContent = `You: ${truncate(turnPreview(messages, "user"), 110)}`;
1317
+ you.textContent = tx("You: {text}", { text: truncate(turnPreview(messages, "user"), 110) });
1022
1318
  const meta = document.createElement("span");
1023
1319
  meta.className = "turn-meta";
1024
- meta.textContent = `${hidden} of ${messages.length} message${messages.length === 1 ? "" : "s"} folded`;
1320
+ meta.textContent = tx("{n} of {m} messages folded", { n: hidden, m: messages.length });
1025
1321
  // The hint cannot live in the static text: it says the opposite once open.
1026
1322
  const hint = document.createElement("span");
1027
1323
  hint.className = "turn-hint";
@@ -1033,7 +1329,7 @@ function renderTurnFold(start, messages) {
1033
1329
  group.appendChild(details);
1034
1330
 
1035
1331
  const syncHint = () => {
1036
- hint.textContent = details.open ? " — click to collapse" : " — click to expand";
1332
+ hint.textContent = details.open ? tx(" — click to collapse") : tx(" — click to expand");
1037
1333
  };
1038
1334
  syncHint();
1039
1335
 
@@ -1102,6 +1398,10 @@ async function sendMessage() {
1102
1398
  if (!selection) return;
1103
1399
  const text = el.input.value;
1104
1400
  if (!text.trim()) return;
1401
+ if (PRO_MODE) {
1402
+ try { await proUI?.flush(); }
1403
+ catch (error) { el.sub.textContent = error.message; return; }
1404
+ }
1105
1405
 
1106
1406
  if (selection.kind === "live") {
1107
1407
  el.input.value = "";
@@ -1110,6 +1410,24 @@ async function sendMessage() {
1110
1410
  return;
1111
1411
  }
1112
1412
 
1413
+ if (selection.kind === "new") {
1414
+ // A workspace landing page: typing starts a fresh conversation there, just
1415
+ // like typing on the terminal's start screen.
1416
+ el.input.value = "";
1417
+ autosize();
1418
+ try {
1419
+ const created = await createConversation(selection.cwd, preferences.defaultSelection?.());
1420
+ await selectLive(created.key);
1421
+ setConversationView(true);
1422
+ await postAction(created.key, "message", { text });
1423
+ } catch (error) {
1424
+ el.input.value = text;
1425
+ autosize();
1426
+ el.sub.textContent = tr("Could not start the conversation: {error}", { error: error.message });
1427
+ }
1428
+ return;
1429
+ }
1430
+
1113
1431
  // Reading a saved transcript: resume it in the browser, then deliver the text.
1114
1432
  el.input.value = "";
1115
1433
  autosize();
@@ -1119,7 +1437,7 @@ async function sendMessage() {
1119
1437
  } catch (error) {
1120
1438
  el.input.value = text;
1121
1439
  autosize();
1122
- el.sub.textContent = `Could not continue this conversation: ${error.message}`;
1440
+ el.sub.textContent = tr("Could not continue this conversation: {error}", { error: error.message });
1123
1441
  }
1124
1442
  }
1125
1443
 
@@ -1128,6 +1446,1061 @@ function autosize() {
1128
1446
  el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
1129
1447
  }
1130
1448
 
1449
+ // ------------------------------------------------------- workspaces & dialogs
1450
+
1451
+ function renderWorkspaceChrome() {
1452
+ if (!el.workspaceLabel) return;
1453
+ const workspace = state.workspace;
1454
+ el.workspaceLabel.textContent = workspace ? workspaceName(workspace.path) : tr("All directories");
1455
+ if (el.workspaceButton) {
1456
+ el.workspaceButton.title = workspace ? workspace.path : tr("Working directory");
1457
+ el.workspaceButton.classList.toggle("active", Boolean(workspace));
1458
+ }
1459
+ }
1460
+
1461
+ function joinPath(parent, name) {
1462
+ const separator = String(parent ?? "").includes("\\") ? "\\" : "/";
1463
+ return `${String(parent ?? "").replace(/[\\/]+$/, "")}${separator}${name}`;
1464
+ }
1465
+
1466
+ function selectionCwd() {
1467
+ const selection = state.selection;
1468
+ if (!selection) return "";
1469
+ if (selection.kind === "live") return state.snapshot?.cwd || "";
1470
+ if (selection.kind === "file") return state.fileData?.cwd || "";
1471
+ if (selection.kind === "new") return selection.cwd || "";
1472
+ if (selection.kind === "agent") return state.agentData?.cwd || state.workspace?.path || "";
1473
+ return "";
1474
+ }
1475
+
1476
+ function confirmAction(message) {
1477
+ try {
1478
+ return typeof window === "undefined" || typeof window.confirm !== "function" ? true : window.confirm(message);
1479
+ } catch {
1480
+ return true;
1481
+ }
1482
+ }
1483
+
1484
+ function apiError(data, fallback) {
1485
+ return tr(data && typeof data.error === "string" ? data.error : fallback);
1486
+ }
1487
+
1488
+ function renderHomeEmpty() {
1489
+ el.messages.innerHTML = "";
1490
+ const empty = document.createElement("div");
1491
+ empty.className = "empty";
1492
+ empty.innerHTML =
1493
+ '<span class="empty-mark" aria-hidden="true">' +
1494
+ '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
1495
+ '<ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />' +
1496
+ '<circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />' +
1497
+ "</svg></span>" +
1498
+ `<strong>${escapeHtml(tr("No conversation open"))}</strong>` +
1499
+ `<span>${escapeHtml(tr("Choose a conversation from the sidebar to view it."))}</span>`;
1500
+ const button = document.createElement("button");
1501
+ button.className = "control primary";
1502
+ button.type = "button";
1503
+ button.textContent = tr("New conversation");
1504
+ button.addEventListener("click", () => openNewChatDialog());
1505
+ empty.appendChild(button);
1506
+ el.messages.appendChild(empty);
1507
+ }
1508
+
1509
+ /** Back to the neutral landing view, with no conversation selected. */
1510
+ function showHome({ menu = true } = {}) {
1511
+ closeStream();
1512
+ state.selection = null;
1513
+ state.snapshot = null;
1514
+ state.fileData = null;
1515
+ state.agentData = null;
1516
+ state.planOpen = false;
1517
+ state.planSeen = "";
1518
+ preferences.clear();
1519
+ renderSidebar();
1520
+ renderWorkspaceChrome();
1521
+ el.controls.innerHTML = "";
1522
+ renderApproval(null);
1523
+ renderPlanCard(null);
1524
+ hideComposer();
1525
+ el.title.textContent = tr("Select a conversation");
1526
+ el.sub.textContent = "";
1527
+ renderHomeEmpty();
1528
+ if (menu) setConversationView(false);
1529
+ }
1530
+
1531
+ /** Show a folder as an empty workspace ready for its first message. */
1532
+ function renderWorkspaceLanding(path) {
1533
+ state.selection = { kind: "new", cwd: path };
1534
+ state.snapshot = null;
1535
+ state.fileData = null;
1536
+ state.planOpen = false;
1537
+ state.planSeen = "";
1538
+ closeStream();
1539
+ preferences.clear();
1540
+ renderSidebar();
1541
+ renderWorkspaceChrome();
1542
+ el.controls.innerHTML = "";
1543
+ renderApproval(null);
1544
+ renderPlanCard(null);
1545
+ el.title.textContent = workspaceName(path) || tr("Working directory");
1546
+ el.sub.textContent = path;
1547
+ setComposerMode("new");
1548
+ proUI?.show(null, `new:${path}`);
1549
+ el.messages.innerHTML = "";
1550
+
1551
+ const empty = document.createElement("div");
1552
+ empty.className = "empty workspace-empty";
1553
+ empty.innerHTML =
1554
+ '<span class="empty-mark" aria-hidden="true">' +
1555
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
1556
+ '<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" />' +
1557
+ "</svg></span>" +
1558
+ `<strong>${escapeHtml(workspaceName(path) || path)}</strong>` +
1559
+ `<span class="workspace-path">${escapeHtml(path)}</span>` +
1560
+ `<p class="workspace-prompt">${escapeHtml(tr("Type a message below to start a conversation in this folder."))}</p>`;
1561
+ const button = document.createElement("button");
1562
+ button.className = "control primary";
1563
+ button.type = "button";
1564
+ button.textContent = tr("New conversation");
1565
+ button.addEventListener("click", () => openNewChatDialog({ cwd: path }));
1566
+ empty.appendChild(button);
1567
+ el.messages.appendChild(empty);
1568
+ }
1569
+
1570
+ /** Open a working directory and make it the active sidebar scope. */
1571
+ async function openWorkspace(path, { record = true } = {}) {
1572
+ const target = String(path ?? "").trim();
1573
+ if (!target) return;
1574
+ setWorkspace({ path: target });
1575
+ renderWorkspaceLanding(target);
1576
+ setConversationView(true);
1577
+ if (record) {
1578
+ try {
1579
+ const response = await apiFetch("/api/workspaces", {
1580
+ method: "POST",
1581
+ headers: { "Content-Type": "application/json" },
1582
+ body: JSON.stringify({ path: target }),
1583
+ });
1584
+ const data = await response.json().catch(() => ({}));
1585
+ if (response.ok && Array.isArray(data.workspaces)) {
1586
+ state.workspaces = { ...(state.workspaces ?? {}), workspaces: data.workspaces };
1587
+ }
1588
+ } catch {
1589
+ // Opening the folder still works when remembering it does not.
1590
+ }
1591
+ }
1592
+ await loadOverview({ silent: true });
1593
+ }
1594
+
1595
+ function renderWorkspaceList() {
1596
+ if (!el.workspaceList) return;
1597
+ el.workspaceList.innerHTML = "";
1598
+ const folders = state.workspaces?.workspaces ?? [];
1599
+ if (!folders.length) {
1600
+ const empty = document.createElement("div");
1601
+ empty.className = "empty-side";
1602
+ empty.textContent = tr("No recent folders.");
1603
+ el.workspaceList.appendChild(empty);
1604
+ return;
1605
+ }
1606
+ for (const folder of folders) {
1607
+ const item = document.createElement("button");
1608
+ item.className = "workspace-item";
1609
+ item.type = "button";
1610
+ item.title = folder.path;
1611
+ if (state.workspace && pathKey(state.workspace.path) === pathKey(folder.path)) {
1612
+ item.classList.add("selected");
1613
+ }
1614
+ if (!folder.exists) item.classList.add("missing");
1615
+ const count = Number(folder.sessions ?? 0);
1616
+ item.innerHTML =
1617
+ '<span class="workspace-icon" aria-hidden="true">' +
1618
+ '<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>' +
1619
+ "</span>" +
1620
+ `<span class="workspace-info"><strong>${escapeHtml(folder.name || workspaceName(folder.path))}</strong>` +
1621
+ `<small>${escapeHtml(shortenPath(folder.path, 3))}</small></span>` +
1622
+ `<span class="workspace-count">${escapeHtml(tr("{n} conversations", { n: count }))}</span>`;
1623
+ item.addEventListener("click", async () => {
1624
+ if (!folder.exists) {
1625
+ el.workspaceFeedback.textContent = tr("This folder no longer exists.");
1626
+ return;
1627
+ }
1628
+ el.workspaceDialog.close();
1629
+ await openWorkspace(folder.path, { record: false });
1630
+ });
1631
+ el.workspaceList.appendChild(item);
1632
+ }
1633
+ }
1634
+
1635
+ async function openWorkspaceDialog() {
1636
+ el.workspaceFeedback.textContent = "";
1637
+ if (!el.workspaceDialog.open) el.workspaceDialog.showModal();
1638
+ renderWorkspaceList();
1639
+ try {
1640
+ const response = await apiFetch("/api/workspaces", { cache: "no-store" });
1641
+ const data = await response.json();
1642
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
1643
+ state.workspaces = data;
1644
+ renderWorkspaceList();
1645
+ } catch (error) {
1646
+ el.workspaceFeedback.textContent = tr("Could not open this folder: {error}", { error: error.message });
1647
+ }
1648
+ }
1649
+
1650
+ function renderNewChatRecents() {
1651
+ if (!el.newChatRecents) return;
1652
+ el.newChatRecents.innerHTML = "";
1653
+ const folders = (state.workspaces?.workspaces ?? []).filter((folder) => folder.exists !== false).slice(0, 8);
1654
+ if (!folders.length) {
1655
+ const empty = document.createElement("span");
1656
+ empty.className = "recents-empty";
1657
+ empty.textContent = tr("No recent folders.");
1658
+ el.newChatRecents.appendChild(empty);
1659
+ return;
1660
+ }
1661
+ for (const folder of folders) {
1662
+ const chip = document.createElement("button");
1663
+ chip.className = "path-chip";
1664
+ chip.type = "button";
1665
+ chip.title = folder.path;
1666
+ chip.textContent = workspaceName(folder.path) || folder.path;
1667
+ if (el.newChatCwd.value.trim() && pathKey(el.newChatCwd.value) === pathKey(folder.path)) {
1668
+ chip.classList.add("selected");
1669
+ }
1670
+ chip.addEventListener("click", () => {
1671
+ el.newChatCwd.value = folder.path;
1672
+ renderNewChatRecents();
1673
+ });
1674
+ el.newChatRecents.appendChild(chip);
1675
+ }
1676
+ }
1677
+
1678
+ function fillSelect(select, entries, selected) {
1679
+ select.replaceChildren();
1680
+ for (const entry of entries) {
1681
+ const option = document.createElement("option");
1682
+ option.value = entry.value;
1683
+ option.textContent = entry.label;
1684
+ select.appendChild(option);
1685
+ }
1686
+ select.value = entries.some((entry) => entry.value === selected) ? selected : entries[0]?.value ?? "";
1687
+ }
1688
+
1689
+ function fillNewChatModels(catalog, selectedModel) {
1690
+ const provider = catalog.providers.find((entry) => entry.name === el.newChatProvider.value) ?? catalog.providers[0];
1691
+ const models = provider?.models ?? [];
1692
+ fillSelect(el.newChatModel, models.map((model) => ({
1693
+ value: model.name,
1694
+ label: model.name + (model.configured ? "" : ` ${tr("(API key missing)")}`),
1695
+ })), selectedModel);
1696
+ }
1697
+
1698
+ /** Offer only the levels the chosen model declares, keeping the draft if valid. */
1699
+ function fillNewChatIntensities(catalog, selected) {
1700
+ const provider = catalog.providers.find((entry) => entry.name === el.newChatProvider.value) ?? catalog.providers[0];
1701
+ const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
1702
+ const levels = Array.isArray(model?.intensities) && model.intensities.length ? model.intensities : catalog.intensities;
1703
+ let wanted = selected;
1704
+ if (!levels.includes(wanted)) {
1705
+ const fallback = catalog.settings?.default_intensity;
1706
+ wanted = levels.includes(fallback) ? fallback : levels.includes("High") ? "High" : levels[0] ?? "";
1707
+ }
1708
+ fillSelect(el.newChatIntensity, levels.map((value) => ({ value, label: value })), wanted);
1709
+ }
1710
+
1711
+ function updateNewChatNote(catalog) {
1712
+ const provider = catalog?.providers.find((entry) => entry.name === el.newChatProvider.value);
1713
+ const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
1714
+ if (model && !model.configured) {
1715
+ el.newChatNote.textContent = tr("This model has no API key configured. Add one in the terminal model screen, or choose another model.");
1716
+ } else {
1717
+ el.newChatNote.textContent = "";
1718
+ }
1719
+ }
1720
+
1721
+ function renderProNewChatPresets(catalog, selected = catalog.pro?.preset) {
1722
+ if (!PRO_MODE) return;
1723
+ const select = document.getElementById("new-chat-pro-preset");
1724
+ if (!select) return;
1725
+ fillSelect(select, (catalog.pro?.presets || []).map(preset => ({
1726
+ value: preset.name, label: uiLanguage() === "zh" ? preset.chinese_name || preset.name : preset.name,
1727
+ })), selected);
1728
+ }
1729
+
1730
+ /** Relabel the open new-conversation sheet without losing its selections. */
1731
+ function refreshNewChatTexts() {
1732
+ if (!el.newChatDialog?.open) return;
1733
+ const catalog = preferences.catalog?.();
1734
+ if (!catalog) return;
1735
+ const provider = el.newChatProvider.value;
1736
+ const model = el.newChatModel.value;
1737
+ const intensity = el.newChatIntensity.value;
1738
+ const approval = el.newChatApproval.value;
1739
+ fillSelect(el.newChatProvider, catalog.providers.map((entry) => ({ value: entry.name, label: entry.name })), provider);
1740
+ fillNewChatModels(catalog, model);
1741
+ fillNewChatIntensities(catalog, intensity);
1742
+ el.newChatApproval.value = approval;
1743
+ updateNewChatNote(catalog);
1744
+ renderProNewChatPresets(catalog, document.getElementById("new-chat-pro-preset")?.value);
1745
+ renderNewChatRecents();
1746
+ }
1747
+
1748
+ async function openNewChatDialog(options = {}) {
1749
+ el.newChatFeedback.textContent = "";
1750
+ if (!el.newChatDialog.open) el.newChatDialog.showModal();
1751
+ let catalog;
1752
+ try {
1753
+ catalog = await preferences.load();
1754
+ const response = await apiFetch("/api/workspaces", { cache: "no-store" });
1755
+ if (response.ok) state.workspaces = await response.json();
1756
+ } catch (error) {
1757
+ el.newChatFeedback.textContent = tr(error.message);
1758
+ }
1759
+ if (!catalog) return;
1760
+
1761
+ const defaults = catalog.selection;
1762
+ fillSelect(el.newChatProvider, catalog.providers.map((provider) => ({ value: provider.name, label: provider.name })), defaults.provider);
1763
+ fillNewChatModels(catalog, defaults.model);
1764
+ fillNewChatIntensities(catalog, defaults.intensity);
1765
+ el.newChatApproval.value = catalog.settings.approval_mode === "auto" ? "auto" : "request";
1766
+
1767
+ const start = String(options.cwd || selectionCwd() || state.workspace?.path || state.workspaces?.cwd || "").trim();
1768
+ el.newChatCwd.value = start;
1769
+ renderNewChatRecents();
1770
+ updateNewChatNote(catalog);
1771
+ renderProNewChatPresets(catalog);
1772
+ if (typeof el.newChatCwd.focus === "function") el.newChatCwd.focus();
1773
+ }
1774
+
1775
+ async function submitNewChat() {
1776
+ if (el.newChatStart.disabled) return;
1777
+ const cwd = el.newChatCwd.value.trim();
1778
+ if (!cwd) {
1779
+ el.newChatFeedback.textContent = tr("working directory is required");
1780
+ el.newChatCwd.focus();
1781
+ return;
1782
+ }
1783
+ const selection = {
1784
+ provider: el.newChatProvider.value,
1785
+ model: el.newChatModel.value,
1786
+ intensity: el.newChatIntensity.value,
1787
+ };
1788
+ el.newChatStart.disabled = true;
1789
+ el.newChatStart.textContent = tr("Creating…");
1790
+ el.newChatFeedback.textContent = "";
1791
+ try {
1792
+ const created = await createConversation(cwd, selection, el.newChatApproval.value,
1793
+ PRO_MODE ? document.getElementById("new-chat-pro-preset")?.value : undefined);
1794
+ el.newChatDialog.close();
1795
+ await selectLive(created.key);
1796
+ setConversationView(true);
1797
+ el.input.focus();
1798
+ } catch (error) {
1799
+ el.newChatFeedback.textContent = tr("Could not start the conversation: {error}", { error: error.message });
1800
+ } finally {
1801
+ el.newChatStart.disabled = false;
1802
+ el.newChatStart.textContent = tr("Start conversation");
1803
+ }
1804
+ }
1805
+
1806
+ /** Create a browser-owned conversation in `cwd` and reveal its folder. */
1807
+ async function createConversation(cwd, selection, approvalMode, proPreset = currentProPreset()) {
1808
+ const payload = { cwd };
1809
+ if (PRO_MODE) payload.pro_preset = proPreset;
1810
+ else if (selection && selection.provider && selection.model) payload.selection = selection;
1811
+ if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
1812
+ const response = await apiFetch("/api/conversations", {
1813
+ method: "POST",
1814
+ headers: { "Content-Type": "application/json" },
1815
+ body: JSON.stringify(payload),
1816
+ });
1817
+ const data = await response.json().catch(() => ({}));
1818
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
1819
+ setWorkspace({ path: data.cwd || cwd });
1820
+ await loadOverview({ silent: true });
1821
+ return data;
1822
+ }
1823
+
1824
+ // ------------------------------------------------------------- folder picker
1825
+
1826
+ function renderFolder() {
1827
+ if (!el.folderList) return;
1828
+ const folder = state.folder;
1829
+ el.folderPath.value = folder.path || "";
1830
+ el.folderUp.disabled = !folder.parent;
1831
+ el.folderUse.disabled = !folder.path || folder.loading;
1832
+
1833
+ el.folderRoots.innerHTML = "";
1834
+ for (const root of folder.roots ?? []) {
1835
+ const button = document.createElement("button");
1836
+ button.className = "root-chip";
1837
+ button.type = "button";
1838
+ button.textContent = root;
1839
+ button.addEventListener("click", () => loadFolder(root));
1840
+ el.folderRoots.appendChild(button);
1841
+ }
1842
+
1843
+ el.folderList.innerHTML = "";
1844
+ const entries = folder.entries ?? [];
1845
+ if (!entries.length) {
1846
+ const empty = document.createElement("div");
1847
+ empty.className = "empty-side";
1848
+ empty.textContent = folder.loading ? tr("Loading folders…") : tr("No subfolders.");
1849
+ el.folderList.appendChild(empty);
1850
+ return;
1851
+ }
1852
+ for (const entry of entries) {
1853
+ const item = document.createElement("button");
1854
+ item.className = "folder-item";
1855
+ item.type = "button";
1856
+ item.title = entry.path;
1857
+ item.innerHTML =
1858
+ '<span class="folder-item-icon" aria-hidden="true">' +
1859
+ '<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>' +
1860
+ `</span><span>${escapeHtml(entry.name)}</span>`;
1861
+ item.addEventListener("click", () => loadFolder(entry.path));
1862
+ if (typeof item.addEventListener === "function") {
1863
+ item.addEventListener("dblclick", () => useCurrentFolder());
1864
+ }
1865
+ el.folderList.appendChild(item);
1866
+ }
1867
+ }
1868
+
1869
+ let folderLoadSeq = 0;
1870
+
1871
+ async function loadFolder(path) {
1872
+ const folder = state.folder;
1873
+ const request = ++folderLoadSeq;
1874
+ folder.loading = true;
1875
+ el.folderFeedback.textContent = tr("Loading folders…");
1876
+ el.folderUse.disabled = true;
1877
+ try {
1878
+ const query = path ? `?path=${encodeURIComponent(path)}` : "";
1879
+ const response = await apiFetch(`/api/fs${query}`, { cache: "no-store" });
1880
+ const data = await response.json().catch(() => ({}));
1881
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
1882
+ // A slower, older answer must not replace the folder the user is on now.
1883
+ if (request !== folderLoadSeq) return;
1884
+ state.folder = {
1885
+ ...folder,
1886
+ loading: false,
1887
+ path: data.path,
1888
+ parent: data.parent,
1889
+ roots: data.roots ?? [],
1890
+ entries: data.entries ?? [],
1891
+ };
1892
+ el.folderFeedback.textContent = "";
1893
+ renderFolder();
1894
+ if (typeof el.folderPath.focus === "function") el.folderPath.focus();
1895
+ } catch (error) {
1896
+ if (request !== folderLoadSeq) return;
1897
+ folder.loading = false;
1898
+ el.folderFeedback.textContent = tr("Could not open this folder: {error}", { error: error.message });
1899
+ el.folderUse.disabled = !state.folder.path;
1900
+ }
1901
+ }
1902
+
1903
+ function openFolderDialog({ start = "", onPick = null } = {}) {
1904
+ state.folder = {
1905
+ path: "",
1906
+ parent: null,
1907
+ roots: [],
1908
+ entries: [],
1909
+ loading: false,
1910
+ onPick,
1911
+ };
1912
+ el.folderNew.classList.add("hidden");
1913
+ el.folderNewName.value = "";
1914
+ el.folderFeedback.textContent = "";
1915
+ if (!el.folderDialog.open) el.folderDialog.showModal();
1916
+ renderFolder();
1917
+ const from = String(start || state.workspace?.path || state.workspaces?.cwd || "").trim();
1918
+ void loadFolder(from);
1919
+ }
1920
+
1921
+ function useCurrentFolder() {
1922
+ const path = state.folder.path;
1923
+ if (!path) return;
1924
+ const onPick = state.folder.onPick;
1925
+ el.folderDialog.close();
1926
+ if (typeof onPick === "function") onPick(path);
1927
+ }
1928
+
1929
+ async function createFolderFromDialog() {
1930
+ const name = el.folderNewName.value.trim();
1931
+ if (!name) {
1932
+ el.folderFeedback.textContent = tr("Enter a folder name.");
1933
+ el.folderNewName.focus();
1934
+ return;
1935
+ }
1936
+ if (/[\\/]/.test(name)) {
1937
+ el.folderFeedback.textContent = tr("folder name cannot contain a path separator");
1938
+ return;
1939
+ }
1940
+ const target = joinPath(state.folder.path, name);
1941
+ el.folderNewCreate.disabled = true;
1942
+ try {
1943
+ const response = await apiFetch("/api/fs/mkdir", {
1944
+ method: "POST",
1945
+ headers: { "Content-Type": "application/json" },
1946
+ body: JSON.stringify({ path: target }),
1947
+ });
1948
+ const data = await response.json().catch(() => ({}));
1949
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
1950
+ el.folderNew.classList.add("hidden");
1951
+ el.folderNewName.value = "";
1952
+ await loadFolder(data.path || target);
1953
+ el.folderFeedback.textContent = tr("Folder created.");
1954
+ } catch (error) {
1955
+ el.folderFeedback.textContent = apiError({ error: error.message }, error.message);
1956
+ } finally {
1957
+ el.folderNewCreate.disabled = false;
1958
+ }
1959
+ }
1960
+
1961
+ // -------------------------------------------------------------- plan / usage
1962
+
1963
+ function planSignature(plan) {
1964
+ if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return "";
1965
+ return `${plan.goal ?? ""}|${plan.steps.map((step) => step.title ?? "").join("|")}`;
1966
+ }
1967
+
1968
+ function maybeOpenPlan(plan) {
1969
+ const signature = planSignature(plan);
1970
+ if (signature && signature !== state.planSeen) {
1971
+ state.planSeen = signature;
1972
+ state.planOpen = true;
1973
+ }
1974
+ }
1975
+
1976
+ function planToggleButton() {
1977
+ const button = document.createElement("button");
1978
+ button.className = "icon-btn plan-toggle";
1979
+ button.type = "button";
1980
+ button.setAttribute("aria-expanded", String(state.planOpen));
1981
+ button.title = state.planOpen ? tr("Hide plan") : tr("Show plan");
1982
+ button.setAttribute("aria-label", button.title);
1983
+ button.innerHTML =
1984
+ '<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>';
1985
+ button.addEventListener("click", () => {
1986
+ state.planOpen = !state.planOpen;
1987
+ button.classList.toggle("active", state.planOpen);
1988
+ button.setAttribute("aria-expanded", String(state.planOpen));
1989
+ button.title = state.planOpen ? tr("Hide plan") : tr("Show plan");
1990
+ renderPlanCard(state.snapshot ?? state.fileData ?? null);
1991
+ });
1992
+ button.classList.toggle("active", state.planOpen);
1993
+ return button;
1994
+ }
1995
+
1996
+ function planStatusLabel(status) {
1997
+ if (status === "done") return tr("Done");
1998
+ if (status === "active") return tr("Active");
1999
+ if (status === "failed") return tr("Failed");
2000
+ return tr("Pending");
2001
+ }
2002
+
2003
+ function renderPlanCard(source) {
2004
+ if (!el.planCard) return;
2005
+ const plan = source && source.plan && Array.isArray(source.plan.steps) && source.plan.steps.length ? source.plan : null;
2006
+ const tokens = source && source.tokens ? source.tokens : null;
2007
+ const hasUsage = tokens && (Number(tokens.prompt) > 0 || Number(tokens.completion) > 0);
2008
+ if (!state.planOpen || (!plan && !hasUsage)) {
2009
+ el.planCard.classList.add("hidden");
2010
+ el.planCard.replaceChildren();
2011
+ return;
2012
+ }
2013
+
2014
+ const card = document.createElement("div");
2015
+ card.className = "plan-card-inner";
2016
+ const head = document.createElement("div");
2017
+ head.className = "plan-card-head";
2018
+ const title = document.createElement("strong");
2019
+ title.textContent = plan ? tr("Plan") : tr("Usage");
2020
+ const close = document.createElement("button");
2021
+ close.className = "icon-btn";
2022
+ close.type = "button";
2023
+ close.setAttribute("aria-label", tr("Close"));
2024
+ close.innerHTML =
2025
+ '<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>';
2026
+ close.addEventListener("click", () => {
2027
+ state.planOpen = false;
2028
+ renderPlanCard(source);
2029
+ });
2030
+ head.append(title, close);
2031
+ card.appendChild(head);
2032
+
2033
+ if (plan) {
2034
+ if (plan.goal) {
2035
+ const goal = document.createElement("div");
2036
+ goal.className = "plan-goal";
2037
+ goal.textContent = plan.goal;
2038
+ card.appendChild(goal);
2039
+ }
2040
+ const steps = document.createElement("div");
2041
+ steps.className = "plan-steps";
2042
+ let done = 0;
2043
+ for (const step of plan.steps) {
2044
+ const status = ["done", "active", "failed", "pending"].includes(step.status) ? step.status : "pending";
2045
+ if (status === "done") done += 1;
2046
+ const row = document.createElement("div");
2047
+ row.className = `plan-step ${status}`;
2048
+ const node = document.createElement("span");
2049
+ node.className = "plan-node";
2050
+ node.setAttribute("aria-hidden", "true");
2051
+ const body = document.createElement("div");
2052
+ body.className = "plan-step-body";
2053
+ const stepTitle = document.createElement("strong");
2054
+ stepTitle.textContent = step.title || "";
2055
+ const stateLabel = document.createElement("span");
2056
+ stateLabel.className = "plan-step-state";
2057
+ stateLabel.textContent = planStatusLabel(status);
2058
+ const titleRow = document.createElement("div");
2059
+ titleRow.className = "plan-step-title";
2060
+ titleRow.append(stepTitle, stateLabel);
2061
+ body.appendChild(titleRow);
2062
+ if (step.detail) {
2063
+ const detail = document.createElement("div");
2064
+ detail.className = "plan-step-detail";
2065
+ detail.textContent = step.detail;
2066
+ body.appendChild(detail);
2067
+ }
2068
+ row.append(node, body);
2069
+ steps.appendChild(row);
2070
+ }
2071
+ card.appendChild(steps);
2072
+ const meta = document.createElement("div");
2073
+ meta.className = "plan-meta";
2074
+ const stopped = plan.stopped ? ` · ${tr("Stopped")}` : "";
2075
+ meta.textContent = tr("{done} of {total} steps done", { done, total: plan.steps.length }) + stopped;
2076
+ card.appendChild(meta);
2077
+ }
2078
+
2079
+ if (hasUsage) {
2080
+ const usage = document.createElement("div");
2081
+ usage.className = "plan-usage";
2082
+ const rows = [
2083
+ [tr("Prompt"), Number(tokens.prompt)],
2084
+ [tr("Completion"), Number(tokens.completion)],
2085
+ [tr("Total"), Number(tokens.prompt) + Number(tokens.completion)],
2086
+ ];
2087
+ for (const [label, value] of rows) {
2088
+ const row = document.createElement("div");
2089
+ row.className = "plan-usage-row";
2090
+ const name = document.createElement("span");
2091
+ name.textContent = label;
2092
+ const count = document.createElement("strong");
2093
+ count.textContent = value.toLocaleString(localeTag());
2094
+ row.append(name, count);
2095
+ usage.appendChild(row);
2096
+ }
2097
+ card.appendChild(usage);
2098
+ }
2099
+
2100
+ el.planCard.replaceChildren(card);
2101
+ el.planCard.classList.remove("hidden");
2102
+ }
2103
+
2104
+ // ------------------------------------------------------------------- actions
2105
+
2106
+ async function clearConversation(key) {
2107
+ try {
2108
+ const response = await apiFetch(`/api/live/${encodeURIComponent(key)}/clear`, { method: "POST" });
2109
+ const data = await response.json().catch(() => ({}));
2110
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2111
+ if (state.selection?.kind === "live" && state.selection.key === key) {
2112
+ state.snapshot = data.snapshot ?? state.snapshot;
2113
+ // The cleared conversation has no transcript file until its next message.
2114
+ state.lastSessionId = null;
2115
+ state.planOpen = false;
2116
+ state.planSeen = "";
2117
+ if (state.snapshot) renderLive();
2118
+ if (state.snapshot) renderPlanCard(null);
2119
+ el.sub.textContent = tr("Conversation cleared.");
2120
+ }
2121
+ } catch (error) {
2122
+ el.sub.textContent = tr("Could not clear this conversation: {error}", { error: error.message });
2123
+ }
2124
+ }
2125
+
2126
+ async function deleteConversation(id, button) {
2127
+ if (!confirmAction(tr("Delete this conversation? Its saved history will be removed."))) return;
2128
+ if (button) button.disabled = true;
2129
+ try {
2130
+ const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/delete`, { method: "POST" });
2131
+ const data = await response.json().catch(() => ({}));
2132
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2133
+ showHome();
2134
+ await loadOverview();
2135
+ } catch (error) {
2136
+ if (button) button.disabled = false;
2137
+ el.sub.textContent = tr("Could not delete this conversation: {error}", { error: error.message });
2138
+ }
2139
+ }
2140
+
2141
+ // -------------------------------------------------------------- other agents
2142
+
2143
+ const OTHER_AGENTS = [
2144
+ { id: "claude", label: "Claude Code" },
2145
+ { id: "codex", label: "Codex" },
2146
+ { id: "opencode", label: "OpenCode" },
2147
+ ];
2148
+
2149
+ function agentLabel(agent) {
2150
+ return OTHER_AGENTS.find((entry) => entry.id === agent)?.label ?? agent;
2151
+ }
2152
+
2153
+ /** Working directory the other-agent lists are scoped to. */
2154
+ async function agentsScopeCwd() {
2155
+ if (!state.workspaces) {
2156
+ try {
2157
+ const response = await apiFetch("/api/workspaces", { cache: "no-store" });
2158
+ if (response.ok) state.workspaces = await response.json();
2159
+ } catch {
2160
+ // The dialog can still show a readable error below.
2161
+ }
2162
+ }
2163
+ return state.workspace?.path || selectionCwd() || state.agentData?.cwd || state.workspaces?.cwd || "";
2164
+ }
2165
+
2166
+ function renderAgentList(groups) {
2167
+ el.agentsList.innerHTML = "";
2168
+ const total = groups.reduce((count, group) => count + group.sessions.length, 0);
2169
+ if (total === 0) {
2170
+ const empty = document.createElement("div");
2171
+ empty.className = "empty-side";
2172
+ empty.textContent = tr("No saved sessions from other agents in this folder.");
2173
+ el.agentsList.appendChild(empty);
2174
+ return;
2175
+ }
2176
+ for (const group of groups) {
2177
+ if (!group.sessions.length) continue;
2178
+ const section = document.createElement("section");
2179
+ section.className = "agent-section";
2180
+ const title = document.createElement("h3");
2181
+ title.textContent = group.label;
2182
+ section.appendChild(title);
2183
+ for (const session of group.sessions) {
2184
+ const item = document.createElement("button");
2185
+ item.className = "agent-session";
2186
+ item.type = "button";
2187
+ item.title = session.title || session.id;
2188
+ item.innerHTML =
2189
+ '<span class="agent-session-body">' +
2190
+ `<span class="agent-session-title">${escapeHtml(session.title || tr("Conversation"))}</span>` +
2191
+ `<span class="agent-session-meta">${escapeHtml([session.model, formatTime(session.updated_at)].filter(Boolean).join(" · "))}</span>` +
2192
+ "</span>";
2193
+ item.addEventListener("click", () => {
2194
+ el.agentsDialog.close();
2195
+ void selectAgentSession(group.id, session.id, session.title || "");
2196
+ });
2197
+ section.appendChild(item);
2198
+ }
2199
+ el.agentsList.appendChild(section);
2200
+ }
2201
+ }
2202
+
2203
+ async function openAgentsDialog() {
2204
+ el.agentsFeedback.textContent = tr("Loading…");
2205
+ el.agentsList.innerHTML = "";
2206
+ if (!el.agentsDialog.open) el.agentsDialog.showModal();
2207
+ const cwd = await agentsScopeCwd();
2208
+ el.agentsCwd.textContent = cwd;
2209
+ try {
2210
+ const groups = await Promise.all(OTHER_AGENTS.map(async (agent) => {
2211
+ const response = await apiFetch(`/api/agents/${agent.id}/sessions?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" });
2212
+ const data = await response.json().catch(() => ({}));
2213
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2214
+ return { ...agent, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
2215
+ }));
2216
+ el.agentsFeedback.textContent = "";
2217
+ renderAgentList(groups);
2218
+ } catch (error) {
2219
+ el.agentsFeedback.textContent = tr("Could not load other agents' history: {error}", { error: error.message });
2220
+ }
2221
+ }
2222
+
2223
+ function renderAgentView(data) {
2224
+ const messages = Array.isArray(data.messages) ? data.messages : [];
2225
+ const label = agentLabel(state.selection?.agent ?? data.agent ?? "");
2226
+ el.title.textContent = state.selection?.title || firstUserMessage(messages) || tr("Conversation");
2227
+ el.sub.textContent = [label, data.cwd || state.agentData?.cwd].filter(Boolean).join(" · ");
2228
+ renderMessageList(messages, null, { readOnly: true, active: false });
2229
+ el.controls.innerHTML = "";
2230
+ const badge = document.createElement("span");
2231
+ badge.className = "badge readonly";
2232
+ badge.textContent = tr("Read-only");
2233
+ el.controls.appendChild(badge);
2234
+ const source = document.createElement("span");
2235
+ source.className = "badge readonly";
2236
+ source.textContent = label;
2237
+ el.controls.appendChild(source);
2238
+ renderApproval(null);
2239
+ renderPlanCard(null);
2240
+ hideComposer();
2241
+ }
2242
+
2243
+ async function selectAgentSession(agent, id, title) {
2244
+ closeStream();
2245
+ state.selection = { kind: "agent", agent, id, title };
2246
+ state.snapshot = null;
2247
+ state.fileData = null;
2248
+ state.agentData = null;
2249
+ state.planOpen = false;
2250
+ state.planSeen = "";
2251
+ preferences.clear();
2252
+ renderSidebar();
2253
+ el.controls.innerHTML = "";
2254
+ renderApproval(null);
2255
+ renderPlanCard(null);
2256
+ hideComposer();
2257
+ el.title.textContent = tr("Loading…");
2258
+ el.sub.textContent = title || "";
2259
+ resetMessagePane(tr("Loading…"));
2260
+ setConversationView(true);
2261
+ try {
2262
+ const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}`, { cache: "no-store" });
2263
+ const data = await response.json().catch(() => ({}));
2264
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2265
+ state.agentData = { ...data, agent, cwd: state.agentData?.cwd || state.workspace?.path || "" };
2266
+ renderAgentView(state.agentData);
2267
+ } catch (error) {
2268
+ el.title.textContent = title || tr("Conversation");
2269
+ el.sub.textContent = tr("Could not load this session: {error}", { error: error.message });
2270
+ resetMessagePane(tr("Could not load this session: {error}", { error: error.message }));
2271
+ }
2272
+ }
2273
+
2274
+ // ------------------------------------------------------------------ language
2275
+
2276
+ function syncLanguageSelects(language) {
2277
+ if (el.languageSelect) el.languageSelect.value = language;
2278
+ const settingsLanguage = document.getElementById("settings-language");
2279
+ if (settingsLanguage) settingsLanguage.value = language;
2280
+ }
2281
+
2282
+ async function changeInterfaceLanguage(language, { persist = true } = {}) {
2283
+ const normalized = String(language ?? "").toLowerCase().startsWith("zh") ? "zh" : "en";
2284
+ setUiLanguage(normalized);
2285
+ writeStorage(modeStorageKey("neutrino.language"), normalized);
2286
+ applyStaticTranslations();
2287
+ syncLanguageSelects(normalized);
2288
+ if (state.status) setStatus(state.status.key, state.status.kind);
2289
+ preferences.refreshTexts?.();
2290
+ proUI?.refresh();
2291
+ refreshNewChatTexts();
2292
+ renderWorkspaceChrome();
2293
+ renderSidebar();
2294
+ const kind = state.selection?.kind;
2295
+ if (kind === "live" && state.snapshot) renderLive();
2296
+ else if (kind === "file" && state.fileData) renderFile(state.fileData);
2297
+ else if (kind === "agent" && state.agentData) renderAgentView(state.agentData);
2298
+ else if (kind === "new" && state.selection.cwd) renderWorkspaceLanding(state.selection.cwd);
2299
+ else if (!state.selection) renderHomeEmpty();
2300
+ if (kind === "live") setComposerMode("live");
2301
+ else if (kind === "file") setComposerMode("resume");
2302
+ else if (kind === "new") setComposerMode("new");
2303
+ else if (!state.selection) hideComposer();
2304
+ if (persist) {
2305
+ try {
2306
+ await apiFetch("/api/settings", {
2307
+ method: "POST",
2308
+ headers: { "Content-Type": "application/json" },
2309
+ body: JSON.stringify({ settings: { language: normalized } }),
2310
+ });
2311
+ } catch (error) {
2312
+ console.error(error);
2313
+ }
2314
+ }
2315
+ }
2316
+
2317
+ function initializeLanguage() {
2318
+ const stored = readStorage(modeStorageKey("neutrino.language"));
2319
+ if (stored) setUiLanguage(stored);
2320
+ applyStaticTranslations();
2321
+ syncLanguageSelects(uiLanguage());
2322
+ if (state.status) setStatus(state.status.key, state.status.kind);
2323
+ }
2324
+
2325
+ // ------------------------------------------------------------------ pro mode
2326
+ //
2327
+ // Both workspaces reuse this console. Clicking the orbit badge on the normal
2328
+ // console starts a short wordmark curtain; the recorded start time lets /pro
2329
+ // finish the same animation instead of flashing the freshly loaded page.
2330
+
2331
+ const PRO_TRANSITION_KEY = "neutrino.pro.transition";
2332
+ const PRO_TIP_KEY = "neutrino.pro.tip.dismissed";
2333
+ const PRO_TRANSITION_FADE_MS = 350; // curtain fade in / fade out
2334
+ const PRO_TRANSITION_COVER_MS = 450; // navigate once the curtain is fully opaque
2335
+ const PRO_TRANSITION_HOLD_MS = 3000; // wordmark stays up until 3s after the switch began
2336
+ const PRO_TRANSITION_RESUME_MS = 8000; // older records are stale and never replayed
2337
+ const PRO_LOGO_MARK = '<img class="pro-logo-mark" src="/neutrino.png" alt="Neutrino Pro">';
2338
+
2339
+ let proTransitionStarted = false;
2340
+
2341
+ function proCurtain() {
2342
+ const curtain = document.createElement("div");
2343
+ curtain.className = "pro-curtain";
2344
+ curtain.setAttribute("aria-hidden", "true");
2345
+ const image = document.createElement("img");
2346
+ image.src = "/neutrino-pro.gif";
2347
+ image.alt = "";
2348
+ curtain.appendChild(image);
2349
+ return curtain;
2350
+ }
2351
+
2352
+ /** Timers exist in the browser; the test sandboxes load this file without them. */
2353
+ function proDelay(callback, ms) {
2354
+ if (typeof setTimeout === "function") setTimeout(callback, ms);
2355
+ }
2356
+
2357
+ function startProTransition() {
2358
+ if (PRO_MODE || proTransitionStarted) return;
2359
+ proTransitionStarted = true;
2360
+ const started = Date.now();
2361
+ try {
2362
+ sessionStorage.setItem(PRO_TRANSITION_KEY, String(started));
2363
+ } catch { /* Storage may be blocked; navigation still works. */ }
2364
+
2365
+ const curtain = proCurtain();
2366
+ curtain.style.transition = `opacity ${PRO_TRANSITION_FADE_MS}ms ease`;
2367
+ document.body.appendChild(curtain);
2368
+ // Commit the transparent first frame, then let the style transition fade in.
2369
+ void curtain.offsetWidth;
2370
+ curtain.classList.add("pro-curtain-visible");
2371
+
2372
+ proDelay(() => {
2373
+ try { location.assign("/pro"); } catch { location.href = "/pro"; }
2374
+ }, PRO_TRANSITION_COVER_MS);
2375
+ }
2376
+
2377
+ /** Remove every switch curtain left in this document. */
2378
+ function removeProCurtains() {
2379
+ if (typeof document.querySelectorAll !== "function") return;
2380
+ for (const curtain of document.querySelectorAll(".pro-curtain")) curtain.remove?.();
2381
+ }
2382
+
2383
+ /**
2384
+ * Drop the one-shot switch chrome.
2385
+ *
2386
+ * The curtain lives in the document that navigated away, so the browser's
2387
+ * back/forward cache can restore it fully opaque — a Back or Forward would
2388
+ * otherwise land on that frozen frame. `clearRecord` stays false while this
2389
+ * document is being hidden for /pro: the destination still needs the recorded
2390
+ * start time to finish the same animation.
2391
+ */
2392
+ function resetProTransition({ clearRecord = true } = {}) {
2393
+ removeProCurtains();
2394
+ document.documentElement?.classList?.remove?.("pro-transitioning");
2395
+ proTransitionStarted = false;
2396
+ if (!clearRecord) return;
2397
+ try { sessionStorage.removeItem(PRO_TRANSITION_KEY); } catch { /* Storage may be blocked. */ }
2398
+ }
2399
+
2400
+ /** Undo the switch chrome when the browser restores either page from bfcache. */
2401
+ function watchProNavigation() {
2402
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
2403
+ window.addEventListener("pagehide", (event) => {
2404
+ if (!event?.persisted) return;
2405
+ // The page is entering the back/forward cache: clean the snapshot, but
2406
+ // keep the record for the /pro page that is currently loading.
2407
+ resetProTransition({ clearRecord: false });
2408
+ });
2409
+ window.addEventListener("pageshow", (event) => {
2410
+ if (!event?.persisted) return;
2411
+ // Restored by Back/Forward: any curtain captured in the snapshot is stale.
2412
+ resetProTransition();
2413
+ });
2414
+ }
2415
+
2416
+ /** Consume a just-recorded switch and finish the animation on /pro. */
2417
+ function resumeProTransition() {
2418
+ if (!PRO_MODE) return;
2419
+ let started = 0;
2420
+ try { started = Number(sessionStorage.getItem(PRO_TRANSITION_KEY)) || 0; } catch { /* Storage may be blocked. */ }
2421
+ // Consume the record even when stale: direct visits never show a curtain.
2422
+ if (started) {
2423
+ try { sessionStorage.removeItem(PRO_TRANSITION_KEY); } catch { /* Ignore. */ }
2424
+ }
2425
+ const now = Date.now();
2426
+ const age = now - started;
2427
+ if (!started || age < 0 || age > PRO_TRANSITION_RESUME_MS) {
2428
+ document.documentElement.classList.remove("pro-transitioning");
2429
+ return;
2430
+ }
2431
+
2432
+ const curtain = proCurtain();
2433
+ // Start fully opaque on the first frame so the new page never flashes through.
2434
+ curtain.style.transition = "none";
2435
+ curtain.style.opacity = "1";
2436
+ document.body.appendChild(curtain);
2437
+ // The real curtain is up: retire the pre-paint cover from index.html so it
2438
+ // cannot remain over the page after the fade completes.
2439
+ document.documentElement.classList.remove("pro-transitioning");
2440
+
2441
+ proDelay(() => {
2442
+ curtain.style.transition = `opacity ${PRO_TRANSITION_FADE_MS}ms ease`;
2443
+ curtain.style.opacity = "0";
2444
+ proDelay(() => curtain.remove(), PRO_TRANSITION_FADE_MS);
2445
+ }, Math.max(0, started + PRO_TRANSITION_HOLD_MS - now));
2446
+ }
2447
+
2448
+ /** In Pro mode the badge is a static "you are here" mark, not a control. */
2449
+ function applyProBrand() {
2450
+ document.title = "Neutrino Pro";
2451
+ if (el.brandName) el.brandName.textContent = "Neutrino Pro";
2452
+ const logo = el.brandLogo;
2453
+ if (!logo) return;
2454
+ logo.innerHTML = PRO_LOGO_MARK;
2455
+ logo.classList.add("pro-logo");
2456
+ logo.tabIndex = -1;
2457
+ // Drop the i18n annotations too, or a language switch restores the tooltip.
2458
+ const annotations = ["title", "aria-label", "data-i18n-title", "data-i18n-aria-label"];
2459
+ for (const attribute of annotations) logo.removeAttribute?.(attribute);
2460
+ }
2461
+
2462
+ /** The blue tip explains the badge on the normal console; Pro never shows it. */
2463
+ function initializeProTip() {
2464
+ if (PRO_MODE || !el.proTip) return;
2465
+ if (readStorage(PRO_TIP_KEY) === "1") return;
2466
+ el.proTip.classList.remove("hidden");
2467
+ el.proTipClose?.addEventListener("click", () => {
2468
+ writeStorage(PRO_TIP_KEY, "1");
2469
+ el.proTip.classList.add("hidden");
2470
+ });
2471
+ }
2472
+
2473
+ /** The Pro workspace's top-right escape hatch back to the regular console. */
2474
+ function initializeProExit() {
2475
+ if (!el.proExit) return;
2476
+ el.proExit.classList.remove("hidden");
2477
+ el.proExit.addEventListener("click", () => {
2478
+ el.proExit.disabled = true;
2479
+ try {
2480
+ location.assign("/");
2481
+ } catch {
2482
+ location.href = "/";
2483
+ }
2484
+ });
2485
+ }
2486
+
2487
+ /** Wire the shared brand and either start the switch or resume its animation. */
2488
+ function initializeProWorkspace() {
2489
+ watchProNavigation();
2490
+ if (PRO_MODE) {
2491
+ applyProBrand();
2492
+ initializeProExit();
2493
+ resumeProTransition();
2494
+ return;
2495
+ }
2496
+ // A switch that was interrupted (Stop, Back, a failed navigation) can leave
2497
+ // the record and the pre-paint cover behind. Neither may ever affect the
2498
+ // regular console, so clear them before wiring the badge.
2499
+ resetProTransition();
2500
+ el.brandLogo?.addEventListener("click", startProTransition);
2501
+ initializeProTip();
2502
+ }
2503
+
1131
2504
  // --------------------------------------------------------------------- init
1132
2505
 
1133
2506
  el.refresh.addEventListener("click", () => loadOverview());
@@ -1141,8 +2514,110 @@ el.input.addEventListener("keydown", (event) => {
1141
2514
  }
1142
2515
  });
1143
2516
 
2517
+ el.newChat.addEventListener("click", () => openNewChatDialog());
2518
+ if (el.emptyNewChat) el.emptyNewChat.addEventListener("click", () => openNewChatDialog());
2519
+ el.workspaceButton.addEventListener("click", openWorkspaceDialog);
2520
+ el.workspaceClose.addEventListener("click", () => el.workspaceDialog.close());
2521
+ el.workspaceAll.addEventListener("click", () => {
2522
+ el.workspaceDialog.close();
2523
+ setWorkspace(null);
2524
+ showHome();
2525
+ });
2526
+ el.workspaceAdd.addEventListener("click", () => {
2527
+ el.workspaceDialog.close();
2528
+ openFolderDialog({ start: state.workspace?.path || state.workspaces?.cwd || "", onPick: (path) => openWorkspace(path) });
2529
+ });
2530
+ el.agentsOpen.addEventListener("click", () => openAgentsDialog());
2531
+ el.agentsClose.addEventListener("click", () => el.agentsDialog.close());
2532
+ el.agentsDialog.addEventListener("cancel", (event) => {
2533
+ event.preventDefault();
2534
+ el.agentsDialog.close();
2535
+ });
2536
+ el.languageSelect?.addEventListener("change", () => changeInterfaceLanguage(el.languageSelect.value));
2537
+
2538
+ el.newChatClose.addEventListener("click", () => el.newChatDialog.close());
2539
+ el.newChatCancel.addEventListener("click", () => el.newChatDialog.close());
2540
+ el.newChatDialog.addEventListener("cancel", (event) => {
2541
+ event.preventDefault();
2542
+ el.newChatDialog.close();
2543
+ });
2544
+ el.newChatForm.addEventListener("submit", (event) => {
2545
+ event.preventDefault();
2546
+ submitNewChat();
2547
+ });
2548
+ el.newChatBrowse.addEventListener("click", () => {
2549
+ openFolderDialog({
2550
+ start: el.newChatCwd.value.trim() || state.workspace?.path || "",
2551
+ onPick: (path) => {
2552
+ el.newChatCwd.value = path;
2553
+ renderNewChatRecents();
2554
+ },
2555
+ });
2556
+ });
2557
+ el.newChatCwd.addEventListener("input", renderNewChatRecents);
2558
+ el.newChatProvider.addEventListener("change", () => {
2559
+ const catalog = preferences.catalog?.();
2560
+ if (catalog) {
2561
+ fillNewChatModels(catalog, "");
2562
+ fillNewChatIntensities(catalog, el.newChatIntensity.value);
2563
+ updateNewChatNote(catalog);
2564
+ }
2565
+ });
2566
+ el.newChatModel.addEventListener("change", () => {
2567
+ const catalog = preferences.catalog?.();
2568
+ if (catalog) {
2569
+ fillNewChatIntensities(catalog, el.newChatIntensity.value);
2570
+ updateNewChatNote(catalog);
2571
+ }
2572
+ });
2573
+
2574
+ el.folderClose.addEventListener("click", () => el.folderDialog.close());
2575
+ el.folderCancel.addEventListener("click", () => el.folderDialog.close());
2576
+ el.folderUse.addEventListener("click", useCurrentFolder);
2577
+ el.folderUp.addEventListener("click", () => {
2578
+ if (state.folder.parent) void loadFolder(state.folder.parent);
2579
+ });
2580
+ el.folderGo.addEventListener("click", () => void loadFolder(el.folderPath.value.trim()));
2581
+ el.folderPath.addEventListener("keydown", (event) => {
2582
+ if (event.key === "Enter") {
2583
+ event.preventDefault();
2584
+ void loadFolder(el.folderPath.value.trim());
2585
+ }
2586
+ });
2587
+ el.folderMkdir.addEventListener("click", () => {
2588
+ el.folderNew.classList.remove("hidden");
2589
+ el.folderFeedback.textContent = "";
2590
+ el.folderNewName.value = "";
2591
+ el.folderNewName.focus();
2592
+ });
2593
+ el.folderNewCreate.addEventListener("click", () => void createFolderFromDialog());
2594
+ el.folderNewCancel.addEventListener("click", () => el.folderNew.classList.add("hidden"));
2595
+ el.folderNewName.addEventListener("keydown", (event) => {
2596
+ if (event.key === "Enter") {
2597
+ event.preventDefault();
2598
+ void createFolderFromDialog();
2599
+ }
2600
+ if (event.key === "Escape") el.folderNew.classList.add("hidden");
2601
+ });
2602
+ el.folderDialog.addEventListener("cancel", (event) => {
2603
+ event.preventDefault();
2604
+ el.folderDialog.close();
2605
+ });
2606
+
1144
2607
  window.addEventListener("beforeunload", closeStream);
1145
2608
 
1146
- preferences.load().catch(error => console.error(error));
1147
- loadOverview();
2609
+ initializeLanguage();
2610
+ initializeProWorkspace();
2611
+ preferences.load()
2612
+ .then((config) => {
2613
+ const language = config?.settings?.language;
2614
+ if (language && language !== uiLanguage()) return changeInterfaceLanguage(language, { persist: false });
2615
+ return undefined;
2616
+ })
2617
+ .catch(error => console.error(error));
2618
+ loadOverview().then(() => {
2619
+ // Reopen the folder the user had open last time, without stealing a click
2620
+ // that landed on a saved conversation while the overview was loading.
2621
+ if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);
2622
+ });
1148
2623
  setInterval(() => loadOverview({ silent: true }), REFRESH_MS);