@co0ontty/wand 1.7.0 → 1.10.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.
@@ -117,6 +117,11 @@
117
117
  sessionCreateKind: "structured",
118
118
  sessionCreateWorktree: false,
119
119
  sessionTool: "claude",
120
+ activeWorktreeMergeSessionId: null,
121
+ worktreeMergeCheckResult: null,
122
+ worktreeMergeLoading: false,
123
+ worktreeMergeSubmitting: false,
124
+ worktreeMergeError: "",
120
125
  preferredCommand: "claude",
121
126
  structuredRunner: "claude-cli-print",
122
127
  lastResize: { cols: 0, rows: 0 },
@@ -125,6 +130,13 @@
125
130
  showInstallPrompt: false,
126
131
  ws: null,
127
132
  wsConnected: false,
133
+ _updateBubbleShown: false,
134
+ notifSound: (function() {
135
+ try { var v = localStorage.getItem("wand-notif-sound"); return v === null ? true : v === "true"; } catch (e) { return true; }
136
+ })(),
137
+ notifBubble: (function() {
138
+ try { var v = localStorage.getItem("wand-notif-bubble"); return v === null ? true : v === "true"; } catch (e) { return true; }
139
+ })(),
128
140
  currentView: "terminal",
129
141
  terminalScale: (function() {
130
142
  try {
@@ -178,6 +190,8 @@
178
190
  sessionsManageMode: false,
179
191
  selectedSessionIds: {},
180
192
  selectedClaudeHistoryIds: {},
193
+ askUserSelections: {}, // { toolUseId: { 0: [optIdx...], submitted: false } }
194
+ queueEpoch: 0, // Monotonic counter for queue state freshness
181
195
  // Load last used working directory from localStorage
182
196
  workingDir: (function() {
183
197
  try {
@@ -322,6 +336,8 @@
322
336
  if (button) {
323
337
  button.classList.toggle("visible", shouldShow);
324
338
  }
339
+ var chatContainer = document.getElementById("chat-output");
340
+ if (chatContainer) chatContainer.classList.toggle("has-jump-btn", shouldShow);
325
341
  }
326
342
 
327
343
  function scrollChatToBottom(smooth) {
@@ -688,6 +704,7 @@
688
704
  state.lastRenderedMsgCount = 0;
689
705
  state.lastRenderedEmpty = null;
690
706
  state.renderPending = false;
707
+ state.askUserSelections = {};
691
708
  if (state.chatScrollElement && state.chatScrollHandler) {
692
709
  state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
693
710
  }
@@ -836,18 +853,7 @@
836
853
  refreshAll();
837
854
  requestNotificationPermission();
838
855
  if (config.updateAvailable && config.latestVersion) {
839
- showNotificationBubble({
840
- title: "\u53d1\u73b0\u65b0\u7248\u672c",
841
- body: "\u5f53\u524d " + (config.currentVersion || "-") + " \u2192 \u6700\u65b0 " + config.latestVersion,
842
- type: "info",
843
- icon: "\u2191",
844
- duration: 10000,
845
- actionLabel: "\u53bb\u66f4\u65b0",
846
- action: function() {
847
- var settingsBtn = document.getElementById("open-settings-btn") || document.querySelector("[data-action='settings']");
848
- if (settingsBtn) settingsBtn.click();
849
- }
850
- });
856
+ showUpdateBubble(config.currentVersion || "-", config.latestVersion);
851
857
  sendBrowserNotification("Wand \u53d1\u73b0\u65b0\u7248\u672c", "\u5f53\u524d " + (config.currentVersion || "-") + " \u2192 \u6700\u65b0 " + config.latestVersion, { tag: "wand-update" });
852
858
  }
853
859
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
@@ -1043,11 +1049,6 @@
1043
1049
  var composerMode = getSafeModeForTool(preferredTool, state.chatMode);
1044
1050
 
1045
1051
  return '<div class="app-container">' +
1046
- '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="Toggle sidebar">' +
1047
- '<span class="hamburger-icon">' +
1048
- '<span></span><span></span><span></span>' +
1049
- '</span>' +
1050
- '</button>' +
1051
1052
  '<div id="sessions-drawer-backdrop" class="drawer-backdrop' + drawerClass + '"></div>' +
1052
1053
  '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + '">' +
1053
1054
  '<aside id="sessions-drawer" class="sidebar' + drawerClass + '">' +
@@ -1089,10 +1090,13 @@
1089
1090
  '</div>' +
1090
1091
  '</aside>' +
1091
1092
  '<main class="main-content">' +
1092
- '<span class="current-task hidden" id="current-task"></span>' +
1093
- '<div class="view-toggle-bar' + (state.selectedId ? '' : ' hidden') + '" id="view-toggle-bar">' +
1094
- '<button id="view-terminal-btn" class="topbar-btn' + (state.currentView === "terminal" ? ' active' : '') + '" type="button" title="查看原始终端输出">终端</button>' +
1095
- '<button id="view-chat-btn" class="topbar-btn' + (state.currentView !== "terminal" ? ' active' : '') + '" type="button" title="查看聊天解析视图">聊天</button>' +
1093
+ '<div class="main-header-row">' +
1094
+ '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="切换会话侧栏" type="button">' +
1095
+ '<span class="hamburger-icon">' +
1096
+ '<span></span><span></span><span></span>' +
1097
+ '</span>' +
1098
+ '</button>' +
1099
+ '<span class="current-task hidden" id="current-task"></span>' +
1096
1100
  '</div>' +
1097
1101
  // File panel backdrop (mobile)
1098
1102
  '<div id="file-panel-backdrop" class="file-panel-backdrop' + (state.filePanelOpen ? " open" : "") + '"></div>' +
@@ -1239,7 +1243,29 @@
1239
1243
  '</section>' +
1240
1244
  '</main>' +
1241
1245
  '</div>' +
1242
- '</div>' + renderSessionModal() + renderSettingsModal();
1246
+ '</div>' + renderSessionModal() + renderWorktreeMergeModal() + renderSettingsModal();
1247
+ }
1248
+
1249
+ function renderWorktreeMergeModal() {
1250
+ return '<section id="worktree-merge-modal" class="modal-backdrop hidden">' +
1251
+ '<div class="modal worktree-merge-modal">' +
1252
+ '<div class="modal-header">' +
1253
+ '<div>' +
1254
+ '<h2 class="modal-title">合并 Worktree</h2>' +
1255
+ '<p class="modal-subtitle">检查当前任务分支并快捷合并到主分支。</p>' +
1256
+ '</div>' +
1257
+ '<button id="close-worktree-merge-button" class="btn btn-ghost btn-icon">&times;</button>' +
1258
+ '</div>' +
1259
+ '<div class="modal-body">' +
1260
+ '<div id="worktree-merge-content" class="worktree-merge-content"></div>' +
1261
+ '<p id="worktree-merge-error" class="error-message hidden"></p>' +
1262
+ '<div class="worktree-merge-actions">' +
1263
+ '<button id="worktree-merge-cancel-button" class="btn btn-secondary">取消</button>' +
1264
+ '<button id="worktree-merge-confirm-button" class="btn btn-primary">确认合并并清理</button>' +
1265
+ '</div>' +
1266
+ '</div>' +
1267
+ '</div>' +
1268
+ '</section>';
1243
1269
  }
1244
1270
 
1245
1271
  function renderSettingsModal() {
@@ -1252,10 +1278,11 @@
1252
1278
  '<div class="modal-body">' +
1253
1279
  // Tabs
1254
1280
  '<div class="settings-tabs">' +
1255
- '<button class="settings-tab active" data-tab="about">关于</button>' +
1256
- '<button class="settings-tab" data-tab="general">基本配置</button>' +
1257
- '<button class="settings-tab" data-tab="security">安全</button>' +
1258
- '<button class="settings-tab" data-tab="presets">命令预设</button>' +
1281
+ '<button class="settings-tab active" data-tab="about">\u5173\u4e8e</button>' +
1282
+ '<button class="settings-tab" data-tab="general">\u57fa\u672c\u914d\u7f6e</button>' +
1283
+ '<button class="settings-tab" data-tab="notifications">\u901a\u77e5</button>' +
1284
+ '<button class="settings-tab" data-tab="security">\u5b89\u5168</button>' +
1285
+ '<button class="settings-tab" data-tab="presets">\u547d\u4ee4\u9884\u8bbe</button>' +
1259
1286
  '</div>' +
1260
1287
 
1261
1288
  // About tab
@@ -1272,19 +1299,36 @@
1272
1299
  '<span class="settings-value" id="settings-latest-version">-</span>' +
1273
1300
  '</div>' +
1274
1301
  '<div class="settings-update-actions">' +
1275
- '<button id="check-update-button" class="btn btn-ghost btn-sm">检查更新</button>' +
1276
- '<button id="do-update-button" class="btn btn-primary btn-sm hidden">更新到最新版</button>' +
1302
+ '<button id="check-update-button" class="btn btn-ghost btn-sm">\u68c0\u67e5\u66f4\u65b0</button>' +
1303
+ '<button id="do-update-button" class="btn btn-primary btn-sm hidden">\u66f4\u65b0\u5230\u6700\u65b0\u7248</button>' +
1304
+ '<button id="do-restart-button" class="btn btn-success btn-sm hidden">\u91cd\u542f\u751f\u6548</button>' +
1277
1305
  '</div>' +
1278
1306
  '<p id="update-message" class="hint hidden"></p>' +
1279
1307
  '</div>' +
1280
- '<div class="settings-notification-section">' +
1281
- '<div class="settings-section-title">\u901a\u77e5\u72b6\u6001</div>' +
1308
+ '</div>' +
1309
+
1310
+ // Notifications tab
1311
+ '<div class="settings-panel" id="settings-tab-notifications">' +
1312
+ '<div class="settings-section-title">\u901a\u77e5\u504f\u597d</div>' +
1313
+ '<div class="field field-inline">' +
1314
+ '<input id="cfg-notif-sound" type="checkbox" class="field-checkbox" />' +
1315
+ '<label class="field-label" for="cfg-notif-sound">\u64ad\u653e\u63d0\u793a\u97f3</label>' +
1316
+ '</div>' +
1317
+ '<p class="hint" style="margin-top:0;margin-bottom:10px">\u91cd\u8981\u901a\u77e5\uff08\u7248\u672c\u66f4\u65b0\u3001\u6743\u9650\u7b49\u5f85\u7b49\uff09\u65f6\u64ad\u653e\u67d4\u548c\u7684\u63d0\u793a\u97f3</p>' +
1318
+ '<div class="field field-inline">' +
1319
+ '<input id="cfg-notif-bubble" type="checkbox" class="field-checkbox" />' +
1320
+ '<label class="field-label" for="cfg-notif-bubble">\u5e94\u7528\u5185\u901a\u77e5\u6c14\u6ce1</label>' +
1321
+ '</div>' +
1322
+ '<p class="hint" style="margin-top:0;margin-bottom:10px">\u5728\u9875\u9762\u9876\u90e8\u5f39\u51fa\u6d6e\u52a8\u901a\u77e5\u6c14\u6ce1</p>' +
1323
+ '<div class="settings-notification-section" style="margin-top:6px">' +
1324
+ '<div class="settings-section-title">\u6d4f\u89c8\u5668\u901a\u77e5</div>' +
1282
1325
  '<div class="settings-about-row">' +
1283
- '<span class="settings-label">\u6d4f\u89c8\u5668\u901a\u77e5</span>' +
1326
+ '<span class="settings-label">\u6388\u6743\u72b6\u6001</span>' +
1284
1327
  '<span class="settings-value" id="notification-permission-status">-</span>' +
1285
1328
  '</div>' +
1286
1329
  '<div class="settings-update-actions">' +
1287
1330
  '<button id="notification-request-btn" class="btn btn-ghost btn-sm hidden">\u6388\u6743\u901a\u77e5</button>' +
1331
+ '<button id="notification-reset-btn" class="btn btn-ghost btn-sm hidden">\u91cd\u65b0\u6388\u6743</button>' +
1288
1332
  '<button id="notification-test-btn" class="btn btn-ghost btn-sm">\u53d1\u9001\u6d4b\u8bd5\u901a\u77e5</button>' +
1289
1333
  '</div>' +
1290
1334
  '<p id="notification-test-message" class="hint hidden"></p>' +
@@ -2296,9 +2340,18 @@
2296
2340
  recoveryHint = '<span class="session-id" title="自动恢复的会话">自动恢复</span>';
2297
2341
  }
2298
2342
 
2343
+ var canOpenMerge = !state.sessionsManageMode && session.worktreeEnabled && session.worktree && session.worktree.branch && session.worktree.path;
2344
+ var needsCleanup = session.worktreeMergeStatus === "merged" && session.worktreeMergeInfo && session.worktreeMergeInfo.cleanupDone === false;
2345
+ var mergeDisabled = session.status === "running" || session.worktreeMergeStatus === "merging";
2346
+ var mergeTitle = needsCleanup ? "重试清理 worktree" : "合并到主分支";
2347
+ var mergeButton = canOpenMerge && session.worktreeMergeStatus !== "merged"
2348
+ ? '<button class="session-action-btn merge-btn" data-action="worktree-merge" data-session-id="' + session.id + '" type="button" aria-label="' + escapeHtml(mergeTitle) + '" title="' + escapeHtml(mergeTitle) + '"' + (mergeDisabled ? ' disabled' : '') + '><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h10"/><path d="M7 12h10"/><path d="M7 17h10"/><path d="M5 7l-2 2 2 2"/><path d="M19 15l2 2-2 2"/></svg></button>'
2349
+ : needsCleanup
2350
+ ? '<button class="session-action-btn merge-btn" data-action="worktree-cleanup" data-session-id="' + session.id + '" type="button" aria-label="重试清理 worktree" title="重试清理 worktree"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></button>'
2351
+ : "";
2299
2352
  var deleteButton = state.sessionsManageMode ? '' : '<button class="session-action-btn delete-btn" data-action="delete-session" data-session-id="' + session.id + '" type="button" aria-label="删除会话" title="删除此会话"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></button>';
2300
2353
  var modeBadge = renderSessionKindBadge(session);
2301
- var actionsHtml = '<span class="session-actions">' + resumeButton + deleteButton + '</span>';
2354
+ var actionsHtml = '<span class="session-actions">' + resumeButton + mergeButton + deleteButton + '</span>';
2302
2355
 
2303
2356
  return '<div class="session-item' + activeClass + selectedClass + '" data-session-id="' + session.id + '" role="button" tabindex="0">' +
2304
2357
  '<div class="session-item-content">' +
@@ -2322,12 +2375,35 @@
2322
2375
  '</div>';
2323
2376
  }
2324
2377
 
2378
+ function getWorktreeMergeStatusLabel(session) {
2379
+ if (!session || !session.worktreeMergeStatus) return "";
2380
+ var labels = {
2381
+ ready: "可合并",
2382
+ checking: "检查中",
2383
+ merging: "合并中",
2384
+ merged: session.worktreeMergeInfo && session.worktreeMergeInfo.cleanupDone === false ? "已合并待清理" : "已合并",
2385
+ failed: "合并失败"
2386
+ };
2387
+ return labels[session.worktreeMergeStatus] || "";
2388
+ }
2389
+
2390
+ function renderWorktreeMergeBadge(session) {
2391
+ var label = getWorktreeMergeStatusLabel(session);
2392
+ if (!label) return "";
2393
+ return '<span class="session-kind-badge worktree-merge ' + escapeHtml(session.worktreeMergeStatus || "") + '">' + escapeHtml(label) + '</span>';
2394
+ }
2395
+
2325
2396
  function renderWorktreeBadge(session) {
2326
2397
  if (!session || !session.worktreeEnabled) return "";
2327
- var title = session.worktree && session.worktree.branch
2328
- ? ' title="' + escapeHtml('Worktree: ' + session.worktree.branch) + '"'
2329
- : '';
2330
- return '<span class="session-kind-badge worktree"' + title + '>Worktree</span>';
2398
+ var titleParts = [];
2399
+ if (session.worktree && session.worktree.branch) {
2400
+ titleParts.push('Worktree: ' + session.worktree.branch);
2401
+ }
2402
+ if (session.worktree && session.worktree.path) {
2403
+ titleParts.push('Path: ' + session.worktree.path);
2404
+ }
2405
+ var title = titleParts.length > 0 ? ' title="' + escapeHtml(titleParts.join('\n')) + '"' : '';
2406
+ return '<span class="session-kind-badge worktree"' + title + '>Worktree</span>' + renderWorktreeMergeBadge(session);
2331
2407
  }
2332
2408
 
2333
2409
  function renderSessionKindBadge(session) {
@@ -2531,33 +2607,85 @@
2531
2607
  }
2532
2608
  }
2533
2609
  }
2534
- // Global handler for ask-user option buttons called via onclick
2535
- window.__askOption = function(btnEl) {
2536
- var optionLabel = btnEl.dataset.optionLabel;
2537
- if (optionLabel && state.selectedId) {
2538
- btnEl.classList.add("selected");
2539
- // Only disable options within the same question group, not globally
2540
- var questionGroup = btnEl.closest(".ask-user-question-group");
2541
- if (questionGroup) {
2542
- questionGroup.querySelectorAll(".ask-user-option").forEach(function(opt) {
2543
- opt.classList.add("selected");
2544
- opt.style.pointerEvents = "none";
2545
- });
2546
- var sentDiv = document.createElement("div");
2547
- sentDiv.className = "ask-user-answer-sent";
2548
- sentDiv.innerHTML = "\u2713 \u5df2\u53d1\u9001: " + escapeHtml(optionLabel);
2549
- questionGroup.appendChild(sentDiv);
2550
- }
2551
- fetch("/api/sessions/" + state.selectedId + "/input", {
2552
- method: "POST",
2553
- headers: { "Content-Type": "application/json" },
2554
- credentials: "same-origin",
2555
- body: JSON.stringify({ input: optionLabel + "\n", view: state.currentView })
2556
- }).catch(function(err) {
2557
- console.error("[wand] Error sending answer:", err);
2610
+ // ── AskUserQuestion handlers: select render submit ──
2611
+ window.__askSelect = function(toolUseId, qIdx, optIdx, isMulti) {
2612
+ var sel = state.askUserSelections[toolUseId];
2613
+ if (!sel) {
2614
+ sel = { submitted: false };
2615
+ state.askUserSelections[toolUseId] = sel;
2616
+ }
2617
+ if (sel.submitted) return;
2618
+ var current = sel[qIdx] || [];
2619
+ if (isMulti) {
2620
+ var pos = current.indexOf(optIdx);
2621
+ if (pos === -1) { current.push(optIdx); } else { current.splice(pos, 1); }
2622
+ } else {
2623
+ current = current[0] === optIdx ? [] : [optIdx];
2624
+ }
2625
+ sel[qIdx] = current;
2626
+ window.__askRender(toolUseId);
2627
+ };
2628
+
2629
+ window.__askRender = function(toolUseId) {
2630
+ var card = document.querySelector('[data-tool-use-id="' + toolUseId + '"]');
2631
+ if (!card) return;
2632
+ var sel = state.askUserSelections[toolUseId] || {};
2633
+ // Update option selected states
2634
+ card.querySelectorAll(".ask-user-option").forEach(function(btn) {
2635
+ var qIdx = parseInt(btn.dataset.questionIndex, 10);
2636
+ var oIdx = parseInt(btn.dataset.optionIndex, 10);
2637
+ var chosen = (sel[qIdx] || []).indexOf(oIdx) !== -1;
2638
+ btn.classList.toggle("selected", chosen);
2639
+ });
2640
+ // Update submit button: enabled only when every question has at least one selection
2641
+ var submitBtn = card.querySelector(".ask-user-submit");
2642
+ if (submitBtn) {
2643
+ var groups = card.querySelectorAll(".ask-user-question-group");
2644
+ var allAnswered = true;
2645
+ groups.forEach(function(g, i) {
2646
+ if (!sel[i] || sel[i].length === 0) allAnswered = false;
2558
2647
  });
2648
+ submitBtn.disabled = !allAnswered || !!sel.submitted;
2649
+ if (sel.submitted) {
2650
+ submitBtn.textContent = "已提交...";
2651
+ submitBtn.classList.add("ask-user-submitted");
2652
+ }
2559
2653
  }
2560
2654
  };
2655
+
2656
+ window.__askSubmit = function(toolUseId) {
2657
+ var sel = state.askUserSelections[toolUseId];
2658
+ if (!sel || sel.submitted || !state.selectedId) return;
2659
+ var card = document.querySelector('[data-tool-use-id="' + toolUseId + '"]');
2660
+ if (!card) return;
2661
+ var groups = card.querySelectorAll(".ask-user-question-group");
2662
+ var lines = [];
2663
+ var allAnswered = true;
2664
+ groups.forEach(function(group, qIdx) {
2665
+ var selected = sel[qIdx] || [];
2666
+ if (selected.length === 0) { allAnswered = false; return; }
2667
+ var labels = [];
2668
+ selected.forEach(function(optIdx) {
2669
+ var btn = group.querySelector('[data-option-index="' + optIdx + '"]');
2670
+ if (btn) labels.push(btn.dataset.optionLabel);
2671
+ });
2672
+ lines.push(labels.join(", "));
2673
+ });
2674
+ if (!allAnswered) return;
2675
+ sel.submitted = true;
2676
+ window.__askRender(toolUseId);
2677
+ var answerText = lines.join("\n");
2678
+ fetch("/api/sessions/" + state.selectedId + "/input", {
2679
+ method: "POST",
2680
+ headers: { "Content-Type": "application/json" },
2681
+ credentials: "same-origin",
2682
+ body: JSON.stringify({ input: answerText + "\n", view: state.currentView })
2683
+ }).catch(function(err) {
2684
+ console.error("[wand] Error sending answer:", err);
2685
+ sel.submitted = false;
2686
+ window.__askRender(toolUseId);
2687
+ });
2688
+ };
2561
2689
  function attachEventListeners() {
2562
2690
 
2563
2691
  var loginButton = document.getElementById("login-button");
@@ -2738,13 +2866,36 @@
2738
2866
  if (checkUpdateBtn) checkUpdateBtn.addEventListener("click", checkForUpdate);
2739
2867
  var doUpdateBtn = document.getElementById("do-update-button");
2740
2868
  if (doUpdateBtn) doUpdateBtn.addEventListener("click", performUpdate);
2741
- // Notification test section
2869
+ var doRestartBtn = document.getElementById("do-restart-button");
2870
+ if (doRestartBtn) doRestartBtn.addEventListener("click", performSettingsRestart);
2871
+ // Notification preferences
2872
+ var notifSoundEl = document.getElementById("cfg-notif-sound");
2873
+ if (notifSoundEl) {
2874
+ notifSoundEl.checked = state.notifSound;
2875
+ notifSoundEl.addEventListener("change", function() {
2876
+ state.notifSound = notifSoundEl.checked;
2877
+ try { localStorage.setItem("wand-notif-sound", String(state.notifSound)); } catch (e) {}
2878
+ // Preview sound when toggling on
2879
+ if (state.notifSound) _doPlaySound();
2880
+ });
2881
+ }
2882
+ var notifBubbleEl = document.getElementById("cfg-notif-bubble");
2883
+ if (notifBubbleEl) {
2884
+ notifBubbleEl.checked = state.notifBubble;
2885
+ notifBubbleEl.addEventListener("change", function() {
2886
+ state.notifBubble = notifBubbleEl.checked;
2887
+ try { localStorage.setItem("wand-notif-bubble", String(state.notifBubble)); } catch (e) {}
2888
+ });
2889
+ }
2890
+ // Browser notification section
2742
2891
  var notifRequestBtn = document.getElementById("notification-request-btn");
2743
2892
  if (notifRequestBtn) notifRequestBtn.addEventListener("click", function() {
2744
2893
  if (typeof Notification !== "undefined") {
2745
2894
  Notification.requestPermission().then(function() { updateNotificationStatus(); });
2746
2895
  }
2747
2896
  });
2897
+ var notifResetBtn = document.getElementById("notification-reset-btn");
2898
+ if (notifResetBtn) notifResetBtn.addEventListener("click", resetNotificationPermission);
2748
2899
  var notifTestBtn = document.getElementById("notification-test-btn");
2749
2900
  if (notifTestBtn) notifTestBtn.addEventListener("click", testNotification);
2750
2901
  updateNotificationStatus();
@@ -2754,6 +2905,12 @@
2754
2905
  if (drawerNewSessBtn) drawerNewSessBtn.addEventListener("click", openSessionModal);
2755
2906
  var closeModalBtn = document.getElementById("close-modal-button");
2756
2907
  if (closeModalBtn) closeModalBtn.addEventListener("click", closeSessionModal);
2908
+ var closeWorktreeMergeBtn = document.getElementById("close-worktree-merge-button");
2909
+ if (closeWorktreeMergeBtn) closeWorktreeMergeBtn.addEventListener("click", closeWorktreeMergeModal);
2910
+ var worktreeMergeCancelBtn = document.getElementById("worktree-merge-cancel-button");
2911
+ if (worktreeMergeCancelBtn) worktreeMergeCancelBtn.addEventListener("click", closeWorktreeMergeModal);
2912
+ var worktreeMergeConfirmBtn = document.getElementById("worktree-merge-confirm-button");
2913
+ if (worktreeMergeConfirmBtn) worktreeMergeConfirmBtn.addEventListener("click", confirmWorktreeMerge);
2757
2914
  var runBtn = document.getElementById("run-button");
2758
2915
  if (runBtn) runBtn.addEventListener("click", runCommand);
2759
2916
  var approvePermissionBtn = document.getElementById("approve-permission-btn");
@@ -2778,6 +2935,7 @@
2778
2935
  var sessionModal = document.getElementById("session-modal");
2779
2936
  if (sessionModal) sessionModal.addEventListener("click", function(e) {
2780
2937
  if (e.target.id === "session-modal") closeSessionModal();
2938
+ if (e.target.id === "worktree-merge-modal") closeWorktreeMergeModal();
2781
2939
  });
2782
2940
 
2783
2941
  var inputBox = document.getElementById("input-box");
@@ -2800,11 +2958,6 @@
2800
2958
  inputBox.addEventListener("blur", handleInputBoxBlur);
2801
2959
  }
2802
2960
 
2803
- // View toggle handlers
2804
- var viewTermBtn = document.getElementById("view-terminal-btn");
2805
- if (viewTermBtn) viewTermBtn.addEventListener("click", function() { setView("terminal"); });
2806
- var viewChatBtn = document.getElementById("view-chat-btn");
2807
- if (viewChatBtn) viewChatBtn.addEventListener("click", function() { setView("chat"); });
2808
2961
  // Terminal interactive toggle (both topbar and terminal-header)
2809
2962
  var terminalInteractiveToggles = ["terminal-interactive-toggle-top"];
2810
2963
  terminalInteractiveToggles.forEach(function(id) {
@@ -3371,6 +3524,10 @@
3371
3524
  handleResumeAction(actionButton);
3372
3525
  } else if (actionButton.dataset.action === "resume-history" && actionButton.dataset.claudeSessionId) {
3373
3526
  handleResumeHistoryAction(actionButton);
3527
+ } else if (actionButton.dataset.action === "worktree-merge" && actionButton.dataset.sessionId) {
3528
+ openWorktreeMergeModal(actionButton.dataset.sessionId);
3529
+ } else if (actionButton.dataset.action === "worktree-cleanup" && actionButton.dataset.sessionId) {
3530
+ retryWorktreeCleanup(actionButton.dataset.sessionId);
3374
3531
  }
3375
3532
  return;
3376
3533
  }
@@ -3489,6 +3646,8 @@
3489
3646
  if (button) {
3490
3647
  button.classList.toggle("visible", shouldShow);
3491
3648
  }
3649
+ var termContainer = document.getElementById("output");
3650
+ if (termContainer) termContainer.classList.toggle("has-jump-btn", shouldShow);
3492
3651
  }
3493
3652
 
3494
3653
  function isTerminalNearBottom() {
@@ -4219,9 +4378,6 @@
4219
4378
 
4220
4379
  function applyCurrentView() {
4221
4380
  var hasSession = !!state.selectedId;
4222
- var terminalBtn = document.getElementById("view-terminal-btn");
4223
- var chatBtn = document.getElementById("view-chat-btn");
4224
- var toggleBar = document.getElementById("view-toggle-bar");
4225
4381
  var terminalContainer = document.getElementById("output");
4226
4382
  var chatContainer = document.getElementById("chat-output");
4227
4383
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
@@ -4234,17 +4390,6 @@
4234
4390
  state.currentView = "terminal";
4235
4391
  }
4236
4392
 
4237
- if (toggleBar) {
4238
- toggleBar.classList.toggle("hidden", !hasSession);
4239
- }
4240
- if (terminalBtn) {
4241
- terminalBtn.classList.toggle("hidden", structured || !hasSession);
4242
- terminalBtn.classList.toggle("active", showTerminal);
4243
- }
4244
- if (chatBtn) {
4245
- chatBtn.classList.toggle("hidden", !hasSession);
4246
- chatBtn.classList.toggle("active", showChat);
4247
- }
4248
4393
  if (terminalContainer) {
4249
4394
  terminalContainer.classList.toggle("active", showTerminal);
4250
4395
  terminalContainer.classList.toggle("hidden", !showTerminal);
@@ -4795,6 +4940,162 @@
4795
4940
  document.addEventListener("keydown", focusTrapHandler);
4796
4941
  }
4797
4942
 
4943
+ function getActiveWorktreeMergeSession() {
4944
+ if (!state.activeWorktreeMergeSessionId) return null;
4945
+ return state.sessions.find(function(session) { return session.id === state.activeWorktreeMergeSessionId; }) || null;
4946
+ }
4947
+
4948
+ function renderWorktreeMergeContent() {
4949
+ var container = document.getElementById("worktree-merge-content");
4950
+ var confirmBtn = document.getElementById("worktree-merge-confirm-button");
4951
+ var errorEl = document.getElementById("worktree-merge-error");
4952
+ var session = getActiveWorktreeMergeSession();
4953
+ var result = state.worktreeMergeCheckResult;
4954
+ if (!container || !confirmBtn) return;
4955
+ if (!session || !session.worktree) {
4956
+ container.innerHTML = '<p class="field-hint">未找到可合并的 worktree 会话。</p>';
4957
+ confirmBtn.disabled = true;
4958
+ return;
4959
+ }
4960
+ if (errorEl) {
4961
+ if (state.worktreeMergeError) {
4962
+ showError(errorEl, state.worktreeMergeError);
4963
+ } else {
4964
+ hideError(errorEl);
4965
+ }
4966
+ }
4967
+ var rows = [
4968
+ '<div class="worktree-merge-row"><span>来源分支</span><strong>' + escapeHtml(session.worktree.branch || "-") + '</strong></div>',
4969
+ '<div class="worktree-merge-row"><span>工作目录</span><strong>' + escapeHtml(session.worktree.path || "-") + '</strong></div>'
4970
+ ];
4971
+ if (result) {
4972
+ rows.push('<div class="worktree-merge-row"><span>目标分支</span><strong>' + escapeHtml(result.targetBranch || "-") + '</strong></div>');
4973
+ rows.push('<div class="worktree-merge-row"><span>待合并提交</span><strong>' + escapeHtml(String(result.aheadCount || 0)) + '</strong></div>');
4974
+ rows.push('<div class="worktree-merge-row"><span>未提交改动</span><strong>' + escapeHtml(result.hasUncommittedChanges ? "有" : "无") + '</strong></div>');
4975
+ rows.push('<div class="worktree-merge-row"><span>冲突风险</span><strong>' + escapeHtml(result.hasConflicts ? "有" : "无") + '</strong></div>');
4976
+ if (result.reason) {
4977
+ rows.push('<p class="field-hint">' + escapeHtml(result.reason) + '</p>');
4978
+ }
4979
+ } else if (state.worktreeMergeLoading) {
4980
+ rows.push('<p class="field-hint">正在检查 worktree 合并状态…</p>');
4981
+ }
4982
+ container.innerHTML = rows.join("");
4983
+ confirmBtn.disabled = state.worktreeMergeLoading || state.worktreeMergeSubmitting || !result || result.ok !== true;
4984
+ confirmBtn.textContent = state.worktreeMergeSubmitting ? "合并中..." : "确认合并并清理";
4985
+ }
4986
+
4987
+ function openWorktreeMergeModal(sessionId) {
4988
+ state.activeWorktreeMergeSessionId = sessionId;
4989
+ state.worktreeMergeCheckResult = null;
4990
+ state.worktreeMergeLoading = true;
4991
+ state.worktreeMergeSubmitting = false;
4992
+ state.worktreeMergeError = "";
4993
+ closeSessionModal();
4994
+ closeSettingsModal();
4995
+ var modal = document.getElementById("worktree-merge-modal");
4996
+ if (modal) {
4997
+ modal.classList.remove("hidden");
4998
+ lastFocusedElement = document.activeElement;
4999
+ setupFocusTrap(modal);
5000
+ }
5001
+ renderWorktreeMergeContent();
5002
+ fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/worktree/merge/check", {
5003
+ method: "POST",
5004
+ credentials: "same-origin"
5005
+ })
5006
+ .then(function(res) { return res.json(); })
5007
+ .then(function(data) {
5008
+ if (data && data.error) {
5009
+ throw new Error(data.error);
5010
+ }
5011
+ if (data && data.session) {
5012
+ updateSessionSnapshot(data.session);
5013
+ }
5014
+ state.worktreeMergeCheckResult = data.result || null;
5015
+ state.worktreeMergeError = "";
5016
+ })
5017
+ .catch(function(error) {
5018
+ state.worktreeMergeError = (error && error.message) || "无法检查 worktree 合并状态。";
5019
+ })
5020
+ .finally(function() {
5021
+ state.worktreeMergeLoading = false;
5022
+ renderWorktreeMergeContent();
5023
+ });
5024
+ }
5025
+
5026
+ function closeWorktreeMergeModal() {
5027
+ var modal = document.getElementById("worktree-merge-modal");
5028
+ state.activeWorktreeMergeSessionId = null;
5029
+ state.worktreeMergeCheckResult = null;
5030
+ state.worktreeMergeLoading = false;
5031
+ state.worktreeMergeSubmitting = false;
5032
+ state.worktreeMergeError = "";
5033
+ if (modal) {
5034
+ modal.classList.add("hidden");
5035
+ }
5036
+ if (focusTrapHandler) {
5037
+ document.removeEventListener("keydown", focusTrapHandler);
5038
+ focusTrapHandler = null;
5039
+ }
5040
+ if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
5041
+ lastFocusedElement.focus();
5042
+ }
5043
+ }
5044
+
5045
+ function confirmWorktreeMerge() {
5046
+ if (!state.activeWorktreeMergeSessionId || state.worktreeMergeSubmitting) return;
5047
+ state.worktreeMergeSubmitting = true;
5048
+ state.worktreeMergeError = "";
5049
+ renderWorktreeMergeContent();
5050
+ fetch("/api/sessions/" + encodeURIComponent(state.activeWorktreeMergeSessionId) + "/worktree/merge", {
5051
+ method: "POST",
5052
+ credentials: "same-origin",
5053
+ headers: { "Content-Type": "application/json" },
5054
+ body: JSON.stringify({})
5055
+ })
5056
+ .then(function(res) { return res.json(); })
5057
+ .then(function(data) {
5058
+ if (data && data.error) {
5059
+ throw new Error(data.error);
5060
+ }
5061
+ if (data && data.session) {
5062
+ updateSessionSnapshot(data.session);
5063
+ }
5064
+ showToast("已合并到 " + escapeHtml((data.result && data.result.targetBranch) || "主分支") + ((data.result && data.result.cleanupDone === false) ? ",但工作树待清理。" : "。"), "info");
5065
+ closeWorktreeMergeModal();
5066
+ return refreshAll();
5067
+ })
5068
+ .catch(function(error) {
5069
+ state.worktreeMergeError = (error && error.message) || "无法合并 worktree。";
5070
+ renderWorktreeMergeContent();
5071
+ })
5072
+ .finally(function() {
5073
+ state.worktreeMergeSubmitting = false;
5074
+ renderWorktreeMergeContent();
5075
+ });
5076
+ }
5077
+
5078
+ function retryWorktreeCleanup(sessionId) {
5079
+ fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/worktree/cleanup", {
5080
+ method: "POST",
5081
+ credentials: "same-origin"
5082
+ })
5083
+ .then(function(res) { return res.json(); })
5084
+ .then(function(data) {
5085
+ if (data && data.error) {
5086
+ throw new Error(data.error);
5087
+ }
5088
+ if (data && data.session) {
5089
+ updateSessionSnapshot(data.session);
5090
+ }
5091
+ showToast("已完成 worktree 清理。", "info");
5092
+ return refreshAll();
5093
+ })
5094
+ .catch(function(error) {
5095
+ showToast((error && error.message) || "无法清理 worktree。", "error");
5096
+ });
5097
+ }
5098
+
4798
5099
  function openSettingsModal() {
4799
5100
  // Close session modal first if open (mutual exclusion)
4800
5101
  closeSessionModal();
@@ -4812,6 +5113,12 @@
4812
5113
  switchSettingsTab("about");
4813
5114
  // Load settings data
4814
5115
  loadSettingsData();
5116
+ // Sync notification preferences
5117
+ var soundEl = document.getElementById("cfg-notif-sound");
5118
+ var bubbleEl = document.getElementById("cfg-notif-bubble");
5119
+ if (soundEl) soundEl.checked = state.notifSound;
5120
+ if (bubbleEl) bubbleEl.checked = state.notifBubble;
5121
+ updateNotificationStatus();
4815
5122
  }
4816
5123
  }
4817
5124
 
@@ -4919,6 +5226,16 @@
4919
5226
  repoEl.innerHTML = '<a href="' + escapeHtml(data.repoUrl) + '" target="_blank" rel="noopener">' + escapeHtml(data.repoUrl) + '</a>';
4920
5227
  }
4921
5228
 
5229
+ // Prefill update info if available
5230
+ var latestEl = document.getElementById("settings-latest-version");
5231
+ var updateBtn = document.getElementById("do-update-button");
5232
+ if (data.latestVersion && latestEl) {
5233
+ latestEl.textContent = data.latestVersion;
5234
+ if (data.updateAvailable && updateBtn) {
5235
+ updateBtn.classList.remove("hidden");
5236
+ }
5237
+ }
5238
+
4922
5239
  // Config fields
4923
5240
  var cfg = data.config || {};
4924
5241
  var hostEl = document.getElementById("cfg-host");
@@ -5111,7 +5428,7 @@
5111
5428
  .then(function(res) { return res.json(); })
5112
5429
  .then(function(data) {
5113
5430
  if (msgEl) {
5114
- msgEl.textContent = data.message || data.error || "更新完成。";
5431
+ msgEl.textContent = data.message || data.error || "\u66f4\u65b0\u5b8c\u6210\u3002";
5115
5432
  msgEl.style.color = data.error ? "var(--error)" : "var(--success)";
5116
5433
  msgEl.classList.remove("hidden");
5117
5434
  }
@@ -5119,11 +5436,14 @@
5119
5436
  updateBtn.disabled = false;
5120
5437
  } else {
5121
5438
  updateBtn.classList.add("hidden");
5439
+ // Show restart button
5440
+ var restartBtn = document.getElementById("do-restart-button");
5441
+ if (restartBtn) restartBtn.classList.remove("hidden");
5122
5442
  }
5123
5443
  })
5124
5444
  .catch(function() {
5125
5445
  if (msgEl) {
5126
- msgEl.textContent = "更新失败。";
5446
+ msgEl.textContent = "\u66f4\u65b0\u5931\u8d25\u3002";
5127
5447
  msgEl.style.color = "var(--error)";
5128
5448
  msgEl.classList.remove("hidden");
5129
5449
  }
@@ -5131,11 +5451,18 @@
5131
5451
  });
5132
5452
  }
5133
5453
 
5454
+ function performSettingsRestart() {
5455
+ var restartBtn = document.getElementById("do-restart-button");
5456
+ var msgEl = document.getElementById("update-message");
5457
+ performRestart(restartBtn, msgEl);
5458
+ }
5459
+
5134
5460
  // ── Notification Settings Helpers ──
5135
5461
 
5136
5462
  function updateNotificationStatus() {
5137
5463
  var statusEl = document.getElementById("notification-permission-status");
5138
5464
  var requestBtn = document.getElementById("notification-request-btn");
5465
+ var resetBtn = document.getElementById("notification-reset-btn");
5139
5466
  var testMsgEl = document.getElementById("notification-test-message");
5140
5467
  if (!statusEl) return;
5141
5468
 
@@ -5143,6 +5470,7 @@
5143
5470
  statusEl.textContent = "\u4e0d\u652f\u6301";
5144
5471
  statusEl.style.color = "var(--fg-muted)";
5145
5472
  if (requestBtn) requestBtn.classList.add("hidden");
5473
+ if (resetBtn) resetBtn.classList.add("hidden");
5146
5474
  return;
5147
5475
  }
5148
5476
 
@@ -5151,45 +5479,86 @@
5151
5479
  statusEl.textContent = "\u5df2\u6388\u6743 \u2713";
5152
5480
  statusEl.style.color = "var(--success)";
5153
5481
  if (requestBtn) requestBtn.classList.add("hidden");
5482
+ if (resetBtn) resetBtn.classList.add("hidden");
5154
5483
  } else if (perm === "denied") {
5155
5484
  statusEl.textContent = "\u5df2\u62d2\u7edd";
5156
5485
  statusEl.style.color = "var(--danger)";
5157
5486
  if (requestBtn) requestBtn.classList.add("hidden");
5158
- if (testMsgEl) {
5159
- testMsgEl.textContent = "\u6d4f\u89c8\u5668\u5df2\u62d2\u7edd\u901a\u77e5\u6743\u9650\uff0c\u8bf7\u5728\u6d4f\u89c8\u5668\u8bbe\u7f6e\u4e2d\u624b\u52a8\u5f00\u542f";
5160
- testMsgEl.style.color = "var(--fg-muted)";
5161
- testMsgEl.classList.remove("hidden");
5162
- }
5487
+ if (resetBtn) resetBtn.classList.remove("hidden");
5163
5488
  } else {
5164
5489
  statusEl.textContent = "\u672a\u6388\u6743";
5165
5490
  statusEl.style.color = "var(--warning)";
5166
5491
  if (requestBtn) requestBtn.classList.remove("hidden");
5492
+ if (resetBtn) resetBtn.classList.remove("hidden");
5167
5493
  }
5168
5494
  }
5169
5495
 
5496
+ function resetNotificationPermission() {
5497
+ var testMsgEl = document.getElementById("notification-test-message");
5498
+ if (typeof Notification === "undefined") return;
5499
+
5500
+ // Always call requestPermission — this triggers the browser's native
5501
+ // permission dialog when allowed. In "default" state it always works.
5502
+ // In "denied" state, some browsers (newer Chrome) re-prompt, others don't.
5503
+ Notification.requestPermission().then(function(result) {
5504
+ updateNotificationStatus();
5505
+ if (result === "granted") {
5506
+ if (testMsgEl) {
5507
+ testMsgEl.textContent = "\u2713 \u5df2\u6388\u6743";
5508
+ testMsgEl.style.color = "var(--success)";
5509
+ testMsgEl.classList.remove("hidden");
5510
+ }
5511
+ } else if (result === "denied") {
5512
+ // Browser blocked re-prompting — show inline guide with site-settings shortcut
5513
+ if (testMsgEl) {
5514
+ var origin = location.origin;
5515
+ testMsgEl.innerHTML =
5516
+ "\u6d4f\u89c8\u5668\u5df2\u62e6\u622a\u6388\u6743\u5f39\u7a97\uff0c\u8bf7\u624b\u52a8\u91cd\u7f6e\uff1a<br>" +
5517
+ '<span style="display:inline-flex;align-items:center;gap:4px;margin:4px 0">' +
5518
+ "\u2460 \u70b9\u51fb\u5730\u5740\u680f\u5de6\u4fa7\u7684 " +
5519
+ '<span style="display:inline-flex;align-items:center;justify-content:center;' +
5520
+ "width:16px;height:16px;border-radius:50%;border:1px solid var(--border);" +
5521
+ 'font-size:11px;vertical-align:middle">i</span>' +
5522
+ " \u6216\u9501\u56fe\u6807" +
5523
+ "</span><br>" +
5524
+ "\u2461 \u627e\u5230\u300c\u901a\u77e5\u300d\u2192 \u6539\u4e3a\u300c\u5141\u8bb8\u300d<br>" +
5525
+ "\u2462 \u5237\u65b0\u9875\u9762\u5373\u53ef";
5526
+ testMsgEl.style.color = "var(--fg-muted)";
5527
+ testMsgEl.classList.remove("hidden");
5528
+ }
5529
+ }
5530
+ });
5531
+ }
5532
+
5170
5533
  function testNotification() {
5171
5534
  var testMsgEl = document.getElementById("notification-test-message");
5535
+ var results = [];
5536
+
5537
+ // 1. Test sound playback
5538
+ var soundOk = tryPlayNotificationSound();
5539
+ results.push(soundOk ? "\u2713 \u63d0\u793a\u97f3" : "\u2717 \u63d0\u793a\u97f3\uff08\u65e0\u6cd5\u64ad\u653e\uff09");
5172
5540
 
5173
- // Always show in-app bubble
5541
+ // 2. Test in-app bubble
5542
+ var bubbleEnabled = state.notifBubble;
5174
5543
  showNotificationBubble({
5175
5544
  title: "\u6d4b\u8bd5\u901a\u77e5",
5176
- body: "\u8fd9\u662f\u4e00\u6761\u6d4b\u8bd5\u901a\u77e5\uff0c\u5e94\u7528\u5185\u6c14\u6ce1\u5df2\u6b63\u5e38\u5de5\u4f5c\u3002",
5545
+ body: "\u8fd9\u662f\u4e00\u6761\u6d4b\u8bd5\u901a\u77e5\u3002",
5177
5546
  type: "info",
5178
5547
  icon: "\u266a",
5179
5548
  duration: 5000,
5549
+ playSound: false, // sound already played above
5180
5550
  });
5551
+ results.push(bubbleEnabled ? "\u2713 \u5e94\u7528\u5185\u6c14\u6ce1" : "\u2013 \u5e94\u7528\u5185\u6c14\u6ce1\uff08\u5df2\u5173\u95ed\uff09");
5181
5552
 
5182
- // Test browser notification
5553
+ // 3. Test browser notification
5183
5554
  if (typeof Notification === "undefined") {
5184
- if (testMsgEl) {
5185
- testMsgEl.textContent = "\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u901a\u77e5 API\uff0c\u4ec5\u53ef\u4f7f\u7528\u5e94\u7528\u5185\u6c14\u6ce1\u901a\u77e5\u3002";
5186
- testMsgEl.style.color = "var(--fg-muted)";
5187
- testMsgEl.classList.remove("hidden");
5188
- }
5555
+ results.push("\u2013 \u6d4f\u89c8\u5668\u901a\u77e5\uff08\u4e0d\u652f\u6301\uff09");
5556
+ showTestResults(testMsgEl, results);
5189
5557
  return;
5190
5558
  }
5191
5559
 
5192
- if (Notification.permission === "granted") {
5560
+ var perm = Notification.permission;
5561
+ if (perm === "granted") {
5193
5562
  try {
5194
5563
  var n = new Notification("Wand \u6d4b\u8bd5\u901a\u77e5", {
5195
5564
  body: "\u6d4f\u89c8\u5668\u901a\u77e5\u5df2\u6b63\u5e38\u5de5\u4f5c\u3002",
@@ -5197,35 +5566,37 @@
5197
5566
  tag: "wand-test",
5198
5567
  });
5199
5568
  setTimeout(function() { n.close(); }, 5000);
5200
- if (testMsgEl) {
5201
- testMsgEl.textContent = "\u2713 \u6d4f\u89c8\u5668\u901a\u77e5 + \u5e94\u7528\u5185\u6c14\u6ce1\u5747\u5df2\u53d1\u9001";
5202
- testMsgEl.style.color = "var(--success)";
5203
- testMsgEl.classList.remove("hidden");
5204
- }
5569
+ results.push("\u2713 \u6d4f\u89c8\u5668\u901a\u77e5");
5205
5570
  } catch (_e) {
5206
- if (testMsgEl) {
5207
- testMsgEl.textContent = "\u6d4f\u89c8\u5668\u901a\u77e5\u53d1\u9001\u5931\u8d25\uff0c\u53ef\u80fd\u9700\u8981 HTTPS";
5208
- testMsgEl.style.color = "var(--warning)";
5209
- testMsgEl.classList.remove("hidden");
5210
- }
5571
+ results.push("\u2717 \u6d4f\u89c8\u5668\u901a\u77e5\uff08\u53d1\u9001\u5931\u8d25\uff0c\u53ef\u80fd\u9700\u8981 HTTPS\uff09");
5211
5572
  }
5573
+ showTestResults(testMsgEl, results);
5574
+ } else if (perm === "denied") {
5575
+ results.push("\u2717 \u6d4f\u89c8\u5668\u901a\u77e5\uff08\u5df2\u62d2\u7edd\uff09");
5576
+ showTestResults(testMsgEl, results);
5212
5577
  } else {
5213
- // permission is "default" or "denied" always try requesting
5578
+ // "default" — try requesting
5214
5579
  Notification.requestPermission().then(function(result) {
5215
5580
  updateNotificationStatus();
5216
5581
  if (result === "granted") {
5217
- testNotification();
5218
- } else if (result === "denied") {
5219
- if (testMsgEl) {
5220
- testMsgEl.textContent = "\u6d4f\u89c8\u5668\u5df2\u62d2\u7edd\u901a\u77e5\u6743\u9650\uff0c\u8bf7\u70b9\u51fb\u5730\u5740\u680f\u5de6\u4fa7\u9501\u56fe\u6807\u6216\u5728\u6d4f\u89c8\u5668\u8bbe\u7f6e\u4e2d\u624b\u52a8\u5f00\u542f";
5221
- testMsgEl.style.color = "var(--fg-muted)";
5222
- testMsgEl.classList.remove("hidden");
5223
- }
5582
+ results.push("\u2713 \u6d4f\u89c8\u5668\u901a\u77e5\uff08\u5df2\u6388\u6743\uff09");
5583
+ } else {
5584
+ results.push("\u2717 \u6d4f\u89c8\u5668\u901a\u77e5\uff08\u672a\u6388\u6743\uff09");
5224
5585
  }
5586
+ showTestResults(testMsgEl, results);
5225
5587
  });
5226
5588
  }
5227
5589
  }
5228
5590
 
5591
+ function showTestResults(el, results) {
5592
+ if (!el) return;
5593
+ el.innerHTML = results.map(function(r) { return escapeHtml(r); }).join("<br>");
5594
+ // color based on whether all passed
5595
+ var allOk = results.every(function(r) { return r.indexOf("\u2713") === 0 || r.indexOf("\u2013") === 0; });
5596
+ el.style.color = allOk ? "var(--success)" : "var(--warning)";
5597
+ el.classList.remove("hidden");
5598
+ }
5599
+
5229
5600
  function quickStartSession() {
5230
5601
  var command = getPreferredTool();
5231
5602
  var defaultCwd = getEffectiveCwd();
@@ -6225,13 +6596,17 @@
6225
6596
  var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
6226
6597
  userMsgs.push(userTurn);
6227
6598
  var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
6599
+ // Write optimistic user turn into session.messages so WS updates
6600
+ // that arrive before the HTTP response don't erase it.
6228
6601
  updateSessionSnapshot({
6229
6602
  id: session.id,
6230
6603
  status: "running",
6604
+ messages: userMsgs,
6231
6605
  structuredState: optimisticStructuredState,
6232
6606
  });
6233
6607
  state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
6234
6608
  status: "running",
6609
+ messages: userMsgs,
6235
6610
  structuredState: optimisticStructuredState,
6236
6611
  }), userMsgs);
6237
6612
  updateInputHint("思考中…");
@@ -6244,6 +6619,11 @@
6244
6619
  }
6245
6620
  setDraftValue("");
6246
6621
 
6622
+ // Capture queue epoch before the POST so we can detect whether
6623
+ // a newer WS update has already refreshed the queue by the time
6624
+ // the HTTP response arrives.
6625
+ var epochBeforePost = state.queueEpoch;
6626
+
6247
6627
  return fetch("/api/structured-sessions/" + state.selectedId + "/messages", {
6248
6628
  method: "POST",
6249
6629
  headers: { "Content-Type": "application/json" },
@@ -6256,13 +6636,21 @@
6256
6636
  throw new Error(snapshot.error);
6257
6637
  }
6258
6638
  if (snapshot && snapshot.id) {
6639
+ // If a WS update has already bumped the queue epoch, the HTTP
6640
+ // response's queuedMessages is stale — drop it to avoid
6641
+ // re-introducing already-dequeued items.
6642
+ if (state.queueEpoch > epochBeforePost && snapshot.queuedMessages) {
6643
+ delete snapshot.queuedMessages;
6644
+ }
6259
6645
  updateSessionSnapshot(snapshot);
6260
6646
  var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
6261
6647
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
6262
6648
  renderChat(true);
6263
6649
  if (isQueueing) {
6264
6650
  var queuedCount = getStructuredQueuedInputs(refreshedSession).length;
6265
- showToast("已排队(第 " + queuedCount + " 条),将在当前消息处理完成后自动发送。", "info");
6651
+ if (queuedCount > 0) {
6652
+ showToast("已排队(第 " + queuedCount + " 条),将在当前消息处理完成后自动发送。", "info");
6653
+ }
6266
6654
  } else {
6267
6655
  updateInputHint("Enter 发送 · Shift+Enter 换行");
6268
6656
  }
@@ -6314,7 +6702,25 @@
6314
6702
  }
6315
6703
  var queued = getStructuredQueuedInputs(session);
6316
6704
  if (queued && queued.length > 0) {
6705
+ // Collect recent user message texts to deduplicate against queued items.
6706
+ // A queued message that already appears as a real user turn should not
6707
+ // be rendered a second time with the "排队中" badge.
6708
+ var existingUserTexts = {};
6709
+ for (var ei = base.length - 1; ei >= 0 && Object.keys(existingUserTexts).length < queued.length + 5; ei--) {
6710
+ var em = base[ei];
6711
+ if (em && em.role === "user" && Array.isArray(em.content)) {
6712
+ for (var ej = 0; ej < em.content.length; ej++) {
6713
+ if (em.content[ej] && em.content[ej].type === "text" && em.content[ej].text) {
6714
+ existingUserTexts[em.content[ej].text] = (existingUserTexts[em.content[ej].text] || 0) + 1;
6715
+ }
6716
+ }
6717
+ }
6718
+ }
6317
6719
  for (var qi = 0; qi < queued.length; qi++) {
6720
+ if (existingUserTexts[queued[qi]]) {
6721
+ existingUserTexts[queued[qi]]--;
6722
+ continue; // Skip — this queued text is already shown as a real message
6723
+ }
6318
6724
  base.push({ role: "user", content: [{ type: "text", text: queued[qi], __queued: true }] });
6319
6725
  }
6320
6726
  }
@@ -7429,24 +7835,7 @@
7429
7835
 
7430
7836
  function handleInputBoxBlur() {
7431
7837
  resetInputPanelViewportSpacing();
7432
- // Restore app container height when keyboard closes.
7433
- // Use a short delay because on iOS the visualViewport may not
7434
- // have updated yet at the moment blur fires.
7435
7838
  setTimeout(function() {
7436
- var appContainer = document.querySelector('.app-container');
7437
- if (appContainer) {
7438
- // Only clear if keyboard is actually closed now
7439
- var vv = window.visualViewport;
7440
- if (vv) {
7441
- var offsetBottom = window.innerHeight - vv.height - vv.offsetTop;
7442
- if (offsetBottom <= 50) {
7443
- appContainer.style.height = '';
7444
- }
7445
- } else {
7446
- appContainer.style.height = '';
7447
- }
7448
- }
7449
- // Scroll the window back to top to fix any residual offset
7450
7839
  window.scrollTo(0, 0);
7451
7840
  }, 100);
7452
7841
  }
@@ -8169,20 +8558,6 @@
8169
8558
  var isKeyboardOpen = offsetBottom > 50;
8170
8559
  var heightChanged = Math.abs(vv.height - lastHeight) > 8;
8171
8560
 
8172
- // Dynamically resize the app container to match visible viewport.
8173
- // This is needed because 100dvh does NOT shrink when the keyboard
8174
- // appears in PWA standalone mode, and on some browsers the layout
8175
- // viewport doesn't update on keyboard dismiss without this.
8176
- var appContainer = document.querySelector('.app-container');
8177
- if (appContainer) {
8178
- if (isKeyboardOpen) {
8179
- appContainer.style.height = vv.height + 'px';
8180
- } else if (keyboardOpen) {
8181
- // Keyboard just closed — clear forced height
8182
- appContainer.style.height = '';
8183
- }
8184
- }
8185
-
8186
8561
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
8187
8562
  syncInputBoxScroll(inputBox);
8188
8563
  }
@@ -8507,8 +8882,9 @@
8507
8882
  if (msg.data.messages) {
8508
8883
  snapshot.messages = msg.data.messages;
8509
8884
  }
8510
- if (msg.data.queuedMessages) {
8511
- snapshot.queuedMessages = msg.data.queuedMessages;
8885
+ if (Object.prototype.hasOwnProperty.call(msg.data, 'queuedMessages')) {
8886
+ snapshot.queuedMessages = msg.data.queuedMessages || [];
8887
+ state.queueEpoch++;
8512
8888
  }
8513
8889
  if (msg.data.structuredState) {
8514
8890
  snapshot.structuredState = msg.data.structuredState;
@@ -8696,6 +9072,10 @@
8696
9072
  });
8697
9073
  }
8698
9074
  }
9075
+ if (Object.prototype.hasOwnProperty.call(msg.data, 'queuedMessages')) {
9076
+ statusUpdate.queuedMessages = msg.data.queuedMessages || [];
9077
+ state.queueEpoch++;
9078
+ }
8699
9079
  if (Object.prototype.hasOwnProperty.call(msg.data, 'permissionBlocked')) {
8700
9080
  statusUpdate.permissionBlocked = !!msg.data.permissionBlocked;
8701
9081
  }
@@ -8749,12 +9129,13 @@
8749
9129
  }
8750
9130
  // Re-render chat when structured session inFlight state changes
8751
9131
  if (statusUpdate.structuredState) {
8752
- scheduleChatRender();
8753
- // Flush queued structured messages when inFlight clears
9132
+ // Flush queued structured messages synchronously before render
9133
+ // so the chat view uses up-to-date queue state.
8754
9134
  if (!statusUpdate.structuredState.inFlight) {
8755
9135
  updateInputHint("Enter 发送 · Shift+Enter 换行");
8756
- setTimeout(flushStructuredInputQueue, 50);
9136
+ flushStructuredInputQueue();
8757
9137
  }
9138
+ scheduleChatRender();
8758
9139
  }
8759
9140
  }
8760
9141
  }
@@ -8762,23 +9143,14 @@
8762
9143
  case 'notification':
8763
9144
  if (msg.data) {
8764
9145
  if (msg.data.kind === "update") {
8765
- showNotificationBubble({
8766
- title: "\u53d1\u73b0\u65b0\u7248\u672c",
8767
- body: "\u5f53\u524d " + (msg.data.current || "-") + " \u2192 \u6700\u65b0 " + (msg.data.latest || "-"),
8768
- type: "info",
8769
- icon: "\u2191",
8770
- duration: 0,
8771
- actionLabel: "\u53bb\u66f4\u65b0",
8772
- action: function() {
8773
- var settingsBtn = document.getElementById("open-settings-btn") || document.querySelector("[data-action='settings']");
8774
- if (settingsBtn) settingsBtn.click();
8775
- }
8776
- });
9146
+ showUpdateBubble(msg.data.current || "-", msg.data.latest || "-");
8777
9147
  sendBrowserNotification(
8778
9148
  "Wand \u53d1\u73b0\u65b0\u7248\u672c",
8779
9149
  "\u5f53\u524d " + (msg.data.current || "-") + " \u2192 \u6700\u65b0 " + (msg.data.latest || "-"),
8780
9150
  { tag: "wand-update" }
8781
9151
  );
9152
+ } else if (msg.data.kind === "restart") {
9153
+ showRestartOverlay();
8782
9154
  }
8783
9155
  }
8784
9156
  break;
@@ -9455,7 +9827,7 @@
9455
9827
  updateChatJumpToBottomButton();
9456
9828
  return;
9457
9829
  }
9458
- var chatMsgs = container && container.classList && container.classList.contains("chat-messages")
9830
+ var chatMsgs = (container && container.classList && container.classList.contains("chat-messages"))
9459
9831
  ? container
9460
9832
  : getChatScrollElement();
9461
9833
  if (!chatMsgs || !chatMsgs.isConnected) return;
@@ -11068,33 +11440,116 @@
11068
11440
  return renderDiffTool(block, toolResult, toolName);
11069
11441
  }
11070
11442
 
11071
- // ── AskUserQuestion tool — special card
11443
+ // ── AskUserQuestion tool — special card with batch submit
11072
11444
  if (toolName === "AskUserQuestion" && block.input && block.input.questions) {
11073
11445
  var questions = block.input.questions;
11074
11446
  if (questions && questions.length > 0) {
11447
+ var isAnswered = !!toolResult;
11448
+ var sel = state.askUserSelections[toolId] || {};
11449
+ var isSubmitted = !!sel.submitted;
11450
+ var answerText = isAnswered ? extractToolResultText(toolResult.content) : "";
11451
+ var answerLines = answerText ? answerText.trim().split("\n") : [];
11452
+
11453
+ // Build header summary
11454
+ var headerLabel = "";
11455
+ for (var hi = 0; hi < questions.length; hi++) {
11456
+ if (questions[hi].header) { headerLabel = questions[hi].header; break; }
11457
+ }
11458
+ var headerSummary = headerLabel ? '<span class="tool-use-summary">' + escapeHtml(headerLabel) + '</span>' : "";
11459
+
11075
11460
  var questionsHtml = "";
11076
11461
  questions.forEach(function(question, qIdx) {
11462
+ var isMulti = !!question.multiSelect;
11077
11463
  var questionText = question.question ? '<div class="ask-user-title">' + escapeHtml(question.question) + '</div>' : "";
11078
11464
  var optionsHtml = "";
11079
11465
  if (question.options && question.options.length > 0) {
11080
- optionsHtml = '<div class="ask-user-options">';
11466
+ optionsHtml = '<div class="ask-user-options" data-multi-select="' + isMulti + '">';
11081
11467
  question.options.forEach(function(opt, idx) {
11082
11468
  var label = opt.label ? escapeHtml(opt.label) : "选项 " + (idx + 1);
11083
- optionsHtml += '<button class="ask-user-option" data-option-index="' + idx + '" data-question-index="' + qIdx + '" data-option-label="' + escapeHtml(label) + '" onclick="__askOption(this)">' +
11084
- '<div class="ask-user-option-label">' + label + '</div>' +
11085
- '</button>';
11469
+ var descHtml = opt.description ? '<div class="ask-user-option-desc">' + escapeHtml(opt.description) + '</div>' : "";
11470
+
11471
+ if (isAnswered) {
11472
+ // Read-only: check if this option was the chosen answer
11473
+ var answerLine = answerLines[qIdx] || answerLines[0] || "";
11474
+ var chosenLabels = answerLine.split(",").map(function(s) { return s.trim(); });
11475
+ var isChosen = chosenLabels.indexOf(opt.label || "") !== -1;
11476
+ optionsHtml += '<div class="ask-user-option ask-user-option-readonly' + (isChosen ? ' ask-user-option-chosen' : '') + '">' +
11477
+ '<span class="ask-user-indicator"></span>' +
11478
+ '<div class="ask-user-option-content">' +
11479
+ '<div class="ask-user-option-label">' + label + '</div>' +
11480
+ descHtml +
11481
+ '</div>' +
11482
+ '</div>';
11483
+ } else {
11484
+ // Interactive: selection state from askUserSelections
11485
+ var isSelected = (sel[qIdx] || []).indexOf(idx) !== -1;
11486
+ var disabledAttr = isSubmitted ? ' disabled' : '';
11487
+ optionsHtml += '<button class="ask-user-option' + (isSelected ? ' selected' : '') + '"' +
11488
+ ' data-option-index="' + idx + '"' +
11489
+ ' data-question-index="' + qIdx + '"' +
11490
+ ' data-option-label="' + escapeHtml(opt.label || "选项 " + (idx + 1)) + '"' +
11491
+ ' onclick="__askSelect(\'' + escapeHtml(toolId) + '\',' + qIdx + ',' + idx + ',' + isMulti + ')"' +
11492
+ disabledAttr + '>' +
11493
+ '<span class="ask-user-indicator"></span>' +
11494
+ '<div class="ask-user-option-content">' +
11495
+ '<div class="ask-user-option-label">' + label + '</div>' +
11496
+ descHtml +
11497
+ '</div>' +
11498
+ '</button>';
11499
+ }
11086
11500
  });
11087
11501
  optionsHtml += '</div>';
11088
11502
  }
11089
- questionsHtml += '<div class="ask-user-question-group">' + questionText + optionsHtml + '</div>';
11503
+ questionsHtml += '<div class="ask-user-question-group" data-question-index="' + qIdx + '">' + questionText + optionsHtml + '</div>';
11090
11504
  });
11091
- return '<div class="tool-use-card ask-user" data-tool-use-id="' + escapeHtml(toolId) + '">' +
11505
+
11506
+ // Submit button (only for interactive state)
11507
+ var actionsHtml = "";
11508
+ if (!isAnswered) {
11509
+ var allAnsweredCheck = true;
11510
+ for (var qi = 0; qi < questions.length; qi++) {
11511
+ if (!sel[qi] || sel[qi].length === 0) { allAnsweredCheck = false; break; }
11512
+ }
11513
+ var submitDisabled = (!allAnsweredCheck || isSubmitted) ? " disabled" : "";
11514
+ var submitClass = isSubmitted ? " ask-user-submitted" : "";
11515
+ var submitText = isSubmitted ? "已提交..." : "确认提交";
11516
+ actionsHtml = '<div class="ask-user-actions">' +
11517
+ '<button class="ask-user-submit' + submitClass + '" data-tool-use-id="' + escapeHtml(toolId) + '"' +
11518
+ ' onclick="__askSubmit(\'' + escapeHtml(toolId) + '\')"' + submitDisabled + '>' +
11519
+ submitText +
11520
+ '</button>' +
11521
+ '</div>';
11522
+ }
11523
+
11524
+ // Answered summary for header
11525
+ var answeredSummary = "";
11526
+ if (isAnswered && answerText) {
11527
+ var shortAnswer = answerText.trim().replace(/\n/g, ", ");
11528
+ if (shortAnswer.length > 40) shortAnswer = shortAnswer.slice(0, 37) + "...";
11529
+ answeredSummary = '<span class="tool-use-file">' + escapeHtml(shortAnswer) + '</span>';
11530
+ }
11531
+
11532
+ // Expand state: default expanded when unanswered, collapsed when answered
11533
+ var askExpandKey = buildExpandKey("tool-card", [messageKey, toolId]);
11534
+ var askPersisted = getPersistedExpandState(askExpandKey);
11535
+ var askShouldExpand = askPersisted === null ? !isAnswered : askPersisted;
11536
+ var askCollapsed = askShouldExpand ? "" : " collapsed";
11537
+ var answeredClass = isAnswered ? " ask-user-answered" : "";
11538
+
11539
+ return '<div class="tool-use-card ask-user' + answeredClass + askCollapsed + '"' +
11540
+ ' data-tool-use-id="' + escapeHtml(toolId) + '"' +
11541
+ ' data-expand-kind="tool-card"' +
11542
+ ' data-expand-key="' + escapeHtml(askExpandKey) + '">' +
11092
11543
  '<div class="tool-use-header" data-tool-toggle onclick="__tcToggle(event,this)">' +
11093
- '<span class="tool-use-icon">?</span>' +
11544
+ '<span class="tool-use-icon">' + (isAnswered ? '✓' : '?') + '</span>' +
11094
11545
  '<span class="tool-use-name">提问</span>' +
11546
+ headerSummary +
11547
+ answeredSummary +
11548
+ '<span class="tool-use-toggle">▼</span>' +
11095
11549
  '</div>' +
11096
11550
  '<div class="tool-use-body ask-user-body">' +
11097
11551
  questionsHtml +
11552
+ actionsHtml +
11098
11553
  '</div>' +
11099
11554
  '</div>';
11100
11555
  }
@@ -11565,8 +12020,8 @@
11565
12020
 
11566
12021
  var notificationStack = [];
11567
12022
  var notificationIdCounter = 0;
11568
- var NOTIFICATION_GAP = 8;
11569
- var NOTIFICATION_TOP = 24;
12023
+ var NOTIFICATION_GAP = 6;
12024
+ var NOTIFICATION_TOP = 16;
11570
12025
 
11571
12026
  /**
11572
12027
  * Show an in-app notification bubble at bottom-right.
@@ -11581,6 +12036,12 @@
11581
12036
  * @returns {{ dismiss: function }} handle
11582
12037
  */
11583
12038
  function showNotificationBubble(opts) {
12039
+ // Play sound for important notifications — independent of bubble setting
12040
+ if (opts.actionLabel || opts.playSound) playNotificationSound();
12041
+
12042
+ // Respect user preference (skip if bubbles disabled)
12043
+ if (!state.notifBubble) return { dismiss: function() {} };
12044
+
11584
12045
  var id = ++notificationIdCounter;
11585
12046
  var type = opts.type || "info";
11586
12047
  var icon = opts.icon || (type === "warning" ? "!" : type === "success" ? "\u2713" : "i");
@@ -11691,6 +12152,221 @@
11691
12152
  }
11692
12153
  }
11693
12154
 
12155
+ /**
12156
+ * Play a soft, rounded notification chime using Web Audio API.
12157
+ * Two ascending sine tones with smooth gain envelope — gentle on the ears.
12158
+ */
12159
+ function playNotificationSound() {
12160
+ if (!state.notifSound) return;
12161
+ _doPlaySound();
12162
+ }
12163
+
12164
+ /**
12165
+ * Try to play the notification sound regardless of user preference.
12166
+ * Returns true if playback was initiated successfully.
12167
+ * Used by the test function to always attempt playback.
12168
+ */
12169
+ function tryPlayNotificationSound() {
12170
+ return _doPlaySound();
12171
+ }
12172
+
12173
+ function _doPlaySound() {
12174
+ try {
12175
+ var AudioCtx = window.AudioContext || window.webkitAudioContext;
12176
+ if (!AudioCtx) return false;
12177
+ var ctx = new AudioCtx();
12178
+
12179
+ // Some browsers suspend AudioContext until user gesture — resume it
12180
+ if (ctx.state === "suspended") ctx.resume();
12181
+
12182
+ function tone(freq, start, dur) {
12183
+ var osc = ctx.createOscillator();
12184
+ var gain = ctx.createGain();
12185
+ osc.type = "sine";
12186
+ osc.frequency.value = freq;
12187
+ gain.gain.setValueAtTime(0, ctx.currentTime + start);
12188
+ gain.gain.linearRampToValueAtTime(0.18, ctx.currentTime + start + 0.04);
12189
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
12190
+ osc.connect(gain);
12191
+ gain.connect(ctx.destination);
12192
+ osc.start(ctx.currentTime + start);
12193
+ osc.stop(ctx.currentTime + start + dur);
12194
+ }
12195
+
12196
+ // Two-tone ascending chime: C5 → E5, soft and brief
12197
+ tone(523, 0, 0.25);
12198
+ tone(659, 0.12, 0.3);
12199
+
12200
+ // Clean up context after playback
12201
+ setTimeout(function() { ctx.close(); }, 600);
12202
+ return true;
12203
+ } catch (_e) {
12204
+ // Web Audio not available or blocked
12205
+ return false;
12206
+ }
12207
+ }
12208
+
12209
+ /**
12210
+ * Show an interactive update bubble that allows updating and restarting
12211
+ * directly from the notification, without navigating to settings.
12212
+ */
12213
+ function showUpdateBubble(currentVer, latestVer) {
12214
+ // Prevent duplicate bubbles
12215
+ if (state._updateBubbleShown) return;
12216
+ state._updateBubbleShown = true;
12217
+
12218
+ playNotificationSound();
12219
+
12220
+ var id = ++notificationIdCounter;
12221
+ var bubble = document.createElement("div");
12222
+ bubble.className = "notification-bubble";
12223
+ bubble.setAttribute("data-nid", id);
12224
+
12225
+ bubble.innerHTML =
12226
+ '<div class="notification-bubble-header">' +
12227
+ '<span class="notification-bubble-icon info">\u2191</span>' +
12228
+ '<span class="notification-bubble-title">\u53d1\u73b0\u65b0\u7248\u672c</span>' +
12229
+ '<button class="notification-bubble-close" title="\u5173\u95ed">\u00d7</button>' +
12230
+ '</div>' +
12231
+ '<div class="notification-bubble-body">' +
12232
+ escapeHtml(currentVer) + ' \u2192 ' + escapeHtml(latestVer) +
12233
+ '</div>' +
12234
+ '<div class="notification-bubble-actions">' +
12235
+ '<button class="primary" id="update-bubble-action">\u7acb\u5373\u66f4\u65b0</button>' +
12236
+ '</div>';
12237
+
12238
+ document.body.appendChild(bubble);
12239
+
12240
+ var entry = { id: id, el: bubble };
12241
+ notificationStack.push(entry);
12242
+ repositionNotifications();
12243
+
12244
+ var closeBtn = bubble.querySelector(".notification-bubble-close");
12245
+ if (closeBtn) closeBtn.onclick = function() {
12246
+ dismissNotification(id);
12247
+ state._updateBubbleShown = false;
12248
+ };
12249
+
12250
+ var actionBtn = bubble.querySelector("#update-bubble-action");
12251
+ var bodyEl = bubble.querySelector(".notification-bubble-body");
12252
+
12253
+ if (actionBtn) actionBtn.onclick = function() {
12254
+ // Phase 1: Performing update
12255
+ actionBtn.disabled = true;
12256
+ actionBtn.textContent = "\u66f4\u65b0\u4e2d\u2026";
12257
+ if (bodyEl) bodyEl.textContent = "\u6b63\u5728\u4e0b\u8f7d\u5e76\u5b89\u88c5\u65b0\u7248\u672c\u2026";
12258
+
12259
+ fetch("/api/update", {
12260
+ method: "POST",
12261
+ headers: { "Content-Type": "application/json" },
12262
+ credentials: "same-origin"
12263
+ })
12264
+ .then(function(res) { return res.json(); })
12265
+ .then(function(data) {
12266
+ if (data.error) {
12267
+ // Update failed
12268
+ if (bodyEl) {
12269
+ bodyEl.textContent = data.error;
12270
+ bodyEl.style.color = "var(--error)";
12271
+ }
12272
+ actionBtn.disabled = false;
12273
+ actionBtn.textContent = "\u91cd\u8bd5";
12274
+ return;
12275
+ }
12276
+ // Phase 2: Update succeeded, show restart button
12277
+ if (bodyEl) {
12278
+ bodyEl.textContent = data.message || "\u66f4\u65b0\u5b8c\u6210";
12279
+ bodyEl.style.color = "var(--success)";
12280
+ }
12281
+ actionBtn.textContent = "\u91cd\u542f\u751f\u6548";
12282
+ actionBtn.disabled = false;
12283
+ actionBtn.className = "primary success";
12284
+ actionBtn.onclick = function() {
12285
+ performRestart(actionBtn, bodyEl);
12286
+ };
12287
+ })
12288
+ .catch(function() {
12289
+ if (bodyEl) {
12290
+ bodyEl.textContent = "\u66f4\u65b0\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u7f51\u7edc\u8fde\u63a5\u3002";
12291
+ bodyEl.style.color = "var(--error)";
12292
+ }
12293
+ actionBtn.disabled = false;
12294
+ actionBtn.textContent = "\u91cd\u8bd5";
12295
+ });
12296
+ };
12297
+ }
12298
+
12299
+ /**
12300
+ * Call POST /api/restart and show the restart overlay.
12301
+ */
12302
+ function performRestart(btn, msgEl) {
12303
+ if (btn) {
12304
+ btn.disabled = true;
12305
+ btn.textContent = "\u6b63\u5728\u91cd\u542f\u2026";
12306
+ }
12307
+ if (msgEl) {
12308
+ msgEl.textContent = "\u670d\u52a1\u6b63\u5728\u91cd\u542f\u2026";
12309
+ msgEl.style.color = "var(--text-secondary)";
12310
+ }
12311
+
12312
+ fetch("/api/restart", {
12313
+ method: "POST",
12314
+ headers: { "Content-Type": "application/json" },
12315
+ credentials: "same-origin"
12316
+ })
12317
+ .then(function(res) { return res.json(); })
12318
+ .then(function() {
12319
+ showRestartOverlay();
12320
+ })
12321
+ .catch(function() {
12322
+ // Network error likely means server already shut down — show overlay anyway
12323
+ showRestartOverlay();
12324
+ });
12325
+ }
12326
+
12327
+ /**
12328
+ * Full-screen overlay shown during server restart.
12329
+ * Polls /api/config until the server comes back, then reloads the page.
12330
+ */
12331
+ function showRestartOverlay() {
12332
+ // Avoid duplicates
12333
+ if (document.getElementById("restart-overlay")) return;
12334
+
12335
+ var overlay = document.createElement("div");
12336
+ overlay.id = "restart-overlay";
12337
+ overlay.className = "restart-overlay";
12338
+ overlay.innerHTML =
12339
+ '<div class="restart-overlay-content">' +
12340
+ '<div class="restart-spinner"></div>' +
12341
+ '<div class="restart-title">\u670d\u52a1\u6b63\u5728\u91cd\u542f</div>' +
12342
+ '<div class="restart-subtitle">\u7a0d\u540e\u5c06\u81ea\u52a8\u5237\u65b0\u9875\u9762\u2026</div>' +
12343
+ '</div>';
12344
+ document.body.appendChild(overlay);
12345
+
12346
+ var attempts = 0;
12347
+ var maxAttempts = 20; // 20 * 2s = 40s
12348
+ var timer = setInterval(function() {
12349
+ attempts++;
12350
+ fetch("/api/config", { credentials: "same-origin" })
12351
+ .then(function(res) {
12352
+ if (res.ok) {
12353
+ clearInterval(timer);
12354
+ location.reload();
12355
+ }
12356
+ })
12357
+ .catch(function() {
12358
+ // Server not ready yet
12359
+ });
12360
+ if (attempts >= maxAttempts) {
12361
+ clearInterval(timer);
12362
+ var subtitle = overlay.querySelector(".restart-subtitle");
12363
+ if (subtitle) {
12364
+ subtitle.innerHTML = '\u91cd\u542f\u8d85\u65f6\uff0c\u8bf7 <a href="javascript:location.reload()" style="color:var(--accent);text-decoration:underline">\u624b\u52a8\u5237\u65b0</a> \u9875\u9762\u3002';
12365
+ }
12366
+ }
12367
+ }, 2000);
12368
+ }
12369
+
11694
12370
  function escapeHtml(value) {
11695
12371
  return String(value)
11696
12372
  .replace(/&/g, "&amp;")