@xingjisoft/neutrino 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/app.js CHANGED
@@ -145,6 +145,7 @@ function showGate(message = "") {
145
145
  if (gateShown) return;
146
146
  gateShown = true;
147
147
  closeStream();
148
+ closeUpdateEvents();
148
149
  setStatus("locked", "offline");
149
150
  document.getElementById("settings-dialog").close();
150
151
  document.getElementById("background-dialog")?.close?.();
@@ -259,20 +260,57 @@ async function answerBackgroundPrompt(enable) {
259
260
 
260
261
  // ------------------------------------------------------------- update notice
261
262
  //
262
- // The server compares the version it runs with the one npm publishes, and both
263
- // workspaces read that from the settings payload: a Pro tab announces a new
264
- // release exactly like the regular console. Confirming installs it on this
265
- // machine; the running process keeps the old code either way, so a successful
266
- // update ends by telling the user to restart.
263
+ // The server compares the version it runs with the one npm publishes — at
264
+ // start-up, every 30 minutes and on demand from Settings — and every answer
265
+ // reaches the page through one announcement stream, so the notice shown when
266
+ // the console opens and the corner badge cannot disagree about the release. A
267
+ // Pro tab listens to the same stream under its own API prefix. Confirming
268
+ // installs it on this machine; the running process keeps the old code either
269
+ // way, so a successful update ends by telling the user to restart.
267
270
  //
268
- // Two dialogs never open at once: when an update is offered, the background
269
- // invitation waits for the next page load (it is not consumed by being skipped).
271
+ // Two dialogs never open at once: the background invitation is modal too, so a
272
+ // release announced while it is up is offered once it is out of the way.
270
273
 
271
274
  let updatePrompted = false;
272
275
 
273
- function maybePromptUpdate(config) {
274
- const update = config?.update;
275
- if (updatePrompted || !update?.has_update) return false;
276
+ // The release the page currently knows about. The notice and the corner badge
277
+ // read this one value, so the reminder can never name a different version.
278
+ let updateOffer = null;
279
+
280
+ // The console's own announcement stream, and the release that arrived while the
281
+ // background invitation was holding the screen.
282
+ let updateSource = null;
283
+ let pendingUpdate = null;
284
+
285
+ /**
286
+ * Remember a release and light the corner badge. The announcement stream and a
287
+ * check started in Settings both report here.
288
+ */
289
+ function noteUpdateAvailable(update) {
290
+ if (!update?.has_update) return false;
291
+ updateOffer = update;
292
+ if (el.updateBadge) {
293
+ const label = tr("A new version is available");
294
+ el.updateBadge.title = label;
295
+ el.updateBadge.setAttribute("aria-label", label);
296
+ el.updateBadge.classList.remove("hidden");
297
+ }
298
+ return true;
299
+ }
300
+
301
+ /** The release is installed or was never on offer: drop the reminder. */
302
+ function clearUpdateBadge() {
303
+ updateOffer = null;
304
+ pendingUpdate = null;
305
+ el.updateBadge?.classList.add("hidden");
306
+ }
307
+
308
+ /**
309
+ * Offer a known release. The notice shown on load and a check started from
310
+ * Settings both end up here, so an update is announced with the same words and
311
+ * the same buttons however the user found out about it.
312
+ */
313
+ function openUpdateDialog(update) {
276
314
  if (!el.updateDialog || typeof el.updateDialog.showModal !== "function") return false;
277
315
  updatePrompted = true;
278
316
  if (el.updateVersions) {
@@ -299,6 +337,56 @@ function maybePromptUpdate(config) {
299
337
  return true;
300
338
  }
301
339
 
340
+ /**
341
+ * Announce a release. The console pushes what it knows when a tab connects and
342
+ * again whenever the 30-minute background check finds a new one, so a window
343
+ * that stays open is told in the same words as one that has just loaded.
344
+ */
345
+ function applyUpdateStatus(update) {
346
+ // The badge is lit even when the notice was already shown, so a dismissed
347
+ // offer can still be picked up again from the corner.
348
+ if (!noteUpdateAvailable(update)) {
349
+ // The console reports no release any more, so the reminder has nothing
350
+ // left to stand for.
351
+ clearUpdateBadge();
352
+ return false;
353
+ }
354
+ if (updatePrompted) return false;
355
+ // The background invitation is a modal dialog too, and two of them must never
356
+ // stack: this release is offered once that one is out of the way.
357
+ if (el.backgroundDialog?.open) {
358
+ pendingUpdate = update;
359
+ return false;
360
+ }
361
+ return openUpdateDialog(update);
362
+ }
363
+
364
+ /**
365
+ * Listen for the console's announcements. EventSource reconnects by itself, so
366
+ * a dropped connection only leaves the page with the release it already knows.
367
+ */
368
+ function openUpdateEvents() {
369
+ if (updateSource) return;
370
+ const source = new EventSource(apiPath("/api/update/events"));
371
+ updateSource = source;
372
+ source.addEventListener("update", (event) => {
373
+ let update;
374
+ try {
375
+ update = JSON.parse(event.data);
376
+ } catch {
377
+ return;
378
+ }
379
+ applyUpdateStatus(update);
380
+ });
381
+ }
382
+
383
+ /** The console is locked behind the login gate: its announcements are moot. */
384
+ function closeUpdateEvents() {
385
+ if (!updateSource) return;
386
+ updateSource.close();
387
+ updateSource = null;
388
+ }
389
+
302
390
  /** Run npm through the console's own server and report what happened. */
303
391
  async function runUpdate() {
304
392
  if (!el.updateNow || el.updateNow.disabled) return;
@@ -322,6 +410,7 @@ async function runUpdate() {
322
410
  // The install is done: the only thing left is to close the notice.
323
411
  el.updateLater?.classList.add("hidden");
324
412
  el.updateNow?.classList.add("hidden");
413
+ clearUpdateBadge();
325
414
  } catch (error) {
326
415
  if (feedback) {
327
416
  feedback.textContent = tr("Update failed: {error}", { error: error.message });
@@ -356,6 +445,9 @@ const el = {
356
445
  messages: document.getElementById("messages"),
357
446
  composer: document.getElementById("composer"),
358
447
  input: document.getElementById("input"),
448
+ modePicker: document.getElementById("agent-mode-picker"),
449
+ modeLabel: document.getElementById("agent-mode-label"),
450
+ modeFeedback: document.getElementById("agent-mode-feedback"),
359
451
  send: document.getElementById("send"),
360
452
  approval: document.getElementById("approval"),
361
453
  emptyNewChat: document.getElementById("empty-new-chat"),
@@ -422,6 +514,7 @@ const el = {
422
514
  updateClose: document.getElementById("update-close"),
423
515
  updateLater: document.getElementById("update-later"),
424
516
  updateNow: document.getElementById("update-now"),
517
+ updateBadge: document.getElementById("update-badge"),
425
518
  };
426
519
 
427
520
  if (el.backgroundCancel) {
@@ -435,6 +528,14 @@ if (el.backgroundDialog) {
435
528
  event.preventDefault();
436
529
  void answerBackgroundPrompt(false);
437
530
  });
531
+ // A release announced while the invitation was up is offered right after it.
532
+ el.backgroundDialog.addEventListener("close", () => {
533
+ const waiting = pendingUpdate;
534
+ pendingUpdate = null;
535
+ // The login gate closes this dialog on its way in, and nothing may sit on
536
+ // top of it.
537
+ if (waiting && !gateShown) openUpdateDialog(waiting);
538
+ });
438
539
  }
439
540
 
440
541
  if (el.updateNow) el.updateNow.addEventListener("click", () => { void runUpdate(); });
@@ -447,6 +548,12 @@ if (el.updateDialog) {
447
548
  el.updateDialog.close();
448
549
  });
449
550
  }
551
+ // The corner disc reopens the same notice, so a dismissal is never final.
552
+ if (el.updateBadge) {
553
+ el.updateBadge.addEventListener("click", () => {
554
+ if (updateOffer) openUpdateDialog(updateOffer);
555
+ });
556
+ }
450
557
 
451
558
  const state = {
452
559
  overview: null,
@@ -489,12 +596,160 @@ function storedWorkspace() {
489
596
  return { key: pathKey(path), path };
490
597
  }
491
598
 
599
+ const AGENT_MODE_LABELS = { standard: "Standard mode", ptc: "PTC mode", fast: "Fast mode", pro: "Professional mode" };
600
+ let newAgentMode = readStorage("neutrino.agent-mode");
601
+ if (!["standard", "ptc", "fast"].includes(newAgentMode)) newAgentMode = "standard";
602
+ let agentModePending = Promise.resolve();
603
+ let agentModeChanging = false;
604
+ let workspaceNavigation = false;
605
+ let agentModeError = "";
606
+
607
+ function clearAgentModeFeedback() {
608
+ agentModeError = "";
609
+ agentModePending = Promise.resolve();
610
+ }
611
+
612
+ function currentAgentMode() {
613
+ if (PRO_MODE) return "pro";
614
+ if (state.selection?.kind === "live") return state.snapshot?.agentMode || "standard";
615
+ if (state.selection?.kind === "file") return state.fileData?.agent_mode || "standard";
616
+ return newAgentMode;
617
+ }
618
+
619
+ function renderAgentMode() {
620
+ if (!el.modePicker || !el.modeLabel) return;
621
+ const mode = currentAgentMode();
622
+ el.modeLabel.removeAttribute?.("data-i18n");
623
+ el.modeLabel.textContent = tr(AGENT_MODE_LABELS[mode] || AGENT_MODE_LABELS.standard);
624
+ const nextTurn = state.snapshot?.busy && state.snapshot.activeAgentMode !== state.snapshot.agentMode;
625
+ el.modePicker.classList.toggle("is-pending", Boolean(agentModeChanging || nextTurn));
626
+ for (const button of el.modePicker.querySelectorAll?.("[data-agent-mode]") || []) {
627
+ button.setAttribute("aria-checked", String(button.dataset.agentMode === mode));
628
+ button.disabled = agentModeChanging || workspaceNavigation;
629
+ }
630
+ if (el.modeFeedback) {
631
+ el.modeFeedback.textContent = agentModeError || (agentModeChanging ? tr("Changing mode…") : nextTurn ? tr("Applies to the next turn") : "");
632
+ el.modeFeedback.classList.toggle("error", Boolean(agentModeError));
633
+ }
634
+ }
635
+
636
+ async function chooseAgentMode(mode) {
637
+ if (!Object.hasOwn(AGENT_MODE_LABELS, mode) || agentModeChanging || workspaceNavigation || mode === currentAgentMode()) return;
638
+ const selected = state.selection;
639
+ agentModeChanging = true;
640
+ agentModeError = "";
641
+ if (el.modePicker) renderAgentMode();
642
+ el.modePicker.open = false;
643
+ try {
644
+ if (mode === "pro" || PRO_MODE) {
645
+ await switchWorkspaceMode(mode);
646
+ } else if (selected?.kind === "live") {
647
+ const response = await apiFetch(`/api/live/${encodeURIComponent(selected.key)}/agent-mode`, {
648
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }),
649
+ });
650
+ const body = await response.json();
651
+ if (!response.ok) throw new Error(apiError(body, `HTTP ${response.status}`));
652
+ if (state.selection === selected) { state.snapshot = body.snapshot; renderLive(); }
653
+ } else if (selected?.kind === "file") {
654
+ await continueSession(selected.id, preferences.selectionForFile(selected.id), mode);
655
+ } else {
656
+ newAgentMode = mode;
657
+ }
658
+ if (mode !== "pro") { newAgentMode = mode; writeStorage("neutrino.agent-mode", mode); }
659
+ } catch (error) {
660
+ if (state.selection !== selected) return;
661
+ agentModeError = tr("Could not change mode: {error}", { error: error.message });
662
+ throw error;
663
+ } finally { agentModeChanging = false; renderAgentMode(); }
664
+ }
665
+
666
+ async function switchWorkspaceMode(mode) {
667
+ const cwd = selectionCwd() || state.workspace?.path;
668
+ if (!cwd) throw new Error(tr("Select a working directory first."));
669
+ const targetPro = mode === "pro";
670
+ const response = await fetch(targetPro ? "/pro/api/conversations" : "/api/conversations", {
671
+ method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
672
+ body: JSON.stringify({ cwd, ...(targetPro ? {} : { agent_mode: mode }) }),
673
+ });
674
+ const body = await response.json();
675
+ if (response.status === 401) showGate();
676
+ if (!response.ok) throw new Error(apiError(body, `HTTP ${response.status}`));
677
+ workspaceNavigation = true;
678
+ if (el.modePicker) renderAgentMode();
679
+ try { sessionStorage.setItem(`neutrino.workspace-draft.${body.key}`, el.input.value); } catch { /* Navigation still works without storage. */ }
680
+ const target = `${targetPro ? "/pro" : "/"}?live=${encodeURIComponent(body.key)}`;
681
+ if (targetPro) startProTransition(target);
682
+ else { try { location.assign(target); } catch { location.href = target; } }
683
+ }
684
+
685
+ /** A transferred chat remains reloadable until the user chooses another view. */
686
+ function clearLinkedConversation(keepKey) {
687
+ const params = new URLSearchParams(location.search);
688
+ if (!params.has("live") || params.get("live") === keepKey) return;
689
+ params.delete("live");
690
+ const query = params.toString();
691
+ try { history.replaceState(history.state, "", location.pathname + (query ? `?${query}` : "") + location.hash); } catch { /* History is optional. */ }
692
+ }
693
+
694
+ async function openLinkedConversation() {
695
+ const key = new URLSearchParams(location.search).get("live");
696
+ if (!key || state.selection) return false;
697
+ try {
698
+ const response = await apiFetch(`/api/live/${encodeURIComponent(key)}`, { cache: "no-store" });
699
+ const body = await response.json();
700
+ if (!response.ok) throw new Error(apiError(body, `HTTP ${response.status}`));
701
+ if (state.selection) return false;
702
+ setWorkspace({ path: body.cwd });
703
+ await selectLive(key);
704
+ state.snapshot = body;
705
+ renderLive();
706
+ setConversationView(true);
707
+ try {
708
+ const storageKey = `neutrino.workspace-draft.${key}`;
709
+ el.input.value = sessionStorage.getItem(storageKey) || "";
710
+ sessionStorage.removeItem(storageKey);
711
+ } catch { /* Storage is optional. */ }
712
+ autosize(); el.input.focus();
713
+ return true;
714
+ } catch (error) { el.sub.textContent = error.message; return false; }
715
+ }
716
+
717
+ function initializeAgentMode() {
718
+ if (!el.modePicker?.querySelectorAll) return;
719
+ for (const button of el.modePicker.querySelectorAll("[data-agent-mode]")) {
720
+ button.addEventListener("click", () => {
721
+ agentModePending = chooseAgentMode(button.dataset.agentMode);
722
+ // Keep the rejection available to sendMessage while avoiding an unhandled promise.
723
+ void agentModePending.catch(() => {});
724
+ });
725
+ }
726
+ el.modePicker.addEventListener("keydown", event => {
727
+ if (event.key === "Escape") { el.modePicker.open = false; el.modePicker.querySelector("summary")?.focus(); event.stopPropagation(); }
728
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
729
+ event.preventDefault(); el.modePicker.open = true;
730
+ const buttons = [...el.modePicker.querySelectorAll("[data-agent-mode]")];
731
+ let index = buttons.indexOf(document.activeElement);
732
+ index = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length;
733
+ buttons[index]?.focus();
734
+ });
735
+ document.addEventListener("pointerdown", event => {
736
+ if (el.modePicker.open && !el.modePicker.contains(event.target)) el.modePicker.open = false;
737
+ });
738
+ if (el.modePicker) renderAgentMode();
739
+ }
740
+
492
741
  let proUI = null;
493
742
  const preferences = createSettingsUI({
494
743
  apiFetch,
495
744
  proMode: PRO_MODE,
496
745
  onCatalogChange: (catalog) => proUI?.load(catalog),
497
746
  onLanguageChange: (language) => changeInterfaceLanguage(language),
747
+ // A check started from Settings offers its result through the same dialog as
748
+ // the notice shown on load, and lights the corner badge with it.
749
+ onUpdateFound: (update) => {
750
+ noteUpdateAvailable(update);
751
+ return openUpdateDialog(update);
752
+ },
498
753
  async applyModel(selection) {
499
754
  const current = state.selection;
500
755
  if (!current) throw new Error("Select a conversation first.");
@@ -799,20 +1054,42 @@ function renderGroup(group, options = {}) {
799
1054
  wrap.className = "group";
800
1055
 
801
1056
  const collapsed = !options.forceOpen && state.collapsed.has(group.key);
1057
+ // The row answers "start something here", not "how much is already here": a
1058
+ // plus beside the folder opens a fresh conversation in it, the way Codex puts
1059
+ // a project's own new-chat control next to the project. Two actions cannot
1060
+ // share one button, so the row holds the folder and the plus side by side.
1061
+ const head = document.createElement("div");
1062
+ head.className = "group-head";
1063
+
802
1064
  const header = document.createElement("button");
803
1065
  header.className = "group-header";
1066
+ header.type = "button";
1067
+ header.setAttribute("aria-expanded", String(!collapsed));
804
1068
  header.innerHTML =
805
1069
  `<span class="chevron${collapsed ? " collapsed" : ""}">` +
806
1070
  '<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>' +
807
1071
  "</span>" +
808
- `<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(shortenPath(group.cwd))}</span>` +
809
- `<span class="count">${group.sessions.length}</span>`;
1072
+ `<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(shortenPath(group.cwd))}</span>`;
810
1073
  header.addEventListener("click", () => {
811
1074
  if (state.collapsed.has(group.key)) state.collapsed.delete(group.key);
812
1075
  else state.collapsed.add(group.key);
813
1076
  renderSidebar();
814
1077
  });
815
- wrap.appendChild(header);
1078
+ head.appendChild(header);
1079
+
1080
+ if (group.cwd) {
1081
+ const start = document.createElement("button");
1082
+ start.className = "icon-btn group-new";
1083
+ start.type = "button";
1084
+ start.title = tr("New conversation in this folder");
1085
+ start.setAttribute("aria-label", tr("New conversation in this folder"));
1086
+ start.innerHTML =
1087
+ '<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="M12 5v14M5 12h14"/></svg>';
1088
+ start.addEventListener("click", () => startConversationIn(group.cwd));
1089
+ head.appendChild(start);
1090
+ }
1091
+
1092
+ wrap.appendChild(head);
816
1093
 
817
1094
  if (collapsed) return wrap;
818
1095
 
@@ -846,6 +1123,19 @@ function renderGroup(group, options = {}) {
846
1123
  return wrap;
847
1124
  }
848
1125
 
1126
+ /**
1127
+ * The plus on a folder header. It opens that folder's landing view, where the
1128
+ * next message becomes a new conversation in that directory, so starting one
1129
+ * costs a click instead of a dialog.
1130
+ */
1131
+ function startConversationIn(cwd) {
1132
+ const path = String(cwd ?? "").trim();
1133
+ if (!path) return;
1134
+ renderWorkspaceLanding(path);
1135
+ setConversationView(true);
1136
+ el.input?.focus?.();
1137
+ }
1138
+
849
1139
  function isSelected(session) {
850
1140
  const selection = state.selection;
851
1141
  if (!selection) return false;
@@ -1133,6 +1423,7 @@ function resetMessagePane(text) {
1133
1423
  }
1134
1424
 
1135
1425
  async function selectLive(key) {
1426
+ if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(key); }
1136
1427
  preferences.clear();
1137
1428
  closeStream();
1138
1429
  state.selection = { kind: "live", key };
@@ -1209,6 +1500,7 @@ function closeStream() {
1209
1500
  }
1210
1501
 
1211
1502
  async function selectFile(id) {
1503
+ if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(); }
1212
1504
  preferences.clear();
1213
1505
  closeStream();
1214
1506
  state.selection = { kind: "file", id };
@@ -1254,6 +1546,7 @@ function renderFile(data) {
1254
1546
  renderFileControls(data);
1255
1547
  maybeOpenPlan(data.plan);
1256
1548
  renderPlanCard(data);
1549
+ if (el.modePicker) renderAgentMode();
1257
1550
  }
1258
1551
 
1259
1552
  /**
@@ -1300,8 +1593,10 @@ function renderFileControls(data) {
1300
1593
  }
1301
1594
 
1302
1595
  /** Attach to a resumed conversation, creating it server-side when needed. */
1303
- async function continueSession(id, selection = preferences.selectionForFile(id)) {
1304
- const payload = PRO_MODE ? { pro_preset: currentProPreset() } : selection ? { selection } : undefined;
1596
+ async function continueSession(id, selection = preferences.selectionForFile(id), agentMode) {
1597
+ const payload = PRO_MODE ? { pro_preset: currentProPreset() } : {
1598
+ ...(selection ? { selection } : {}), ...(agentMode ? { agent_mode: agentMode } : {}),
1599
+ };
1305
1600
  const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/continue`, {
1306
1601
  method: "POST",
1307
1602
  headers: payload ? { "Content-Type": "application/json" } : undefined,
@@ -1315,6 +1610,7 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
1315
1610
 
1316
1611
  function setComposerMode(mode) {
1317
1612
  state.composerMode = mode;
1613
+ if (el.modePicker) renderAgentMode();
1318
1614
  el.agentActions.classList.add("hidden");
1319
1615
  el.composer.classList.remove("hidden");
1320
1616
  el.input.placeholder = mode === "resume"
@@ -1516,6 +1812,7 @@ function renderLive() {
1516
1812
  });
1517
1813
  maybeOpenPlan(snapshot.plan);
1518
1814
  renderPlanCard(snapshot);
1815
+ if (el.modePicker) renderAgentMode();
1519
1816
  }
1520
1817
 
1521
1818
  async function changeApprovalMode(key, mode) {
@@ -1577,7 +1874,7 @@ function renderControls(snapshot) {
1577
1874
  // review callback is still winding down.
1578
1875
  const reviewing = snapshot.approval ? null : snapshot.reviewing;
1579
1876
  const subagent = snapshot.subagent;
1580
- badge.textContent = snapshot.paused
1877
+ badge.textContent = snapshot.task?.state === "cancelling" ? tr("Stopping tasks…") : snapshot.paused
1581
1878
  ? tr("paused")
1582
1879
  : snapshot.busy
1583
1880
  ? snapshot.compressing
@@ -1592,10 +1889,10 @@ function renderControls(snapshot) {
1592
1889
  : tr("idle");
1593
1890
  el.controls.appendChild(badge);
1594
1891
 
1595
- if (snapshot.saveError) {
1892
+ if (snapshot.saveError || snapshot.task?.persistenceError) {
1596
1893
  const warning = document.createElement("span");
1597
1894
  warning.className = "badge paused";
1598
- warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError });
1895
+ warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError || snapshot.task?.persistenceError });
1599
1896
  el.controls.appendChild(warning);
1600
1897
  }
1601
1898
 
@@ -1732,6 +2029,12 @@ function renderMessage(message, index) {
1732
2029
  `<span class="tool-args">${escapeHtml(truncate(safeJson(message.arguments), 120))}</span>`;
1733
2030
  details.appendChild(summary);
1734
2031
 
2032
+ if (message.name === "execute_ts" && typeof message.arguments?.code === "string") {
2033
+ const program = document.createElement("pre");
2034
+ program.className = "tool-result program-source";
2035
+ program.textContent = message.arguments.code;
2036
+ details.appendChild(program);
2037
+ }
1735
2038
  const result = document.createElement("pre");
1736
2039
  result.className = "tool-result";
1737
2040
  result.textContent = message.result || "(no output)";
@@ -1871,6 +2174,7 @@ function activateComposer() {
1871
2174
  }
1872
2175
 
1873
2176
  async function sendMessage() {
2177
+ try { await agentModePending; } catch (error) { el.sub.textContent = error.message; return; }
1874
2178
  const selection = state.selection;
1875
2179
  if (!selection) return;
1876
2180
  const text = el.input.value;
@@ -2009,6 +2313,7 @@ function showHome({ menu = true } = {}) {
2009
2313
 
2010
2314
  /** Show a folder as an empty workspace ready for its first message. */
2011
2315
  function renderWorkspaceLanding(path) {
2316
+ if (el.modePicker) { clearAgentModeFeedback(); clearLinkedConversation(); }
2012
2317
  const sameLanding = state.selection?.kind === "new" && state.selection.cwd === path;
2013
2318
  state.selection = { kind: "new", cwd: path };
2014
2319
  state.snapshot = null;
@@ -2284,11 +2589,14 @@ async function submitNewChat() {
2284
2589
  }
2285
2590
  }
2286
2591
 
2287
- /** Create a browser-owned conversation in `cwd` and reveal its folder. */
2592
+ /** Create a browser-owned conversation in `cwd` and show all sidebar directories. */
2288
2593
  async function createConversation(cwd, selection, approvalMode, proPreset = currentProPreset()) {
2289
2594
  const payload = { cwd };
2290
2595
  if (PRO_MODE) payload.pro_preset = proPreset;
2291
- else if (selection && selection.provider && selection.model) payload.selection = selection;
2596
+ else {
2597
+ payload.agent_mode = currentAgentMode();
2598
+ if (selection && selection.provider && selection.model) payload.selection = selection;
2599
+ }
2292
2600
  if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
2293
2601
  const response = await apiFetch("/api/conversations", {
2294
2602
  method: "POST",
@@ -2297,7 +2605,7 @@ async function createConversation(cwd, selection, approvalMode, proPreset = curr
2297
2605
  });
2298
2606
  const data = await response.json().catch(() => ({}));
2299
2607
  if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2300
- setWorkspace({ path: data.cwd || cwd });
2608
+ setWorkspace(null);
2301
2609
  await loadOverview({ silent: true });
2302
2610
  return data;
2303
2611
  }
@@ -2478,6 +2786,9 @@ function planStatusLabel(status) {
2478
2786
  if (status === "done") return tr("Done");
2479
2787
  if (status === "active") return tr("Active");
2480
2788
  if (status === "failed") return tr("Failed");
2789
+ if (status === "blocked") return tr("Blocked");
2790
+ if (status === "cancelled") return tr("Cancelled");
2791
+ if (status === "skipped") return tr("Skipped");
2481
2792
  return tr("Pending");
2482
2793
  }
2483
2794
 
@@ -2522,7 +2833,7 @@ function renderPlanCard(source) {
2522
2833
  steps.className = "plan-steps";
2523
2834
  let done = 0;
2524
2835
  for (const step of plan.steps) {
2525
- const status = ["done", "active", "failed", "pending"].includes(step.status) ? step.status : "pending";
2836
+ const status = ["done", "active", "failed", "pending", "blocked", "cancelled", "skipped"].includes(step.status) ? step.status : "pending";
2526
2837
  if (status === "done") done += 1;
2527
2838
  const row = document.createElement("div");
2528
2839
  row.className = `plan-step ${status}`;
@@ -2880,7 +3191,7 @@ function proDelay(callback, ms) {
2880
3191
  if (typeof setTimeout === "function") setTimeout(callback, ms);
2881
3192
  }
2882
3193
 
2883
- function startProTransition() {
3194
+ function startProTransition(target = "/pro") {
2884
3195
  if (PRO_MODE || proTransitionStarted) return;
2885
3196
  proTransitionStarted = true;
2886
3197
  const started = Date.now();
@@ -2896,7 +3207,7 @@ function startProTransition() {
2896
3207
  curtain.classList.add("pro-curtain-visible");
2897
3208
 
2898
3209
  proDelay(() => {
2899
- try { location.assign("/pro"); } catch { location.href = "/pro"; }
3210
+ try { location.assign(typeof target === "string" ? target : "/pro"); } catch { location.href = typeof target === "string" ? target : "/pro"; }
2900
3211
  }, PRO_TRANSITION_COVER_MS);
2901
3212
  }
2902
3213
 
@@ -2917,6 +3228,8 @@ function removeProCurtains() {
2917
3228
  */
2918
3229
  function resetProTransition({ clearRecord = true } = {}) {
2919
3230
  removeProCurtains();
3231
+ if (typeof workspaceNavigation !== "undefined") workspaceNavigation = false;
3232
+ if (typeof el !== "undefined" && el.modePicker) renderAgentMode();
2920
3233
  document.documentElement?.classList?.remove?.("pro-transitioning");
2921
3234
  proTransitionStarted = false;
2922
3235
  if (!clearRecord) return;
@@ -3023,7 +3336,7 @@ function initializeProWorkspace() {
3023
3336
  // the record and the pre-paint cover behind. Neither may ever affect the
3024
3337
  // regular console, so clear them before wiring the badge.
3025
3338
  resetProTransition();
3026
- el.brandLogo?.addEventListener("click", startProTransition);
3339
+ el.brandLogo?.addEventListener("click", () => startProTransition());
3027
3340
  initializeProTip();
3028
3341
  }
3029
3342
 
@@ -3140,18 +3453,23 @@ forgetStaleConversationEntry();
3140
3453
 
3141
3454
  initializeLanguage();
3142
3455
  initializeProWorkspace();
3456
+ initializeAgentMode();
3143
3457
  preferences.load()
3144
3458
  .then(async (config) => {
3145
3459
  const language = config?.settings?.language;
3146
3460
  if (language && language !== uiLanguage()) {
3147
3461
  await changeInterfaceLanguage(language, { persist: false });
3148
3462
  }
3149
- // Offer the update first, and only then the background invitation: two
3150
- // modal dialogs must never stack on top of each other.
3151
- if (!maybePromptUpdate(config)) maybePromptBackground(config);
3463
+ // The announcement stream needs the login cookie, so it opens once the
3464
+ // settings payload has loaded. A release it reports right away opens the
3465
+ // notice; one that lands while the background invitation is up waits for it
3466
+ // to close, because two modal dialogs must never stack on top of each other.
3467
+ openUpdateEvents();
3468
+ maybePromptBackground(config);
3152
3469
  })
3153
3470
  .catch(error => console.error(error));
3154
- loadSeen().then(() => loadOverview()).then(() => {
3471
+ loadSeen().then(() => loadOverview()).then(async () => {
3472
+ if (await openLinkedConversation()) return;
3155
3473
  // Reopen the folder the user had open last time, without stealing a click
3156
3474
  // that landed on a saved conversation while the overview was loading.
3157
3475
  if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);
package/web/i18n.js CHANGED
@@ -79,6 +79,21 @@ export function locale() {
79
79
  }
80
80
 
81
81
  const ZH = {
82
+ "Agent mode": "Agent 模式",
83
+ "Standard mode": "标准模式",
84
+ "PTC mode": "PTC模式",
85
+ "Fast mode": "快速模式",
86
+ "Professional mode": "专业模式",
87
+ "Full-featured Agent for every scenario": "功能完整的Agent,适用于所有场景",
88
+ "All standard capabilities, with code-based planning to save tokens": "拥有标准模式所有功能,通过代码实现规划,节省Token",
89
+ "Less thinking and fewer calls for quick answers": "减少思考和调用,迅速回答问题",
90
+ "Assign tasks to the best-suited models so multiple models complement each other": "适合需要多个模型取长补短的任务,将任务分给最适合他的模型",
91
+ "Applies to the next turn": "从下一轮开始生效",
92
+ "Changing mode…": "正在切换模式…",
93
+ "Could not change mode: {error}": "无法切换模式:{error}",
94
+ "Select a working directory first.": "请先选择工作目录。",
95
+ "Program": "程序",
96
+
82
97
  "Pro preset": "Pro 预设",
83
98
  "Preset": "预设",
84
99
  "Neutrino Pro": "Neutrino Pro",
@@ -115,6 +130,13 @@ const ZH = {
115
130
  "Updated to {version}. Restart Neutrino to use it.": "已更新到 {version},请重新启动 Neutrino 以使用新版本。",
116
131
  "Update failed: {error}": "更新失败:{error}",
117
132
  "Update by hand with:": "也可以手动更新:",
133
+ "Updates": "更新",
134
+ "Check for updates": "检查更新",
135
+ "Checking for updates…": "正在检查更新…",
136
+ "Version {latest} is available. This install runs {current}.": "发现新版本 {latest},当前安装为 {current}。",
137
+ "Neutrino {version} is up to date.": "Neutrino {version} 已是最新版本。",
138
+ "Update check failed: {error}": "检查更新失败:{error}",
139
+ "Neutrino checks npm for new releases every 30 minutes. Both workspaces share that check.": "Neutrino 每 30 分钟通过 npm 检查一次新版本,普通模式与 Pro 模式共用该检查。",
118
140
  // -- chrome -------------------------------------------------------------
119
141
  "Neutrino": "Neutrino",
120
142
  "Switch to Pro mode": "切换至Pro模式",
@@ -211,6 +233,7 @@ const ZH = {
211
233
  "Choose a conversation from the sidebar to view it.": "从侧边栏选择一个对话查看。",
212
234
  "No conversations yet.": "暂无对话。",
213
235
  "No conversations in this folder yet.": "此文件夹中暂无对话。",
236
+ "New conversation in this folder": "在此文件夹中新建对话",
214
237
  "Type a message below to start a conversation in this folder.": "在下方输入消息,即可在此文件夹中开始对话。",
215
238
  "Send a message to get started.": "发送一条消息开始对话。",
216
239
  "This conversation is empty.": "此对话为空。",
@@ -261,6 +284,10 @@ const ZH = {
261
284
 
262
285
  // -- live status --------------------------------------------------------
263
286
  "paused": "已暂停",
287
+ "Stopping tasks…": "正在停止任务…",
288
+ "Blocked": "受阻",
289
+ "Cancelled": "已取消",
290
+ "Skipped": "已跳过",
264
291
  "working": "处理中",
265
292
  "idle": "空闲",
266
293
  "compressing": "正在压缩上下文",