@co0ontty/wand 1.14.3 → 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,
@@ -109,6 +110,9 @@
109
110
  loginChecked: false,
110
111
  bootstrapping: true,
111
112
  sessionsDrawerOpen: false,
113
+ sidebarPinned: (function() {
114
+ try { return localStorage.getItem("wand-sidebar-pinned") === "true"; } catch (e) { return false; }
115
+ })(),
112
116
  modalOpen: false,
113
117
  presetValue: "",
114
118
  cwdValue: "",
@@ -131,9 +135,14 @@
131
135
  ws: null,
132
136
  wsConnected: false,
133
137
  _updateBubbleShown: false,
138
+ notificationHistory: {},
139
+ delayedNotificationTimer: null,
134
140
  notifSound: (function() {
135
141
  try { var v = localStorage.getItem("wand-notif-sound"); return v === null ? true : v === "true"; } catch (e) { return true; }
136
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
+ })(),
137
146
  notifBubble: (function() {
138
147
  try { var v = localStorage.getItem("wand-notif-bubble"); return v === null ? true : v === "true"; } catch (e) { return true; }
139
148
  })(),
@@ -652,6 +661,7 @@
652
661
  if (!el) return false;
653
662
  switch (kind) {
654
663
  case "tool-card":
664
+ case "diff":
655
665
  return !el.classList.contains("collapsed");
656
666
  case "thinking":
657
667
  return el.classList.contains("expanded") && !el.classList.contains("collapsed");
@@ -672,7 +682,8 @@
672
682
  function applyExpandedState(el, kind, expanded) {
673
683
  if (!el) return;
674
684
  switch (kind) {
675
- case "tool-card": {
685
+ case "tool-card":
686
+ case "diff": {
676
687
  el.classList.toggle("collapsed", !expanded);
677
688
  break;
678
689
  }
@@ -886,8 +897,7 @@
886
897
  refreshAll();
887
898
  requestNotificationPermission();
888
899
  if (config.updateAvailable && config.latestVersion) {
889
- showUpdateBubble(config.currentVersion || "-", config.latestVersion);
890
- 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);
891
901
  }
892
902
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
893
903
  loadClaudeHistory();
@@ -932,6 +942,10 @@
932
942
  // Suppress CSS transitions during initial DOM build
933
943
  document.documentElement.classList.add("no-transition");
934
944
 
945
+ // Apply persisted pin state before rendering
946
+ if (state.sidebarPinned && !isMobileLayout()) {
947
+ state.sessionsDrawerOpen = true;
948
+ }
935
949
  app.innerHTML = isLoggedIn ? renderAppShell() : renderLogin();
936
950
  // Reset chat render tracking since DOM was fully replaced
937
951
  resetChatRenderCache();
@@ -1083,8 +1097,8 @@
1083
1097
 
1084
1098
  return '<div class="app-container">' +
1085
1099
  '<div id="sessions-drawer-backdrop" class="drawer-backdrop' + drawerClass + '"></div>' +
1086
- '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + '">' +
1087
- '<aside id="sessions-drawer" class="sidebar' + drawerClass + '">' +
1100
+ '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + (state.sidebarPinned && !isMobileLayout() ? ' sidebar-pinned' : '') + '">' +
1101
+ '<aside id="sessions-drawer" class="sidebar' + drawerClass + (state.sidebarPinned && !isMobileLayout() ? ' pinned' : '') + '">' +
1088
1102
  '<div class="sidebar-header">' +
1089
1103
  '<div class="sidebar-header-main">' +
1090
1104
  '<div class="topbar-logo-icon">W</div>' +
@@ -1098,6 +1112,9 @@
1098
1112
  '<button id="sidebar-refresh-btn" class="btn btn-ghost btn-sm" type="button" title="刷新页面">' +
1099
1113
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>' +
1100
1114
  '</button>' +
1115
+ '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '取消固定侧栏' : '固定侧栏') + '">' +
1116
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg>' +
1117
+ '</button>' +
1101
1118
  '<button id="close-drawer-button" class="btn btn-ghost btn-sm sidebar-close" type="button" aria-label="关闭菜单">×</button>' +
1102
1119
  '</div>' +
1103
1120
  '</div>' +
@@ -1304,23 +1321,55 @@
1304
1321
  function renderSettingsModal() {
1305
1322
  return '<section id="settings-modal" class="modal-backdrop hidden">' +
1306
1323
  '<div class="modal settings-modal">' +
1307
- '<div class="modal-header">' +
1308
- '<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>' +
1309
1329
  '<button id="close-settings-button" class="btn btn-ghost btn-icon">×</button>' +
1310
1330
  '</div>' +
1311
- '<div class="modal-body">' +
1312
- // Tabs
1313
- '<div class="settings-tabs">' +
1314
- '<button class="settings-tab active" data-tab="about">\u5173\u4e8e</button>' +
1315
- '<button class="settings-tab" data-tab="general">\u57fa\u672c\u914d\u7f6e</button>' +
1316
- '<button class="settings-tab" data-tab="notifications">\u901a\u77e5</button>' +
1317
- '<button class="settings-tab" data-tab="security">\u5b89\u5168</button>' +
1318
- '<button class="settings-tab" data-tab="presets">\u547d\u4ee4\u9884\u8bbe</button>' +
1319
- '<button class="settings-tab" data-tab="display">\u663e\u793a</button>' +
1320
- '</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">' +
1321
1366
 
1322
1367
  // About tab
1323
- '<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>' +
1324
1373
  '<div class="settings-about-info">' +
1325
1374
  '<div class="settings-about-row"><span class="settings-label">包名</span><span class="settings-value" id="settings-pkg-name">-</span></div>' +
1326
1375
  '<div class="settings-about-row"><span class="settings-label">当前版本</span><span class="settings-value" id="settings-version">-</span></div>' +
@@ -1367,18 +1416,40 @@
1367
1416
  '</div>' +
1368
1417
 
1369
1418
  // Notifications tab
1370
- '<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>' +
1371
1424
  '<div class="settings-section-title">\u901a\u77e5\u504f\u597d</div>' +
1372
1425
  '<div class="field field-inline">' +
1373
1426
  '<input id="cfg-notif-sound" type="checkbox" class="field-checkbox" />' +
1374
1427
  '<label class="field-label" for="cfg-notif-sound">\u64ad\u653e\u63d0\u793a\u97f3</label>' +
1375
1428
  '</div>' +
1376
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>' +
1377
1437
  '<div class="field field-inline">' +
1378
1438
  '<input id="cfg-notif-bubble" type="checkbox" class="field-checkbox" />' +
1379
1439
  '<label class="field-label" for="cfg-notif-bubble">\u5e94\u7528\u5185\u901a\u77e5\u6c14\u6ce1</label>' +
1380
1440
  '</div>' +
1381
1441
  '<p class="hint" style="margin-top:0;margin-bottom:10px">\u5728\u9875\u9762\u9876\u90e8\u5f39\u51fa\u6d6e\u52a8\u901a\u77e5\u6c14\u6ce1</p>' +
1442
+ '<div id="native-sound-section" class="settings-notification-section hidden" style="margin-top:6px">' +
1443
+ '<div class="settings-section-title">\u7cfb\u7edf\u901a\u77e5\u94c3\u58f0</div>' +
1444
+ '<div class="settings-about-row">' +
1445
+ '<span class="settings-label">\u94c3\u58f0</span>' +
1446
+ '<div style="display:flex;align-items:center;gap:6px">' +
1447
+ '<select id="native-sound-select" class="field-select" style="min-width:100px"></select>' +
1448
+ '<button id="native-sound-preview" class="btn btn-ghost btn-sm">\u25b6 \u8bd5\u542c</button>' +
1449
+ '</div>' +
1450
+ '</div>' +
1451
+ '<p class="hint" style="margin-top:0">\u9009\u62e9 Android \u7cfb\u7edf\u901a\u77e5\u4f7f\u7528\u7684\u94c3\u58f0</p>' +
1452
+ '</div>' +
1382
1453
  '<div class="settings-notification-section" style="margin-top:6px">' +
1383
1454
  '<div class="settings-section-title">\u6d4f\u89c8\u5668\u901a\u77e5</div>' +
1384
1455
  '<div class="settings-about-row">' +
@@ -1389,13 +1460,18 @@
1389
1460
  '<button id="notification-request-btn" class="btn btn-ghost btn-sm hidden">\u6388\u6743\u901a\u77e5</button>' +
1390
1461
  '<button id="notification-reset-btn" class="btn btn-ghost btn-sm hidden">\u91cd\u65b0\u6388\u6743</button>' +
1391
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>' +
1392
1464
  '</div>' +
1393
1465
  '<p id="notification-test-message" class="hint hidden"></p>' +
1394
1466
  '</div>' +
1395
1467
  '</div>' +
1396
1468
 
1397
1469
  // General config tab
1398
- '<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>' +
1399
1475
  '<div class="field-row">' +
1400
1476
  '<div class="field">' +
1401
1477
  '<label class="field-label" for="cfg-host">监听地址 (host)</label>' +
@@ -1469,12 +1545,18 @@
1469
1545
  '<p id="app-icon-message" class="hint hidden" style="margin-top:8px"></p>' +
1470
1546
  '</div>'
1471
1547
  : '') +
1472
- '<button id="save-config-button" class="btn btn-primary btn-block">保存配置</button>' +
1473
- '<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>' +
1474
1552
  '</div>' +
1475
1553
 
1476
1554
  // Security tab
1477
- '<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>' +
1478
1560
  '<div class="settings-card">' +
1479
1561
  '<h3 class="settings-section-title">\ud83d\udd12 修改密码</h3>' +
1480
1562
  '<div class="field">' +
@@ -1506,14 +1588,22 @@
1506
1588
  '</div>' +
1507
1589
 
1508
1590
  // Command presets tab
1509
- '<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>' +
1510
1596
  '<div id="presets-list" class="presets-list"></div>' +
1511
1597
  '</div>' +
1512
1598
 
1513
1599
  // Display settings tab
1514
- '<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>' +
1515
1605
  '<div class="settings-section-title">卡片默认展开状态</div>' +
1516
- '<p class="hint" style="margin-top:-4px;margin-bottom:12px">设置结构化聊天视图中各类卡片的默认展开/折叠状态。手动操作的展开状态优先于此默认设置。</p>' +
1606
+ '<p class="hint settings-inline-hint">设置结构化聊天视图中各类卡片的默认展开/折叠状态。手动操作的展开状态优先于此默认设置。</p>' +
1517
1607
  '<div class="switch-card-list">' +
1518
1608
  '<label class="switch-card" for="cfg-card-edit">' +
1519
1609
  '<div class="switch-card-header">' +
@@ -1556,8 +1646,10 @@
1556
1646
  '<div class="switch-card-desc">连续同类工具调用的折叠组</div>' +
1557
1647
  '</label>' +
1558
1648
  '</div>' +
1559
- '<button id="save-display-button" class="btn btn-primary btn-block" style="margin-top:16px">保存显示设置</button>' +
1560
- '<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>' +
1561
1653
  '</div>' +
1562
1654
  '</div>' +
1563
1655
  '</div>' +
@@ -2773,29 +2865,46 @@
2773
2865
  });
2774
2866
  }
2775
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
+
2776
2891
  window.__tcToggle = function(e, headerEl) {
2777
- var card = headerEl.closest(".tool-use-card");
2892
+ var card = headerEl.closest(".tool-use-card") || headerEl.closest(".inline-diff");
2778
2893
  if (card) {
2779
2894
  var wasCollapsed = card.classList.contains("collapsed");
2780
2895
  card.classList.toggle("collapsed");
2781
- persistElementExpandState(card, "tool-card");
2782
- // Lazy-load truncated content on expand
2783
- if (wasCollapsed && card.dataset.truncated === "true" && card.dataset.loaded !== "true") {
2784
- var toolUseId = card.dataset.toolUseId;
2896
+ var expandKind = card.dataset.expandKind || "tool-card";
2897
+ persistElementExpandState(card, expandKind);
2898
+ if (wasCollapsed) {
2785
2899
  var resultDiv = card.querySelector(".tool-use-result");
2786
- if (resultDiv) resultDiv.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2787
- card.dataset.loaded = "loading";
2788
- __fetchToolContent(toolUseId, function(err, data) {
2789
- if (err) {
2790
- if (resultDiv) resultDiv.innerHTML = '<div class="tool-content-error" onclick="__tcToggle(null, card.querySelector(\'.tool-use-header\'))">加载失败,点击重试</div>';
2791
- card.dataset.loaded = "";
2792
- } else {
2793
- card.dataset.truncated = "false";
2794
- card.dataset.loaded = "true";
2795
- var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2900
+ lazyLoadTruncatedToolContent(
2901
+ card,
2902
+ resultDiv,
2903
+ function(content) {
2796
2904
  if (resultDiv) resultDiv.innerHTML = '<pre class="tool-use-result-content">' + escapeHtml(content) + '</pre>';
2797
- }
2798
- });
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
+ );
2799
2908
  }
2800
2909
  }
2801
2910
  if (e) { e.preventDefault(); e.stopPropagation(); }
@@ -2835,22 +2944,10 @@
2835
2944
  statusSpan.textContent = "✓";
2836
2945
  }
2837
2946
  }
2838
- // Lazy-load truncated content on expand
2839
- if (expanded && el.dataset.truncated === "true" && el.dataset.loaded !== "true") {
2840
- var toolUseId = el.dataset.toolUseId;
2841
- if (body) body.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2842
- el.dataset.loaded = "loading";
2843
- __fetchToolContent(toolUseId, function(err, data) {
2844
- if (err) {
2845
- if (body) body.innerHTML = '<div class="tool-content-error">加载失败,点击重试</div>';
2846
- el.dataset.loaded = "";
2847
- } else {
2848
- el.dataset.truncated = "false";
2849
- el.dataset.loaded = "true";
2850
- var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2851
- el.dataset.result = content;
2852
- if (body) body.innerHTML = '<div class="inline-tool-result">' + formatInlineResult(content, "") + '</div>';
2853
- }
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>';
2854
2951
  });
2855
2952
  }
2856
2953
  persistElementExpandState(el, "inline-tool");
@@ -2867,29 +2964,17 @@
2867
2964
  var toggleIcon = el.querySelector(".term-toggle-icon");
2868
2965
  if (toggleIcon) toggleIcon.textContent = isHidden ? "▼" : "▶";
2869
2966
  persistElementExpandState(container, "terminal");
2870
- // Lazy-load truncated content on expand
2871
- if (isHidden && container.dataset.truncated === "true" && container.dataset.loaded !== "true") {
2872
- var toolUseId = container.dataset.toolUseId;
2967
+ if (isHidden) {
2873
2968
  var termOutput = body.querySelector(".term-output");
2874
- if (termOutput) termOutput.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2875
- container.dataset.loaded = "loading";
2876
- __fetchToolContent(toolUseId, function(err, data) {
2877
- if (err) {
2878
- if (termOutput) termOutput.innerHTML = '<div class="tool-content-error">加载失败,点击重试</div>';
2879
- container.dataset.loaded = "";
2880
- } else {
2881
- container.dataset.truncated = "false";
2882
- container.dataset.loaded = "true";
2883
- var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2884
- if (termOutput) {
2885
- var lines = content.split("\n");
2886
- var html = "";
2887
- for (var i = 0; i < lines.length; i++) {
2888
- if (!lines[i] && i === lines.length - 1) continue;
2889
- html += '<div class="term-line">' + escapeHtml(lines[i]) + '</div>';
2890
- }
2891
- 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>';
2892
2976
  }
2977
+ termOutput.innerHTML = html;
2893
2978
  }
2894
2979
  });
2895
2980
  }
@@ -3130,6 +3215,8 @@
3130
3215
  if (drawerBackdrop) drawerBackdrop.addEventListener("click", closeSessionsDrawer);
3131
3216
  var closeDrawerBtn = document.getElementById("close-drawer-button");
3132
3217
  if (closeDrawerBtn) closeDrawerBtn.addEventListener("click", closeSessionsDrawer);
3218
+ var pinBtn = document.getElementById("sidebar-pin-btn");
3219
+ if (pinBtn) pinBtn.addEventListener("click", toggleSidebarPin);
3133
3220
  var homeBtn = document.getElementById("sidebar-home-btn");
3134
3221
  if (homeBtn) homeBtn.addEventListener("click", function() {
3135
3222
  state.selectedId = null;
@@ -3209,6 +3296,33 @@
3209
3296
  try { localStorage.setItem("wand-notif-sound", String(state.notifSound)); } catch (e) {}
3210
3297
  // Preview sound when toggling on
3211
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();
3212
3326
  });
3213
3327
  }
3214
3328
  var notifBubbleEl = document.getElementById("cfg-notif-bubble");
@@ -3236,7 +3350,38 @@
3236
3350
  if (notifResetBtn) notifResetBtn.addEventListener("click", resetNotificationPermission);
3237
3351
  var notifTestBtn = document.getElementById("notification-test-btn");
3238
3352
  if (notifTestBtn) notifTestBtn.addEventListener("click", testNotification);
3353
+ var notifTestDelayBtn = document.getElementById("notification-test-delay-btn");
3354
+ if (notifTestDelayBtn) notifTestDelayBtn.addEventListener("click", scheduleTestNotification);
3239
3355
  updateNotificationStatus();
3356
+ // Native notification sound selector (APK only)
3357
+ if (_hasNativeBridge && typeof WandNative.getAvailableSounds === "function") {
3358
+ var nativeSoundSection = document.getElementById("native-sound-section");
3359
+ var nativeSoundSelect = document.getElementById("native-sound-select");
3360
+ var nativeSoundPreview = document.getElementById("native-sound-preview");
3361
+ if (nativeSoundSection && nativeSoundSelect) {
3362
+ nativeSoundSection.classList.remove("hidden");
3363
+ try {
3364
+ var sounds = JSON.parse(WandNative.getAvailableSounds());
3365
+ var current = WandNative.getNotificationSound();
3366
+ nativeSoundSelect.innerHTML = "";
3367
+ for (var si = 0; si < sounds.length; si++) {
3368
+ var opt = document.createElement("option");
3369
+ opt.value = sounds[si].id;
3370
+ opt.textContent = sounds[si].name;
3371
+ if (sounds[si].id === current) opt.selected = true;
3372
+ nativeSoundSelect.appendChild(opt);
3373
+ }
3374
+ nativeSoundSelect.addEventListener("change", function() {
3375
+ try { WandNative.setNotificationSound(nativeSoundSelect.value); } catch (_e) {}
3376
+ });
3377
+ if (nativeSoundPreview) {
3378
+ nativeSoundPreview.addEventListener("click", function() {
3379
+ try { WandNative.previewSound(nativeSoundSelect.value); } catch (_e) {}
3380
+ });
3381
+ }
3382
+ } catch (_e) {}
3383
+ }
3384
+ }
3240
3385
  var newSessBtn = document.getElementById("topbar-new-session-button");
3241
3386
  if (newSessBtn) newSessBtn.addEventListener("click", openSessionModal);
3242
3387
  var drawerNewSessBtn = document.getElementById("drawer-new-session-button");
@@ -3803,7 +3948,9 @@
3803
3948
  } else {
3804
3949
  selectSession(sessionId);
3805
3950
  }
3806
- closeSessionsDrawer();
3951
+ if (!state.sidebarPinned || isMobileLayout()) {
3952
+ closeSessionsDrawer();
3953
+ }
3807
3954
  }
3808
3955
 
3809
3956
  function handleSessionItemClick(event) {
@@ -4308,6 +4455,12 @@
4308
4455
  } else {
4309
4456
  updateTerminalJumpToBottomButton();
4310
4457
  }
4458
+ // When switching sessions, re-fit the terminal so the PTY receives
4459
+ // the correct dimensions for this client's viewport.
4460
+ if (sessionChanged && state.fitAddon) {
4461
+ state.terminalViewportSize = { width: 0, height: 0 };
4462
+ scheduleTerminalResize(true);
4463
+ }
4311
4464
  return wrote || sessionChanged;
4312
4465
  }
4313
4466
 
@@ -4382,6 +4535,26 @@
4382
4535
  state.fitAddon = fitAddonConstructor ? new fitAddonConstructor() : null;
4383
4536
  if (state.fitAddon) {
4384
4537
  state.terminal.loadAddon(state.fitAddon);
4538
+ // Patch: FitAddon subtracts 14px for a scrollbar that CSS hides;
4539
+ // recalculate cols without the scrollbar deduction.
4540
+ var _origPropose = state.fitAddon.proposeDimensions;
4541
+ state.fitAddon.proposeDimensions = function() {
4542
+ var result = _origPropose.call(state.fitAddon);
4543
+ if (result && state.terminal) {
4544
+ try {
4545
+ var core = state.terminal._core;
4546
+ var cellW = core._renderService.dimensions.css.cell.width;
4547
+ var el = state.terminal.element;
4548
+ if (cellW > 0 && el && el.parentElement) {
4549
+ var pw = Math.max(0, parseInt(window.getComputedStyle(el.parentElement).getPropertyValue("width")));
4550
+ var es = window.getComputedStyle(el);
4551
+ var ePad = parseInt(es.getPropertyValue("padding-left")) + parseInt(es.getPropertyValue("padding-right"));
4552
+ result.cols = Math.max(2, Math.floor((pw - ePad) / cellW));
4553
+ }
4554
+ } catch(e) {}
4555
+ }
4556
+ return result;
4557
+ };
4385
4558
  } else {
4386
4559
  console.error("[wand] xterm fit addon failed to load; continuing without fit support.");
4387
4560
  }
@@ -4400,6 +4573,24 @@
4400
4573
  // Retry-based fit: wait for browser to complete layout before measuring and fitting
4401
4574
  if (state.fitAddon) {
4402
4575
  ensureTerminalFit();
4576
+ // Secondary fit after fonts are loaded — FitAddon measures character
4577
+ // dimensions from the rendered font; if a custom web font (e.g. Geist
4578
+ // Mono) hasn't loaded yet the initial fit() uses fallback metrics and
4579
+ // computes too few columns.
4580
+ if (document.fonts && document.fonts.ready) {
4581
+ document.fonts.ready.then(function() {
4582
+ state.terminalViewportSize = { width: 0, height: 0 };
4583
+ ensureTerminalFit();
4584
+ });
4585
+ }
4586
+ // Safety-net fit after layout has fully stabilised (CSS transitions,
4587
+ // deferred reflows, late font loads, etc.)
4588
+ setTimeout(function() {
4589
+ if (state.terminal && state.fitAddon) {
4590
+ state.terminalViewportSize = { width: 0, height: 0 };
4591
+ ensureTerminalFit();
4592
+ }
4593
+ }, 500);
4403
4594
  }
4404
4595
 
4405
4596
  var viewport = getTerminalViewport();
@@ -5139,7 +5330,6 @@
5139
5330
  }
5140
5331
  state.selectedId = id;
5141
5332
  persistSelectedId();
5142
- // Clear tool content cache on session switch
5143
5333
  state.toolContentCache = {};
5144
5334
  // Clear queued inputs from the previous session to prevent cross-session leaks
5145
5335
  state.messageQueue = [];
@@ -5170,6 +5360,22 @@
5170
5360
  subscribeToSession(id);
5171
5361
  }
5172
5362
 
5363
+ function updatePinState() {
5364
+ var drawer = document.getElementById("sessions-drawer");
5365
+ var mainLayout = document.querySelector(".main-layout");
5366
+ var pinBtn = document.getElementById("sidebar-pin-btn");
5367
+ if (drawer) {
5368
+ drawer.classList.toggle("pinned", state.sidebarPinned && !isMobileLayout());
5369
+ }
5370
+ if (mainLayout) {
5371
+ mainLayout.classList.toggle("sidebar-pinned", state.sidebarPinned && !isMobileLayout());
5372
+ }
5373
+ if (pinBtn) {
5374
+ pinBtn.classList.toggle("pinned", state.sidebarPinned);
5375
+ pinBtn.title = state.sidebarPinned ? "取消固定侧栏" : "固定侧栏";
5376
+ }
5377
+ }
5378
+
5173
5379
  function updateDrawerState() {
5174
5380
  var drawer = document.getElementById("sessions-drawer");
5175
5381
  var backdrop = document.getElementById("sessions-drawer-backdrop");
@@ -5187,9 +5393,11 @@
5187
5393
  if (toggleBtn) {
5188
5394
  toggleBtn.classList.toggle("active", state.sessionsDrawerOpen);
5189
5395
  }
5396
+ updatePinState();
5190
5397
  }
5191
5398
 
5192
5399
  function toggleSessionsDrawer() {
5400
+ if (state.sidebarPinned && !isMobileLayout()) return;
5193
5401
  state.sessionsDrawerOpen = !state.sessionsDrawerOpen;
5194
5402
  if (state.sessionsDrawerOpen && isMobileLayout()) {
5195
5403
  state.filePanelOpen = false;
@@ -5201,12 +5409,38 @@
5201
5409
  }
5202
5410
 
5203
5411
  function closeSessionsDrawer() {
5412
+ if (state.sidebarPinned && !isMobileLayout()) return;
5204
5413
  if (!state.sessionsDrawerOpen) return;
5205
5414
  closeSwipedItem();
5206
5415
  state.sessionsDrawerOpen = false;
5207
5416
  updateLayoutState();
5208
5417
  }
5209
5418
 
5419
+ function toggleSidebarPin() {
5420
+ if (isMobileLayout()) return;
5421
+ state.sidebarPinned = !state.sidebarPinned;
5422
+ try {
5423
+ localStorage.setItem("wand-sidebar-pinned", String(state.sidebarPinned));
5424
+ } catch (e) {}
5425
+ if (state.sidebarPinned) {
5426
+ state.sessionsDrawerOpen = true;
5427
+ }
5428
+ updateLayoutState();
5429
+ // Refit terminal after padding-left transition completes
5430
+ var mainLayout = document.querySelector(".main-layout");
5431
+ if (mainLayout) {
5432
+ var onEnd = function(e) {
5433
+ if (e.propertyName === "padding-left") {
5434
+ mainLayout.removeEventListener("transitionend", onEnd);
5435
+ scheduleTerminalResize(true);
5436
+ }
5437
+ };
5438
+ mainLayout.addEventListener("transitionend", onEnd);
5439
+ }
5440
+ // Fallback refit in case transition doesn't fire
5441
+ setTimeout(function() { scheduleTerminalResize(true); }, 350);
5442
+ }
5443
+
5210
5444
  // Store last focused element for focus trap
5211
5445
  var lastFocusedElement = null;
5212
5446
  var focusTrapHandler = null;
@@ -5458,6 +5692,7 @@
5458
5692
  if (confirmEl) confirmEl.value = "";
5459
5693
  hideSettingsMessages();
5460
5694
  setupFocusTrap(modal);
5695
+ bindSettingsTabKeyboardNavigation();
5461
5696
  // Activate first tab
5462
5697
  switchSettingsTab("about");
5463
5698
  // Load settings data
@@ -5467,11 +5702,35 @@
5467
5702
  var bubbleEl = document.getElementById("cfg-notif-bubble");
5468
5703
  if (soundEl) soundEl.checked = state.notifSound;
5469
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";
5470
5713
  updateNotificationStatus();
5471
5714
  // Load current app icon selection (APK only)
5472
5715
  if (typeof WandNative !== "undefined" && typeof WandNative.getAppIcon === "function") {
5473
5716
  try { _updateAppIconSelection(WandNative.getAppIcon() || "shorthair"); } catch (_e) {}
5474
5717
  }
5718
+ // Sync native notification sound selector and volume (APK only)
5719
+ if (_hasNativeBridge && typeof WandNative.getNotificationSound === "function") {
5720
+ try {
5721
+ var nsSel = document.getElementById("native-sound-select");
5722
+ if (nsSel) nsSel.value = WandNative.getNotificationSound();
5723
+ } catch (_e) {}
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
+ }
5475
5734
  }
5476
5735
  }
5477
5736
 
@@ -5548,21 +5807,76 @@
5548
5807
  var tabs = document.querySelectorAll(".settings-tab");
5549
5808
  var panels = document.querySelectorAll(".settings-panel");
5550
5809
  for (var i = 0; i < tabs.length; i++) {
5551
- if (tabs[i].getAttribute("data-tab") === tabName) {
5810
+ var isActive = tabs[i].getAttribute("data-tab") === tabName;
5811
+ if (isActive) {
5552
5812
  tabs[i].classList.add("active");
5553
5813
  } else {
5554
5814
  tabs[i].classList.remove("active");
5555
5815
  }
5816
+ tabs[i].setAttribute("aria-selected", isActive ? "true" : "false");
5817
+ tabs[i].setAttribute("tabindex", isActive ? "0" : "-1");
5556
5818
  }
5557
5819
  for (var j = 0; j < panels.length; j++) {
5558
- if (panels[j].id === "settings-tab-" + tabName) {
5820
+ var isPanelActive = panels[j].id === "settings-tab-" + tabName;
5821
+ if (isPanelActive) {
5559
5822
  panels[j].classList.add("active");
5823
+ panels[j].removeAttribute("hidden");
5560
5824
  } else {
5561
5825
  panels[j].classList.remove("active");
5826
+ panels[j].setAttribute("hidden", "hidden");
5562
5827
  }
5563
5828
  }
5564
5829
  }
5565
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
+
5566
5880
  function copyToClipboard(text, triggerBtn) {
5567
5881
  if (!text) return;
5568
5882
  navigator.clipboard.writeText(text).then(function() {
@@ -5607,6 +5921,7 @@
5607
5921
  fetch("/api/settings", { credentials: "same-origin" })
5608
5922
  .then(function(res) { return res.json(); })
5609
5923
  .then(function(data) {
5924
+ updateSettingsSidebarStatus(data);
5610
5925
  // About
5611
5926
  var nameEl = document.getElementById("settings-pkg-name");
5612
5927
  var verEl = document.getElementById("settings-version");
@@ -5691,7 +6006,7 @@
5691
6006
  apkMessageEl.classList.remove("hidden");
5692
6007
  }
5693
6008
  } else {
5694
- // ── 浏览器模式:只显示线上版本 + 下载按钮 ──
6009
+ // ── 浏览器模式:显示线上版本 + 本地版本 + 下载按钮 ──
5695
6010
  if (androidApk.github && apkGithubRow && apkGithubEl) {
5696
6011
  var ghLabel2 = androidApk.github.version ? ("v" + androidApk.github.version) : androidApk.github.fileName;
5697
6012
  if (typeof androidApk.github.size === "number") ghLabel2 += " · " + formatBytes(androidApk.github.size);
@@ -5704,7 +6019,22 @@
5704
6019
  window.open(androidApk.github.downloadUrl, "_blank");
5705
6020
  };
5706
6021
  }
5707
- } 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) {
5708
6038
  apkMessageEl.textContent = "暂未提供";
5709
6039
  apkMessageEl.classList.remove("hidden");
5710
6040
  }
@@ -6110,9 +6440,44 @@
6110
6440
  });
6111
6441
  }
6112
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
+
6113
6473
  function testNotification() {
6114
6474
  var testMsgEl = document.getElementById("notification-test-message");
6115
6475
  var results = [];
6476
+ if (state.delayedNotificationTimer) {
6477
+ clearTimeout(state.delayedNotificationTimer);
6478
+ state.delayedNotificationTimer = null;
6479
+ resetDelayedNotificationButton();
6480
+ }
6116
6481
 
6117
6482
  // 1. Test sound playback
6118
6483
  var soundOk = tryPlayNotificationSound();
@@ -9295,7 +9660,22 @@
9295
9660
  state.resizeObserver = new ResizeObserver(function() { scheduleTerminalResize(true); });
9296
9661
  state.resizeObserver.observe(output);
9297
9662
  }
9298
- state.resizeHandler = function() { scheduleTerminalResize(true); };
9663
+ var lastKnownDesktop = !isMobileLayout();
9664
+ state.resizeHandler = function() {
9665
+ scheduleTerminalResize(true);
9666
+ // Handle sidebar pin state across mobile/desktop breakpoint
9667
+ var isDesktop = !isMobileLayout();
9668
+ if (lastKnownDesktop !== isDesktop) {
9669
+ lastKnownDesktop = isDesktop;
9670
+ if (!isDesktop && state.sidebarPinned && state.sessionsDrawerOpen) {
9671
+ state.sessionsDrawerOpen = false;
9672
+ updateDrawerState();
9673
+ } else if (isDesktop && state.sidebarPinned && !state.sessionsDrawerOpen) {
9674
+ state.sessionsDrawerOpen = true;
9675
+ updateDrawerState();
9676
+ }
9677
+ }
9678
+ };
9299
9679
  window.addEventListener("resize", state.resizeHandler);
9300
9680
  // Also listen to visualViewport resize for pinch-zoom / browser zoom
9301
9681
  if (window.visualViewport) {
@@ -9365,32 +9745,53 @@
9365
9745
  updateTerminalJumpToBottomButton();
9366
9746
  }
9367
9747
 
9748
+ function sendTerminalResize(cols, rows) {
9749
+ if (!state.selectedId) return;
9750
+ var selectedSess = state.sessions.find(function(s) { return s.id === state.selectedId; });
9751
+ if (isStructuredSession(selectedSess)) return;
9752
+ var nextSize = { cols: cols, rows: rows };
9753
+ if (state.lastResize.cols !== nextSize.cols || state.lastResize.rows !== nextSize.rows) {
9754
+ state.lastResize = nextSize;
9755
+ fetch("/api/sessions/" + state.selectedId + "/resize", {
9756
+ method: "POST",
9757
+ headers: { "Content-Type": "application/json" },
9758
+ credentials: "same-origin",
9759
+ body: JSON.stringify(nextSize)
9760
+ }).catch(function() {});
9761
+ }
9762
+ }
9763
+
9368
9764
  function ensureTerminalFit() {
9369
- var maxAttempts = 10;
9765
+ if (state.terminalFitInProgress) return;
9766
+ state.terminalFitInProgress = true;
9767
+ var maxAttempts = 20;
9370
9768
  var attempt = 0;
9769
+ function finishFit() {
9770
+ state.terminalFitInProgress = false;
9771
+ }
9371
9772
  function tryFit() {
9372
9773
  attempt++;
9373
9774
  state.terminalViewportSize = { width: 0, height: 0 };
9374
9775
  if (shouldResizeTerminalViewport() && state.fitAddon) {
9375
9776
  state.fitAddon.fit();
9376
9777
  maybeScrollTerminalToBottom("resize");
9377
- if (state.selectedId && state.terminal) {
9378
- var selectedSess = state.sessions.find(function(s) { return s.id === state.selectedId; });
9379
- if (!isStructuredSession(selectedSess)) {
9380
- var nextSize = { cols: state.terminal.cols, rows: state.terminal.rows };
9381
- if (state.lastResize.cols !== nextSize.cols || state.lastResize.rows !== nextSize.rows) {
9382
- state.lastResize = nextSize;
9383
- fetch("/api/sessions/" + state.selectedId + "/resize", {
9384
- method: "POST",
9385
- headers: { "Content-Type": "application/json" },
9386
- credentials: "same-origin",
9387
- body: JSON.stringify(nextSize)
9388
- }).catch(function() {});
9389
- }
9778
+ if (state.terminal) {
9779
+ sendTerminalResize(state.terminal.cols, state.terminal.rows);
9780
+ }
9781
+ var output = document.getElementById("output");
9782
+ if (output && state.terminal) {
9783
+ var containerW = output.getBoundingClientRect().width;
9784
+ var expectedMinCols = Math.floor(containerW / 20);
9785
+ if (state.terminal.cols < expectedMinCols && attempt < maxAttempts) {
9786
+ requestAnimationFrame(tryFit);
9787
+ return;
9390
9788
  }
9391
9789
  }
9790
+ finishFit();
9392
9791
  } else if (attempt < maxAttempts) {
9393
9792
  requestAnimationFrame(tryFit);
9793
+ } else {
9794
+ finishFit();
9394
9795
  }
9395
9796
  }
9396
9797
  requestAnimationFrame(tryFit);
@@ -9419,27 +9820,7 @@
9419
9820
  maybeScrollTerminalToBottom("resize");
9420
9821
  }
9421
9822
 
9422
- var nextSize = {
9423
- cols: state.terminal.cols,
9424
- rows: state.terminal.rows
9425
- };
9426
-
9427
- if (!state.selectedId) return;
9428
-
9429
- // Skip resize for structured sessions (no PTY)
9430
- var resizeSess = state.sessions.find(function(s) { return s.id === state.selectedId; });
9431
- if (isStructuredSession(resizeSess)) return;
9432
-
9433
- // Only send resize API call if dimensions actually changed
9434
- if (state.lastResize.cols !== nextSize.cols || state.lastResize.rows !== nextSize.rows) {
9435
- state.lastResize = nextSize;
9436
- fetch("/api/sessions/" + state.selectedId + "/resize", {
9437
- method: "POST",
9438
- headers: { "Content-Type": "application/json" },
9439
- credentials: "same-origin",
9440
- body: JSON.stringify(nextSize)
9441
- }).catch(function() {});
9442
- }
9823
+ sendTerminalResize(state.terminal.cols, state.terminal.rows);
9443
9824
  }
9444
9825
 
9445
9826
  function startPolling() {
@@ -9482,6 +9863,12 @@
9482
9863
  subscribeToSession(state.selectedId);
9483
9864
  // Flush pending messages after reconnection
9484
9865
  flushPendingMessages();
9866
+ // Re-fit terminal on reconnect — the viewport may have changed
9867
+ // while disconnected, and the PTY needs up-to-date dimensions.
9868
+ if (state.terminal && state.fitAddon) {
9869
+ state.terminalViewportSize = { width: 0, height: 0 };
9870
+ ensureTerminalFit();
9871
+ }
9485
9872
  };
9486
9873
 
9487
9874
  ws.onmessage = function(event) {
@@ -9615,16 +10002,7 @@
9615
10002
  } else {
9616
10003
  endedNotifBody = endedSession ? (endedSession.command || msg.sessionId) : msg.sessionId;
9617
10004
  }
9618
- sendBrowserNotification(
9619
- endedNotifTitle,
9620
- endedNotifBody,
9621
- {
9622
- tag: "wand-ended-" + msg.sessionId,
9623
- onClick: function() {
9624
- if (msg.sessionId !== state.selectedId) selectSession(msg.sessionId);
9625
- }
9626
- }
9627
- );
10005
+ notifyTaskEnded(msg.sessionId, endedNotifTitle, endedNotifBody);
9628
10006
  if (msg.sessionId !== state.selectedId || document.hidden) {
9629
10007
  showNotificationBubble({
9630
10008
  title: endedNotifTitle,
@@ -9710,6 +10088,7 @@
9710
10088
  state.currentTask = msg.data || null;
9711
10089
  updateTaskDisplay();
9712
10090
  }
10091
+ notifyTaskProgress(msg.sessionId, msg.data || null);
9713
10092
  // Update session list to reflect current activity (debounced)
9714
10093
  scheduleSessionListUpdate();
9715
10094
  break;
@@ -9756,18 +10135,7 @@
9756
10135
  } else {
9757
10136
  permBody += "\n" + permDetail;
9758
10137
  }
9759
- sendBrowserNotification(
9760
- "需要你的授权",
9761
- permBody,
9762
- {
9763
- tag: "wand-perm-" + msg.sessionId,
9764
- onClick: function() {
9765
- if (msg.sessionId !== state.selectedId) {
9766
- selectSession(msg.sessionId);
9767
- }
9768
- }
9769
- }
9770
- );
10138
+ notifyPermissionRequest(msg.sessionId, permBody);
9771
10139
  // In-app bubble if not currently viewing this session
9772
10140
  if (msg.sessionId !== state.selectedId) {
9773
10141
  showNotificationBubble({
@@ -9811,12 +10179,7 @@
9811
10179
  case 'notification':
9812
10180
  if (msg.data) {
9813
10181
  if (msg.data.kind === "update") {
9814
- showUpdateBubble(msg.data.current || "-", msg.data.latest || "-");
9815
- sendBrowserNotification(
9816
- "Wand \u53d1\u73b0\u65b0\u7248\u672c",
9817
- "\u5f53\u524d " + (msg.data.current || "-") + " \u2192 \u6700\u65b0 " + (msg.data.latest || "-"),
9818
- { tag: "wand-update" }
9819
- );
10182
+ notifyUpdateAvailable(msg.data.current || "-", msg.data.latest || "-");
9820
10183
  } else if (msg.data.kind === "restart") {
9821
10184
  showRestartOverlay();
9822
10185
  }
@@ -10335,7 +10698,7 @@
10335
10698
  // Only expand the single newest tool card (first chat-message = newest due to column-reverse)
10336
10699
  var firstMsg = chatMessages.querySelector(".chat-message:not(.system-info)");
10337
10700
  if (firstMsg) {
10338
- var cards = firstMsg.querySelectorAll(".tool-use-card");
10701
+ var cards = firstMsg.querySelectorAll(".tool-use-card, .inline-diff[data-expand-key]");
10339
10702
  if (cards.length > 0) {
10340
10703
  var firstCard = cards[0];
10341
10704
  var firstCardKey = getElementExpandKey(firstCard);
@@ -10345,6 +10708,8 @@
10345
10708
  for (var ci = 1; ci < cards.length; ci++) {
10346
10709
  var cardKey = getElementExpandKey(cards[ci]);
10347
10710
  if (getPersistedExpandState(cardKey) === null) {
10711
+ // Never collapse unanswered AskUserQuestion cards
10712
+ if (cards[ci].classList.contains("ask-user") && !cards[ci].classList.contains("ask-user-answered")) continue;
10348
10713
  cards[ci].classList.add("collapsed");
10349
10714
  }
10350
10715
  }
@@ -10360,10 +10725,13 @@
10360
10725
  // Collapse all tool-use cards except those in the new message elements (marked with animate-in)
10361
10726
  // newEls: NodeList/Array of newly added message elements, or null to keep only the first card expanded
10362
10727
  function collapseOldToolCards(container, newEls) {
10363
- var allCards = container.querySelectorAll(".tool-use-card");
10728
+ var allCards = container.querySelectorAll(".tool-use-card, .inline-diff[data-expand-key]");
10364
10729
  allCards.forEach(function(c) {
10365
10730
  var cardKey = getElementExpandKey(c);
10366
10731
  if (getPersistedExpandState(cardKey) !== null) return;
10732
+ // Never collapse unanswered AskUserQuestion cards — the user
10733
+ // needs to interact with the options.
10734
+ if (c.classList.contains("ask-user") && !c.classList.contains("ask-user-answered")) return;
10367
10735
  // Keep expanded if this card is inside a newly added message
10368
10736
  if (newEls) {
10369
10737
  for (var i = 0; i < newEls.length; i++) {
@@ -10483,11 +10851,13 @@
10483
10851
  smartScrollToBottom(chatMessages);
10484
10852
  });
10485
10853
  var newestMsgEl = chatMessages.querySelector(".chat-message");
10486
- var allCards = chatMessages.querySelectorAll(".tool-use-card");
10854
+ var allCards = chatMessages.querySelectorAll(".tool-use-card, .inline-diff[data-expand-key]");
10487
10855
  var newestCard = null;
10488
10856
  allCards.forEach(function(c) {
10489
10857
  var cardKey = getElementExpandKey(c);
10490
10858
  if (getPersistedExpandState(cardKey) !== null) return;
10859
+ // Never collapse unanswered AskUserQuestion cards
10860
+ if (c.classList.contains("ask-user") && !c.classList.contains("ask-user-answered")) return;
10491
10861
  if (newestMsgEl && newestMsgEl.contains(c)) {
10492
10862
  if (!newestCard) newestCard = c;
10493
10863
  else c.classList.add("collapsed");
@@ -11578,7 +11948,7 @@
11578
11948
  if (msg.role === "thinking") {
11579
11949
  var thinkingKey = buildExpandKey("thinking", [getMessageKey(msg, messageIndex), "pty"]);
11580
11950
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
11581
- var thinkingExpanded = thinkingPersisted === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.thinking) : thinkingPersisted;
11951
+ var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
11582
11952
  return '<div class="chat-message thinking">' +
11583
11953
  '<div class="thinking-inline thinking-pty ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="" onclick="__thinkingToggle(this)">' +
11584
11954
  '<span class="thinking-inline-icon">⦿</span>' +
@@ -11716,7 +12086,7 @@
11716
12086
  var summaryText = parts.join(" · ");
11717
12087
  var groupKey = buildExpandKey("tool-group", [messageKey, items[0] && items[0].index, items.length]);
11718
12088
  var persistedExpanded = getPersistedExpandState(groupKey);
11719
- var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.toolGroup) : persistedExpanded;
12089
+ var shouldExpand = persistedExpanded === null ? getCardDefault("toolGroup") : persistedExpanded;
11720
12090
 
11721
12091
  // Render each item's inline-tool card
11722
12092
  var innerHtml = "";
@@ -11828,7 +12198,7 @@
11828
12198
  }
11829
12199
  var thinkingKey = buildExpandKey("thinking", [messageKey, index]);
11830
12200
  var thinkingPersisted = getPersistedExpandState(thinkingKey);
11831
- var thinkingExpanded = thinkingPersisted === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.thinking) : thinkingPersisted;
12201
+ var thinkingExpanded = thinkingPersisted === null ? getCardDefault("thinking") : thinkingPersisted;
11832
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)">' +
11833
12203
  '<span class="thinking-inline-icon">⦿</span>' +
11834
12204
  '<span class="thinking-inline-preview">' + escapeHtml(thinkingExpanded ? thinkingText : preview) + '</span>' +
@@ -11925,7 +12295,7 @@
11925
12295
  var fullResult = resultContent;
11926
12296
 
11927
12297
  var expandedHtml = "";
11928
- var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.inlineTools) : persistedExpanded;
12298
+ var shouldExpand = persistedExpanded === null ? getCardDefault("inlineTools") : persistedExpanded;
11929
12299
  if (hasResult) {
11930
12300
  expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';">' +
11931
12301
  '<div class="inline-tool-result">' + formatInlineResult(resultContent, toolName) + '</div>' +
@@ -12010,7 +12380,7 @@
12010
12380
 
12011
12381
  // Show command preview in header (truncate long commands)
12012
12382
  var cmdPreview = command.length > 80 ? command.slice(0, 77) + "…" : command;
12013
- var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.terminal) : persistedExpanded;
12383
+ var shouldExpand = persistedExpanded === null ? getCardDefault("terminal") : persistedExpanded;
12014
12384
 
12015
12385
  var termTruncated = toolResult && toolResult._truncated === true;
12016
12386
  var termTruncAttrs = termTruncated
@@ -12047,10 +12417,11 @@
12047
12417
  return "";
12048
12418
  }
12049
12419
 
12050
- function renderDiffTool(block, toolResult, toolName) {
12420
+ function renderDiffTool(block, toolResult, toolName, messageKey, index) {
12051
12421
  var inputData = block.input || {};
12052
12422
  var path = inputData.file_path || inputData.path || "";
12053
12423
  var fileName = path.split("/").pop() || path;
12424
+ var toolId = block.id || "tool-" + toolName + "-" + (typeof index === "number" ? index : 0);
12054
12425
 
12055
12426
  var oldStr = inputData.old_string || "";
12056
12427
  var newStr = inputData.new_string || inputData.content || "";
@@ -12095,16 +12466,26 @@
12095
12466
  statusText = "执行中";
12096
12467
  }
12097
12468
 
12469
+ // Expand state: respect cardDefaults.editCards and persisted state
12470
+ var expandKey = buildExpandKey("diff", [messageKey, toolId || index, index]);
12471
+ var persistedExpanded = getPersistedExpandState(expandKey);
12472
+ var cardDefaultExpand = getCardDefault("editCards");
12473
+ var shouldExpand = persistedExpanded === null ? (statusClass === "diff-pending" || cardDefaultExpand) : persistedExpanded;
12474
+ var collapsedClass = shouldExpand ? "" : " collapsed";
12475
+
12098
12476
  // If only one column has content, show full width
12099
12477
  var bothCols = leftCol && rightCol;
12100
12478
  var colClass = bothCols ? "diff-col-half" : "diff-col-full";
12101
12479
 
12102
- return '<div class="inline-diff" data-tool-name="' + escapeHtml(toolName) + '">' +
12103
- '<div class="diff-header">' +
12480
+ return '<div class="inline-diff' + collapsedClass + '" data-tool-name="' + escapeHtml(toolName) + '"' +
12481
+ ' data-expand-kind="diff" data-expand-key="' + escapeHtml(expandKey) + '"' +
12482
+ ' data-tool-use-id="' + escapeHtml(toolId) + '">' +
12483
+ '<div class="diff-header" onclick="__tcToggle(event,this)">' +
12104
12484
  '<span class="diff-file-icon"></span>' +
12105
12485
  '<span class="diff-file-name">' + escapeHtml(fileName) + '</span>' +
12106
12486
  '<span class="diff-path">' + escapeHtml(path) + '</span>' +
12107
12487
  '<span class="diff-status ' + statusClass + '">' + statusText + '</span>' +
12488
+ '<span class="diff-toggle">▼</span>' +
12108
12489
  '</div>' +
12109
12490
  '<div class="diff-body">' +
12110
12491
  '<div class="diff-columns">' +
@@ -12138,7 +12519,7 @@
12138
12519
 
12139
12520
  // ── Diff-style: Edit, Write, MultiEdit
12140
12521
  if (toolName === "Edit" || toolName === "Write" || toolName === "MultiEdit") {
12141
- return renderDiffTool(block, toolResult, toolName);
12522
+ return renderDiffTool(block, toolResult, toolName, messageKey, index);
12142
12523
  }
12143
12524
 
12144
12525
  // ── AskUserQuestion tool — special card with batch submit
@@ -12297,7 +12678,7 @@
12297
12678
 
12298
12679
  var expandKey = buildExpandKey("tool-card", [messageKey, toolId]);
12299
12680
  var persistedExpanded = getPersistedExpandState(expandKey);
12300
- var cardDefaultExpand = !!(state.config && state.config.cardDefaults && state.config.cardDefaults.editCards);
12681
+ var cardDefaultExpand = getCardDefault("editCards");
12301
12682
  var shouldExpand = persistedExpanded === null ? (statusClass === "loading" || cardDefaultExpand) : persistedExpanded;
12302
12683
  var tcTruncated = toolResult && toolResult._truncated === true;
12303
12684
  var collapsedClass = shouldExpand ? "" : " collapsed";
@@ -12854,13 +13235,39 @@
12854
13235
  }
12855
13236
  }
12856
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
+
12857
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;
12858
13261
  // Native Android bridge path
12859
13262
  if (_hasNativeBridge) {
12860
13263
  var perm = _getNativePermission();
12861
13264
  if (perm !== "granted") return;
12862
13265
  try {
12863
- 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 || "");
12864
13271
  } catch (_e) {}
12865
13272
  return;
12866
13273
  }
@@ -12870,13 +13277,13 @@
12870
13277
  try {
12871
13278
  var n = new Notification(title, {
12872
13279
  body: body || "",
12873
- icon: (opts && opts.icon) || "/favicon.ico",
12874
- tag: (opts && opts.tag) || undefined,
13280
+ icon: options.icon || "/favicon.ico",
13281
+ tag: tag || undefined,
12875
13282
  });
12876
13283
  n.onclick = function() {
12877
13284
  window.focus();
12878
13285
  n.close();
12879
- if (opts && opts.onClick) opts.onClick();
13286
+ if (options.onClick) options.onClick();
12880
13287
  };
12881
13288
  // Auto-close after 10s
12882
13289
  setTimeout(function() { n.close(); }, 10000);
@@ -12885,6 +13292,76 @@
12885
13292
  }
12886
13293
  }
12887
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
+
12888
13365
  /**
12889
13366
  * Play a soft, rounded notification chime using Web Audio API.
12890
13367
  * Two ascending sine tones with smooth gain envelope — gentle on the ears.
@@ -12912,13 +13389,15 @@
12912
13389
  // Some browsers suspend AudioContext until user gesture — resume it
12913
13390
  if (ctx.state === "suspended") ctx.resume();
12914
13391
 
13392
+ var vol = (state.notifVolume || 0) / 100;
13393
+
12915
13394
  function tone(freq, start, dur) {
12916
13395
  var osc = ctx.createOscillator();
12917
13396
  var gain = ctx.createGain();
12918
13397
  osc.type = "sine";
12919
13398
  osc.frequency.value = freq;
12920
13399
  gain.gain.setValueAtTime(0, ctx.currentTime + start);
12921
- gain.gain.linearRampToValueAtTime(0.18, ctx.currentTime + start + 0.04);
13400
+ gain.gain.linearRampToValueAtTime(0.5 * vol, ctx.currentTime + start + 0.04);
12922
13401
  gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
12923
13402
  osc.connect(gain);
12924
13403
  gain.connect(ctx.destination);