@xingjisoft/neutrino 0.4.0 → 0.5.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
@@ -147,6 +147,7 @@ function showGate(message = "") {
147
147
  closeStream();
148
148
  setStatus("locked", "offline");
149
149
  document.getElementById("settings-dialog").close();
150
+ document.getElementById("background-dialog")?.close?.();
150
151
 
151
152
  // A modal dialog stays above streaming repaints and traps keyboard focus.
152
153
  const gate = document.createElement("dialog");
@@ -184,6 +185,159 @@ function showGate(message = "") {
184
185
  gate.showModal();
185
186
  }
186
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
+
260
+ // ------------------------------------------------------------- update notice
261
+ //
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.
267
+ //
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).
270
+
271
+ let updatePrompted = false;
272
+
273
+ function maybePromptUpdate(config) {
274
+ const update = config?.update;
275
+ if (updatePrompted || !update?.has_update) return false;
276
+ if (!el.updateDialog || typeof el.updateDialog.showModal !== "function") return false;
277
+ updatePrompted = true;
278
+ if (el.updateVersions) {
279
+ el.updateVersions.textContent = tr("Running {current} · published {latest}", {
280
+ current: update.current_version, latest: update.latest_version,
281
+ });
282
+ }
283
+ if (el.updateFeedback) {
284
+ el.updateFeedback.textContent = "";
285
+ el.updateFeedback.classList.remove("error");
286
+ }
287
+ el.updateCommandBlock?.classList.add("hidden");
288
+ el.updateLater?.classList.remove("hidden");
289
+ el.updateNow?.classList.remove("hidden");
290
+ for (const button of [el.updateLater, el.updateNow, el.updateClose]) {
291
+ if (button) button.disabled = false;
292
+ }
293
+ if (el.updateNow) el.updateNow.textContent = tr("Update now");
294
+ try {
295
+ if (!el.updateDialog.open) el.updateDialog.showModal();
296
+ } catch {
297
+ return false; // An embedder without modal support still gets the console.
298
+ }
299
+ return true;
300
+ }
301
+
302
+ /** Run npm through the console's own server and report what happened. */
303
+ async function runUpdate() {
304
+ if (!el.updateNow || el.updateNow.disabled) return;
305
+ const buttons = [el.updateNow, el.updateLater, el.updateClose].filter(Boolean);
306
+ for (const button of buttons) button.disabled = true;
307
+ const feedback = el.updateFeedback;
308
+ if (feedback) {
309
+ feedback.textContent = tr("Updating with npm…");
310
+ feedback.classList.remove("error");
311
+ }
312
+ let body = {};
313
+ try {
314
+ const response = await apiFetch("/api/update", { method: "POST" });
315
+ body = await response.json().catch(() => ({}));
316
+ if (!response.ok || body.ok !== true) throw new Error(body.error || `HTTP ${response.status}`);
317
+ if (feedback) {
318
+ feedback.textContent = tr("Updated to {version}. Restart Neutrino to use it.", { version: body.version || "" });
319
+ feedback.classList.remove("error");
320
+ }
321
+ el.updateCommandBlock?.classList.add("hidden");
322
+ // The install is done: the only thing left is to close the notice.
323
+ el.updateLater?.classList.add("hidden");
324
+ el.updateNow?.classList.add("hidden");
325
+ } catch (error) {
326
+ if (feedback) {
327
+ feedback.textContent = tr("Update failed: {error}", { error: error.message });
328
+ feedback.classList.add("error");
329
+ }
330
+ // npm failures are usually something the user has to fix, so the dialog
331
+ // keeps the command that finishes the job by hand.
332
+ if (el.updateCommand && typeof body?.command === "string" && body.command) {
333
+ el.updateCommand.textContent = body.command;
334
+ }
335
+ el.updateCommandBlock?.classList.remove("hidden");
336
+ } finally {
337
+ for (const button of buttons) button.disabled = false;
338
+ }
339
+ }
340
+
187
341
  const el = {
188
342
  app: document.getElementById("app"),
189
343
  back: document.getElementById("back-to-menu"),
@@ -195,6 +349,7 @@ const el = {
195
349
  proTip: document.getElementById("pro-tip"),
196
350
  proTipClose: document.getElementById("pro-tip-close"),
197
351
  proExit: document.getElementById("pro-exit"),
352
+ header: document.getElementById("main-header"),
198
353
  title: document.getElementById("session-title"),
199
354
  sub: document.getElementById("session-sub"),
200
355
  controls: document.getElementById("controls"),
@@ -252,8 +407,47 @@ const el = {
252
407
  agentsList: document.getElementById("agents-list"),
253
408
  agentsFeedback: document.getElementById("agents-feedback"),
254
409
  agentsCwd: document.getElementById("agents-cwd"),
410
+ agentActions: document.getElementById("agent-actions"),
411
+ agentMigrate: document.getElementById("agent-migrate"),
412
+ agentMigrateFeedback: document.getElementById("agent-migrate-feedback"),
413
+ backgroundDialog: document.getElementById("background-dialog"),
414
+ backgroundFeedback: document.getElementById("background-feedback"),
415
+ backgroundCancel: document.getElementById("background-cancel"),
416
+ backgroundEnable: document.getElementById("background-enable"),
417
+ updateDialog: document.getElementById("update-dialog"),
418
+ updateVersions: document.getElementById("update-versions"),
419
+ updateCommandBlock: document.getElementById("update-command-block"),
420
+ updateCommand: document.getElementById("update-command"),
421
+ updateFeedback: document.getElementById("update-feedback"),
422
+ updateClose: document.getElementById("update-close"),
423
+ updateLater: document.getElementById("update-later"),
424
+ updateNow: document.getElementById("update-now"),
255
425
  };
256
426
 
427
+ if (el.backgroundCancel) {
428
+ el.backgroundCancel.addEventListener("click", () => { void answerBackgroundPrompt(false); });
429
+ }
430
+ if (el.backgroundEnable) {
431
+ el.backgroundEnable.addEventListener("click", () => { void answerBackgroundPrompt(true); });
432
+ }
433
+ if (el.backgroundDialog) {
434
+ el.backgroundDialog.addEventListener("cancel", (event) => {
435
+ event.preventDefault();
436
+ void answerBackgroundPrompt(false);
437
+ });
438
+ }
439
+
440
+ if (el.updateNow) el.updateNow.addEventListener("click", () => { void runUpdate(); });
441
+ if (el.updateLater) el.updateLater.addEventListener("click", () => el.updateDialog?.close?.());
442
+ if (el.updateClose) el.updateClose.addEventListener("click", () => el.updateDialog?.close?.());
443
+ if (el.updateDialog) {
444
+ // Esc closes it like "Later": the notice is an offer, never a gate.
445
+ el.updateDialog.addEventListener("cancel", (event) => {
446
+ event.preventDefault();
447
+ el.updateDialog.close();
448
+ });
449
+ }
450
+
257
451
  const state = {
258
452
  overview: null,
259
453
  // { kind: "live", key } | { kind: "file", id } | { kind: "new", cwd } |
@@ -272,6 +466,7 @@ const state = {
272
466
  fileData: null,
273
467
  // Last loaded read-only transcript from another agent.
274
468
  agentData: null,
469
+ agentMigration: null,
275
470
  // { key, path } | null — the working directory the sidebar is scoped to.
276
471
  workspace: storedWorkspace(),
277
472
  // Plan / usage card visibility and the plan it was opened for.
@@ -283,6 +478,9 @@ const state = {
283
478
  workspaces: null,
284
479
  // Translation key and colour class of the sidebar status line.
285
480
  status: { key: "connecting…", kind: "" },
481
+ // { identity: 1 } of finished conversations the reader has already opened,
482
+ // restored from the server before the first sidebar paint (see loadSeen).
483
+ seen: {},
286
484
  };
287
485
 
288
486
  function storedWorkspace() {
@@ -414,6 +612,11 @@ function announce(previous, next) {
414
612
  else if (previous.busy && !next.busy && !next.approval) chime("turn");
415
613
  }
416
614
 
615
+ /** Whether a live conversation has anything to show (and so belongs in the list). */
616
+ function liveHasContent(snapshot) {
617
+ return Boolean(snapshot.messages?.length || snapshot.streamingContent);
618
+ }
619
+
417
620
  // ------------------------------------------------------------------ helpers
418
621
 
419
622
  function escapeHtml(value) {
@@ -650,6 +853,154 @@ function isSelected(session) {
650
853
  return !session.liveKey && session.id === selection.id;
651
854
  }
652
855
 
856
+ // --------------------------------------------------- the "already seen" record
857
+ //
858
+ // A blue dot means "this conversation finished and you have not looked at it
859
+ // since". Opening a row records that, so the dot is not a permanent decoration;
860
+ // new activity forgets the record again, so the next finished turn gets a mark
861
+ // of its own. The record survives a reload and a restart of the console: a
862
+ // conversation the reader has already read must not keep asking.
863
+
864
+ /**
865
+ * Identity of a row in the record. A conversation keeps its id while it becomes
866
+ * live, so the id is the stable name; a conversation with no history yet has
867
+ * only its live key.
868
+ */
869
+ function sessionIdentity(session) {
870
+ if (session.id) return `id:${session.id}`;
871
+ return session.liveKey ? `live:${session.liveKey}` : "";
872
+ }
873
+
874
+ // Key of the record an older console kept in localStorage. It is only read once
875
+ // now, to hand that history over to the server.
876
+ const SEEN_KEY = "neutrino.seen";
877
+
878
+ /**
879
+ * Turn the server's list into the lookup the renderer uses.
880
+ *
881
+ * The record lives in ~/.neutrino/web.json (src/web/clientState.ts): one file
882
+ * the console owns, so the terminal UI never carries data it does not read and
883
+ * the answer survives a restart. localStorage could not do that — it is scoped
884
+ * to the address and port the console happened to listen on.
885
+ */
886
+ function seenMap(list) {
887
+ const out = {};
888
+ if (Array.isArray(list)) {
889
+ for (const identity of list) {
890
+ if (typeof identity === "string" && identity) out[identity] = 1;
891
+ }
892
+ }
893
+ return out;
894
+ }
895
+
896
+ /** Report a change. A write that fails costs one dot, so it is never surfaced. */
897
+ async function persistSeen(identities, seen) {
898
+ if (!identities.length) return true;
899
+ try {
900
+ const response = await apiFetch("/api/seen", {
901
+ method: "POST",
902
+ headers: { "Content-Type": "application/json" },
903
+ body: JSON.stringify({ identities, seen }),
904
+ });
905
+ return response.ok;
906
+ } catch {
907
+ return false;
908
+ }
909
+ }
910
+
911
+ /** The record left in localStorage by an older console, if any. */
912
+ function legacySeen() {
913
+ try {
914
+ const stored = JSON.parse(readStorage(modeStorageKey(SEEN_KEY)) || "{}");
915
+ return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {};
916
+ } catch {
917
+ return {};
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Load the record before the first sidebar paint, so a reload never starts by
923
+ * marking every finished conversation again. The localStorage record of an
924
+ * older console is handed over once, and dropped as soon as the server has it.
925
+ */
926
+ async function loadSeen() {
927
+ let stored;
928
+ try {
929
+ const response = await apiFetch("/api/seen", { cache: "no-store" });
930
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
931
+ stored = seenMap((await response.json())?.seen);
932
+ } catch {
933
+ // Without a record every finished conversation shows its dot again, which
934
+ // is a readable page rather than a broken one.
935
+ return;
936
+ }
937
+ state.seen = stored;
938
+ const identities = Object.keys(legacySeen());
939
+ if (!identities.length) return;
940
+ const missing = identities.filter((identity) => !stored[identity]);
941
+ for (const identity of missing) state.seen[identity] = 1;
942
+ if (await persistSeen(missing, true)) writeStorage(modeStorageKey(SEEN_KEY), "");
943
+ }
944
+
945
+ function isSeen(identity) {
946
+ return Boolean(identity) && Boolean(state.seen[identity]);
947
+ }
948
+
949
+ /** The reader opened this row. */
950
+ function rememberSeen(identity) {
951
+ if (!identity || state.seen[identity]) return;
952
+ state.seen[identity] = 1;
953
+ // The dot is already gone from the page; the write only has to reach the disk.
954
+ void persistSeen([identity], true);
955
+ }
956
+
957
+ /** New activity: the turn that comes next deserves a mark of its own. */
958
+ function forgetSeen(identity) {
959
+ if (!identity || !state.seen[identity]) return;
960
+ delete state.seen[identity];
961
+ void persistSeen([identity], false);
962
+ }
963
+
964
+ /**
965
+ * Status of a live conversation: a tool call waiting for the user's decision, a
966
+ * turn in flight, or nothing left to run. An automated review answers for the
967
+ * user, so only manual approval leaves a conversation waiting on a human.
968
+ */
969
+ function statusOfLive(live) {
970
+ if (!live) return "done";
971
+ if (live.approval && (live.approvalMode ?? "request") === "request") return "approval";
972
+ return live.busy || live.paused || live.queued || live.compressing ? "running" : "done";
973
+ }
974
+
975
+ /** Status of a sidebar row; a saved transcript has finished by definition. */
976
+ function sessionStatus(session) {
977
+ if (!session.liveKey) return "done";
978
+ // The open stream is newer than the last overview poll.
979
+ const snapshot = state.snapshot;
980
+ if (snapshot && snapshot.key === session.liveKey) return statusOfLive(snapshot);
981
+ return statusOfLive((state.overview?.live ?? []).find((entry) => entry.key === session.liveKey));
982
+ }
983
+
984
+ /**
985
+ * The status mark of a row: blue dot when done, ring when running, amber when
986
+ * waiting. The blue dot asks for attention once — opening the conversation takes
987
+ * it away. Running and waiting are live states, so they are always drawn, and
988
+ * seeing one clears the record for the turn that follows.
989
+ */
990
+ function sessionStatusMark(session) {
991
+ const status = sessionStatus(session);
992
+ const identity = sessionIdentity(session);
993
+ if (status === "done") {
994
+ if (isSeen(identity)) return "";
995
+ } else {
996
+ forgetSeen(identity);
997
+ }
998
+ const label = status === "running"
999
+ ? tr("Running")
1000
+ : status === "approval" ? tr("Waiting for approval") : tr("Done");
1001
+ return `<span class="session-state ${status}" title="${escapeHtml(label)}"></span>`;
1002
+ }
1003
+
653
1004
  function renderSession(session) {
654
1005
  const item = document.createElement("button");
655
1006
  item.className = "session";
@@ -661,15 +1012,16 @@ function renderSession(session) {
661
1012
  item.title = title;
662
1013
  item.innerHTML =
663
1014
  `<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
664
- `${session.live ? `<span class="dot" title="${escapeHtml(tr("Live"))}"></span>` : ""}</div>` +
1015
+ `${sessionStatusMark(session)}</div>` +
665
1016
  `<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
666
1017
  `<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
667
1018
  `<span class="count">${escapeHtml(count === 1 ? tr("{n} message", { n: count }) : tr("{n} messages", { n: count }))}</span></div>`;
668
1019
 
669
1020
  item.addEventListener("click", () => {
1021
+ if (!session.liveKey && !session.id) return;
1022
+ rememberSeen(sessionIdentity(session));
670
1023
  if (session.liveKey) selectLive(session.liveKey);
671
- else if (session.id) selectFile(session.id);
672
- else return;
1024
+ else selectFile(session.id);
673
1025
  setConversationView(true);
674
1026
  });
675
1027
  return item;
@@ -679,13 +1031,99 @@ function renderSession(session) {
679
1031
 
680
1032
  // Only explicit navigation changes the mobile pane. Background refreshes and
681
1033
  // live-to-saved transitions must not take the user away from the menu.
682
- function setConversationView(open) {
1034
+ //
1035
+ // On a phone the two panes share one screen, so the browser's own Back button
1036
+ // has to walk the same levels as the back arrow in the header: from an open
1037
+ // conversation to the list, and Forward back into that conversation. Each
1038
+ // explicit navigation records one marked entry, and the entries beneath it are
1039
+ // the list.
1040
+ const CONVERSATION_ENTRY = "conversation";
1041
+
1042
+ function isConversationEntry(entry) {
1043
+ return Boolean(entry) && entry.neutrinoView === CONVERSATION_ENTRY;
1044
+ }
1045
+
1046
+ /** The narrow layout that swaps the panes; wider screens show both at once. */
1047
+ function stackedPanes() {
1048
+ try {
1049
+ return typeof window !== "undefined" && typeof window.matchMedia === "function"
1050
+ && window.matchMedia("(max-width: 760px)").matches;
1051
+ } catch {
1052
+ return false;
1053
+ }
1054
+ }
1055
+
1056
+ /** Record the open conversation as a level the browser can return from. */
1057
+ function pushConversationEntry() {
1058
+ try {
1059
+ // Forward restored this entry already; recording it twice would take two
1060
+ // presses to leave the conversation.
1061
+ if (isConversationEntry(history.state)) return;
1062
+ history.pushState({ neutrinoView: CONVERSATION_ENTRY }, "", location.href);
1063
+ } catch {
1064
+ // Without history the header's back arrow still closes the pane.
1065
+ }
1066
+ }
1067
+
1068
+ /** The list is the entry beneath an open conversation: step back onto it. */
1069
+ function stepBackToList() {
1070
+ try {
1071
+ if (isConversationEntry(history.state)) history.back();
1072
+ } catch {
1073
+ // Without history the header's back arrow still closes the pane.
1074
+ }
1075
+ }
1076
+
1077
+ function setConversationView(open, { record = true } = {}) {
1078
+ const wasOpen = el.app.classList.contains("conversation-open");
683
1079
  el.app.classList.toggle("conversation-open", open);
684
- if (!window.matchMedia("(max-width: 760px)").matches) return;
1080
+ const stacked = stackedPanes();
1081
+ // A click records one level; Back and Forward already moved the browser.
1082
+ if (record && stacked && open !== wasOpen) {
1083
+ if (open) pushConversationEntry();
1084
+ else stepBackToList();
1085
+ }
1086
+ if (!stacked) return;
685
1087
  if (open) el.back.focus();
686
1088
  else (el.sessions.querySelector(".session.selected") || el.refresh).focus();
687
1089
  }
688
1090
 
1091
+ /** Back and Forward: show the level the browser landed on, without recording it. */
1092
+ function followViewHistory(event) {
1093
+ setConversationView(isConversationEntry(event?.state), { record: false });
1094
+ }
1095
+
1096
+ /**
1097
+ * A reload starts on the list, but the browser is still on the entry that
1098
+ * recorded a conversation. Forget the mark, so the first Back press tells the
1099
+ * truth instead of leaving a screen that never looked like a conversation.
1100
+ */
1101
+ function forgetStaleConversationEntry() {
1102
+ try {
1103
+ if (!isConversationEntry(history.state)) return;
1104
+ history.replaceState(null, "", location.href);
1105
+ } catch {
1106
+ // No history in this embedder: there is nothing to correct.
1107
+ }
1108
+ }
1109
+
1110
+ /**
1111
+ * Rotating a phone crosses the width that swaps the panes. A conversation
1112
+ * opened while both panes were shown never recorded a level, so record it on
1113
+ * the way into the single-pane layout: otherwise Back would leave the console
1114
+ * from a screen that shows nothing but a conversation.
1115
+ */
1116
+ function watchPanes() {
1117
+ const query = typeof window !== "undefined" && typeof window.matchMedia === "function"
1118
+ ? window.matchMedia("(max-width: 760px)")
1119
+ : null;
1120
+ if (!query || typeof query.addEventListener !== "function") return;
1121
+ query.addEventListener("change", (event) => {
1122
+ if (!event?.matches) return;
1123
+ if (el.app.classList.contains("conversation-open")) pushConversationEntry();
1124
+ });
1125
+ }
1126
+
689
1127
  function resetMessagePane(text) {
690
1128
  el.messages.innerHTML = "";
691
1129
  const empty = document.createElement("div");
@@ -737,6 +1175,16 @@ function openStream(key) {
737
1175
  announce(previous, snapshot);
738
1176
  setStatus("live", "live");
739
1177
  renderLive();
1178
+ // A turn that finishes in the conversation on screen needs no blue dot:
1179
+ // recording it before the repaint keeps the mark off as the turn ends.
1180
+ if (snapshot.sessionId && statusOfLive(snapshot) === "done") rememberSeen(`id:${snapshot.sessionId}`);
1181
+ // The sidebar mirrors the open conversation, so a turn that starts, waits
1182
+ // for approval or finishes shows there instead of on the next poll.
1183
+ if (statusOfLive(previous) !== statusOfLive(snapshot)) renderSidebar();
1184
+ // A conversation stays out of the sidebar until it has content; the first
1185
+ // message makes it history, so list it now instead of on the next poll.
1186
+ const started = previous && !liveHasContent(previous) && liveHasContent(snapshot);
1187
+ if (started) loadOverview({ silent: true });
740
1188
  });
741
1189
 
742
1190
  source.addEventListener("gone", () => {
@@ -746,12 +1194,14 @@ function openStream(key) {
746
1194
 
747
1195
  source.onopen = () => setStatus("live", "live");
748
1196
  source.onerror = () => {
749
- // EventSource retries automatically; just surface the state.
1197
+ // EventSource retries automatically; wait for a fresh snapshot to flash.
1198
+ el.header?.classList.remove("is-thinking");
750
1199
  setStatus("reconnecting…", "offline");
751
1200
  };
752
1201
  }
753
1202
 
754
1203
  function closeStream() {
1204
+ el.header?.classList.remove("is-thinking");
755
1205
  if (state.eventSource) {
756
1206
  state.eventSource.close();
757
1207
  state.eventSource = null;
@@ -865,17 +1315,37 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
865
1315
 
866
1316
  function setComposerMode(mode) {
867
1317
  state.composerMode = mode;
1318
+ el.agentActions.classList.add("hidden");
868
1319
  el.composer.classList.remove("hidden");
869
1320
  el.input.placeholder = mode === "resume"
870
1321
  ? tr("Continue this conversation…")
871
1322
  : mode === "new"
872
1323
  ? tr("Message Neutrino in this folder…")
873
1324
  : tr("Message Neutrino…");
1325
+ renderComposerAction();
1326
+ }
1327
+
1328
+ function canPauseFromComposer() {
1329
+ const selection = state.selection;
1330
+ const snapshot = state.snapshot;
1331
+ return Boolean(selection?.kind === "live" && snapshot?.key === selection.key
1332
+ && snapshot.busy && !snapshot.paused && !el.input.value.trim());
1333
+ }
1334
+
1335
+ function renderComposerAction() {
1336
+ const pause = canPauseFromComposer();
1337
+ const label = pause ? "Pause" : "Send message";
1338
+ el.send.classList.toggle("is-pause", pause);
1339
+ el.send.setAttribute("data-i18n-title", label);
1340
+ el.send.setAttribute("data-i18n-aria-label", label);
1341
+ el.send.title = tr(label);
1342
+ el.send.setAttribute("aria-label", tr(label));
874
1343
  }
875
1344
 
876
1345
  function hideComposer() {
877
1346
  preferences.clear();
878
1347
  el.composer.classList.add("hidden");
1348
+ el.agentActions.classList.add("hidden");
879
1349
  renderApproval(null);
880
1350
  }
881
1351
 
@@ -1027,6 +1497,8 @@ function decideApproval(approved) {
1027
1497
  function renderLive() {
1028
1498
  const snapshot = state.snapshot;
1029
1499
  if (!snapshot) return;
1500
+ renderComposerAction();
1501
+ el.header?.classList.toggle("is-thinking", Boolean(snapshot.busy && !snapshot.paused && !snapshot.approval));
1030
1502
  state.fileData = null;
1031
1503
  state.agentData = null;
1032
1504
  el.title.textContent = snapshot.title || tr("New conversation");
@@ -1393,15 +1865,21 @@ async function postAction(key, action, body) {
1393
1865
  }
1394
1866
  }
1395
1867
 
1868
+ function activateComposer() {
1869
+ if (canPauseFromComposer()) return postAction(state.selection.key, "pause");
1870
+ return sendMessage();
1871
+ }
1872
+
1396
1873
  async function sendMessage() {
1397
1874
  const selection = state.selection;
1398
1875
  if (!selection) return;
1399
1876
  const text = el.input.value;
1400
1877
  if (!text.trim()) return;
1401
- if (PRO_MODE) {
1402
- try { await proUI?.flush(); }
1403
- catch (error) { el.sub.textContent = error.message; return; }
1404
- }
1878
+ try {
1879
+ if (PRO_MODE) await proUI?.flush();
1880
+ else await preferences.flush?.();
1881
+ } catch (error) { el.sub.textContent = tr(error.message); return; }
1882
+ if (state.selection !== selection) return;
1405
1883
 
1406
1884
  if (selection.kind === "live") {
1407
1885
  el.input.value = "";
@@ -1444,6 +1922,7 @@ async function sendMessage() {
1444
1922
  function autosize() {
1445
1923
  el.input.style.height = "auto";
1446
1924
  el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
1925
+ renderComposerAction();
1447
1926
  }
1448
1927
 
1449
1928
  // ------------------------------------------------------- workspaces & dialogs
@@ -1530,13 +2009,14 @@ function showHome({ menu = true } = {}) {
1530
2009
 
1531
2010
  /** Show a folder as an empty workspace ready for its first message. */
1532
2011
  function renderWorkspaceLanding(path) {
2012
+ const sameLanding = state.selection?.kind === "new" && state.selection.cwd === path;
1533
2013
  state.selection = { kind: "new", cwd: path };
1534
2014
  state.snapshot = null;
1535
2015
  state.fileData = null;
1536
2016
  state.planOpen = false;
1537
2017
  state.planSeen = "";
1538
2018
  closeStream();
1539
- preferences.clear();
2019
+ if (!sameLanding) preferences.clear();
1540
2020
  renderSidebar();
1541
2021
  renderWorkspaceChrome();
1542
2022
  el.controls.innerHTML = "";
@@ -1545,6 +2025,7 @@ function renderWorkspaceLanding(path) {
1545
2025
  el.title.textContent = workspaceName(path) || tr("Working directory");
1546
2026
  el.sub.textContent = path;
1547
2027
  setComposerMode("new");
2028
+ preferences.showConversation({}, `new:${path}`);
1548
2029
  proUI?.show(null, `new:${path}`);
1549
2030
  el.messages.innerHTML = "";
1550
2031
 
@@ -2192,7 +2673,7 @@ function renderAgentList(groups) {
2192
2673
  "</span>";
2193
2674
  item.addEventListener("click", () => {
2194
2675
  el.agentsDialog.close();
2195
- void selectAgentSession(group.id, session.id, session.title || "");
2676
+ void selectAgentSession(group.id, session.id, session.title || "", group.cwd);
2196
2677
  });
2197
2678
  section.appendChild(item);
2198
2679
  }
@@ -2211,7 +2692,7 @@ async function openAgentsDialog() {
2211
2692
  const response = await apiFetch(`/api/agents/${agent.id}/sessions?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" });
2212
2693
  const data = await response.json().catch(() => ({}));
2213
2694
  if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2214
- return { ...agent, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
2695
+ return { ...agent, cwd: data.cwd || cwd, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
2215
2696
  }));
2216
2697
  el.agentsFeedback.textContent = "";
2217
2698
  renderAgentList(groups);
@@ -2238,11 +2719,53 @@ function renderAgentView(data) {
2238
2719
  renderApproval(null);
2239
2720
  renderPlanCard(null);
2240
2721
  hideComposer();
2722
+ renderAgentMigration(data);
2723
+ }
2724
+
2725
+ function renderAgentMigration(data) {
2726
+ el.agentActions.classList.remove("hidden");
2727
+ el.agentMigrate.disabled = Boolean(state.agentMigration) || !data.cwd || !data.messages?.length;
2728
+ el.agentMigrate.textContent = state.agentMigration === state.selection ? tr("Migrating…") : tr("Migrate session");
2729
+ }
2730
+
2731
+ async function migrateSelectedAgentSession() {
2732
+ const selection = state.selection;
2733
+ const source = state.agentData;
2734
+ if (selection?.kind !== "agent" || !source?.cwd || !source.messages?.length || state.agentMigration) return;
2735
+ state.agentMigration = selection;
2736
+ el.agentMigrateFeedback.textContent = "";
2737
+ renderAgentMigration(source);
2738
+ try {
2739
+ const payload = { cwd: source.cwd };
2740
+ if (PRO_MODE) payload.pro_preset = currentProPreset();
2741
+ const response = await apiFetch(`/api/agents/${encodeURIComponent(selection.agent)}/sessions/${encodeURIComponent(selection.id)}/migrate`, {
2742
+ method: "POST",
2743
+ headers: { "Content-Type": "application/json" },
2744
+ body: JSON.stringify(payload),
2745
+ });
2746
+ const data = await response.json().catch(() => ({}));
2747
+ if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2748
+ if (state.selection === selection) {
2749
+ setWorkspace({ path: data.cwd });
2750
+ await selectLive(data.key);
2751
+ el.input.focus();
2752
+ }
2753
+ await loadOverview({ silent: true });
2754
+ } catch (error) {
2755
+ if (state.selection === selection) {
2756
+ el.agentMigrateFeedback.textContent = tr("Could not migrate this session: {error}", { error: error.message });
2757
+ }
2758
+ } finally {
2759
+ state.agentMigration = null;
2760
+ if (state.selection?.kind === "agent" && state.agentData) renderAgentMigration(state.agentData);
2761
+ }
2241
2762
  }
2242
2763
 
2243
- async function selectAgentSession(agent, id, title) {
2764
+ async function selectAgentSession(agent, id, title, cwd) {
2765
+ const scope = cwd || await agentsScopeCwd();
2244
2766
  closeStream();
2245
- state.selection = { kind: "agent", agent, id, title };
2767
+ const selection = { kind: "agent", agent, id, title, cwd: scope };
2768
+ state.selection = selection;
2246
2769
  state.snapshot = null;
2247
2770
  state.fileData = null;
2248
2771
  state.agentData = null;
@@ -2254,17 +2777,20 @@ async function selectAgentSession(agent, id, title) {
2254
2777
  renderApproval(null);
2255
2778
  renderPlanCard(null);
2256
2779
  hideComposer();
2780
+ el.agentMigrateFeedback.textContent = "";
2257
2781
  el.title.textContent = tr("Loading…");
2258
2782
  el.sub.textContent = title || "";
2259
2783
  resetMessagePane(tr("Loading…"));
2260
2784
  setConversationView(true);
2261
2785
  try {
2262
- const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}`, { cache: "no-store" });
2786
+ const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}?cwd=${encodeURIComponent(scope)}`, { cache: "no-store" });
2263
2787
  const data = await response.json().catch(() => ({}));
2788
+ if (state.selection !== selection) return;
2264
2789
  if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2265
- state.agentData = { ...data, agent, cwd: state.agentData?.cwd || state.workspace?.path || "" };
2790
+ state.agentData = { ...data, agent, cwd: data.cwd || scope };
2266
2791
  renderAgentView(state.agentData);
2267
2792
  } catch (error) {
2793
+ if (state.selection !== selection) return;
2268
2794
  el.title.textContent = title || tr("Conversation");
2269
2795
  el.sub.textContent = tr("Could not load this session: {error}", { error: error.message });
2270
2796
  resetMessagePane(tr("Could not load this session: {error}", { error: error.message }));
@@ -2505,7 +3031,7 @@ function initializeProWorkspace() {
2505
3031
 
2506
3032
  el.refresh.addEventListener("click", () => loadOverview());
2507
3033
  el.back.addEventListener("click", () => setConversationView(false));
2508
- el.send.addEventListener("click", sendMessage);
3034
+ el.send.addEventListener("click", activateComposer);
2509
3035
  el.input.addEventListener("input", autosize);
2510
3036
  el.input.addEventListener("keydown", (event) => {
2511
3037
  if (event.key === "Enter" && !event.shiftKey) {
@@ -2528,6 +3054,7 @@ el.workspaceAdd.addEventListener("click", () => {
2528
3054
  openFolderDialog({ start: state.workspace?.path || state.workspaces?.cwd || "", onPick: (path) => openWorkspace(path) });
2529
3055
  });
2530
3056
  el.agentsOpen.addEventListener("click", () => openAgentsDialog());
3057
+ el.agentMigrate.addEventListener("click", () => migrateSelectedAgentSession());
2531
3058
  el.agentsClose.addEventListener("click", () => el.agentsDialog.close());
2532
3059
  el.agentsDialog.addEventListener("cancel", (event) => {
2533
3060
  event.preventDefault();
@@ -2605,17 +3132,26 @@ el.folderDialog.addEventListener("cancel", (event) => {
2605
3132
  });
2606
3133
 
2607
3134
  window.addEventListener("beforeunload", closeStream);
3135
+ // On the phone layout the browser's Back and Forward buttons walk the same
3136
+ // levels as the header's back arrow: an open conversation returns to the list.
3137
+ window.addEventListener("popstate", followViewHistory);
3138
+ watchPanes();
3139
+ forgetStaleConversationEntry();
2608
3140
 
2609
3141
  initializeLanguage();
2610
3142
  initializeProWorkspace();
2611
3143
  preferences.load()
2612
- .then((config) => {
3144
+ .then(async (config) => {
2613
3145
  const language = config?.settings?.language;
2614
- if (language && language !== uiLanguage()) return changeInterfaceLanguage(language, { persist: false });
2615
- return undefined;
3146
+ if (language && language !== uiLanguage()) {
3147
+ await changeInterfaceLanguage(language, { persist: false });
3148
+ }
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);
2616
3152
  })
2617
3153
  .catch(error => console.error(error));
2618
- loadOverview().then(() => {
3154
+ loadSeen().then(() => loadOverview()).then(() => {
2619
3155
  // Reopen the folder the user had open last time, without stealing a click
2620
3156
  // that landed on a saved conversation while the overview was loading.
2621
3157
  if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);