@xingjisoft/neutrino 0.4.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.
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,78 @@ 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
+
187
260
  const el = {
188
261
  app: document.getElementById("app"),
189
262
  back: document.getElementById("back-to-menu"),
@@ -195,6 +268,7 @@ const el = {
195
268
  proTip: document.getElementById("pro-tip"),
196
269
  proTipClose: document.getElementById("pro-tip-close"),
197
270
  proExit: document.getElementById("pro-exit"),
271
+ header: document.getElementById("main-header"),
198
272
  title: document.getElementById("session-title"),
199
273
  sub: document.getElementById("session-sub"),
200
274
  controls: document.getElementById("controls"),
@@ -252,8 +326,28 @@ const el = {
252
326
  agentsList: document.getElementById("agents-list"),
253
327
  agentsFeedback: document.getElementById("agents-feedback"),
254
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"),
255
336
  };
256
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
+
257
351
  const state = {
258
352
  overview: null,
259
353
  // { kind: "live", key } | { kind: "file", id } | { kind: "new", cwd } |
@@ -272,6 +366,7 @@ const state = {
272
366
  fileData: null,
273
367
  // Last loaded read-only transcript from another agent.
274
368
  agentData: null,
369
+ agentMigration: null,
275
370
  // { key, path } | null — the working directory the sidebar is scoped to.
276
371
  workspace: storedWorkspace(),
277
372
  // Plan / usage card visibility and the plan it was opened for.
@@ -746,12 +841,14 @@ function openStream(key) {
746
841
 
747
842
  source.onopen = () => setStatus("live", "live");
748
843
  source.onerror = () => {
749
- // 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");
750
846
  setStatus("reconnecting…", "offline");
751
847
  };
752
848
  }
753
849
 
754
850
  function closeStream() {
851
+ el.header?.classList.remove("is-thinking");
755
852
  if (state.eventSource) {
756
853
  state.eventSource.close();
757
854
  state.eventSource = null;
@@ -865,17 +962,37 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
865
962
 
866
963
  function setComposerMode(mode) {
867
964
  state.composerMode = mode;
965
+ el.agentActions.classList.add("hidden");
868
966
  el.composer.classList.remove("hidden");
869
967
  el.input.placeholder = mode === "resume"
870
968
  ? tr("Continue this conversation…")
871
969
  : mode === "new"
872
970
  ? tr("Message Neutrino in this folder…")
873
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));
874
990
  }
875
991
 
876
992
  function hideComposer() {
877
993
  preferences.clear();
878
994
  el.composer.classList.add("hidden");
995
+ el.agentActions.classList.add("hidden");
879
996
  renderApproval(null);
880
997
  }
881
998
 
@@ -1027,6 +1144,8 @@ function decideApproval(approved) {
1027
1144
  function renderLive() {
1028
1145
  const snapshot = state.snapshot;
1029
1146
  if (!snapshot) return;
1147
+ renderComposerAction();
1148
+ el.header?.classList.toggle("is-thinking", Boolean(snapshot.busy && !snapshot.paused && !snapshot.approval));
1030
1149
  state.fileData = null;
1031
1150
  state.agentData = null;
1032
1151
  el.title.textContent = snapshot.title || tr("New conversation");
@@ -1393,6 +1512,11 @@ async function postAction(key, action, body) {
1393
1512
  }
1394
1513
  }
1395
1514
 
1515
+ function activateComposer() {
1516
+ if (canPauseFromComposer()) return postAction(state.selection.key, "pause");
1517
+ return sendMessage();
1518
+ }
1519
+
1396
1520
  async function sendMessage() {
1397
1521
  const selection = state.selection;
1398
1522
  if (!selection) return;
@@ -1444,6 +1568,7 @@ async function sendMessage() {
1444
1568
  function autosize() {
1445
1569
  el.input.style.height = "auto";
1446
1570
  el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
1571
+ renderComposerAction();
1447
1572
  }
1448
1573
 
1449
1574
  // ------------------------------------------------------- workspaces & dialogs
@@ -2192,7 +2317,7 @@ function renderAgentList(groups) {
2192
2317
  "</span>";
2193
2318
  item.addEventListener("click", () => {
2194
2319
  el.agentsDialog.close();
2195
- void selectAgentSession(group.id, session.id, session.title || "");
2320
+ void selectAgentSession(group.id, session.id, session.title || "", group.cwd);
2196
2321
  });
2197
2322
  section.appendChild(item);
2198
2323
  }
@@ -2211,7 +2336,7 @@ async function openAgentsDialog() {
2211
2336
  const response = await apiFetch(`/api/agents/${agent.id}/sessions?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" });
2212
2337
  const data = await response.json().catch(() => ({}));
2213
2338
  if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2214
- return { ...agent, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
2339
+ return { ...agent, cwd: data.cwd || cwd, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
2215
2340
  }));
2216
2341
  el.agentsFeedback.textContent = "";
2217
2342
  renderAgentList(groups);
@@ -2238,11 +2363,53 @@ function renderAgentView(data) {
2238
2363
  renderApproval(null);
2239
2364
  renderPlanCard(null);
2240
2365
  hideComposer();
2366
+ renderAgentMigration(data);
2241
2367
  }
2242
2368
 
2243
- async function selectAgentSession(agent, id, title) {
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
+ }
2406
+ }
2407
+
2408
+ async function selectAgentSession(agent, id, title, cwd) {
2409
+ const scope = cwd || await agentsScopeCwd();
2244
2410
  closeStream();
2245
- state.selection = { kind: "agent", agent, id, title };
2411
+ const selection = { kind: "agent", agent, id, title, cwd: scope };
2412
+ state.selection = selection;
2246
2413
  state.snapshot = null;
2247
2414
  state.fileData = null;
2248
2415
  state.agentData = null;
@@ -2254,17 +2421,20 @@ async function selectAgentSession(agent, id, title) {
2254
2421
  renderApproval(null);
2255
2422
  renderPlanCard(null);
2256
2423
  hideComposer();
2424
+ el.agentMigrateFeedback.textContent = "";
2257
2425
  el.title.textContent = tr("Loading…");
2258
2426
  el.sub.textContent = title || "";
2259
2427
  resetMessagePane(tr("Loading…"));
2260
2428
  setConversationView(true);
2261
2429
  try {
2262
- 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" });
2263
2431
  const data = await response.json().catch(() => ({}));
2432
+ if (state.selection !== selection) return;
2264
2433
  if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
2265
- state.agentData = { ...data, agent, cwd: state.agentData?.cwd || state.workspace?.path || "" };
2434
+ state.agentData = { ...data, agent, cwd: data.cwd || scope };
2266
2435
  renderAgentView(state.agentData);
2267
2436
  } catch (error) {
2437
+ if (state.selection !== selection) return;
2268
2438
  el.title.textContent = title || tr("Conversation");
2269
2439
  el.sub.textContent = tr("Could not load this session: {error}", { error: error.message });
2270
2440
  resetMessagePane(tr("Could not load this session: {error}", { error: error.message }));
@@ -2505,7 +2675,7 @@ function initializeProWorkspace() {
2505
2675
 
2506
2676
  el.refresh.addEventListener("click", () => loadOverview());
2507
2677
  el.back.addEventListener("click", () => setConversationView(false));
2508
- el.send.addEventListener("click", sendMessage);
2678
+ el.send.addEventListener("click", activateComposer);
2509
2679
  el.input.addEventListener("input", autosize);
2510
2680
  el.input.addEventListener("keydown", (event) => {
2511
2681
  if (event.key === "Enter" && !event.shiftKey) {
@@ -2528,6 +2698,7 @@ el.workspaceAdd.addEventListener("click", () => {
2528
2698
  openFolderDialog({ start: state.workspace?.path || state.workspaces?.cwd || "", onPick: (path) => openWorkspace(path) });
2529
2699
  });
2530
2700
  el.agentsOpen.addEventListener("click", () => openAgentsDialog());
2701
+ el.agentMigrate.addEventListener("click", () => migrateSelectedAgentSession());
2531
2702
  el.agentsClose.addEventListener("click", () => el.agentsDialog.close());
2532
2703
  el.agentsDialog.addEventListener("cancel", (event) => {
2533
2704
  event.preventDefault();
@@ -2609,10 +2780,14 @@ window.addEventListener("beforeunload", closeStream);
2609
2780
  initializeLanguage();
2610
2781
  initializeProWorkspace();
2611
2782
  preferences.load()
2612
- .then((config) => {
2783
+ .then(async (config) => {
2613
2784
  const language = config?.settings?.language;
2614
- if (language && language !== uiLanguage()) return changeInterfaceLanguage(language, { persist: false });
2615
- 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);
2616
2791
  })
2617
2792
  .catch(error => console.error(error));
2618
2793
  loadOverview().then(() => {
package/web/i18n.js CHANGED
@@ -132,6 +132,12 @@ const ZH = {
132
132
  "Working directory": "工作目录",
133
133
  "Working directories": "工作目录",
134
134
  "Other agents": "其他 Agent",
135
+ "Migrate session": "迁移会话",
136
+ "Migrating…": "迁移中…",
137
+ "Copy this history into a new Neutrino conversation in the same folder": "将历史复制为同一工作目录下的新 Neutrino 会话",
138
+ "Could not migrate this session: {error}": "无法迁移此会话:{error}",
139
+ "session not found in this working directory": "此工作目录中未找到该会话",
140
+ "this session has no messages to migrate": "此会话没有可迁移的消息",
135
141
  "Read-only sessions recorded by other coding agents in this working directory.": "其他编码 Agent 在此工作目录中记录的只读会话。",
136
142
  "No saved sessions from other agents in this folder.": "此文件夹中暂无其他 Agent 保存的会话。",
137
143
  "Claude Code": "Claude Code",
@@ -298,6 +304,14 @@ const ZH = {
298
304
  "Extreme-risk auto-approval (del class, 3 separate review rounds)": "极高风险自动审批(删除类命令,独立审查 3 轮)",
299
305
  "Sound alerts (turn finished, approval waiting)": "声音提醒(回合结束、等待审批)",
300
306
  "Start the Web console with the terminal": "随终端启动 Web 控制台(下次启动生效)",
307
+ "Background residency": "后台常驻",
308
+ "Run the Web console in the background and keep browser tasks alive after the CLI exits": "让 Web 控制台在后台运行,CLI 退出后浏览器中的任务继续执行",
309
+ "Off by default. Changes take effect immediately, including for running browser tasks. The regular and Pro workspaces share this setting. Terminal conversations still end when the terminal closes.": "默认关闭。切换立即生效,正在运行的浏览器任务也会受到保护;普通模式与 Pro 模式共用此设置。终端内的对话仍会在终端关闭时结束。",
310
+ "Enable background residency to avoid accidentally interrupting tasks": "建议开启后台常驻,避免误触中断任务",
311
+ "Enable": "开启",
312
+ "Background residency is on. Browser tasks keep running after the terminal closes.": "后台常驻已立即开启。关闭终端后,浏览器任务会继续执行。",
313
+ "Background residency is off. Current tasks can finish before the console closes.": "后台常驻已立即关闭。控制台退出前会等待当前任务完成。",
314
+ "Background residency could not be applied. Restart the console and try again.": "后台常驻未能生效,请重启控制台后重试。",
301
315
  "Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.": "自动审批取决于安全性;任务相关性只有在两位审查者一致确认极端不匹配时才会阻止执行。删除类命令的自动审批还需要开启此设置。",
302
316
  "Web access": "Web 访问",
303
317
  "Allow access from other devices (LAN, 0.0.0.0)": "允许其他设备访问(局域网,0.0.0.0)",
package/web/index.html CHANGED
@@ -128,6 +128,11 @@
128
128
 
129
129
  <div id="approval" class="approval hidden" role="alert" aria-live="assertive"></div>
130
130
 
131
+ <footer id="agent-actions" class="agent-actions hidden">
132
+ <p id="agent-migrate-feedback" class="form-feedback error" role="status" aria-live="polite"></p>
133
+ <button id="agent-migrate" class="control primary" type="button" data-i18n="Migrate session" title="Copy this history into a new Neutrino conversation in the same folder" data-i18n-title="Copy this history into a new Neutrino conversation in the same folder">Migrate session</button>
134
+ </footer>
135
+
131
136
  <footer id="composer" class="composer hidden">
132
137
  <div class="composer-inner">
133
138
  <div class="composer-card">
@@ -157,7 +162,7 @@
157
162
  </div>
158
163
  </details>
159
164
  <button id="send" class="primary" type="button" title="Send message" aria-label="Send message" data-i18n-title="Send message" data-i18n-aria-label="Send message">
160
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
165
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path class="send-icon" d="M12 19V5M5 12l7-7 7 7" /><rect class="pause-icon" x="5" y="5" width="14" height="14" rx="2" fill="currentColor" stroke="none" /></svg>
161
166
  </button>
162
167
  </div>
163
168
  <div class="composer-hint">
@@ -222,6 +227,9 @@
222
227
  <label class="checkbox-label"><input id="settings-sound" type="checkbox" /> <span data-i18n="Sound alerts (turn finished, approval waiting)">Sound alerts (turn finished, approval waiting)</span></label>
223
228
  <label class="checkbox-label"><input id="settings-web-console" type="checkbox" /> <span data-i18n="Start the Web console with the terminal">Start the Web console with the terminal</span></label>
224
229
  <p class="settings-note" data-i18n="Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.">Safety determines automatic approval; task relevance only blocks unanimously confirmed, extreme mismatches. Delete-class automatic approval also requires this setting.</p>
230
+ <h3 data-i18n="Background residency">Background residency</h3>
231
+ <label class="checkbox-label"><input id="settings-background" type="checkbox" /> <span data-i18n="Run the Web console in the background and keep browser tasks alive after the CLI exits">Run the Web console in the background and keep browser tasks alive after the CLI exits</span></label>
232
+ <p class="settings-note" data-i18n="Off by default. Changes take effect immediately, including for running browser tasks. The regular and Pro workspaces share this setting. Terminal conversations still end when the terminal closes.">Off by default. Changes take effect immediately, including for running browser tasks. The regular and Pro workspaces share this setting. Terminal conversations still end when the terminal closes.</p>
225
233
  <h3 data-i18n="Web access">Web access</h3>
226
234
  <label class="checkbox-label"><input id="settings-web-lan" type="checkbox" /> <span data-i18n="Allow access from other devices (LAN, 0.0.0.0)">Allow access from other devices (LAN, 0.0.0.0)</span></label>
227
235
  <p class="settings-note" data-i18n="Binds the Web console to 0.0.0.0 so other devices on your network can open it, like --lan. Takes effect immediately; turning it off returns the console to 127.0.0.1. Keep the Web password on: anyone who can reach this port can drive the agent.">Binds the Web console to 0.0.0.0 so other devices on your network can open it, like --lan. Takes effect immediately; turning it off returns the console to 127.0.0.1. Keep the Web password on: anyone who can reach this port can drive the agent.</p>
@@ -239,6 +247,27 @@
239
247
  </form>
240
248
  </dialog>
241
249
 
250
+ <dialog id="background-dialog" class="sheet-dialog background-dialog" aria-labelledby="background-title">
251
+ <div class="sheet background-sheet">
252
+ <header class="sheet-header background-header">
253
+ <span class="background-mark" aria-hidden="true">
254
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
255
+ <ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />
256
+ <circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />
257
+ </svg>
258
+ </span>
259
+ <div class="background-copy">
260
+ <h2 id="background-title" data-i18n="Enable background residency to avoid accidentally interrupting tasks">Enable background residency to avoid accidentally interrupting tasks</h2>
261
+ </div>
262
+ </header>
263
+ <p id="background-feedback" class="form-feedback background-feedback" role="status" aria-live="polite"></p>
264
+ <footer class="sheet-actions background-actions">
265
+ <button id="background-cancel" class="control" type="button" data-i18n="Cancel">Cancel</button>
266
+ <button id="background-enable" class="primary" type="button" data-i18n="Enable" autofocus>Enable</button>
267
+ </footer>
268
+ </div>
269
+ </dialog>
270
+
242
271
  <dialog id="new-chat-dialog" class="sheet-dialog" aria-labelledby="new-chat-title">
243
272
  <form id="new-chat-form" class="sheet">
244
273
  <header class="sheet-header">
package/web/settings.js CHANGED
@@ -7,7 +7,7 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
7
7
  "settings-open", "settings-dialog", "settings-form", "settings-close", "settings-cancel", "settings-save", "settings-feedback",
8
8
  "default-provider", "default-model", "default-intensity", "settings-context", "settings-compress-note", "settings-reviewer",
9
9
  "settings-extreme", "settings-sound", "settings-password", "settings-password-confirm",
10
- "settings-language", "settings-plan", "settings-approval", "settings-web-console", "settings-web-lan",
10
+ "settings-language", "settings-plan", "settings-approval", "settings-web-console", "settings-web-lan", "settings-background",
11
11
  "settings-pro", "settings-pro-preset", "settings-pro-depth"];
12
12
  const el = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
13
13
  let config = null;
@@ -21,6 +21,10 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
21
21
  // saved, so editing another setting never pins an interface that came from
22
22
  // --lan/--host on the command line.
23
23
  let lanInitial = false;
24
+ // Same idea for background residency: the dialog must not overwrite a value
25
+ // changed from the terminal while it was open.
26
+ let backgroundInitial = false;
27
+ let backgroundChanging = false;
24
28
 
25
29
  async function json(path, body) {
26
30
  const response = await apiFetch(path, body === undefined ? { cache: "no-store" } : {
@@ -178,8 +182,45 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
178
182
  el["settings-sound"].checked = config.settings.sound_alerts !== false;
179
183
  el["settings-web-console"].checked = config.settings.web_console !== false;
180
184
  el["settings-web-lan"].checked = config.settings.web_lan === true;
185
+ el["settings-background"].checked = config.settings.background_running === true;
186
+ backgroundInitial = el["settings-background"].checked;
181
187
  lanInitial = el["settings-web-lan"].checked;
182
188
  }
189
+ el["settings-background"].addEventListener("change", async () => {
190
+ if (!config || backgroundChanging) return;
191
+ const enabled = el["settings-background"].checked;
192
+ backgroundChanging = true;
193
+ el["settings-background"].disabled = true;
194
+ el["settings-save"].disabled = true;
195
+ message(el["settings-feedback"], t("Applying…"));
196
+ try {
197
+ const updated = await json("/api/settings", { settings: { background: enabled } });
198
+ if (updated.settings.background_running !== enabled) {
199
+ throw new Error(t("Background residency could not be applied. Restart the console and try again."));
200
+ }
201
+ config = updated;
202
+ backgroundInitial = config.settings.background_running === true;
203
+ el["settings-background"].checked = backgroundInitial;
204
+ onCatalogChange?.(config);
205
+ message(el["settings-feedback"], t(enabled
206
+ ? "Background residency is on. Browser tasks keep running after the terminal closes."
207
+ : "Background residency is off. Current tasks can finish before the console closes."));
208
+ } catch (error) {
209
+ // A response can be lost after the service applied the change. Ask the
210
+ // service for its actual state before putting the switch back.
211
+ try {
212
+ config = await json("/api/settings");
213
+ backgroundInitial = config.settings.background_running === true;
214
+ } catch { /* Keep the last confirmed value and show the error. */ }
215
+ el["settings-background"].checked = backgroundInitial;
216
+ message(el["settings-feedback"], t(error.message), true);
217
+ } finally {
218
+ backgroundChanging = false;
219
+ el["settings-background"].disabled = false;
220
+ el["settings-save"].disabled = false;
221
+ }
222
+ });
223
+
183
224
  function renderContextWindow() {
184
225
  const provider = config.providers.find(entry => entry.name === el["default-provider"].value);
185
226
  const model = provider?.models.find(entry => entry.name === el["default-model"].value);
@@ -260,6 +301,7 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
260
301
  el["settings-save"].disabled = true;
261
302
  const wasLan = config?.settings?.web_lan === true;
262
303
  const lanChanged = el["settings-web-lan"].checked !== lanInitial;
304
+ const backgroundChanged = el["settings-background"].checked !== backgroundInitial;
263
305
  const selection = { provider: el["default-provider"].value, model: el["default-model"].value, intensity: el["default-intensity"].value };
264
306
  const settings = {
265
307
  ...(!proMode ? {
@@ -275,6 +317,7 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
275
317
  sound_alerts: el["settings-sound"].checked,
276
318
  web_console: el["settings-web-console"].checked,
277
319
  ...(lanChanged ? { web_lan: el["settings-web-lan"].checked } : {}),
320
+ ...(backgroundChanged ? { background: el["settings-background"].checked } : {}),
278
321
  language: el["settings-language"].value,
279
322
  approval_mode: el["settings-approval"].value,
280
323
  };
@@ -289,16 +332,24 @@ export function createSettingsUI({ apiFetch, applyModel, onLanguageChange, proMo
289
332
  // server bound and (when available) the address to open elsewhere.
290
333
  el["settings-web-lan"].checked = config.settings.web_lan === true;
291
334
  lanInitial = el["settings-web-lan"].checked;
335
+ el["settings-background"].checked = config.settings.background_running === true;
336
+ backgroundInitial = el["settings-background"].checked;
292
337
  const urls = Array.isArray(config.settings.web_urls) ? config.settings.web_urls : [];
293
338
  const lanNote = config.settings.web_lan === wasLan ? "" : config.settings.web_lan
294
339
  ? urls.length
295
340
  ? t("LAN access is on. Open {urls} from another device.", { urls: urls.join(" · ") })
296
341
  : t("LAN access is on, but no network address was found. Only this machine can connect.")
297
342
  : t("LAN access is off. Only this machine can connect.");
343
+ const backgroundNote = !backgroundChanged
344
+ ? ""
345
+ : config.settings.background
346
+ ? t("Background residency is on. Browser tasks keep running after the terminal closes.")
347
+ : t("Background residency is off. Current tasks can finish before the console closes.");
298
348
  const savedText = password
299
349
  ? t("Settings saved. Web password updated permanently.")
300
350
  : t("Settings saved.");
301
- message(el["settings-feedback"], lanNote ? `${savedText} ${lanNote}` : savedText);
351
+ const notes = [lanNote, backgroundNote].filter(Boolean).join(" ");
352
+ message(el["settings-feedback"], notes ? `${savedText} ${notes}` : savedText);
302
353
  } catch (error) { message(el["settings-feedback"], t(error.message), true); }
303
354
  finally { el["settings-save"].disabled = false; }
304
355
  });
package/web/style.css CHANGED
@@ -597,6 +597,16 @@ svg {
597
597
  border-bottom: 1px solid var(--border-soft);
598
598
  }
599
599
 
600
+ /* Match the CLI's 500ms thinking phases across the full title bar. */
601
+ .main-header.is-thinking {
602
+ background-color: #28393d;
603
+ animation: header-thinking 1s steps(1, end) infinite;
604
+ }
605
+
606
+ .main-header.is-thinking .main-title h1 {
607
+ color: #99d9ea;
608
+ }
609
+
600
610
  .main-title {
601
611
  flex: 1;
602
612
  min-width: 0;
@@ -1372,6 +1382,26 @@ svg {
1372
1382
 
1373
1383
  /* --------------------------------------------------------------- composer */
1374
1384
 
1385
+ .agent-actions {
1386
+ display: flex;
1387
+ flex-shrink: 0;
1388
+ align-items: center;
1389
+ justify-content: flex-end;
1390
+ gap: 12px;
1391
+ padding: 12px clamp(16px, 4vw, 40px) max(18px, env(safe-area-inset-bottom));
1392
+ }
1393
+
1394
+ .agent-actions .form-feedback {
1395
+ flex: 1;
1396
+ min-width: 0;
1397
+ margin: 0;
1398
+ overflow-wrap: anywhere;
1399
+ }
1400
+
1401
+ .agent-actions button {
1402
+ flex-shrink: 0;
1403
+ }
1404
+
1375
1405
  .composer {
1376
1406
  flex-shrink: 0;
1377
1407
  padding: 0 clamp(16px, 4vw, 40px) 18px;
@@ -1554,6 +1584,15 @@ svg {
1554
1584
  height: 18px;
1555
1585
  }
1556
1586
 
1587
+ #send .pause-icon,
1588
+ #send.is-pause .send-icon {
1589
+ display: none;
1590
+ }
1591
+
1592
+ #send.is-pause .pause-icon {
1593
+ display: block;
1594
+ }
1595
+
1557
1596
  #send:hover {
1558
1597
  background: var(--primary-hover);
1559
1598
  }
@@ -2659,6 +2698,56 @@ select option {
2659
2698
  flex: 1;
2660
2699
  }
2661
2700
 
2701
+ /* One-time background-residency invitation. The orbit badge is the regular
2702
+ console's own mark, so the offer reads as part of the same product. */
2703
+ .background-dialog {
2704
+ width: min(460px, calc(100vw - 24px));
2705
+ }
2706
+
2707
+ .background-header {
2708
+ align-items: center;
2709
+ justify-content: flex-start;
2710
+ gap: 14px;
2711
+ border-bottom: 0;
2712
+ padding-bottom: 18px;
2713
+ }
2714
+
2715
+ .background-mark {
2716
+ display: flex;
2717
+ flex: none;
2718
+ align-items: center;
2719
+ justify-content: center;
2720
+ width: 46px;
2721
+ height: 46px;
2722
+ color: var(--accent);
2723
+ background: var(--surface-1);
2724
+ border: 1px solid var(--border-soft);
2725
+ border-radius: 13px;
2726
+ }
2727
+
2728
+ .background-mark svg {
2729
+ width: 24px;
2730
+ height: 24px;
2731
+ }
2732
+
2733
+ .background-copy {
2734
+ min-width: 0;
2735
+ }
2736
+
2737
+ .background-copy h2 {
2738
+ font-size: 15px;
2739
+ line-height: 1.55;
2740
+ font-weight: 600;
2741
+ }
2742
+
2743
+ .background-feedback:empty {
2744
+ display: none;
2745
+ }
2746
+
2747
+ .background-actions {
2748
+ border-top: 1px solid var(--border-soft);
2749
+ }
2750
+
2662
2751
  /* Folder picker */
2663
2752
  .folder-sheet {
2664
2753
  max-height: calc(100vh - 32px);
@@ -3032,6 +3121,18 @@ html.pro-transitioning body::before {
3032
3121
 
3033
3122
  /* -------------------------------------------------------------- animations */
3034
3123
 
3124
+ @keyframes header-thinking {
3125
+ 0%,
3126
+ 100% {
3127
+ background-color: #28393d;
3128
+ border-bottom-color: rgba(153, 217, 234, 0.45);
3129
+ }
3130
+ 50% {
3131
+ background-color: var(--bg);
3132
+ border-bottom-color: var(--border-soft);
3133
+ }
3134
+ }
3135
+
3035
3136
  @keyframes blink {
3036
3137
  0%,
3037
3138
  49% {