@xingjisoft/neutrino 0.3.0 → 0.4.1

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 (56) hide show
  1. package/README.md +33 -549
  2. package/assets/neutrino.ico +0 -0
  3. package/assets/neutrino.svg +7 -0
  4. package/assets/pro/backend_output_model.txt +5 -0
  5. package/assets/pro/claude-module-prompt.txt +65 -0
  6. package/assets/pro/common.txt +9 -0
  7. package/assets/pro/confirm_model.txt +5 -0
  8. package/assets/pro/front_output_model.txt +9 -0
  9. package/assets/pro/genimage_model.txt +1 -0
  10. package/assets/pro/main_model.txt +5 -0
  11. package/assets/pro/model-template.json +376 -0
  12. package/assets/pro/modeling_model.txt +5 -0
  13. package/assets/pro/plan_model.txt +8 -0
  14. package/assets/pro/thinking_model.txt +3 -0
  15. package/dist/agents.js +22 -3
  16. package/dist/app.js +199 -26
  17. package/dist/background.js +358 -0
  18. package/dist/bin.js +184 -44
  19. package/dist/browser.js +57 -0
  20. package/dist/config.js +174 -26
  21. package/dist/header.js +2 -1
  22. package/dist/index.js +1 -1
  23. package/dist/llm/approval.js +6 -2
  24. package/dist/llm/client.js +197 -28
  25. package/dist/llm/subagent.js +37 -1
  26. package/dist/llm/tools.js +3 -1
  27. package/dist/migration.js +33 -0
  28. package/dist/pro/agents.js +104 -0
  29. package/dist/pro/config.js +88 -0
  30. package/dist/pro/preflight.js +172 -0
  31. package/dist/screens/agentConversation.js +48 -5
  32. package/dist/screens/agentHistory.js +4 -2
  33. package/dist/screens/chat.js +14 -8
  34. package/dist/screens/modelSelect.js +15 -4
  35. package/dist/screens/settings.js +52 -9
  36. package/dist/screens/start.js +48 -15
  37. package/dist/state.js +1 -0
  38. package/dist/terminal/restore.js +3 -12
  39. package/dist/tray.js +138 -0
  40. package/dist/ui.js +58 -0
  41. package/dist/web/hub.js +26 -4
  42. package/dist/web/server.js +495 -74
  43. package/dist/web/service.js +135 -0
  44. package/dist/web/session.js +278 -25
  45. package/dist/web/settings.js +97 -24
  46. package/dist/web/terminalBridge.js +250 -0
  47. package/package.json +37 -37
  48. package/web/README.md +150 -16
  49. package/web/app.js +490 -25
  50. package/web/i18n.js +396 -345
  51. package/web/index.html +120 -15
  52. package/web/neutrino-pro.gif +0 -0
  53. package/web/neutrino.png +0 -0
  54. package/web/pro.js +184 -0
  55. package/web/settings.js +373 -247
  56. package/web/style.css +398 -0
package/web/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createSettingsUI } from "./settings.js";
2
+ import { createProUI } from "./pro.js";
2
3
  import { renderMarkdown as renderMarkdownHtml } from "./markdown.js";
3
4
  import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
4
5
  import { applyTranslations, getLanguage as currentLanguage, locale as currentLocale, setLanguage as setI18nLanguage, t as translate } from "./i18n.js";
@@ -24,6 +25,37 @@ const REFRESH_MS = 4000;
24
25
  // Remove credentials left behind by older versions of the UI.
25
26
  try { localStorage.removeItem("neutrino.token"); } catch { /* Storage may be blocked. */ }
26
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
+
27
59
  // Translation lookup. `translate` is an ES-module import; the browser tests
28
60
  // that evaluate this file in a sandbox strip imports, so the helper degrades to
29
61
  // the English source text instead of throwing. Interpolation is kept here too.
@@ -92,7 +124,7 @@ const initialLogin = (() => {
92
124
 
93
125
  async function apiFetch(path, init = {}) {
94
126
  if (!await initialLogin) throw new Error("Password login required.");
95
- const response = await fetch(path, { ...init, credentials: "same-origin" });
127
+ const response = await fetch(apiPath(path), { ...init, credentials: "same-origin" });
96
128
  if (response.status === 401) {
97
129
  showGate();
98
130
  throw new Error("Password login required.");
@@ -115,6 +147,7 @@ function showGate(message = "") {
115
147
  closeStream();
116
148
  setStatus("locked", "offline");
117
149
  document.getElementById("settings-dialog").close();
150
+ document.getElementById("background-dialog")?.close?.();
118
151
 
119
152
  // A modal dialog stays above streaming repaints and traps keyboard focus.
120
153
  const gate = document.createElement("dialog");
@@ -152,12 +185,90 @@ function showGate(message = "") {
152
185
  gate.showModal();
153
186
  }
154
187
 
188
+ // -------------------------------------------------------- background invite
189
+ //
190
+ // Accepting changes the running console's lifetime immediately, including
191
+ // conversations already in flight. A resident console never offers the prompt.
192
+
193
+ let backgroundPromptAnswered = false;
194
+
195
+ function showBackgroundPrompt() {
196
+ if (backgroundPromptAnswered || !el.backgroundDialog?.showModal) return;
197
+ if (el.backgroundDialog.open) return;
198
+ if (el.backgroundFeedback) {
199
+ el.backgroundFeedback.textContent = "";
200
+ el.backgroundFeedback.classList.remove("error");
201
+ }
202
+ try {
203
+ el.backgroundDialog.showModal();
204
+ } catch {
205
+ // An embedder without dialog support still gets the setting in Settings.
206
+ }
207
+ }
208
+
209
+ function maybePromptBackground(config) {
210
+ if (!config?.settings?.background_prompt) return;
211
+ if (config.settings.background === true) return;
212
+ showBackgroundPrompt();
213
+ }
214
+
215
+ async function answerBackgroundPrompt(enable) {
216
+ if (backgroundPromptAnswered) return;
217
+ backgroundPromptAnswered = true;
218
+ const feedback = el.backgroundFeedback;
219
+ const cancel = el.backgroundCancel;
220
+ const confirm = el.backgroundEnable;
221
+ if (cancel) cancel.disabled = true;
222
+ if (confirm) confirm.disabled = true;
223
+ if (feedback) {
224
+ feedback.textContent = "";
225
+ feedback.classList.remove("error");
226
+ }
227
+ try {
228
+ if (enable) {
229
+ const response = await apiFetch("/api/settings", {
230
+ method: "POST",
231
+ headers: { "Content-Type": "application/json" },
232
+ body: JSON.stringify({ settings: { background: true } }),
233
+ });
234
+ const body = await response.json();
235
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
236
+ if (body.settings?.background_running !== true) {
237
+ throw new Error(tr("Background residency could not be applied. Restart the console and try again."));
238
+ }
239
+ try { await preferences.load(); } catch { /* Residency is already active. */ }
240
+ } else {
241
+ // Consume the invitation for this CLI run. A failed dismissal is
242
+ // harmless: the prompt simply returns on the next page load.
243
+ try {
244
+ await apiFetch("/api/background/dismiss", { method: "POST" });
245
+ } catch { /* Keep the close local. */ }
246
+ }
247
+ el.backgroundDialog?.close?.();
248
+ } catch (error) {
249
+ backgroundPromptAnswered = false;
250
+ if (feedback) {
251
+ feedback.textContent = tr(error.message);
252
+ feedback.classList.add("error");
253
+ }
254
+ } finally {
255
+ if (cancel) cancel.disabled = false;
256
+ if (confirm) confirm.disabled = false;
257
+ }
258
+ }
259
+
155
260
  const el = {
156
261
  app: document.getElementById("app"),
157
262
  back: document.getElementById("back-to-menu"),
158
263
  sessions: document.getElementById("sessions"),
159
264
  status: document.getElementById("status"),
160
265
  refresh: document.getElementById("refresh"),
266
+ brandLogo: document.getElementById("brand-logo"),
267
+ brandName: document.getElementById("brand-name"),
268
+ proTip: document.getElementById("pro-tip"),
269
+ proTipClose: document.getElementById("pro-tip-close"),
270
+ proExit: document.getElementById("pro-exit"),
271
+ header: document.getElementById("main-header"),
161
272
  title: document.getElementById("session-title"),
162
273
  sub: document.getElementById("session-sub"),
163
274
  controls: document.getElementById("controls"),
@@ -215,8 +326,28 @@ const el = {
215
326
  agentsList: document.getElementById("agents-list"),
216
327
  agentsFeedback: document.getElementById("agents-feedback"),
217
328
  agentsCwd: document.getElementById("agents-cwd"),
329
+ agentActions: document.getElementById("agent-actions"),
330
+ agentMigrate: document.getElementById("agent-migrate"),
331
+ agentMigrateFeedback: document.getElementById("agent-migrate-feedback"),
332
+ backgroundDialog: document.getElementById("background-dialog"),
333
+ backgroundFeedback: document.getElementById("background-feedback"),
334
+ backgroundCancel: document.getElementById("background-cancel"),
335
+ backgroundEnable: document.getElementById("background-enable"),
218
336
  };
219
337
 
338
+ if (el.backgroundCancel) {
339
+ el.backgroundCancel.addEventListener("click", () => { void answerBackgroundPrompt(false); });
340
+ }
341
+ if (el.backgroundEnable) {
342
+ el.backgroundEnable.addEventListener("click", () => { void answerBackgroundPrompt(true); });
343
+ }
344
+ if (el.backgroundDialog) {
345
+ el.backgroundDialog.addEventListener("cancel", (event) => {
346
+ event.preventDefault();
347
+ void answerBackgroundPrompt(false);
348
+ });
349
+ }
350
+
220
351
  const state = {
221
352
  overview: null,
222
353
  // { kind: "live", key } | { kind: "file", id } | { kind: "new", cwd } |
@@ -235,6 +366,7 @@ const state = {
235
366
  fileData: null,
236
367
  // Last loaded read-only transcript from another agent.
237
368
  agentData: null,
369
+ agentMigration: null,
238
370
  // { key, path } | null — the working directory the sidebar is scoped to.
239
371
  workspace: storedWorkspace(),
240
372
  // Plan / usage card visibility and the plan it was opened for.
@@ -249,13 +381,16 @@ const state = {
249
381
  };
250
382
 
251
383
  function storedWorkspace() {
252
- const path = readStorage("neutrino.workspace");
384
+ const path = readStorage(modeStorageKey("neutrino.workspace"));
253
385
  if (!path) return null;
254
386
  return { key: pathKey(path), path };
255
387
  }
256
388
 
389
+ let proUI = null;
257
390
  const preferences = createSettingsUI({
258
391
  apiFetch,
392
+ proMode: PRO_MODE,
393
+ onCatalogChange: (catalog) => proUI?.load(catalog),
259
394
  onLanguageChange: (language) => changeInterfaceLanguage(language),
260
395
  async applyModel(selection) {
261
396
  const current = state.selection;
@@ -276,6 +411,37 @@ const preferences = createSettingsUI({
276
411
  },
277
412
  });
278
413
 
414
+ if (PRO_MODE && typeof createProUI === "function") {
415
+ proUI = createProUI({
416
+ async changePreset(preset) {
417
+ const current = state.selection;
418
+ if (current?.kind === "live") {
419
+ const response = await apiFetch(`/api/live/${encodeURIComponent(current.key)}/pro-preset`, {
420
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ preset }),
421
+ });
422
+ const body = await response.json();
423
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
424
+ if (state.selection?.kind === "live" && state.selection.key === current.key) {
425
+ state.snapshot = body.snapshot;
426
+ renderLive();
427
+ }
428
+ } else if (current?.kind !== "file") {
429
+ const response = await apiFetch("/api/settings", {
430
+ method: "POST", headers: { "Content-Type": "application/json" },
431
+ body: JSON.stringify({ settings: { pro_preset: preset } }),
432
+ });
433
+ const body = await response.json();
434
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
435
+ await preferences.load();
436
+ }
437
+ },
438
+ });
439
+ }
440
+
441
+ function currentProPreset() {
442
+ return proUI?.preset() || preferences.catalog?.()?.pro?.preset;
443
+ }
444
+
279
445
  // ------------------------------------------------------------------- sounds
280
446
  //
281
447
  // A turn that ends while the tab is in the background is invisible, so finished
@@ -395,7 +561,7 @@ function workspaceName(value) {
395
561
 
396
562
  function setWorkspace(workspace, { persist = true } = {}) {
397
563
  state.workspace = workspace ? { key: pathKey(workspace.path), path: workspace.path } : null;
398
- if (persist) writeStorage("neutrino.workspace", state.workspace?.path ?? "");
564
+ if (persist) writeStorage(modeStorageKey("neutrino.workspace"), state.workspace?.path ?? "");
399
565
  renderWorkspaceChrome();
400
566
  renderSidebar();
401
567
  }
@@ -649,7 +815,7 @@ async function selectLive(key) {
649
815
 
650
816
  function openStream(key) {
651
817
  closeStream();
652
- const source = new EventSource(`/api/live/${encodeURIComponent(key)}/stream`);
818
+ const source = new EventSource(apiPath(`/api/live/${encodeURIComponent(key)}/stream`));
653
819
  state.eventSource = source;
654
820
 
655
821
  source.addEventListener("snapshot", (event) => {
@@ -675,12 +841,14 @@ function openStream(key) {
675
841
 
676
842
  source.onopen = () => setStatus("live", "live");
677
843
  source.onerror = () => {
678
- // EventSource retries automatically; just surface the state.
844
+ // EventSource retries automatically; wait for a fresh snapshot to flash.
845
+ el.header?.classList.remove("is-thinking");
679
846
  setStatus("reconnecting…", "offline");
680
847
  };
681
848
  }
682
849
 
683
850
  function closeStream() {
851
+ el.header?.classList.remove("is-thinking");
684
852
  if (state.eventSource) {
685
853
  state.eventSource.close();
686
854
  state.eventSource = null;
@@ -728,6 +896,7 @@ function renderFile(data) {
728
896
  el.title.textContent = firstUserMessage(messages) || tr("Conversation");
729
897
  el.sub.textContent = [data.cwd, data.model, data.provider].filter(Boolean).join(" · ");
730
898
  preferences.showConversation(data, `file:${data.id}`);
899
+ proUI?.show(data, `file:${data.id}`);
731
900
  renderMessageList(messages, null, { readOnly: true, context: data.context, active: false });
732
901
  renderFileControls(data);
733
902
  maybeOpenPlan(data.plan);
@@ -779,10 +948,11 @@ function renderFileControls(data) {
779
948
 
780
949
  /** Attach to a resumed conversation, creating it server-side when needed. */
781
950
  async function continueSession(id, selection = preferences.selectionForFile(id)) {
951
+ const payload = PRO_MODE ? { pro_preset: currentProPreset() } : selection ? { selection } : undefined;
782
952
  const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/continue`, {
783
953
  method: "POST",
784
- headers: selection ? { "Content-Type": "application/json" } : undefined,
785
- body: selection ? JSON.stringify({ selection }) : undefined,
954
+ headers: payload ? { "Content-Type": "application/json" } : undefined,
955
+ body: payload ? JSON.stringify(payload) : undefined,
786
956
  });
787
957
  const body = await response.json().catch(() => ({}));
788
958
  if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
@@ -792,17 +962,37 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
792
962
 
793
963
  function setComposerMode(mode) {
794
964
  state.composerMode = mode;
965
+ el.agentActions.classList.add("hidden");
795
966
  el.composer.classList.remove("hidden");
796
967
  el.input.placeholder = mode === "resume"
797
968
  ? tr("Continue this conversation…")
798
969
  : mode === "new"
799
970
  ? tr("Message Neutrino in this folder…")
800
971
  : tr("Message Neutrino…");
972
+ renderComposerAction();
973
+ }
974
+
975
+ function canPauseFromComposer() {
976
+ const selection = state.selection;
977
+ const snapshot = state.snapshot;
978
+ return Boolean(selection?.kind === "live" && snapshot?.key === selection.key
979
+ && snapshot.busy && !snapshot.paused && !el.input.value.trim());
980
+ }
981
+
982
+ function renderComposerAction() {
983
+ const pause = canPauseFromComposer();
984
+ const label = pause ? "Pause" : "Send message";
985
+ el.send.classList.toggle("is-pause", pause);
986
+ el.send.setAttribute("data-i18n-title", label);
987
+ el.send.setAttribute("data-i18n-aria-label", label);
988
+ el.send.title = tr(label);
989
+ el.send.setAttribute("aria-label", tr(label));
801
990
  }
802
991
 
803
992
  function hideComposer() {
804
993
  preferences.clear();
805
994
  el.composer.classList.add("hidden");
995
+ el.agentActions.classList.add("hidden");
806
996
  renderApproval(null);
807
997
  }
808
998
 
@@ -954,11 +1144,14 @@ function decideApproval(approved) {
954
1144
  function renderLive() {
955
1145
  const snapshot = state.snapshot;
956
1146
  if (!snapshot) return;
1147
+ renderComposerAction();
1148
+ el.header?.classList.toggle("is-thinking", Boolean(snapshot.busy && !snapshot.paused && !snapshot.approval));
957
1149
  state.fileData = null;
958
1150
  state.agentData = null;
959
1151
  el.title.textContent = snapshot.title || tr("New conversation");
960
1152
  el.sub.textContent = [snapshot.cwd, snapshot.model, snapshot.provider].filter(Boolean).join(" · ");
961
1153
  preferences.showConversation(snapshot, `live:${snapshot.key}`);
1154
+ proUI?.show(snapshot, `live:${snapshot.key}`);
962
1155
  renderControls(snapshot);
963
1156
  renderApproval(snapshot);
964
1157
  renderMessageList(snapshot.messages ?? [], snapshot.streamingContent, {
@@ -1319,11 +1512,20 @@ async function postAction(key, action, body) {
1319
1512
  }
1320
1513
  }
1321
1514
 
1515
+ function activateComposer() {
1516
+ if (canPauseFromComposer()) return postAction(state.selection.key, "pause");
1517
+ return sendMessage();
1518
+ }
1519
+
1322
1520
  async function sendMessage() {
1323
1521
  const selection = state.selection;
1324
1522
  if (!selection) return;
1325
1523
  const text = el.input.value;
1326
1524
  if (!text.trim()) return;
1525
+ if (PRO_MODE) {
1526
+ try { await proUI?.flush(); }
1527
+ catch (error) { el.sub.textContent = error.message; return; }
1528
+ }
1327
1529
 
1328
1530
  if (selection.kind === "live") {
1329
1531
  el.input.value = "";
@@ -1366,6 +1568,7 @@ async function sendMessage() {
1366
1568
  function autosize() {
1367
1569
  el.input.style.height = "auto";
1368
1570
  el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
1571
+ renderComposerAction();
1369
1572
  }
1370
1573
 
1371
1574
  // ------------------------------------------------------- workspaces & dialogs
@@ -1467,6 +1670,7 @@ function renderWorkspaceLanding(path) {
1467
1670
  el.title.textContent = workspaceName(path) || tr("Working directory");
1468
1671
  el.sub.textContent = path;
1469
1672
  setComposerMode("new");
1673
+ proUI?.show(null, `new:${path}`);
1470
1674
  el.messages.innerHTML = "";
1471
1675
 
1472
1676
  const empty = document.createElement("div");
@@ -1616,6 +1820,19 @@ function fillNewChatModels(catalog, selectedModel) {
1616
1820
  })), selectedModel);
1617
1821
  }
1618
1822
 
1823
+ /** Offer only the levels the chosen model declares, keeping the draft if valid. */
1824
+ function fillNewChatIntensities(catalog, selected) {
1825
+ const provider = catalog.providers.find((entry) => entry.name === el.newChatProvider.value) ?? catalog.providers[0];
1826
+ const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
1827
+ const levels = Array.isArray(model?.intensities) && model.intensities.length ? model.intensities : catalog.intensities;
1828
+ let wanted = selected;
1829
+ if (!levels.includes(wanted)) {
1830
+ const fallback = catalog.settings?.default_intensity;
1831
+ wanted = levels.includes(fallback) ? fallback : levels.includes("High") ? "High" : levels[0] ?? "";
1832
+ }
1833
+ fillSelect(el.newChatIntensity, levels.map((value) => ({ value, label: value })), wanted);
1834
+ }
1835
+
1619
1836
  function updateNewChatNote(catalog) {
1620
1837
  const provider = catalog?.providers.find((entry) => entry.name === el.newChatProvider.value);
1621
1838
  const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
@@ -1626,6 +1843,15 @@ function updateNewChatNote(catalog) {
1626
1843
  }
1627
1844
  }
1628
1845
 
1846
+ function renderProNewChatPresets(catalog, selected = catalog.pro?.preset) {
1847
+ if (!PRO_MODE) return;
1848
+ const select = document.getElementById("new-chat-pro-preset");
1849
+ if (!select) return;
1850
+ fillSelect(select, (catalog.pro?.presets || []).map(preset => ({
1851
+ value: preset.name, label: uiLanguage() === "zh" ? preset.chinese_name || preset.name : preset.name,
1852
+ })), selected);
1853
+ }
1854
+
1629
1855
  /** Relabel the open new-conversation sheet without losing its selections. */
1630
1856
  function refreshNewChatTexts() {
1631
1857
  if (!el.newChatDialog?.open) return;
@@ -1637,9 +1863,10 @@ function refreshNewChatTexts() {
1637
1863
  const approval = el.newChatApproval.value;
1638
1864
  fillSelect(el.newChatProvider, catalog.providers.map((entry) => ({ value: entry.name, label: entry.name })), provider);
1639
1865
  fillNewChatModels(catalog, model);
1640
- fillSelect(el.newChatIntensity, catalog.intensities.map((value) => ({ value, label: value })), intensity);
1866
+ fillNewChatIntensities(catalog, intensity);
1641
1867
  el.newChatApproval.value = approval;
1642
1868
  updateNewChatNote(catalog);
1869
+ renderProNewChatPresets(catalog, document.getElementById("new-chat-pro-preset")?.value);
1643
1870
  renderNewChatRecents();
1644
1871
  }
1645
1872
 
@@ -1659,13 +1886,14 @@ async function openNewChatDialog(options = {}) {
1659
1886
  const defaults = catalog.selection;
1660
1887
  fillSelect(el.newChatProvider, catalog.providers.map((provider) => ({ value: provider.name, label: provider.name })), defaults.provider);
1661
1888
  fillNewChatModels(catalog, defaults.model);
1662
- fillSelect(el.newChatIntensity, catalog.intensities.map((value) => ({ value, label: value })), defaults.intensity);
1889
+ fillNewChatIntensities(catalog, defaults.intensity);
1663
1890
  el.newChatApproval.value = catalog.settings.approval_mode === "auto" ? "auto" : "request";
1664
1891
 
1665
1892
  const start = String(options.cwd || selectionCwd() || state.workspace?.path || state.workspaces?.cwd || "").trim();
1666
1893
  el.newChatCwd.value = start;
1667
1894
  renderNewChatRecents();
1668
1895
  updateNewChatNote(catalog);
1896
+ renderProNewChatPresets(catalog);
1669
1897
  if (typeof el.newChatCwd.focus === "function") el.newChatCwd.focus();
1670
1898
  }
1671
1899
 
@@ -1686,7 +1914,8 @@ async function submitNewChat() {
1686
1914
  el.newChatStart.textContent = tr("Creating…");
1687
1915
  el.newChatFeedback.textContent = "";
1688
1916
  try {
1689
- const created = await createConversation(cwd, selection, el.newChatApproval.value);
1917
+ const created = await createConversation(cwd, selection, el.newChatApproval.value,
1918
+ PRO_MODE ? document.getElementById("new-chat-pro-preset")?.value : undefined);
1690
1919
  el.newChatDialog.close();
1691
1920
  await selectLive(created.key);
1692
1921
  setConversationView(true);
@@ -1700,9 +1929,10 @@ async function submitNewChat() {
1700
1929
  }
1701
1930
 
1702
1931
  /** Create a browser-owned conversation in `cwd` and reveal its folder. */
1703
- async function createConversation(cwd, selection, approvalMode) {
1932
+ async function createConversation(cwd, selection, approvalMode, proPreset = currentProPreset()) {
1704
1933
  const payload = { cwd };
1705
- if (selection && selection.provider && selection.model) payload.selection = selection;
1934
+ if (PRO_MODE) payload.pro_preset = proPreset;
1935
+ else if (selection && selection.provider && selection.model) payload.selection = selection;
1706
1936
  if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
1707
1937
  const response = await apiFetch("/api/conversations", {
1708
1938
  method: "POST",
@@ -2087,7 +2317,7 @@ function renderAgentList(groups) {
2087
2317
  "</span>";
2088
2318
  item.addEventListener("click", () => {
2089
2319
  el.agentsDialog.close();
2090
- void selectAgentSession(group.id, session.id, session.title || "");
2320
+ void selectAgentSession(group.id, session.id, session.title || "", group.cwd);
2091
2321
  });
2092
2322
  section.appendChild(item);
2093
2323
  }
@@ -2106,7 +2336,7 @@ async function openAgentsDialog() {
2106
2336
  const response = await apiFetch(`/api/agents/${agent.id}/sessions?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" });
2107
2337
  const data = await response.json().catch(() => ({}));
2108
2338
  if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2109
- return { ...agent, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
2339
+ return { ...agent, cwd: data.cwd || cwd, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
2110
2340
  }));
2111
2341
  el.agentsFeedback.textContent = "";
2112
2342
  renderAgentList(groups);
@@ -2133,11 +2363,53 @@ function renderAgentView(data) {
2133
2363
  renderApproval(null);
2134
2364
  renderPlanCard(null);
2135
2365
  hideComposer();
2366
+ renderAgentMigration(data);
2367
+ }
2368
+
2369
+ function renderAgentMigration(data) {
2370
+ el.agentActions.classList.remove("hidden");
2371
+ el.agentMigrate.disabled = Boolean(state.agentMigration) || !data.cwd || !data.messages?.length;
2372
+ el.agentMigrate.textContent = state.agentMigration === state.selection ? tr("Migrating…") : tr("Migrate session");
2373
+ }
2374
+
2375
+ async function migrateSelectedAgentSession() {
2376
+ const selection = state.selection;
2377
+ const source = state.agentData;
2378
+ if (selection?.kind !== "agent" || !source?.cwd || !source.messages?.length || state.agentMigration) return;
2379
+ state.agentMigration = selection;
2380
+ el.agentMigrateFeedback.textContent = "";
2381
+ renderAgentMigration(source);
2382
+ try {
2383
+ const payload = { cwd: source.cwd };
2384
+ if (PRO_MODE) payload.pro_preset = currentProPreset();
2385
+ const response = await apiFetch(`/api/agents/${encodeURIComponent(selection.agent)}/sessions/${encodeURIComponent(selection.id)}/migrate`, {
2386
+ method: "POST",
2387
+ headers: { "Content-Type": "application/json" },
2388
+ body: JSON.stringify(payload),
2389
+ });
2390
+ const data = await response.json().catch(() => ({}));
2391
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2392
+ if (state.selection === selection) {
2393
+ setWorkspace({ path: data.cwd });
2394
+ await selectLive(data.key);
2395
+ el.input.focus();
2396
+ }
2397
+ await loadOverview({ silent: true });
2398
+ } catch (error) {
2399
+ if (state.selection === selection) {
2400
+ el.agentMigrateFeedback.textContent = tr("Could not migrate this session: {error}", { error: error.message });
2401
+ }
2402
+ } finally {
2403
+ state.agentMigration = null;
2404
+ if (state.selection?.kind === "agent" && state.agentData) renderAgentMigration(state.agentData);
2405
+ }
2136
2406
  }
2137
2407
 
2138
- async function selectAgentSession(agent, id, title) {
2408
+ async function selectAgentSession(agent, id, title, cwd) {
2409
+ const scope = cwd || await agentsScopeCwd();
2139
2410
  closeStream();
2140
- state.selection = { kind: "agent", agent, id, title };
2411
+ const selection = { kind: "agent", agent, id, title, cwd: scope };
2412
+ state.selection = selection;
2141
2413
  state.snapshot = null;
2142
2414
  state.fileData = null;
2143
2415
  state.agentData = null;
@@ -2149,17 +2421,20 @@ async function selectAgentSession(agent, id, title) {
2149
2421
  renderApproval(null);
2150
2422
  renderPlanCard(null);
2151
2423
  hideComposer();
2424
+ el.agentMigrateFeedback.textContent = "";
2152
2425
  el.title.textContent = tr("Loading…");
2153
2426
  el.sub.textContent = title || "";
2154
2427
  resetMessagePane(tr("Loading…"));
2155
2428
  setConversationView(true);
2156
2429
  try {
2157
- const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}`, { cache: "no-store" });
2430
+ const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}?cwd=${encodeURIComponent(scope)}`, { cache: "no-store" });
2158
2431
  const data = await response.json().catch(() => ({}));
2432
+ if (state.selection !== selection) return;
2159
2433
  if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2160
- state.agentData = { ...data, agent, cwd: state.agentData?.cwd || state.workspace?.path || "" };
2434
+ state.agentData = { ...data, agent, cwd: data.cwd || scope };
2161
2435
  renderAgentView(state.agentData);
2162
2436
  } catch (error) {
2437
+ if (state.selection !== selection) return;
2163
2438
  el.title.textContent = title || tr("Conversation");
2164
2439
  el.sub.textContent = tr("Could not load this session: {error}", { error: error.message });
2165
2440
  resetMessagePane(tr("Could not load this session: {error}", { error: error.message }));
@@ -2177,11 +2452,12 @@ function syncLanguageSelects(language) {
2177
2452
  async function changeInterfaceLanguage(language, { persist = true } = {}) {
2178
2453
  const normalized = String(language ?? "").toLowerCase().startsWith("zh") ? "zh" : "en";
2179
2454
  setUiLanguage(normalized);
2180
- writeStorage("neutrino.language", normalized);
2455
+ writeStorage(modeStorageKey("neutrino.language"), normalized);
2181
2456
  applyStaticTranslations();
2182
2457
  syncLanguageSelects(normalized);
2183
2458
  if (state.status) setStatus(state.status.key, state.status.kind);
2184
2459
  preferences.refreshTexts?.();
2460
+ proUI?.refresh();
2185
2461
  refreshNewChatTexts();
2186
2462
  renderWorkspaceChrome();
2187
2463
  renderSidebar();
@@ -2209,18 +2485,197 @@ async function changeInterfaceLanguage(language, { persist = true } = {}) {
2209
2485
  }
2210
2486
 
2211
2487
  function initializeLanguage() {
2212
- const stored = readStorage("neutrino.language");
2488
+ const stored = readStorage(modeStorageKey("neutrino.language"));
2213
2489
  if (stored) setUiLanguage(stored);
2214
2490
  applyStaticTranslations();
2215
2491
  syncLanguageSelects(uiLanguage());
2216
2492
  if (state.status) setStatus(state.status.key, state.status.kind);
2217
2493
  }
2218
2494
 
2495
+ // ------------------------------------------------------------------ pro mode
2496
+ //
2497
+ // Both workspaces reuse this console. Clicking the orbit badge on the normal
2498
+ // console starts a short wordmark curtain; the recorded start time lets /pro
2499
+ // finish the same animation instead of flashing the freshly loaded page.
2500
+
2501
+ const PRO_TRANSITION_KEY = "neutrino.pro.transition";
2502
+ const PRO_TIP_KEY = "neutrino.pro.tip.dismissed";
2503
+ const PRO_TRANSITION_FADE_MS = 350; // curtain fade in / fade out
2504
+ const PRO_TRANSITION_COVER_MS = 450; // navigate once the curtain is fully opaque
2505
+ const PRO_TRANSITION_HOLD_MS = 3000; // wordmark stays up until 3s after the switch began
2506
+ const PRO_TRANSITION_RESUME_MS = 8000; // older records are stale and never replayed
2507
+ const PRO_LOGO_MARK = '<img class="pro-logo-mark" src="/neutrino.png" alt="Neutrino Pro">';
2508
+
2509
+ let proTransitionStarted = false;
2510
+
2511
+ function proCurtain() {
2512
+ const curtain = document.createElement("div");
2513
+ curtain.className = "pro-curtain";
2514
+ curtain.setAttribute("aria-hidden", "true");
2515
+ const image = document.createElement("img");
2516
+ image.src = "/neutrino-pro.gif";
2517
+ image.alt = "";
2518
+ curtain.appendChild(image);
2519
+ return curtain;
2520
+ }
2521
+
2522
+ /** Timers exist in the browser; the test sandboxes load this file without them. */
2523
+ function proDelay(callback, ms) {
2524
+ if (typeof setTimeout === "function") setTimeout(callback, ms);
2525
+ }
2526
+
2527
+ function startProTransition() {
2528
+ if (PRO_MODE || proTransitionStarted) return;
2529
+ proTransitionStarted = true;
2530
+ const started = Date.now();
2531
+ try {
2532
+ sessionStorage.setItem(PRO_TRANSITION_KEY, String(started));
2533
+ } catch { /* Storage may be blocked; navigation still works. */ }
2534
+
2535
+ const curtain = proCurtain();
2536
+ curtain.style.transition = `opacity ${PRO_TRANSITION_FADE_MS}ms ease`;
2537
+ document.body.appendChild(curtain);
2538
+ // Commit the transparent first frame, then let the style transition fade in.
2539
+ void curtain.offsetWidth;
2540
+ curtain.classList.add("pro-curtain-visible");
2541
+
2542
+ proDelay(() => {
2543
+ try { location.assign("/pro"); } catch { location.href = "/pro"; }
2544
+ }, PRO_TRANSITION_COVER_MS);
2545
+ }
2546
+
2547
+ /** Remove every switch curtain left in this document. */
2548
+ function removeProCurtains() {
2549
+ if (typeof document.querySelectorAll !== "function") return;
2550
+ for (const curtain of document.querySelectorAll(".pro-curtain")) curtain.remove?.();
2551
+ }
2552
+
2553
+ /**
2554
+ * Drop the one-shot switch chrome.
2555
+ *
2556
+ * The curtain lives in the document that navigated away, so the browser's
2557
+ * back/forward cache can restore it fully opaque — a Back or Forward would
2558
+ * otherwise land on that frozen frame. `clearRecord` stays false while this
2559
+ * document is being hidden for /pro: the destination still needs the recorded
2560
+ * start time to finish the same animation.
2561
+ */
2562
+ function resetProTransition({ clearRecord = true } = {}) {
2563
+ removeProCurtains();
2564
+ document.documentElement?.classList?.remove?.("pro-transitioning");
2565
+ proTransitionStarted = false;
2566
+ if (!clearRecord) return;
2567
+ try { sessionStorage.removeItem(PRO_TRANSITION_KEY); } catch { /* Storage may be blocked. */ }
2568
+ }
2569
+
2570
+ /** Undo the switch chrome when the browser restores either page from bfcache. */
2571
+ function watchProNavigation() {
2572
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
2573
+ window.addEventListener("pagehide", (event) => {
2574
+ if (!event?.persisted) return;
2575
+ // The page is entering the back/forward cache: clean the snapshot, but
2576
+ // keep the record for the /pro page that is currently loading.
2577
+ resetProTransition({ clearRecord: false });
2578
+ });
2579
+ window.addEventListener("pageshow", (event) => {
2580
+ if (!event?.persisted) return;
2581
+ // Restored by Back/Forward: any curtain captured in the snapshot is stale.
2582
+ resetProTransition();
2583
+ });
2584
+ }
2585
+
2586
+ /** Consume a just-recorded switch and finish the animation on /pro. */
2587
+ function resumeProTransition() {
2588
+ if (!PRO_MODE) return;
2589
+ let started = 0;
2590
+ try { started = Number(sessionStorage.getItem(PRO_TRANSITION_KEY)) || 0; } catch { /* Storage may be blocked. */ }
2591
+ // Consume the record even when stale: direct visits never show a curtain.
2592
+ if (started) {
2593
+ try { sessionStorage.removeItem(PRO_TRANSITION_KEY); } catch { /* Ignore. */ }
2594
+ }
2595
+ const now = Date.now();
2596
+ const age = now - started;
2597
+ if (!started || age < 0 || age > PRO_TRANSITION_RESUME_MS) {
2598
+ document.documentElement.classList.remove("pro-transitioning");
2599
+ return;
2600
+ }
2601
+
2602
+ const curtain = proCurtain();
2603
+ // Start fully opaque on the first frame so the new page never flashes through.
2604
+ curtain.style.transition = "none";
2605
+ curtain.style.opacity = "1";
2606
+ document.body.appendChild(curtain);
2607
+ // The real curtain is up: retire the pre-paint cover from index.html so it
2608
+ // cannot remain over the page after the fade completes.
2609
+ document.documentElement.classList.remove("pro-transitioning");
2610
+
2611
+ proDelay(() => {
2612
+ curtain.style.transition = `opacity ${PRO_TRANSITION_FADE_MS}ms ease`;
2613
+ curtain.style.opacity = "0";
2614
+ proDelay(() => curtain.remove(), PRO_TRANSITION_FADE_MS);
2615
+ }, Math.max(0, started + PRO_TRANSITION_HOLD_MS - now));
2616
+ }
2617
+
2618
+ /** In Pro mode the badge is a static "you are here" mark, not a control. */
2619
+ function applyProBrand() {
2620
+ document.title = "Neutrino Pro";
2621
+ if (el.brandName) el.brandName.textContent = "Neutrino Pro";
2622
+ const logo = el.brandLogo;
2623
+ if (!logo) return;
2624
+ logo.innerHTML = PRO_LOGO_MARK;
2625
+ logo.classList.add("pro-logo");
2626
+ logo.tabIndex = -1;
2627
+ // Drop the i18n annotations too, or a language switch restores the tooltip.
2628
+ const annotations = ["title", "aria-label", "data-i18n-title", "data-i18n-aria-label"];
2629
+ for (const attribute of annotations) logo.removeAttribute?.(attribute);
2630
+ }
2631
+
2632
+ /** The blue tip explains the badge on the normal console; Pro never shows it. */
2633
+ function initializeProTip() {
2634
+ if (PRO_MODE || !el.proTip) return;
2635
+ if (readStorage(PRO_TIP_KEY) === "1") return;
2636
+ el.proTip.classList.remove("hidden");
2637
+ el.proTipClose?.addEventListener("click", () => {
2638
+ writeStorage(PRO_TIP_KEY, "1");
2639
+ el.proTip.classList.add("hidden");
2640
+ });
2641
+ }
2642
+
2643
+ /** The Pro workspace's top-right escape hatch back to the regular console. */
2644
+ function initializeProExit() {
2645
+ if (!el.proExit) return;
2646
+ el.proExit.classList.remove("hidden");
2647
+ el.proExit.addEventListener("click", () => {
2648
+ el.proExit.disabled = true;
2649
+ try {
2650
+ location.assign("/");
2651
+ } catch {
2652
+ location.href = "/";
2653
+ }
2654
+ });
2655
+ }
2656
+
2657
+ /** Wire the shared brand and either start the switch or resume its animation. */
2658
+ function initializeProWorkspace() {
2659
+ watchProNavigation();
2660
+ if (PRO_MODE) {
2661
+ applyProBrand();
2662
+ initializeProExit();
2663
+ resumeProTransition();
2664
+ return;
2665
+ }
2666
+ // A switch that was interrupted (Stop, Back, a failed navigation) can leave
2667
+ // the record and the pre-paint cover behind. Neither may ever affect the
2668
+ // regular console, so clear them before wiring the badge.
2669
+ resetProTransition();
2670
+ el.brandLogo?.addEventListener("click", startProTransition);
2671
+ initializeProTip();
2672
+ }
2673
+
2219
2674
  // --------------------------------------------------------------------- init
2220
2675
 
2221
2676
  el.refresh.addEventListener("click", () => loadOverview());
2222
2677
  el.back.addEventListener("click", () => setConversationView(false));
2223
- el.send.addEventListener("click", sendMessage);
2678
+ el.send.addEventListener("click", activateComposer);
2224
2679
  el.input.addEventListener("input", autosize);
2225
2680
  el.input.addEventListener("keydown", (event) => {
2226
2681
  if (event.key === "Enter" && !event.shiftKey) {
@@ -2243,6 +2698,7 @@ el.workspaceAdd.addEventListener("click", () => {
2243
2698
  openFolderDialog({ start: state.workspace?.path || state.workspaces?.cwd || "", onPick: (path) => openWorkspace(path) });
2244
2699
  });
2245
2700
  el.agentsOpen.addEventListener("click", () => openAgentsDialog());
2701
+ el.agentMigrate.addEventListener("click", () => migrateSelectedAgentSession());
2246
2702
  el.agentsClose.addEventListener("click", () => el.agentsDialog.close());
2247
2703
  el.agentsDialog.addEventListener("cancel", (event) => {
2248
2704
  event.preventDefault();
@@ -2274,12 +2730,16 @@ el.newChatProvider.addEventListener("change", () => {
2274
2730
  const catalog = preferences.catalog?.();
2275
2731
  if (catalog) {
2276
2732
  fillNewChatModels(catalog, "");
2733
+ fillNewChatIntensities(catalog, el.newChatIntensity.value);
2277
2734
  updateNewChatNote(catalog);
2278
2735
  }
2279
2736
  });
2280
2737
  el.newChatModel.addEventListener("change", () => {
2281
2738
  const catalog = preferences.catalog?.();
2282
- if (catalog) updateNewChatNote(catalog);
2739
+ if (catalog) {
2740
+ fillNewChatIntensities(catalog, el.newChatIntensity.value);
2741
+ updateNewChatNote(catalog);
2742
+ }
2283
2743
  });
2284
2744
 
2285
2745
  el.folderClose.addEventListener("click", () => el.folderDialog.close());
@@ -2318,11 +2778,16 @@ el.folderDialog.addEventListener("cancel", (event) => {
2318
2778
  window.addEventListener("beforeunload", closeStream);
2319
2779
 
2320
2780
  initializeLanguage();
2781
+ initializeProWorkspace();
2321
2782
  preferences.load()
2322
- .then((config) => {
2783
+ .then(async (config) => {
2323
2784
  const language = config?.settings?.language;
2324
- if (language && language !== uiLanguage()) return changeInterfaceLanguage(language, { persist: false });
2325
- return undefined;
2785
+ if (language && language !== uiLanguage()) {
2786
+ await changeInterfaceLanguage(language, { persist: false });
2787
+ }
2788
+ // Offer background residency only after the interface language is settled,
2789
+ // so the invitation is never briefly shown in the wrong language.
2790
+ maybePromptBackground(config);
2326
2791
  })
2327
2792
  .catch(error => console.error(error));
2328
2793
  loadOverview().then(() => {