@co0ontty/wand 1.14.6 → 1.15.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.
@@ -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);
@@ -9784,16 +10002,7 @@
9784
10002
  } else {
9785
10003
  endedNotifBody = endedSession ? (endedSession.command || msg.sessionId) : msg.sessionId;
9786
10004
  }
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
- );
10005
+ notifyTaskEnded(msg.sessionId, endedNotifTitle, endedNotifBody);
9797
10006
  if (msg.sessionId !== state.selectedId || document.hidden) {
9798
10007
  showNotificationBubble({
9799
10008
  title: endedNotifTitle,
@@ -9879,6 +10088,7 @@
9879
10088
  state.currentTask = msg.data || null;
9880
10089
  updateTaskDisplay();
9881
10090
  }
10091
+ notifyTaskProgress(msg.sessionId, msg.data || null);
9882
10092
  // Update session list to reflect current activity (debounced)
9883
10093
  scheduleSessionListUpdate();
9884
10094
  break;
@@ -9925,18 +10135,7 @@
9925
10135
  } else {
9926
10136
  permBody += "\n" + permDetail;
9927
10137
  }
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
- );
10138
+ notifyPermissionRequest(msg.sessionId, permBody);
9940
10139
  // In-app bubble if not currently viewing this session
9941
10140
  if (msg.sessionId !== state.selectedId) {
9942
10141
  showNotificationBubble({
@@ -9980,12 +10179,7 @@
9980
10179
  case 'notification':
9981
10180
  if (msg.data) {
9982
10181
  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
- );
10182
+ notifyUpdateAvailable(msg.data.current || "-", msg.data.latest || "-");
9989
10183
  } else if (msg.data.kind === "restart") {
9990
10184
  showRestartOverlay();
9991
10185
  }
@@ -11754,7 +11948,7 @@
11754
11948
  if (msg.role === "thinking") {
11755
11949
  var thinkingKey = buildExpandKey("thinking", [getMessageKey(msg, messageIndex), "pty"]);
11756
11950
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
11757
- var thinkingExpanded = thinkingPersisted === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.thinking) : thinkingPersisted;
11951
+ var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
11758
11952
  return '<div class="chat-message thinking">' +
11759
11953
  '<div class="thinking-inline thinking-pty ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="" onclick="__thinkingToggle(this)">' +
11760
11954
  '<span class="thinking-inline-icon">⦿</span>' +
@@ -11892,7 +12086,7 @@
11892
12086
  var summaryText = parts.join(" · ");
11893
12087
  var groupKey = buildExpandKey("tool-group", [messageKey, items[0] && items[0].index, items.length]);
11894
12088
  var persistedExpanded = getPersistedExpandState(groupKey);
11895
- var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.toolGroup) : persistedExpanded;
12089
+ var shouldExpand = persistedExpanded === null ? getCardDefault("toolGroup") : persistedExpanded;
11896
12090
 
11897
12091
  // Render each item's inline-tool card
11898
12092
  var innerHtml = "";
@@ -12004,7 +12198,7 @@
12004
12198
  }
12005
12199
  var thinkingKey = buildExpandKey("thinking", [messageKey, index]);
12006
12200
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
12007
- var thinkingExpanded = thinkingPersisted === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.thinking) : thinkingPersisted;
12201
+ var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
12008
12202
  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
12203
  '<span class="thinking-inline-icon">⦿</span>' +
12010
12204
  '<span class="thinking-inline-preview">' + escapeHtml(thinkingExpanded ? thinkingText : preview) + '</span>' +
@@ -12101,7 +12295,7 @@
12101
12295
  var fullResult = resultContent;
12102
12296
 
12103
12297
  var expandedHtml = "";
12104
- var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.inlineTools) : persistedExpanded;
12298
+ var shouldExpand = persistedExpanded === null ? getCardDefault("inlineTools") : persistedExpanded;
12105
12299
  if (hasResult) {
12106
12300
  expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';">' +
12107
12301
  '<div class="inline-tool-result">' + formatInlineResult(resultContent, toolName) + '</div>' +
@@ -12186,7 +12380,7 @@
12186
12380
 
12187
12381
  // Show command preview in header (truncate long commands)
12188
12382
  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;
12383
+ var shouldExpand = persistedExpanded === null ? getCardDefault("terminal") : persistedExpanded;
12190
12384
 
12191
12385
  var termTruncated = toolResult && toolResult._truncated === true;
12192
12386
  var termTruncAttrs = termTruncated
@@ -12275,7 +12469,7 @@
12275
12469
  // Expand state: respect cardDefaults.editCards and persisted state
12276
12470
  var expandKey = buildExpandKey("diff", [messageKey, toolId || index, index]);
12277
12471
  var persistedExpanded = getPersistedExpandState(expandKey);
12278
- var cardDefaultExpand = !!(state.config && state.config.cardDefaults && state.config.cardDefaults.editCards);
12472
+ var cardDefaultExpand = getCardDefault("editCards");
12279
12473
  var shouldExpand = persistedExpanded === null ? (statusClass === "diff-pending" || cardDefaultExpand) : persistedExpanded;
12280
12474
  var collapsedClass = shouldExpand ? "" : " collapsed";
12281
12475
 
@@ -12484,7 +12678,7 @@
12484
12678
 
12485
12679
  var expandKey = buildExpandKey("tool-card", [messageKey, toolId]);
12486
12680
  var persistedExpanded = getPersistedExpandState(expandKey);
12487
- var cardDefaultExpand = !!(state.config && state.config.cardDefaults && state.config.cardDefaults.editCards);
12681
+ var cardDefaultExpand = getCardDefault("editCards");
12488
12682
  var shouldExpand = persistedExpanded === null ? (statusClass === "loading" || cardDefaultExpand) : persistedExpanded;
12489
12683
  var tcTruncated = toolResult && toolResult._truncated === true;
12490
12684
  var collapsedClass = shouldExpand ? "" : " collapsed";
@@ -13041,13 +13235,39 @@
13041
13235
  }
13042
13236
  }
13043
13237
 
13238
+ function _shouldSendSystemNotification(opts) {
13239
+ var options = opts || {};
13240
+ if (options.onlyWhenHidden && !document.hidden) return false;
13241
+ if (options.skipWhenSelectedSessionId && options.skipWhenSelectedSessionId === state.selectedId && !document.hidden) {
13242
+ return false;
13243
+ }
13244
+ return true;
13245
+ }
13246
+
13247
+ function _isNotificationThrottled(tag, minIntervalMs) {
13248
+ if (!tag || !minIntervalMs || minIntervalMs <= 0) return false;
13249
+ var lastAt = state.notificationHistory[tag] || 0;
13250
+ var now = Date.now();
13251
+ if (now - lastAt < minIntervalMs) return true;
13252
+ state.notificationHistory[tag] = now;
13253
+ return false;
13254
+ }
13255
+
13044
13256
  function sendBrowserNotification(title, body, opts) {
13257
+ var options = opts || {};
13258
+ var tag = options.tag || "";
13259
+ if (!_shouldSendSystemNotification(options)) return;
13260
+ if (_isNotificationThrottled(tag, options.minIntervalMs || 0)) return;
13045
13261
  // Native Android bridge path
13046
13262
  if (_hasNativeBridge) {
13047
13263
  var perm = _getNativePermission();
13048
13264
  if (perm !== "granted") return;
13049
13265
  try {
13050
- WandNative.sendNotification(title || "Wand", body || "", (opts && opts.tag) || "");
13266
+ var nativeTag = tag;
13267
+ if (options.kind) {
13268
+ nativeTag = options.kind + (tag ? ":" + tag : "");
13269
+ }
13270
+ WandNative.sendNotification(title || "Wand", body || "", nativeTag || "");
13051
13271
  } catch (_e) {}
13052
13272
  return;
13053
13273
  }
@@ -13057,13 +13277,13 @@
13057
13277
  try {
13058
13278
  var n = new Notification(title, {
13059
13279
  body: body || "",
13060
- icon: (opts && opts.icon) || "/favicon.ico",
13061
- tag: (opts && opts.tag) || undefined,
13280
+ icon: options.icon || "/favicon.ico",
13281
+ tag: tag || undefined,
13062
13282
  });
13063
13283
  n.onclick = function() {
13064
13284
  window.focus();
13065
13285
  n.close();
13066
- if (opts && opts.onClick) opts.onClick();
13286
+ if (options.onClick) options.onClick();
13067
13287
  };
13068
13288
  // Auto-close after 10s
13069
13289
  setTimeout(function() { n.close(); }, 10000);
@@ -13072,6 +13292,76 @@
13072
13292
  }
13073
13293
  }
13074
13294
 
13295
+ function notifyTaskProgress(sessionId, task) {
13296
+ if (!task || !task.title) return;
13297
+ var session = state.sessions.find(function(s) { return s.id === sessionId; });
13298
+ if (!session) return;
13299
+ var sessionLabel = session.summary || session.command || sessionId;
13300
+ sendBrowserNotification(
13301
+ "任务进行中",
13302
+ sessionLabel + "\n" + task.title,
13303
+ {
13304
+ kind: "task",
13305
+ tag: "wand-task-" + sessionId + "-" + task.title,
13306
+ minIntervalMs: 90000,
13307
+ onlyWhenHidden: true,
13308
+ skipWhenSelectedSessionId: sessionId,
13309
+ onClick: function() {
13310
+ if (sessionId !== state.selectedId) selectSession(sessionId);
13311
+ }
13312
+ }
13313
+ );
13314
+ }
13315
+
13316
+ function notifyUpdateAvailable(currentVersion, latestVersion) {
13317
+ showUpdateBubble(currentVersion || "-", latestVersion || "-");
13318
+ sendBrowserNotification(
13319
+ "Wand 发现新版本",
13320
+ "当前 " + (currentVersion || "-") + " → 最新 " + (latestVersion || "-"),
13321
+ {
13322
+ kind: "update",
13323
+ tag: "wand-update",
13324
+ minIntervalMs: 300000,
13325
+ }
13326
+ );
13327
+ }
13328
+
13329
+ function notifyPermissionRequest(sessionId, body) {
13330
+ sendBrowserNotification(
13331
+ "需要你的授权",
13332
+ body,
13333
+ {
13334
+ kind: "permission",
13335
+ tag: "wand-perm-" + sessionId,
13336
+ minIntervalMs: 60000,
13337
+ onlyWhenHidden: true,
13338
+ skipWhenSelectedSessionId: sessionId,
13339
+ onClick: function() {
13340
+ if (sessionId !== state.selectedId) {
13341
+ selectSession(sessionId);
13342
+ }
13343
+ }
13344
+ }
13345
+ );
13346
+ }
13347
+
13348
+ function notifyTaskEnded(sessionId, title, body) {
13349
+ sendBrowserNotification(
13350
+ title,
13351
+ body,
13352
+ {
13353
+ kind: "task-ended",
13354
+ tag: "wand-ended-" + sessionId,
13355
+ minIntervalMs: 10000,
13356
+ onClick: function() {
13357
+ if (sessionId !== state.selectedId) selectSession(sessionId);
13358
+ }
13359
+ }
13360
+ );
13361
+ }
13362
+
13363
+ /**
13364
+
13075
13365
  /**
13076
13366
  * Play a soft, rounded notification chime using Web Audio API.
13077
13367
  * Two ascending sine tones with smooth gain envelope — gentle on the ears.
@@ -13099,13 +13389,15 @@
13099
13389
  // Some browsers suspend AudioContext until user gesture — resume it
13100
13390
  if (ctx.state === "suspended") ctx.resume();
13101
13391
 
13392
+ var vol = (state.notifVolume || 0) / 100;
13393
+
13102
13394
  function tone(freq, start, dur) {
13103
13395
  var osc = ctx.createOscillator();
13104
13396
  var gain = ctx.createGain();
13105
13397
  osc.type = "sine";
13106
13398
  osc.frequency.value = freq;
13107
13399
  gain.gain.setValueAtTime(0, ctx.currentTime + start);
13108
- gain.gain.linearRampToValueAtTime(0.18, ctx.currentTime + start + 0.04);
13400
+ gain.gain.linearRampToValueAtTime(0.5 * vol, ctx.currentTime + start + 0.04);
13109
13401
  gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
13110
13402
  osc.connect(gain);
13111
13403
  gain.connect(ctx.destination);