@co0ontty/wand 1.14.6 → 1.15.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.
@@ -71,6 +71,7 @@
71
71
  suggestionTimer: null,
72
72
  terminal: null,
73
73
  fitAddon: null,
74
+ terminalFitInProgress: false,
74
75
  serializeAddon: null,
75
76
  terminalDomView: null,
76
77
  terminalDomUpdateTimer: null,
@@ -134,9 +135,14 @@
134
135
  ws: null,
135
136
  wsConnected: false,
136
137
  _updateBubbleShown: false,
138
+ notificationHistory: {},
139
+ delayedNotificationTimer: null,
137
140
  notifSound: (function() {
138
141
  try { var v = localStorage.getItem("wand-notif-sound"); return v === null ? true : v === "true"; } catch (e) { return true; }
139
142
  })(),
143
+ notifVolume: (function() {
144
+ try { var v = localStorage.getItem("wand-notif-volume"); return v === null ? 80 : Math.max(0, Math.min(100, parseInt(v, 10) || 80)); } catch (e) { return 80; }
145
+ })(),
140
146
  notifBubble: (function() {
141
147
  try { var v = localStorage.getItem("wand-notif-bubble"); return v === null ? true : v === "true"; } catch (e) { return true; }
142
148
  })(),
@@ -891,8 +897,7 @@
891
897
  refreshAll();
892
898
  requestNotificationPermission();
893
899
  if (config.updateAvailable && config.latestVersion) {
894
- showUpdateBubble(config.currentVersion || "-", config.latestVersion);
895
- sendBrowserNotification("Wand \u53d1\u73b0\u65b0\u7248\u672c", "\u5f53\u524d " + (config.currentVersion || "-") + " \u2192 \u6700\u65b0 " + config.latestVersion, { tag: "wand-update" });
900
+ notifyUpdateAvailable(config.currentVersion || "-", config.latestVersion);
896
901
  }
897
902
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
898
903
  loadClaudeHistory();
@@ -1316,23 +1321,55 @@
1316
1321
  function renderSettingsModal() {
1317
1322
  return '<section id="settings-modal" class="modal-backdrop hidden">' +
1318
1323
  '<div class="modal settings-modal">' +
1319
- '<div class="modal-header">' +
1320
- '<h2 class="modal-title">设置</h2>' +
1324
+ '<div class="modal-header settings-modal-header">' +
1325
+ '<div class="settings-modal-title-group">' +
1326
+ '<h2 class="modal-title">设置</h2>' +
1327
+ '<p class="settings-modal-subtitle">调整应用配置、通知、安全和显示偏好</p>' +
1328
+ '</div>' +
1321
1329
  '<button id="close-settings-button" class="btn btn-ghost btn-icon">×</button>' +
1322
1330
  '</div>' +
1323
- '<div class="modal-body">' +
1324
- // Tabs
1325
- '<div class="settings-tabs">' +
1326
- '<button class="settings-tab active" data-tab="about">\u5173\u4e8e</button>' +
1327
- '<button class="settings-tab" data-tab="general">\u57fa\u672c\u914d\u7f6e</button>' +
1328
- '<button class="settings-tab" data-tab="notifications">\u901a\u77e5</button>' +
1329
- '<button class="settings-tab" data-tab="security">\u5b89\u5168</button>' +
1330
- '<button class="settings-tab" data-tab="presets">\u547d\u4ee4\u9884\u8bbe</button>' +
1331
- '<button class="settings-tab" data-tab="display">\u663e\u793a</button>' +
1332
- '</div>' +
1331
+ '<div class="modal-body settings-modal-body">' +
1332
+ '<div class="settings-layout">' +
1333
+ '<aside class="settings-sidebar">' +
1334
+ '<div class="settings-sidebar-header">' +
1335
+ '<div class="settings-sidebar-title">偏好设置</div>' +
1336
+ '<div class="settings-sidebar-hint">左侧切换分区,右侧查看详细说明与选项。</div>' +
1337
+ '</div>' +
1338
+ '<div class="settings-tabs" role="tablist" aria-label="设置分组" aria-orientation="vertical">' +
1339
+ '<button class="settings-tab active" data-tab="about" role="tab" aria-selected="true" aria-controls="settings-tab-about">' +
1340
+ '<span class="settings-tab-main">关于</span>' +
1341
+ '<span class="settings-tab-meta">版本、更新与连接方式</span>' +
1342
+ '</button>' +
1343
+ '<button class="settings-tab" data-tab="general" role="tab" aria-selected="false" aria-controls="settings-tab-general">' +
1344
+ '<span class="settings-tab-main">基本配置</span>' +
1345
+ '<span class="settings-tab-meta">主机、模式、语言、目录</span>' +
1346
+ '</button>' +
1347
+ '<button class="settings-tab" data-tab="notifications" role="tab" aria-selected="false" aria-controls="settings-tab-notifications">' +
1348
+ '<span class="settings-tab-main">通知</span>' +
1349
+ '<span class="settings-tab-meta">提示音与浏览器通知</span>' +
1350
+ '</button>' +
1351
+ '<button class="settings-tab" data-tab="security" role="tab" aria-selected="false" aria-controls="settings-tab-security">' +
1352
+ '<span class="settings-tab-main">安全</span>' +
1353
+ '<span class="settings-tab-meta">密码与证书</span>' +
1354
+ '</button>' +
1355
+ '<button class="settings-tab" data-tab="presets" role="tab" aria-selected="false" aria-controls="settings-tab-presets">' +
1356
+ '<span class="settings-tab-main">命令预设</span>' +
1357
+ '<span class="settings-tab-meta">查看已有预设</span>' +
1358
+ '</button>' +
1359
+ '<button class="settings-tab" data-tab="display" role="tab" aria-selected="false" aria-controls="settings-tab-display">' +
1360
+ '<span class="settings-tab-main">显示</span>' +
1361
+ '<span class="settings-tab-meta">卡片默认展开行为</span>' +
1362
+ '</button>' +
1363
+ '</div>' +
1364
+ '</aside>' +
1365
+ '<div class="settings-content">' +
1333
1366
 
1334
1367
  // About tab
1335
- '<div class="settings-panel active" id="settings-tab-about">' +
1368
+ '<div class="settings-panel active" id="settings-tab-about" role="tabpanel">' +
1369
+ '<div class="settings-panel-header">' +
1370
+ '<h3 class="settings-panel-title">关于 Wand</h3>' +
1371
+ '<p class="settings-panel-desc">查看版本信息、更新状态和 Android App 连接方式。</p>' +
1372
+ '</div>' +
1336
1373
  '<div class="settings-about-info">' +
1337
1374
  '<div class="settings-about-row"><span class="settings-label">包名</span><span class="settings-value" id="settings-pkg-name">-</span></div>' +
1338
1375
  '<div class="settings-about-row"><span class="settings-label">当前版本</span><span class="settings-value" id="settings-version">-</span></div>' +
@@ -1379,13 +1416,24 @@
1379
1416
  '</div>' +
1380
1417
 
1381
1418
  // Notifications tab
1382
- '<div class="settings-panel" id="settings-tab-notifications">' +
1419
+ '<div class="settings-panel" id="settings-tab-notifications" role="tabpanel">' +
1420
+ '<div class="settings-panel-header">' +
1421
+ '<h3 class="settings-panel-title">通知</h3>' +
1422
+ '<p class="settings-panel-desc">设置提示音、系统通知和浏览器通知的行为。</p>' +
1423
+ '</div>' +
1383
1424
  '<div class="settings-section-title">\u901a\u77e5\u504f\u597d</div>' +
1384
1425
  '<div class="field field-inline">' +
1385
1426
  '<input id="cfg-notif-sound" type="checkbox" class="field-checkbox" />' +
1386
1427
  '<label class="field-label" for="cfg-notif-sound">\u64ad\u653e\u63d0\u793a\u97f3</label>' +
1387
1428
  '</div>' +
1388
1429
  '<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>' +
1430
+ '<div class="field" id="notif-volume-field" style="margin-bottom:10px">' +
1431
+ '<label class="field-label" style="margin-bottom:4px">\u97f3\u91cf</label>' +
1432
+ '<div style="display:flex;align-items:center;gap:8px">' +
1433
+ '<input id="cfg-notif-volume" type="range" min="0" max="100" step="5" style="flex:1;accent-color:var(--accent)" />' +
1434
+ '<span id="cfg-notif-volume-val" style="min-width:32px;text-align:right;font-size:12px;color:var(--text-secondary)">80%</span>' +
1435
+ '</div>' +
1436
+ '</div>' +
1389
1437
  '<div class="field field-inline">' +
1390
1438
  '<input id="cfg-notif-bubble" type="checkbox" class="field-checkbox" />' +
1391
1439
  '<label class="field-label" for="cfg-notif-bubble">\u5e94\u7528\u5185\u901a\u77e5\u6c14\u6ce1</label>' +
@@ -1412,13 +1460,18 @@
1412
1460
  '<button id="notification-request-btn" class="btn btn-ghost btn-sm hidden">\u6388\u6743\u901a\u77e5</button>' +
1413
1461
  '<button id="notification-reset-btn" class="btn btn-ghost btn-sm hidden">\u91cd\u65b0\u6388\u6743</button>' +
1414
1462
  '<button id="notification-test-btn" class="btn btn-ghost btn-sm">\u53d1\u9001\u6d4b\u8bd5\u901a\u77e5</button>' +
1463
+ '<button id="notification-test-delay-btn" class="btn btn-ghost btn-sm">10 \u79d2\u540e\u53d1\u9001</button>' +
1415
1464
  '</div>' +
1416
1465
  '<p id="notification-test-message" class="hint hidden"></p>' +
1417
1466
  '</div>' +
1418
1467
  '</div>' +
1419
1468
 
1420
1469
  // General config tab
1421
- '<div class="settings-panel" id="settings-tab-general">' +
1470
+ '<div class="settings-panel" id="settings-tab-general" role="tabpanel">' +
1471
+ '<div class="settings-panel-header">' +
1472
+ '<h3 class="settings-panel-title">基本配置</h3>' +
1473
+ '<p class="settings-panel-desc">配置服务监听地址、默认模式、语言和工作目录。</p>' +
1474
+ '</div>' +
1422
1475
  '<div class="field-row">' +
1423
1476
  '<div class="field">' +
1424
1477
  '<label class="field-label" for="cfg-host">监听地址 (host)</label>' +
@@ -1492,12 +1545,18 @@
1492
1545
  '<p id="app-icon-message" class="hint hidden" style="margin-top:8px"></p>' +
1493
1546
  '</div>'
1494
1547
  : '') +
1495
- '<button id="save-config-button" class="btn btn-primary btn-block">保存配置</button>' +
1496
- '<p id="config-message" class="hint hidden"></p>' +
1548
+ '<div class="settings-actions settings-actions-sticky">' +
1549
+ '<button id="save-config-button" class="btn btn-primary btn-block">保存配置</button>' +
1550
+ '</div>' +
1551
+ '<p id="config-message" class="hint hidden settings-status-message"></p>' +
1497
1552
  '</div>' +
1498
1553
 
1499
1554
  // Security tab
1500
- '<div class="settings-panel" id="settings-tab-security">' +
1555
+ '<div class="settings-panel" id="settings-tab-security" role="tabpanel">' +
1556
+ '<div class="settings-panel-header">' +
1557
+ '<h3 class="settings-panel-title">安全</h3>' +
1558
+ '<p class="settings-panel-desc">管理登录密码与 SSL 证书,敏感变更请确认后再保存。</p>' +
1559
+ '</div>' +
1501
1560
  '<div class="settings-card">' +
1502
1561
  '<h3 class="settings-section-title">\ud83d\udd12 修改密码</h3>' +
1503
1562
  '<div class="field">' +
@@ -1529,14 +1588,22 @@
1529
1588
  '</div>' +
1530
1589
 
1531
1590
  // Command presets tab
1532
- '<div class="settings-panel" id="settings-tab-presets">' +
1591
+ '<div class="settings-panel" id="settings-tab-presets" role="tabpanel">' +
1592
+ '<div class="settings-panel-header">' +
1593
+ '<h3 class="settings-panel-title">命令预设</h3>' +
1594
+ '<p class="settings-panel-desc">当前命令预设从 config.json 读取,可在这里快速查看已有配置。</p>' +
1595
+ '</div>' +
1533
1596
  '<div id="presets-list" class="presets-list"></div>' +
1534
1597
  '</div>' +
1535
1598
 
1536
1599
  // Display settings tab
1537
- '<div class="settings-panel" id="settings-tab-display">' +
1600
+ '<div class="settings-panel" id="settings-tab-display" role="tabpanel">' +
1601
+ '<div class="settings-panel-header">' +
1602
+ '<h3 class="settings-panel-title">显示</h3>' +
1603
+ '<p class="settings-panel-desc">控制聊天视图里不同卡片类型的默认展开状态。</p>' +
1604
+ '</div>' +
1538
1605
  '<div class="settings-section-title">卡片默认展开状态</div>' +
1539
- '<p class="hint" style="margin-top:-4px;margin-bottom:12px">设置结构化聊天视图中各类卡片的默认展开/折叠状态。手动操作的展开状态优先于此默认设置。</p>' +
1606
+ '<p class="hint settings-inline-hint">设置结构化聊天视图中各类卡片的默认展开/折叠状态。手动操作的展开状态优先于此默认设置。</p>' +
1540
1607
  '<div class="switch-card-list">' +
1541
1608
  '<label class="switch-card" for="cfg-card-edit">' +
1542
1609
  '<div class="switch-card-header">' +
@@ -1579,8 +1646,10 @@
1579
1646
  '<div class="switch-card-desc">连续同类工具调用的折叠组</div>' +
1580
1647
  '</label>' +
1581
1648
  '</div>' +
1582
- '<button id="save-display-button" class="btn btn-primary btn-block" style="margin-top:16px">保存显示设置</button>' +
1583
- '<p id="display-message" class="hint hidden"></p>' +
1649
+ '<div class="settings-actions settings-actions-sticky">' +
1650
+ '<button id="save-display-button" class="btn btn-primary btn-block">保存显示设置</button>' +
1651
+ '</div>' +
1652
+ '<p id="display-message" class="hint hidden settings-status-message"></p>' +
1584
1653
  '</div>' +
1585
1654
  '</div>' +
1586
1655
  '</div>' +
@@ -2796,6 +2865,29 @@
2796
2865
  });
2797
2866
  }
2798
2867
 
2868
+ function getCardDefault(key) {
2869
+ return !!(state.config && state.config.cardDefaults && state.config.cardDefaults[key]);
2870
+ }
2871
+
2872
+ function lazyLoadTruncatedToolContent(container, targetEl, renderContent, renderError) {
2873
+ if (!container || container.dataset.truncated !== "true" || container.dataset.loaded === "true") return;
2874
+ var toolUseId = container.dataset.toolUseId;
2875
+ if (!toolUseId) return;
2876
+ if (targetEl) targetEl.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2877
+ container.dataset.loaded = "loading";
2878
+ __fetchToolContent(toolUseId, function(err, data) {
2879
+ if (err) {
2880
+ if (targetEl) targetEl.innerHTML = renderError || '<div class="tool-content-error">加载失败,点击重试</div>';
2881
+ container.dataset.loaded = "";
2882
+ return;
2883
+ }
2884
+ container.dataset.truncated = "false";
2885
+ container.dataset.loaded = "true";
2886
+ var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2887
+ renderContent(content, data);
2888
+ });
2889
+ }
2890
+
2799
2891
  window.__tcToggle = function(e, headerEl) {
2800
2892
  var card = headerEl.closest(".tool-use-card") || headerEl.closest(".inline-diff");
2801
2893
  if (card) {
@@ -2803,23 +2895,16 @@
2803
2895
  card.classList.toggle("collapsed");
2804
2896
  var expandKind = card.dataset.expandKind || "tool-card";
2805
2897
  persistElementExpandState(card, expandKind);
2806
- // Lazy-load truncated content on expand
2807
- if (wasCollapsed && card.dataset.truncated === "true" && card.dataset.loaded !== "true") {
2808
- var toolUseId = card.dataset.toolUseId;
2898
+ if (wasCollapsed) {
2809
2899
  var resultDiv = card.querySelector(".tool-use-result");
2810
- if (resultDiv) resultDiv.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2811
- card.dataset.loaded = "loading";
2812
- __fetchToolContent(toolUseId, function(err, data) {
2813
- if (err) {
2814
- if (resultDiv) resultDiv.innerHTML = '<div class="tool-content-error" onclick="__tcToggle(null, card.querySelector(\'.tool-use-header\'))">加载失败,点击重试</div>';
2815
- card.dataset.loaded = "";
2816
- } else {
2817
- card.dataset.truncated = "false";
2818
- card.dataset.loaded = "true";
2819
- var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2900
+ lazyLoadTruncatedToolContent(
2901
+ card,
2902
+ resultDiv,
2903
+ function(content) {
2820
2904
  if (resultDiv) resultDiv.innerHTML = '<pre class="tool-use-result-content">' + escapeHtml(content) + '</pre>';
2821
- }
2822
- });
2905
+ },
2906
+ '<div class="tool-content-error" onclick="__tcToggle(null, this.closest(\'.tool-use-card,.inline-diff\').querySelector(\'.tool-use-header,.diff-header\'))">加载失败,点击重试</div>'
2907
+ );
2823
2908
  }
2824
2909
  }
2825
2910
  if (e) { e.preventDefault(); e.stopPropagation(); }
@@ -2859,22 +2944,10 @@
2859
2944
  statusSpan.textContent = "✓";
2860
2945
  }
2861
2946
  }
2862
- // Lazy-load truncated content on expand
2863
- if (expanded && el.dataset.truncated === "true" && el.dataset.loaded !== "true") {
2864
- var toolUseId = el.dataset.toolUseId;
2865
- if (body) body.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2866
- el.dataset.loaded = "loading";
2867
- __fetchToolContent(toolUseId, function(err, data) {
2868
- if (err) {
2869
- if (body) body.innerHTML = '<div class="tool-content-error">加载失败,点击重试</div>';
2870
- el.dataset.loaded = "";
2871
- } else {
2872
- el.dataset.truncated = "false";
2873
- el.dataset.loaded = "true";
2874
- var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2875
- el.dataset.result = content;
2876
- if (body) body.innerHTML = '<div class="inline-tool-result">' + formatInlineResult(content, "") + '</div>';
2877
- }
2947
+ if (expanded) {
2948
+ lazyLoadTruncatedToolContent(el, body, function(content) {
2949
+ el.dataset.result = content;
2950
+ if (body) body.innerHTML = '<div class="inline-tool-result">' + formatInlineResult(content, "") + '</div>';
2878
2951
  });
2879
2952
  }
2880
2953
  persistElementExpandState(el, "inline-tool");
@@ -2891,29 +2964,17 @@
2891
2964
  var toggleIcon = el.querySelector(".term-toggle-icon");
2892
2965
  if (toggleIcon) toggleIcon.textContent = isHidden ? "▼" : "▶";
2893
2966
  persistElementExpandState(container, "terminal");
2894
- // Lazy-load truncated content on expand
2895
- if (isHidden && container.dataset.truncated === "true" && container.dataset.loaded !== "true") {
2896
- var toolUseId = container.dataset.toolUseId;
2967
+ if (isHidden) {
2897
2968
  var termOutput = body.querySelector(".term-output");
2898
- if (termOutput) termOutput.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2899
- container.dataset.loaded = "loading";
2900
- __fetchToolContent(toolUseId, function(err, data) {
2901
- if (err) {
2902
- if (termOutput) termOutput.innerHTML = '<div class="tool-content-error">加载失败,点击重试</div>';
2903
- container.dataset.loaded = "";
2904
- } else {
2905
- container.dataset.truncated = "false";
2906
- container.dataset.loaded = "true";
2907
- var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2908
- if (termOutput) {
2909
- var lines = content.split("\n");
2910
- var html = "";
2911
- for (var i = 0; i < lines.length; i++) {
2912
- if (!lines[i] && i === lines.length - 1) continue;
2913
- html += '<div class="term-line">' + escapeHtml(lines[i]) + '</div>';
2914
- }
2915
- termOutput.innerHTML = html;
2969
+ lazyLoadTruncatedToolContent(container, termOutput, function(content) {
2970
+ if (termOutput) {
2971
+ var lines = content.split("\n");
2972
+ var html = "";
2973
+ for (var i = 0; i < lines.length; i++) {
2974
+ if (!lines[i] && i === lines.length - 1) continue;
2975
+ html += '<div class="term-line">' + escapeHtml(lines[i]) + '</div>';
2916
2976
  }
2977
+ termOutput.innerHTML = html;
2917
2978
  }
2918
2979
  });
2919
2980
  }
@@ -3235,6 +3296,33 @@
3235
3296
  try { localStorage.setItem("wand-notif-sound", String(state.notifSound)); } catch (e) {}
3236
3297
  // Preview sound when toggling on
3237
3298
  if (state.notifSound) _doPlaySound();
3299
+ // Toggle volume slider visibility
3300
+ var volField = document.getElementById("notif-volume-field");
3301
+ if (volField) volField.style.display = state.notifSound ? "" : "none";
3302
+ });
3303
+ }
3304
+ // Volume slider
3305
+ var notifVolumeEl = document.getElementById("cfg-notif-volume");
3306
+ var notifVolumeVal = document.getElementById("cfg-notif-volume-val");
3307
+ if (notifVolumeEl) {
3308
+ notifVolumeEl.value = state.notifVolume;
3309
+ if (notifVolumeVal) notifVolumeVal.textContent = state.notifVolume + "%";
3310
+ // Hide if sound is off
3311
+ var volField = document.getElementById("notif-volume-field");
3312
+ if (volField) volField.style.display = state.notifSound ? "" : "none";
3313
+ var _volDebounce = null;
3314
+ notifVolumeEl.addEventListener("input", function() {
3315
+ state.notifVolume = parseInt(notifVolumeEl.value, 10);
3316
+ if (notifVolumeVal) notifVolumeVal.textContent = state.notifVolume + "%";
3317
+ try { localStorage.setItem("wand-notif-volume", String(state.notifVolume)); } catch (e) {}
3318
+ // Also sync to native bridge if available
3319
+ if (_hasNativeBridge && typeof WandNative.setNotificationVolume === "function") {
3320
+ try { WandNative.setNotificationVolume(state.notifVolume); } catch (_e) {}
3321
+ }
3322
+ });
3323
+ // Preview on release
3324
+ notifVolumeEl.addEventListener("change", function() {
3325
+ _doPlaySound();
3238
3326
  });
3239
3327
  }
3240
3328
  var notifBubbleEl = document.getElementById("cfg-notif-bubble");
@@ -3262,6 +3350,8 @@
3262
3350
  if (notifResetBtn) notifResetBtn.addEventListener("click", resetNotificationPermission);
3263
3351
  var notifTestBtn = document.getElementById("notification-test-btn");
3264
3352
  if (notifTestBtn) notifTestBtn.addEventListener("click", testNotification);
3353
+ var notifTestDelayBtn = document.getElementById("notification-test-delay-btn");
3354
+ if (notifTestDelayBtn) notifTestDelayBtn.addEventListener("click", scheduleTestNotification);
3265
3355
  updateNotificationStatus();
3266
3356
  // Native notification sound selector (APK only)
3267
3357
  if (_hasNativeBridge && typeof WandNative.getAvailableSounds === "function") {
@@ -5240,7 +5330,6 @@
5240
5330
  }
5241
5331
  state.selectedId = id;
5242
5332
  persistSelectedId();
5243
- // Clear tool content cache on session switch
5244
5333
  state.toolContentCache = {};
5245
5334
  // Clear queued inputs from the previous session to prevent cross-session leaks
5246
5335
  state.messageQueue = [];
@@ -5603,6 +5692,7 @@
5603
5692
  if (confirmEl) confirmEl.value = "";
5604
5693
  hideSettingsMessages();
5605
5694
  setupFocusTrap(modal);
5695
+ bindSettingsTabKeyboardNavigation();
5606
5696
  // Activate first tab
5607
5697
  switchSettingsTab("about");
5608
5698
  // Load settings data
@@ -5612,18 +5702,35 @@
5612
5702
  var bubbleEl = document.getElementById("cfg-notif-bubble");
5613
5703
  if (soundEl) soundEl.checked = state.notifSound;
5614
5704
  if (bubbleEl) bubbleEl.checked = state.notifBubble;
5705
+ var volEl = document.getElementById("cfg-notif-volume");
5706
+ var volValEl = document.getElementById("cfg-notif-volume-val");
5707
+ if (volEl) {
5708
+ volEl.value = state.notifVolume;
5709
+ if (volValEl) volValEl.textContent = state.notifVolume + "%";
5710
+ }
5711
+ var volField = document.getElementById("notif-volume-field");
5712
+ if (volField) volField.style.display = state.notifSound ? "" : "none";
5615
5713
  updateNotificationStatus();
5616
5714
  // Load current app icon selection (APK only)
5617
5715
  if (typeof WandNative !== "undefined" && typeof WandNative.getAppIcon === "function") {
5618
5716
  try { _updateAppIconSelection(WandNative.getAppIcon() || "shorthair"); } catch (_e) {}
5619
5717
  }
5620
- // Sync native notification sound selector (APK only)
5718
+ // Sync native notification sound selector and volume (APK only)
5621
5719
  if (_hasNativeBridge && typeof WandNative.getNotificationSound === "function") {
5622
5720
  try {
5623
5721
  var nsSel = document.getElementById("native-sound-select");
5624
5722
  if (nsSel) nsSel.value = WandNative.getNotificationSound();
5625
5723
  } catch (_e) {}
5626
5724
  }
5725
+ if (_hasNativeBridge && typeof WandNative.getNotificationVolume === "function") {
5726
+ try {
5727
+ var nativeVol = WandNative.getNotificationVolume();
5728
+ state.notifVolume = nativeVol;
5729
+ if (volEl) volEl.value = nativeVol;
5730
+ if (volValEl) volValEl.textContent = nativeVol + "%";
5731
+ try { localStorage.setItem("wand-notif-volume", String(nativeVol)); } catch (_e) {}
5732
+ } catch (_e) {}
5733
+ }
5627
5734
  }
5628
5735
  }
5629
5736
 
@@ -5700,21 +5807,76 @@
5700
5807
  var tabs = document.querySelectorAll(".settings-tab");
5701
5808
  var panels = document.querySelectorAll(".settings-panel");
5702
5809
  for (var i = 0; i < tabs.length; i++) {
5703
- if (tabs[i].getAttribute("data-tab") === tabName) {
5810
+ var isActive = tabs[i].getAttribute("data-tab") === tabName;
5811
+ if (isActive) {
5704
5812
  tabs[i].classList.add("active");
5705
5813
  } else {
5706
5814
  tabs[i].classList.remove("active");
5707
5815
  }
5816
+ tabs[i].setAttribute("aria-selected", isActive ? "true" : "false");
5817
+ tabs[i].setAttribute("tabindex", isActive ? "0" : "-1");
5708
5818
  }
5709
5819
  for (var j = 0; j < panels.length; j++) {
5710
- if (panels[j].id === "settings-tab-" + tabName) {
5820
+ var isPanelActive = panels[j].id === "settings-tab-" + tabName;
5821
+ if (isPanelActive) {
5711
5822
  panels[j].classList.add("active");
5823
+ panels[j].removeAttribute("hidden");
5712
5824
  } else {
5713
5825
  panels[j].classList.remove("active");
5826
+ panels[j].setAttribute("hidden", "hidden");
5714
5827
  }
5715
5828
  }
5716
5829
  }
5717
5830
 
5831
+ function handleSettingsTabKeydown(event) {
5832
+ if (!event) return;
5833
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown" && event.key !== "Home" && event.key !== "End") {
5834
+ return;
5835
+ }
5836
+ var tabs = Array.prototype.slice.call(document.querySelectorAll(".settings-tab"));
5837
+ if (!tabs.length) return;
5838
+ var currentIndex = tabs.indexOf(event.currentTarget);
5839
+ if (currentIndex === -1) return;
5840
+ event.preventDefault();
5841
+ var nextIndex = currentIndex;
5842
+ if (event.key === "ArrowUp") nextIndex = currentIndex > 0 ? currentIndex - 1 : tabs.length - 1;
5843
+ if (event.key === "ArrowDown") nextIndex = currentIndex < tabs.length - 1 ? currentIndex + 1 : 0;
5844
+ if (event.key === "Home") nextIndex = 0;
5845
+ if (event.key === "End") nextIndex = tabs.length - 1;
5846
+ var nextTab = tabs[nextIndex];
5847
+ if (!nextTab) return;
5848
+ var nextName = nextTab.getAttribute("data-tab");
5849
+ if (nextName) switchSettingsTab(nextName);
5850
+ if (typeof nextTab.focus === "function") nextTab.focus();
5851
+ }
5852
+
5853
+ function bindSettingsTabKeyboardNavigation() {
5854
+ var tabs = document.querySelectorAll(".settings-tab");
5855
+ for (var i = 0; i < tabs.length; i++) {
5856
+ tabs[i].removeEventListener("keydown", handleSettingsTabKeydown);
5857
+ tabs[i].addEventListener("keydown", handleSettingsTabKeydown);
5858
+ }
5859
+ }
5860
+
5861
+ function updateSettingsSidebarStatus(data) {
5862
+ if (!data) return;
5863
+ var cfg = data.config || {};
5864
+ var metaMap = {
5865
+ about: data.version ? ("当前 v" + data.version) : "版本与更新信息",
5866
+ general: [cfg.defaultMode || "default", cfg.language || "自动语言"].filter(Boolean).join(" · "),
5867
+ notifications: state.notifSound ? ("提示音 " + state.notifVolume + "%") : "提示音已关闭",
5868
+ security: data.hasCert ? "已安装 SSL 证书" : "密码与证书管理",
5869
+ presets: cfg.commandPresets && cfg.commandPresets.length ? (cfg.commandPresets.length + " 条预设") : "暂无预设",
5870
+ display: "控制卡片默认展开"
5871
+ };
5872
+ for (var key in metaMap) {
5873
+ if (!Object.prototype.hasOwnProperty.call(metaMap, key)) continue;
5874
+ var tab = document.querySelector('.settings-tab[data-tab="' + key + '"] .settings-tab-meta');
5875
+ if (tab) tab.textContent = metaMap[key] || "";
5876
+ }
5877
+ }
5878
+
5879
+
5718
5880
  function copyToClipboard(text, triggerBtn) {
5719
5881
  if (!text) return;
5720
5882
  navigator.clipboard.writeText(text).then(function() {
@@ -5759,6 +5921,7 @@
5759
5921
  fetch("/api/settings", { credentials: "same-origin" })
5760
5922
  .then(function(res) { return res.json(); })
5761
5923
  .then(function(data) {
5924
+ updateSettingsSidebarStatus(data);
5762
5925
  // About
5763
5926
  var nameEl = document.getElementById("settings-pkg-name");
5764
5927
  var verEl = document.getElementById("settings-version");
@@ -5843,7 +6006,7 @@
5843
6006
  apkMessageEl.classList.remove("hidden");
5844
6007
  }
5845
6008
  } else {
5846
- // ── 浏览器模式:只显示线上版本 + 下载按钮 ──
6009
+ // ── 浏览器模式:显示线上版本 + 本地版本 + 下载按钮 ──
5847
6010
  if (androidApk.github && apkGithubRow && apkGithubEl) {
5848
6011
  var ghLabel2 = androidApk.github.version ? ("v" + androidApk.github.version) : androidApk.github.fileName;
5849
6012
  if (typeof androidApk.github.size === "number") ghLabel2 += " · " + formatBytes(androidApk.github.size);
@@ -5856,7 +6019,22 @@
5856
6019
  window.open(androidApk.github.downloadUrl, "_blank");
5857
6020
  };
5858
6021
  }
5859
- } else if (apkMessageEl) {
6022
+ }
6023
+ // 本地版本
6024
+ if (androidApk.local && apkLocalRow && apkLocalEl) {
6025
+ var lcLabel2 = androidApk.local.version ? ("v" + androidApk.local.version) : androidApk.local.fileName;
6026
+ if (typeof androidApk.local.size === "number") lcLabel2 += " · " + formatBytes(androidApk.local.size);
6027
+ apkLocalEl.textContent = lcLabel2;
6028
+ apkLocalRow.classList.remove("hidden");
6029
+ if (apkLocalBtn) {
6030
+ apkLocalBtn.textContent = "下载";
6031
+ apkLocalBtn.classList.remove("hidden");
6032
+ apkLocalBtn.onclick = function() {
6033
+ window.open(androidApk.local.downloadUrl, "_self");
6034
+ };
6035
+ }
6036
+ }
6037
+ if (!androidApk.github && !androidApk.local && apkMessageEl) {
5860
6038
  apkMessageEl.textContent = "暂未提供";
5861
6039
  apkMessageEl.classList.remove("hidden");
5862
6040
  }
@@ -6262,9 +6440,44 @@
6262
6440
  });
6263
6441
  }
6264
6442
 
6443
+ function resetDelayedNotificationButton() {
6444
+ var delayBtn = document.getElementById("notification-test-delay-btn");
6445
+ if (!delayBtn) return;
6446
+ delayBtn.disabled = false;
6447
+ delayBtn.textContent = "10 秒后发送";
6448
+ }
6449
+
6450
+ function scheduleTestNotification() {
6451
+ var testMsgEl = document.getElementById("notification-test-message");
6452
+ if (state.delayedNotificationTimer) {
6453
+ clearTimeout(state.delayedNotificationTimer);
6454
+ state.delayedNotificationTimer = null;
6455
+ }
6456
+ var delayBtn = document.getElementById("notification-test-delay-btn");
6457
+ if (delayBtn) {
6458
+ delayBtn.disabled = true;
6459
+ delayBtn.textContent = "已安排(10s)";
6460
+ }
6461
+ if (testMsgEl) {
6462
+ testMsgEl.innerHTML = "已安排 10 秒后发送测试通知,请切到后台等待。";
6463
+ testMsgEl.style.color = "var(--text-secondary)";
6464
+ testMsgEl.classList.remove("hidden");
6465
+ }
6466
+ state.delayedNotificationTimer = setTimeout(function() {
6467
+ state.delayedNotificationTimer = null;
6468
+ resetDelayedNotificationButton();
6469
+ testNotification();
6470
+ }, 10000);
6471
+ }
6472
+
6265
6473
  function testNotification() {
6266
6474
  var testMsgEl = document.getElementById("notification-test-message");
6267
6475
  var results = [];
6476
+ if (state.delayedNotificationTimer) {
6477
+ clearTimeout(state.delayedNotificationTimer);
6478
+ state.delayedNotificationTimer = null;
6479
+ resetDelayedNotificationButton();
6480
+ }
6268
6481
 
6269
6482
  // 1. Test sound playback
6270
6483
  var soundOk = tryPlayNotificationSound();
@@ -9549,8 +9762,13 @@
9549
9762
  }
9550
9763
 
9551
9764
  function ensureTerminalFit() {
9765
+ if (state.terminalFitInProgress) return;
9766
+ state.terminalFitInProgress = true;
9552
9767
  var maxAttempts = 20;
9553
9768
  var attempt = 0;
9769
+ function finishFit() {
9770
+ state.terminalFitInProgress = false;
9771
+ }
9554
9772
  function tryFit() {
9555
9773
  attempt++;
9556
9774
  state.terminalViewportSize = { width: 0, height: 0 };
@@ -9560,20 +9778,20 @@
9560
9778
  if (state.terminal) {
9561
9779
  sendTerminalResize(state.terminal.cols, state.terminal.rows);
9562
9780
  }
9563
- // Validate: if the fitted cols look suspiciously small relative to
9564
- // the container width, schedule another attempt — the font metrics
9565
- // or layout may not have settled yet.
9566
9781
  var output = document.getElementById("output");
9567
9782
  if (output && state.terminal) {
9568
9783
  var containerW = output.getBoundingClientRect().width;
9569
- var expectedMinCols = Math.floor(containerW / 20); // very conservative estimate
9784
+ var expectedMinCols = Math.floor(containerW / 20);
9570
9785
  if (state.terminal.cols < expectedMinCols && attempt < maxAttempts) {
9571
9786
  requestAnimationFrame(tryFit);
9572
9787
  return;
9573
9788
  }
9574
9789
  }
9790
+ finishFit();
9575
9791
  } else if (attempt < maxAttempts) {
9576
9792
  requestAnimationFrame(tryFit);
9793
+ } else {
9794
+ finishFit();
9577
9795
  }
9578
9796
  }
9579
9797
  requestAnimationFrame(tryFit);
@@ -9688,15 +9906,17 @@
9688
9906
  case 'output':
9689
9907
  // Update session output (for terminal display and local message parsing)
9690
9908
  // NOTE: For structured sessions, output may be "" during streaming — check messages too
9691
- if (msg.data && (msg.data.output || msg.data.messages) && msg.sessionId) {
9692
- var snapshot = { id: msg.sessionId, output: msg.data.output };
9909
+ if (msg.data && msg.sessionId) {
9910
+ var isIncremental = !!msg.data.incremental;
9911
+ var snapshot = { id: msg.sessionId };
9912
+
9913
+ // Carry over small metadata fields present in both modes
9914
+ if (!isIncremental && msg.data.output !== undefined) {
9915
+ snapshot.output = msg.data.output;
9916
+ }
9693
9917
  if (Object.prototype.hasOwnProperty.call(msg.data, 'permissionBlocked')) {
9694
9918
  snapshot.permissionBlocked = !!msg.data.permissionBlocked;
9695
9919
  }
9696
- // Pass structured messages if available from JSON chat mode
9697
- if (msg.data.messages) {
9698
- snapshot.messages = msg.data.messages;
9699
- }
9700
9920
  if (Object.prototype.hasOwnProperty.call(msg.data, 'queuedMessages')) {
9701
9921
  snapshot.queuedMessages = msg.data.queuedMessages || [];
9702
9922
  state.queueEpoch++;
@@ -9704,19 +9924,44 @@
9704
9924
  if (msg.data.structuredState) {
9705
9925
  snapshot.structuredState = msg.data.structuredState;
9706
9926
  }
9707
- updateSessionSnapshot(snapshot);
9708
- if (msg.sessionId === state.selectedId) {
9709
- var updatedSession = state.sessions.find(function(s) { return s.id === msg.sessionId; }) || snapshot;
9710
- state.currentMessages = buildMessagesForRender(updatedSession, getPreferredMessages(updatedSession, msg.data.output, false));
9711
- updateTaskDisplay();
9712
- // Structured sessions: render immediately for responsiveness
9713
- if (updatedSession.sessionKind === 'structured' || msg.data.sessionKind === 'structured') {
9714
- renderChat();
9715
- } else {
9716
- scheduleChatRender();
9927
+ if (msg.data.sessionKind) {
9928
+ snapshot.sessionKind = msg.data.sessionKind;
9929
+ }
9930
+
9931
+ if (isIncremental && msg.data.lastMessage) {
9932
+ // Incremental mode: merge lastMessage into existing session messages
9933
+ var existingSession = state.sessions.find(function(s) { return s.id === msg.sessionId; });
9934
+ if (existingSession) {
9935
+ var msgs = Array.isArray(existingSession.messages) ? existingSession.messages.slice() : [];
9936
+ var expectedCount = msg.data.messageCount || 0;
9937
+ // Replace last turn if same role, or append if new turn
9938
+ if (msgs.length > 0 && msg.data.lastMessage.role && msgs[msgs.length - 1].role === msg.data.lastMessage.role) {
9939
+ msgs[msgs.length - 1] = msg.data.lastMessage;
9940
+ } else if (msgs.length < expectedCount) {
9941
+ msgs.push(msg.data.lastMessage);
9942
+ }
9943
+ snapshot.messages = msgs;
9717
9944
  }
9945
+ } else if (!isIncremental && msg.data.messages) {
9946
+ // Full mode (backward compatible)
9947
+ snapshot.messages = msg.data.messages;
9718
9948
  }
9719
9949
 
9950
+ // Only update if we have meaningful data
9951
+ if (snapshot.output !== undefined || snapshot.messages || isIncremental || msg.data.permissionBlocked !== undefined) {
9952
+ updateSessionSnapshot(snapshot);
9953
+ if (msg.sessionId === state.selectedId) {
9954
+ var updatedSession = state.sessions.find(function(s) { return s.id === msg.sessionId; }) || snapshot;
9955
+ state.currentMessages = buildMessagesForRender(updatedSession, getPreferredMessages(updatedSession, updatedSession.output, false));
9956
+ updateTaskDisplay();
9957
+ // Structured sessions: render immediately for responsiveness
9958
+ if (updatedSession.sessionKind === 'structured' || msg.data.sessionKind === 'structured') {
9959
+ renderChat();
9960
+ } else {
9961
+ scheduleChatRender();
9962
+ }
9963
+ }
9964
+ }
9720
9965
  }
9721
9966
  // Real-time terminal output
9722
9967
  if (msg.sessionId === state.selectedId && state.terminal && msg.data) {
@@ -9734,8 +9979,8 @@
9734
9979
  maybeScrollTerminalToBottom("output");
9735
9980
  updateTerminalJumpToBottomButton();
9736
9981
  scheduleMobileDomUpdate();
9737
- } else if (Object.prototype.hasOwnProperty.call(msg.data, "output")) {
9738
- // Fallback: no chunk available, use full-output comparison
9982
+ } else if (!msg.data.incremental && Object.prototype.hasOwnProperty.call(msg.data, "output")) {
9983
+ // Fallback: no chunk available, use full-output comparison (only in full mode)
9739
9984
  syncTerminalBuffer(msg.sessionId, msg.data.output || "", { mode: "append" });
9740
9985
  }
9741
9986
  }
@@ -9784,16 +10029,7 @@
9784
10029
  } else {
9785
10030
  endedNotifBody = endedSession ? (endedSession.command || msg.sessionId) : msg.sessionId;
9786
10031
  }
9787
- sendBrowserNotification(
9788
- endedNotifTitle,
9789
- endedNotifBody,
9790
- {
9791
- tag: "wand-ended-" + msg.sessionId,
9792
- onClick: function() {
9793
- if (msg.sessionId !== state.selectedId) selectSession(msg.sessionId);
9794
- }
9795
- }
9796
- );
10032
+ notifyTaskEnded(msg.sessionId, endedNotifTitle, endedNotifBody);
9797
10033
  if (msg.sessionId !== state.selectedId || document.hidden) {
9798
10034
  showNotificationBubble({
9799
10035
  title: endedNotifTitle,
@@ -9879,6 +10115,7 @@
9879
10115
  state.currentTask = msg.data || null;
9880
10116
  updateTaskDisplay();
9881
10117
  }
10118
+ notifyTaskProgress(msg.sessionId, msg.data || null);
9882
10119
  // Update session list to reflect current activity (debounced)
9883
10120
  scheduleSessionListUpdate();
9884
10121
  break;
@@ -9925,18 +10162,7 @@
9925
10162
  } else {
9926
10163
  permBody += "\n" + permDetail;
9927
10164
  }
9928
- sendBrowserNotification(
9929
- "需要你的授权",
9930
- permBody,
9931
- {
9932
- tag: "wand-perm-" + msg.sessionId,
9933
- onClick: function() {
9934
- if (msg.sessionId !== state.selectedId) {
9935
- selectSession(msg.sessionId);
9936
- }
9937
- }
9938
- }
9939
- );
10165
+ notifyPermissionRequest(msg.sessionId, permBody);
9940
10166
  // In-app bubble if not currently viewing this session
9941
10167
  if (msg.sessionId !== state.selectedId) {
9942
10168
  showNotificationBubble({
@@ -9980,12 +10206,7 @@
9980
10206
  case 'notification':
9981
10207
  if (msg.data) {
9982
10208
  if (msg.data.kind === "update") {
9983
- showUpdateBubble(msg.data.current || "-", msg.data.latest || "-");
9984
- sendBrowserNotification(
9985
- "Wand \u53d1\u73b0\u65b0\u7248\u672c",
9986
- "\u5f53\u524d " + (msg.data.current || "-") + " \u2192 \u6700\u65b0 " + (msg.data.latest || "-"),
9987
- { tag: "wand-update" }
9988
- );
10209
+ notifyUpdateAvailable(msg.data.current || "-", msg.data.latest || "-");
9989
10210
  } else if (msg.data.kind === "restart") {
9990
10211
  showRestartOverlay();
9991
10212
  }
@@ -11754,7 +11975,7 @@
11754
11975
  if (msg.role === "thinking") {
11755
11976
  var thinkingKey = buildExpandKey("thinking", [getMessageKey(msg, messageIndex), "pty"]);
11756
11977
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
11757
- var thinkingExpanded = thinkingPersisted === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.thinking) : thinkingPersisted;
11978
+ var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
11758
11979
  return '<div class="chat-message thinking">' +
11759
11980
  '<div class="thinking-inline thinking-pty ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="" onclick="__thinkingToggle(this)">' +
11760
11981
  '<span class="thinking-inline-icon">⦿</span>' +
@@ -11892,7 +12113,7 @@
11892
12113
  var summaryText = parts.join(" · ");
11893
12114
  var groupKey = buildExpandKey("tool-group", [messageKey, items[0] && items[0].index, items.length]);
11894
12115
  var persistedExpanded = getPersistedExpandState(groupKey);
11895
- var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.toolGroup) : persistedExpanded;
12116
+ var shouldExpand = persistedExpanded === null ? getCardDefault("toolGroup") : persistedExpanded;
11896
12117
 
11897
12118
  // Render each item's inline-tool card
11898
12119
  var innerHtml = "";
@@ -12004,7 +12225,7 @@
12004
12225
  }
12005
12226
  var thinkingKey = buildExpandKey("thinking", [messageKey, index]);
12006
12227
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
12007
- var thinkingExpanded = thinkingPersisted === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.thinking) : thinkingPersisted;
12228
+ var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
12008
12229
  return '<div class="thinking-inline ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="' + escapeHtml(thinkingText) + '" onclick="__thinkingToggle(this)">' +
12009
12230
  '<span class="thinking-inline-icon">⦿</span>' +
12010
12231
  '<span class="thinking-inline-preview">' + escapeHtml(thinkingExpanded ? thinkingText : preview) + '</span>' +
@@ -12101,7 +12322,7 @@
12101
12322
  var fullResult = resultContent;
12102
12323
 
12103
12324
  var expandedHtml = "";
12104
- var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.inlineTools) : persistedExpanded;
12325
+ var shouldExpand = persistedExpanded === null ? getCardDefault("inlineTools") : persistedExpanded;
12105
12326
  if (hasResult) {
12106
12327
  expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';">' +
12107
12328
  '<div class="inline-tool-result">' + formatInlineResult(resultContent, toolName) + '</div>' +
@@ -12186,7 +12407,7 @@
12186
12407
 
12187
12408
  // Show command preview in header (truncate long commands)
12188
12409
  var cmdPreview = command.length > 80 ? command.slice(0, 77) + "…" : command;
12189
- var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.terminal) : persistedExpanded;
12410
+ var shouldExpand = persistedExpanded === null ? getCardDefault("terminal") : persistedExpanded;
12190
12411
 
12191
12412
  var termTruncated = toolResult && toolResult._truncated === true;
12192
12413
  var termTruncAttrs = termTruncated
@@ -12275,7 +12496,7 @@
12275
12496
  // Expand state: respect cardDefaults.editCards and persisted state
12276
12497
  var expandKey = buildExpandKey("diff", [messageKey, toolId || index, index]);
12277
12498
  var persistedExpanded = getPersistedExpandState(expandKey);
12278
- var cardDefaultExpand = !!(state.config && state.config.cardDefaults && state.config.cardDefaults.editCards);
12499
+ var cardDefaultExpand = getCardDefault("editCards");
12279
12500
  var shouldExpand = persistedExpanded === null ? (statusClass === "diff-pending" || cardDefaultExpand) : persistedExpanded;
12280
12501
  var collapsedClass = shouldExpand ? "" : " collapsed";
12281
12502
 
@@ -12484,7 +12705,7 @@
12484
12705
 
12485
12706
  var expandKey = buildExpandKey("tool-card", [messageKey, toolId]);
12486
12707
  var persistedExpanded = getPersistedExpandState(expandKey);
12487
- var cardDefaultExpand = !!(state.config && state.config.cardDefaults && state.config.cardDefaults.editCards);
12708
+ var cardDefaultExpand = getCardDefault("editCards");
12488
12709
  var shouldExpand = persistedExpanded === null ? (statusClass === "loading" || cardDefaultExpand) : persistedExpanded;
12489
12710
  var tcTruncated = toolResult && toolResult._truncated === true;
12490
12711
  var collapsedClass = shouldExpand ? "" : " collapsed";
@@ -13041,13 +13262,39 @@
13041
13262
  }
13042
13263
  }
13043
13264
 
13265
+ function _shouldSendSystemNotification(opts) {
13266
+ var options = opts || {};
13267
+ if (options.onlyWhenHidden && !document.hidden) return false;
13268
+ if (options.skipWhenSelectedSessionId && options.skipWhenSelectedSessionId === state.selectedId && !document.hidden) {
13269
+ return false;
13270
+ }
13271
+ return true;
13272
+ }
13273
+
13274
+ function _isNotificationThrottled(tag, minIntervalMs) {
13275
+ if (!tag || !minIntervalMs || minIntervalMs <= 0) return false;
13276
+ var lastAt = state.notificationHistory[tag] || 0;
13277
+ var now = Date.now();
13278
+ if (now - lastAt < minIntervalMs) return true;
13279
+ state.notificationHistory[tag] = now;
13280
+ return false;
13281
+ }
13282
+
13044
13283
  function sendBrowserNotification(title, body, opts) {
13284
+ var options = opts || {};
13285
+ var tag = options.tag || "";
13286
+ if (!_shouldSendSystemNotification(options)) return;
13287
+ if (_isNotificationThrottled(tag, options.minIntervalMs || 0)) return;
13045
13288
  // Native Android bridge path
13046
13289
  if (_hasNativeBridge) {
13047
13290
  var perm = _getNativePermission();
13048
13291
  if (perm !== "granted") return;
13049
13292
  try {
13050
- WandNative.sendNotification(title || "Wand", body || "", (opts && opts.tag) || "");
13293
+ var nativeTag = tag;
13294
+ if (options.kind) {
13295
+ nativeTag = options.kind + (tag ? ":" + tag : "");
13296
+ }
13297
+ WandNative.sendNotification(title || "Wand", body || "", nativeTag || "");
13051
13298
  } catch (_e) {}
13052
13299
  return;
13053
13300
  }
@@ -13057,13 +13304,13 @@
13057
13304
  try {
13058
13305
  var n = new Notification(title, {
13059
13306
  body: body || "",
13060
- icon: (opts && opts.icon) || "/favicon.ico",
13061
- tag: (opts && opts.tag) || undefined,
13307
+ icon: options.icon || "/favicon.ico",
13308
+ tag: tag || undefined,
13062
13309
  });
13063
13310
  n.onclick = function() {
13064
13311
  window.focus();
13065
13312
  n.close();
13066
- if (opts && opts.onClick) opts.onClick();
13313
+ if (options.onClick) options.onClick();
13067
13314
  };
13068
13315
  // Auto-close after 10s
13069
13316
  setTimeout(function() { n.close(); }, 10000);
@@ -13072,6 +13319,76 @@
13072
13319
  }
13073
13320
  }
13074
13321
 
13322
+ function notifyTaskProgress(sessionId, task) {
13323
+ if (!task || !task.title) return;
13324
+ var session = state.sessions.find(function(s) { return s.id === sessionId; });
13325
+ if (!session) return;
13326
+ var sessionLabel = session.summary || session.command || sessionId;
13327
+ sendBrowserNotification(
13328
+ "任务进行中",
13329
+ sessionLabel + "\n" + task.title,
13330
+ {
13331
+ kind: "task",
13332
+ tag: "wand-task-" + sessionId + "-" + task.title,
13333
+ minIntervalMs: 90000,
13334
+ onlyWhenHidden: true,
13335
+ skipWhenSelectedSessionId: sessionId,
13336
+ onClick: function() {
13337
+ if (sessionId !== state.selectedId) selectSession(sessionId);
13338
+ }
13339
+ }
13340
+ );
13341
+ }
13342
+
13343
+ function notifyUpdateAvailable(currentVersion, latestVersion) {
13344
+ showUpdateBubble(currentVersion || "-", latestVersion || "-");
13345
+ sendBrowserNotification(
13346
+ "Wand 发现新版本",
13347
+ "当前 " + (currentVersion || "-") + " → 最新 " + (latestVersion || "-"),
13348
+ {
13349
+ kind: "update",
13350
+ tag: "wand-update",
13351
+ minIntervalMs: 300000,
13352
+ }
13353
+ );
13354
+ }
13355
+
13356
+ function notifyPermissionRequest(sessionId, body) {
13357
+ sendBrowserNotification(
13358
+ "需要你的授权",
13359
+ body,
13360
+ {
13361
+ kind: "permission",
13362
+ tag: "wand-perm-" + sessionId,
13363
+ minIntervalMs: 60000,
13364
+ onlyWhenHidden: true,
13365
+ skipWhenSelectedSessionId: sessionId,
13366
+ onClick: function() {
13367
+ if (sessionId !== state.selectedId) {
13368
+ selectSession(sessionId);
13369
+ }
13370
+ }
13371
+ }
13372
+ );
13373
+ }
13374
+
13375
+ function notifyTaskEnded(sessionId, title, body) {
13376
+ sendBrowserNotification(
13377
+ title,
13378
+ body,
13379
+ {
13380
+ kind: "task-ended",
13381
+ tag: "wand-ended-" + sessionId,
13382
+ minIntervalMs: 10000,
13383
+ onClick: function() {
13384
+ if (sessionId !== state.selectedId) selectSession(sessionId);
13385
+ }
13386
+ }
13387
+ );
13388
+ }
13389
+
13390
+ /**
13391
+
13075
13392
  /**
13076
13393
  * Play a soft, rounded notification chime using Web Audio API.
13077
13394
  * Two ascending sine tones with smooth gain envelope — gentle on the ears.
@@ -13099,13 +13416,15 @@
13099
13416
  // Some browsers suspend AudioContext until user gesture — resume it
13100
13417
  if (ctx.state === "suspended") ctx.resume();
13101
13418
 
13419
+ var vol = (state.notifVolume || 0) / 100;
13420
+
13102
13421
  function tone(freq, start, dur) {
13103
13422
  var osc = ctx.createOscillator();
13104
13423
  var gain = ctx.createGain();
13105
13424
  osc.type = "sine";
13106
13425
  osc.frequency.value = freq;
13107
13426
  gain.gain.setValueAtTime(0, ctx.currentTime + start);
13108
- gain.gain.linearRampToValueAtTime(0.18, ctx.currentTime + start + 0.04);
13427
+ gain.gain.linearRampToValueAtTime(0.5 * vol, ctx.currentTime + start + 0.04);
13109
13428
  gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
13110
13429
  osc.connect(gain);
13111
13430
  gain.connect(ctx.destination);