@co0ontty/wand 1.21.5 → 1.21.8

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.
@@ -1065,6 +1065,30 @@ export class StructuredSessionManager {
1065
1065
  model: undefined,
1066
1066
  usage: undefined,
1067
1067
  };
1068
+ // claude -p --output-format stream-json 在同一条消息流式生成期间会重复
1069
+ // emit 同一个 message.id 的 "assistant" 事件,每次 content 略多一些;子
1070
+ // agent 流(Task 工具)则会插入若干 parent_tool_use_id 不同的 message.id。
1071
+ // 朴素的 push(...content) 会让早期片段被反复合并复制,最终被 compact 出
1072
+ // 怪异结果,导致 UI 上 tool_use / 子 agent 输出"显示一下就消失"。
1073
+ // 这里按 (message.id) 去重,相同 id 视作同一消息的更新覆盖;tool_result
1074
+ // 用单调递增的合成 key 顺序追加。每次事件后用插入顺序重建 turnState.blocks。
1075
+ const blocksByKey = new Map();
1076
+ const keyOrder = [];
1077
+ let toolResultSeq = 0;
1078
+ const upsertBlocks = (key, blocks) => {
1079
+ if (!blocksByKey.has(key))
1080
+ keyOrder.push(key);
1081
+ blocksByKey.set(key, blocks);
1082
+ };
1083
+ const rebuildTurnBlocks = () => {
1084
+ const flat = [];
1085
+ for (const key of keyOrder) {
1086
+ const entry = blocksByKey.get(key);
1087
+ if (entry && entry.length > 0)
1088
+ flat.push(...entry);
1089
+ }
1090
+ turnState.blocks = flat;
1091
+ };
1068
1092
  // Line buffer for NDJSON: chunks from stdout may split mid-line.
1069
1093
  let lineBuf = "";
1070
1094
  // Debounce output events to avoid flooding the WebSocket.
@@ -1141,8 +1165,15 @@ export class StructuredSessionManager {
1141
1165
  this.logger?.appendStreamEvent(sessionId, parsed);
1142
1166
  if (parsed && parsed.type === "assistant" && parsed.message) {
1143
1167
  const extracted = this.extractAssistantMessage(parsed.message);
1168
+ // 用 message.id 作为 key:claude -p 流式重发同一条消息时整段覆盖
1169
+ // (而不是与早期片段累加),子 agent 的不同消息 id 各占一格、保留
1170
+ // 父子完整顺序。没有 id 时退化为合成 key 走追加模式。
1171
+ const msgId = typeof parsed.message.id === "string" && parsed.message.id
1172
+ ? `assistant:${parsed.message.id}`
1173
+ : `assistant:anon:${keyOrder.length}`;
1144
1174
  if (extracted.content.length > 0) {
1145
- turnState.blocks.push(...extracted.content);
1175
+ upsertBlocks(msgId, extracted.content);
1176
+ rebuildTurnBlocks();
1146
1177
  }
1147
1178
  // NOTE: usage from streaming "assistant" events contains partial/incremental
1148
1179
  // token counts (e.g. output_tokens=1 during streaming) and is NOT accurate.
@@ -1166,9 +1197,11 @@ export class StructuredSessionManager {
1166
1197
  return;
1167
1198
  }
1168
1199
  if (parsed && parsed.type === "user" && parsed.message && Array.isArray(parsed.message.content)) {
1200
+ // tool_result 没有自身 id,按到达顺序用合成 key 追加(永远不被覆盖)。
1201
+ const collected = [];
1169
1202
  for (const block of parsed.message.content) {
1170
1203
  if (block && block.type === "tool_result") {
1171
- turnState.blocks.push({
1204
+ collected.push({
1172
1205
  type: "tool_result",
1173
1206
  tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "",
1174
1207
  content: this.normalizeToolResultContent(block.content),
@@ -1176,6 +1209,10 @@ export class StructuredSessionManager {
1176
1209
  });
1177
1210
  }
1178
1211
  }
1212
+ if (collected.length > 0) {
1213
+ upsertBlocks(`tool_result:${toolResultSeq++}`, collected);
1214
+ rebuildTurnBlocks();
1215
+ }
1179
1216
  syncSnapshot();
1180
1217
  scheduleEmit();
1181
1218
  return;
@@ -1476,8 +1513,16 @@ export class StructuredSessionManager {
1476
1513
  model: undefined,
1477
1514
  usage: undefined,
1478
1515
  };
1479
- // Tracks in-progress streaming blocks keyed by content_block index from stream_event
1516
+ // Tracks in-progress streaming blocks keyed by content_block index from stream_event.
1517
+ // The map is cleared whenever a complete `assistant` message arrives — its blocks
1518
+ // are then promoted into `finalizedBlocks` below.
1480
1519
  const streamingBlockByIndex = new Map();
1520
+ // Blocks from messages that have already completed within this turn — including
1521
+ // the parent assistant's prior messages, every subagent assistant message, and
1522
+ // every tool_result. Subagent (Task tool) flows produce many assistant messages
1523
+ // back-to-back; without this list, each new streaming message would visually
1524
+ // erase everything that came before it in the same turn.
1525
+ const finalizedBlocks = [];
1481
1526
  let emitTimer = null;
1482
1527
  const flushEmit = () => {
1483
1528
  if (emitTimer) {
@@ -1493,16 +1538,18 @@ export class StructuredSessionManager {
1493
1538
  if (!emitTimer)
1494
1539
  emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
1495
1540
  };
1496
- // Rebuild ContentBlock[] from the in-progress streaming blocks map
1541
+ // Rebuild ContentBlock[] from finalized history + the in-progress streaming map.
1542
+ // Returning only the streaming blocks would drop every prior parent/subagent
1543
+ // message in this turn (the original disappearing-output bug).
1497
1544
  const rebuildStreamingBlocks = () => {
1498
1545
  const sorted = [...streamingBlockByIndex.entries()].sort((a, b) => a[0] - b[0]);
1499
- const blocks = [];
1546
+ const streaming = [];
1500
1547
  for (const [, sb] of sorted) {
1501
1548
  if (sb.type === "text") {
1502
- blocks.push({ type: "text", text: sb.text });
1549
+ streaming.push({ type: "text", text: sb.text });
1503
1550
  }
1504
1551
  else if (sb.type === "thinking") {
1505
- blocks.push({ type: "thinking", thinking: sb.thinking });
1552
+ streaming.push({ type: "thinking", thinking: sb.thinking });
1506
1553
  }
1507
1554
  else if (sb.type === "tool_use" && sb.id && sb.name) {
1508
1555
  let input = {};
@@ -1512,10 +1559,10 @@ export class StructuredSessionManager {
1512
1559
  }
1513
1560
  catch { /* partial json */ }
1514
1561
  }
1515
- blocks.push({ type: "tool_use", id: sb.id, name: sb.name, input });
1562
+ streaming.push({ type: "tool_use", id: sb.id, name: sb.name, input });
1516
1563
  }
1517
1564
  }
1518
- return blocks;
1565
+ return [...finalizedBlocks, ...streaming];
1519
1566
  };
1520
1567
  const syncSnapshot = () => {
1521
1568
  const current = this.sessions.get(sessionId);
@@ -1611,14 +1658,15 @@ export class StructuredSessionManager {
1611
1658
  }
1612
1659
  continue;
1613
1660
  }
1614
- // Complete assistant turn — authoritative content replaces streaming blocks
1661
+ // Complete assistant turn — promote streaming content into the finalized
1662
+ // history so subsequent messages (subagents, follow-up parent messages)
1663
+ // append to it instead of erasing it.
1615
1664
  if (msg.type === "assistant") {
1616
1665
  const assistantMsg = msg;
1617
1666
  const extracted = this.extractAssistantMessage(assistantMsg.message);
1618
- // Keep tool_result blocks from previous user messages, replace streaming assistant content
1619
- const toolResults = turnState.blocks.filter(b => b.type === "tool_result");
1620
- turnState.blocks = [...extracted.content, ...toolResults];
1667
+ finalizedBlocks.push(...extracted.content);
1621
1668
  streamingBlockByIndex.clear();
1669
+ turnState.blocks = rebuildStreamingBlocks();
1622
1670
  if (assistantMsg.session_id)
1623
1671
  turnState.sessionId = assistantMsg.session_id;
1624
1672
  syncSnapshot();
@@ -1634,14 +1682,15 @@ export class StructuredSessionManager {
1634
1682
  }
1635
1683
  continue;
1636
1684
  }
1637
- // Tool results fed back from the claude subprocess
1685
+ // Tool results fed back from the claude subprocess (parent's view of a
1686
+ // tool call, or a subagent's tool_result during Task execution).
1638
1687
  if (msg.type === "user") {
1639
1688
  const userMsg = msg;
1640
1689
  const content = Array.isArray(userMsg.message?.content) ? userMsg.message.content : [];
1641
1690
  for (const block of content) {
1642
1691
  const b = block;
1643
1692
  if (b?.type === "tool_result") {
1644
- turnState.blocks.push({
1693
+ finalizedBlocks.push({
1645
1694
  type: "tool_result",
1646
1695
  tool_use_id: typeof b.tool_use_id === "string" ? b.tool_use_id : "",
1647
1696
  content: this.normalizeToolResultContent(b.content),
@@ -1649,6 +1698,7 @@ export class StructuredSessionManager {
1649
1698
  });
1650
1699
  }
1651
1700
  }
1701
+ turnState.blocks = rebuildStreamingBlocks();
1652
1702
  syncSnapshot();
1653
1703
  scheduleEmit();
1654
1704
  continue;
@@ -1821,7 +1871,10 @@ export class StructuredSessionManager {
1821
1871
  if (previous
1822
1872
  && previous.type === "text"
1823
1873
  && block.type === "text") {
1824
- previous.text = `${previous.text}${block.text}`;
1874
+ // 用新对象替换 compacted 末尾,**不要**就地改 previous.text —— previous
1875
+ // 通常和调用方持有的 turnState.blocks 共享引用,原地 mutate 会让下次
1876
+ // syncSnapshot 把已合并的内容再合并一次,呈指数级复制。
1877
+ compacted[compacted.length - 1] = { type: "text", text: `${previous.text}${block.text}` };
1825
1878
  continue;
1826
1879
  }
1827
1880
  compacted.push(block);
@@ -1277,7 +1277,7 @@
1277
1277
  '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '取消固定侧栏' : '固定侧栏') + '">' +
1278
1278
  '<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>' +
1279
1279
  '</button>' +
1280
- '<button id="close-drawer-button" class="btn btn-ghost btn-sm sidebar-close" type="button" aria-label="关闭菜单">×</button>' +
1280
+ '<button id="close-drawer-button" class="btn btn-ghost btn-icon sidebar-close drawer-close-btn" type="button" aria-label="关闭菜单"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1281
1281
  '</div>' +
1282
1282
  '</div>' +
1283
1283
  '<div class="sidebar-body">' +
@@ -1467,19 +1467,15 @@
1467
1467
  '</div>' +
1468
1468
  '</div>' +
1469
1469
  renderExpandedShortcutsRow() +
1470
- // Session info bar at bottom
1471
- '<div class="input-session-info-bar">' +
1472
- '<span id="session-cwd-display" class="session-cwd-display">' + (selectedSession && selectedSession.cwd ? escapeHtml(selectedSession.cwd) : '未设置目录') + '</span>' +
1473
- '<span class="session-info-separator">|</span>' +
1474
- '<span id="session-mode-display" class="session-mode-display">' + (selectedSession ? getModeLabel(selectedSession.mode) : '默认') + '</span>' +
1475
- (selectedSession && selectedSession.autoApprovePermissions ? '<span class="session-info-separator">|</span><span id="auto-approve-toggle" class="auto-approve-indicator active" title="自动批准已启用 点击关闭">🛡 自动批准</span>' : '<span class="session-info-separator">|</span><span id="auto-approve-toggle" class="auto-approve-indicator" title="自动批准已关闭 点击开启">🛡 手动</span>') +
1476
- '<span class="session-info-separator">|</span>' +
1477
- '<span id="session-kind-display" class="session-kind-display">' + (selectedSession ? getSessionKindLabel(selectedSession) : '终端') + '</span>' +
1478
- '<span class="session-info-separator">|</span>' +
1479
- '<span id="session-status-display" class="session-status-display">' + (selectedSession ? getSessionStatusLabel(selectedSession) : '-') + '</span>' +
1480
- (selectedSession && selectedSession.provider === "claude" && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
1481
- (selectedSession && !isStructuredSession(selectedSession) ? '<span class="session-info-separator">|</span><span id="session-exit-display" class="session-exit-display">退出码=' + (selectedSession.exitCode !== undefined ? selectedSession.exitCode : 'n/a') + '</span>' : '') +
1482
- '</div>' +
1470
+ // Session info bar at bottom — only keeps unique controls/info
1471
+ // (cwd / mode / status / kind are already shown in topbar or composer dropdown)
1472
+ (selectedSession
1473
+ ? '<div class="input-session-info-bar">' +
1474
+ (selectedSession.autoApprovePermissions ? '<span id="auto-approve-toggle" class="auto-approve-indicator active" title="自动批准已启用 点击关闭">🛡 自动批准</span>' : '<span id="auto-approve-toggle" class="auto-approve-indicator" title="自动批准已关闭 — 点击开启">🛡 手动</span>') +
1475
+ (selectedSession.provider === "claude" && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
1476
+ (!isStructuredSession(selectedSession) && selectedSession.exitCode !== undefined && selectedSession.exitCode !== null ? '<span class="session-info-separator">|</span><span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>' : '') +
1477
+ '</div>'
1478
+ : '') +
1483
1479
  '</div>' +
1484
1480
  '<p id="action-error" class="error-message hidden"></p>' +
1485
1481
  '</div>' +
@@ -1488,7 +1484,7 @@
1488
1484
  '<div class="modal folder-picker-modal">' +
1489
1485
  '<div class="modal-header">' +
1490
1486
  '<h2 class="modal-title">选择工作目录</h2>' +
1491
- '<button id="close-folder-picker" class="btn btn-ghost btn-icon">×</button>' +
1487
+ '<button id="close-folder-picker" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1492
1488
  '</div>' +
1493
1489
  '<div class="modal-body">' +
1494
1490
  '<div class="folder-picker-quick-row">' +
@@ -1794,7 +1790,7 @@
1794
1790
  '<h2 id="quick-commit-title" class="modal-title">快捷提交</h2>' +
1795
1791
  '<p class="modal-subtitle">' + escapeHtml((s.branch || "(no branch)") + ' · ' + (s.modifiedCount || 0) + ' 个改动') + '</p>' +
1796
1792
  '</div>' +
1797
- '<button id="quick-commit-close-btn" class="btn btn-ghost btn-icon" type="button" aria-label="关闭">&times;</button>' +
1793
+ '<button id="quick-commit-close-btn" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1798
1794
  '</div>' +
1799
1795
  '<div class="modal-body">' +
1800
1796
  '<div class="qc-files-wrap">' + fileRows + '</div>' +
@@ -1804,17 +1800,23 @@
1804
1800
  '</div>' +
1805
1801
  '<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成">' + escapeHtml(f.customMessage || "") + '</textarea>' +
1806
1802
  '</div>' +
1807
- '<label class="qc-checkbox-row">' +
1808
- '<input type="checkbox" id="quick-commit-make-tag"' + (f.makeTag ? ' checked' : '') + '>' +
1809
- '<span>提交后打 tag' + (s.latestTag ? '(当前:' + escapeHtml(s.latestTag) + ')' : '') + '</span>' +
1810
- '</label>' +
1803
+ '<div class="qc-checkbox-row">' +
1804
+ '<label class="qc-checkbox-label" for="quick-commit-make-tag">提交后打 tag' + (s.latestTag ? '(当前:' + escapeHtml(s.latestTag) + ')' : '') + '</label>' +
1805
+ '<label class="qc-switch">' +
1806
+ '<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle"' + (f.makeTag ? ' checked' : '') + '>' +
1807
+ '<span class="switch-slider"></span>' +
1808
+ '</label>' +
1809
+ '</div>' +
1811
1810
  '<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
1812
1811
  '<input type="text" id="quick-commit-tag" class="field-input" placeholder="留空自动 bump patch' + (s.suggestedNextTag ? '(如 ' + escapeHtml(s.suggestedNextTag) + ')' : '') + '" value="' + escapeHtml(f.tag || "") + '">' +
1813
1812
  '</div>' +
1814
- '<label class="qc-checkbox-row">' +
1815
- '<input type="checkbox" id="quick-commit-push"' + (f.push ? ' checked' : '') + '>' +
1816
- '<span>提交后 push 到远端</span>' +
1817
- '</label>' +
1813
+ '<div class="qc-checkbox-row">' +
1814
+ '<label class="qc-checkbox-label" for="quick-commit-push">提交后 push 到远端</label>' +
1815
+ '<label class="qc-switch">' +
1816
+ '<input type="checkbox" id="quick-commit-push" class="switch-toggle"' + (f.push ? ' checked' : '') + '>' +
1817
+ '<span class="switch-slider"></span>' +
1818
+ '</label>' +
1819
+ '</div>' +
1818
1820
  '<p id="quick-commit-error" class="error-message' + (state.quickCommitError ? '' : ' hidden') + '">' + escapeHtml(state.quickCommitError || "") + '</p>' +
1819
1821
  '<div class="worktree-merge-actions">' +
1820
1822
  '<button id="quick-commit-cancel-btn" class="btn btn-secondary" type="button">取消</button>' +
@@ -1833,7 +1835,7 @@
1833
1835
  '<h2 class="modal-title">合并 Worktree</h2>' +
1834
1836
  '<p class="modal-subtitle">检查当前任务分支并快捷合并到主分支。</p>' +
1835
1837
  '</div>' +
1836
- '<button id="close-worktree-merge-button" class="btn btn-ghost btn-icon">&times;</button>' +
1838
+ '<button id="close-worktree-merge-button" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1837
1839
  '</div>' +
1838
1840
  '<div class="modal-body">' +
1839
1841
  '<div id="worktree-merge-content" class="worktree-merge-content"></div>' +
@@ -1855,7 +1857,7 @@
1855
1857
  '<h2 class="modal-title">设置</h2>' +
1856
1858
  '<p class="settings-modal-subtitle">调整应用配置、通知、安全和显示偏好</p>' +
1857
1859
  '</div>' +
1858
- '<button id="close-settings-button" class="btn btn-ghost btn-icon">×</button>' +
1860
+ '<button id="close-settings-button" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1859
1861
  '</div>' +
1860
1862
  '<div class="modal-body settings-modal-body">' +
1861
1863
  '<div class="settings-layout">' +
@@ -2067,10 +2069,10 @@
2067
2069
  '<span class="settings-value" id="notification-permission-status">-</span>' +
2068
2070
  '</div>' +
2069
2071
  '<div class="settings-update-actions">' +
2070
- '<button id="notification-request-btn" class="btn btn-secondary btn-sm hidden" type="button">授权通知</button>' +
2071
- '<button id="notification-reset-btn" class="btn btn-secondary btn-sm hidden" type="button">重新授权</button>' +
2072
+ '<button id="notification-request-btn" class="btn btn-primary btn-sm hidden" type="button">授权通知</button>' +
2073
+ '<button id="notification-reset-btn" class="btn btn-ghost btn-sm hidden" type="button">重新授权</button>' +
2072
2074
  '<button id="notification-test-btn" class="btn btn-secondary btn-sm" type="button">发送测试通知</button>' +
2073
- '<button id="notification-test-delay-btn" class="btn btn-secondary btn-sm" type="button">10 秒后发送</button>' +
2075
+ '<button id="notification-test-delay-btn" class="btn btn-ghost btn-sm" type="button">10 秒后发送</button>' +
2074
2076
  '</div>' +
2075
2077
  '<p id="notification-test-message" class="hint hidden"></p>' +
2076
2078
  '</div>' +
@@ -2092,9 +2094,15 @@
2092
2094
  '<input id="cfg-port" type="number" class="field-input" placeholder="8443" min="1" max="65535" />' +
2093
2095
  '</div>' +
2094
2096
  '</div>' +
2095
- '<div class="field field-inline">' +
2096
- '<input id="cfg-https" type="checkbox" class="field-checkbox" />' +
2097
- '<label class="field-label" for="cfg-https">启用 HTTPS</label>' +
2097
+ '<div class="settings-toggle-row">' +
2098
+ '<div class="settings-toggle-text">' +
2099
+ '<label class="settings-toggle-title" for="cfg-https">启用 HTTPS</label>' +
2100
+ '<span class="settings-toggle-desc">使用自签名证书加密浏览器到服务的连接,host 为非 127.0.0.1 时建议开启。</span>' +
2101
+ '</div>' +
2102
+ '<label class="settings-switch">' +
2103
+ '<input id="cfg-https" type="checkbox" class="switch-toggle" />' +
2104
+ '<span class="switch-slider"></span>' +
2105
+ '</label>' +
2098
2106
  '</div>' +
2099
2107
  '<div class="field-row">' +
2100
2108
  '<div class="field">' +
@@ -2129,10 +2137,10 @@
2129
2137
  '<div class="field">' +
2130
2138
  '<label class="field-label" for="cfg-structured-runner">结构化会话 Runner</label>' +
2131
2139
  '<select id="cfg-structured-runner" class="field-input">' +
2132
- '<option value="cli">CLI(spawn claude -p,默认)</option>' +
2133
- '<option value="sdk">SDK(@anthropic-ai/claude-agent-sdk)</option>' +
2140
+ '<option value="sdk">SDK(@anthropic-ai/claude-agent-sdk,默认)</option>' +
2141
+ '<option value="cli">CLI(spawn claude -p)</option>' +
2134
2142
  '</select>' +
2135
- '<p class="field-hint" style="margin-top:4px;">SDK 模式使用官方 Agent SDK 替代 CLI subprocess,接口更整洁,功能等价。重启后生效。</p>' +
2143
+ '<p class="field-hint" style="margin-top:4px;">SDK 模式使用官方 Agent SDK 替代 CLI subprocess,接口更整洁,功能等价。保存后对新建会话立即生效。</p>' +
2136
2144
  '</div>' +
2137
2145
  '<div class="field">' +
2138
2146
  '<label class="field-label" for="cfg-default-model">默认模型</label>' +
@@ -2350,7 +2358,10 @@
2350
2358
  if (!state.sessionsManageMode) {
2351
2359
  return '<div class="session-manage-bar">' +
2352
2360
  '<span class="sidebar-intro">最近的会话记录</span>' +
2353
- '<button class="session-manage-toggle" data-action="toggle-manage-mode" type="button">管理</button>' +
2361
+ '<button class="btn btn-ghost btn-xs session-manage-toggle" data-action="toggle-manage-mode" type="button">' +
2362
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>' +
2363
+ '<span>管理</span>' +
2364
+ '</button>' +
2354
2365
  '</div>';
2355
2366
  }
2356
2367
 
@@ -2364,13 +2375,19 @@
2364
2375
  var selectAllAction = allSelected ? "clear-selection" : "select-all-visible";
2365
2376
  var selectAllDisabled = selectable === 0 ? ' disabled' : '';
2366
2377
 
2378
+ // Linear-style toolbar:
2379
+ // [N selected] ───────── [全选] [清空] [delete (danger)] [完成 (primary)]
2367
2380
  return '<div class="session-manage-bar active">' +
2368
- '<div class="session-manage-summary">已选择 ' + totalCount + ' 项</div>' +
2381
+ '<div class="session-manage-summary">' +
2382
+ '<span class="session-manage-count">' + totalCount + '</span>' +
2383
+ '<span class="session-manage-summary-label">已选择</span>' +
2384
+ '</div>' +
2369
2385
  '<div class="session-manage-actions">' +
2370
- '<button class="session-manage-btn" data-action="' + selectAllAction + '" type="button"' + selectAllDisabled + '>' + selectAllLabel + '</button>' +
2371
- '<button class="session-manage-btn" data-action="clear-selection" type="button"' + (hasAny ? '' : ' disabled') + '>清空</button>' +
2372
- '<button class="session-manage-btn danger" data-action="delete-selected" type="button"' + (hasAny ? '' : ' disabled') + '>删除所选</button>' +
2373
- '<button class="session-manage-btn" data-action="toggle-manage-mode" type="button">完成</button>' +
2386
+ '<button class="btn btn-ghost btn-xs" data-action="' + selectAllAction + '" type="button"' + selectAllDisabled + '>' + selectAllLabel + '</button>' +
2387
+ '<button class="btn btn-ghost btn-xs" data-action="clear-selection" type="button"' + (hasAny ? '' : ' disabled') + '>清空</button>' +
2388
+ '<span class="session-manage-divider"></span>' +
2389
+ '<button class="btn btn-danger btn-xs" data-action="delete-selected" type="button"' + (hasAny ? '' : ' disabled') + '>删除</button>' +
2390
+ '<button class="btn btn-primary btn-xs" data-action="toggle-manage-mode" type="button">完成</button>' +
2374
2391
  '</div>' +
2375
2392
  '</div>';
2376
2393
  }
@@ -2416,7 +2433,7 @@
2416
2433
  ? ' <span class="history-count">' + visibleHistory.length + '</span>'
2417
2434
  : '';
2418
2435
  var clearAllButton = state.claudeHistoryExpanded && state.claudeHistoryLoaded && visibleHistory.length > 0
2419
- ? '<button class="session-manage-btn danger compact" data-action="clear-all-history" type="button">清空历史</button>'
2436
+ ? '<button class="btn btn-danger btn-xs session-history-clear" data-action="clear-all-history" type="button">清空</button>'
2420
2437
  : '';
2421
2438
  var header = '<div class="session-group-title claude-history-toggle" id="claude-history-toggle">' +
2422
2439
  '<span class="chevron">' + chevron + '</span> Claude 历史' + countBadge +
@@ -2626,7 +2643,7 @@
2626
2643
  '<div class="session-group-title claude-history-directory-title">' +
2627
2644
  '<span class="chevron">' + chevron + '</span>' +
2628
2645
  '<span class="claude-history-directory-label">' + escapeHtml(cwdShort) + ' (' + count + ')</span>' +
2629
- '<button class="session-manage-btn danger compact claude-history-directory-clear-btn" data-action="delete-history-directory" data-cwd="' +
2646
+ '<button class="btn btn-danger btn-xs claude-history-directory-clear-btn" data-action="delete-history-directory" data-cwd="' +
2630
2647
  escapeHtml(cwd) + '" type="button" aria-label="清空此目录的历史会话" title="清空此目录的历史会话">清空此目录</button>' +
2631
2648
  '</div>' +
2632
2649
  '</div>';
@@ -3461,9 +3478,17 @@
3461
3478
  }
3462
3479
 
3463
3480
  function renderWorktreeToggle(enabled) {
3464
- return '<label class="session-inline-toggle" for="session-worktree-toggle">' +
3465
- '<input id="session-worktree-toggle" type="checkbox" class="field-checkbox"' + (enabled ? ' checked' : '') + ' />' +
3481
+ return '<label class="session-inline-toggle" for="session-worktree-toggle" title="为该会话创建独立的 git worktree 分支">' +
3482
+ '<svg class="session-inline-toggle-icon" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
3483
+ '<circle cx="6" cy="6" r="2.2"/>' +
3484
+ '<circle cx="18" cy="6" r="2.2"/>' +
3485
+ '<circle cx="12" cy="18" r="2.2"/>' +
3486
+ '<path d="M6 8.2v3.4a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8.2"/>' +
3487
+ '<path d="M12 13.6v2.2"/>' +
3488
+ '</svg>' +
3466
3489
  '<span class="session-inline-toggle-label">Worktree 模式</span>' +
3490
+ '<input id="session-worktree-toggle" type="checkbox" class="switch-toggle"' + (enabled ? ' checked' : '') + ' />' +
3491
+ '<span class="switch-slider" aria-hidden="true"></span>' +
3467
3492
  '</label>';
3468
3493
  }
3469
3494
 
@@ -3492,7 +3517,7 @@
3492
3517
  '<h2 class="modal-title">新对话</h2>' +
3493
3518
  '<p class="modal-subtitle">启动 Claude 或 Codex 会话,选择 provider、会话类型、模式和工作目录。</p>' +
3494
3519
  '</div>' +
3495
- '<button id="close-modal-button" class="btn btn-ghost btn-icon">&times;</button>' +
3520
+ '<button id="close-modal-button" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
3496
3521
  '</div>' +
3497
3522
  '<div class="modal-body">' +
3498
3523
  '<div class="field">' +
@@ -4039,9 +4064,21 @@
4039
4064
  // Volume slider
4040
4065
  var notifVolumeEl = document.getElementById("cfg-notif-volume");
4041
4066
  var notifVolumeVal = document.getElementById("cfg-notif-volume-val");
4067
+ // Helper to keep the iOS-style range fill in sync with the input value
4068
+ var _syncRangeFill = function(el) {
4069
+ if (!el) return;
4070
+ var minVal = Number(el.min || 0);
4071
+ var maxVal = Number(el.max || 100);
4072
+ var curVal = Number(el.value || 0);
4073
+ var pct = maxVal > minVal
4074
+ ? Math.max(0, Math.min(100, ((curVal - minVal) / (maxVal - minVal)) * 100))
4075
+ : 0;
4076
+ el.style.setProperty("--range-fill", pct + "%");
4077
+ };
4042
4078
  if (notifVolumeEl) {
4043
4079
  notifVolumeEl.value = state.notifVolume;
4044
4080
  if (notifVolumeVal) notifVolumeVal.textContent = state.notifVolume + "%";
4081
+ _syncRangeFill(notifVolumeEl);
4045
4082
  // Hide if sound is off
4046
4083
  var volField = document.getElementById("notif-volume-field");
4047
4084
  if (volField) volField.style.display = state.notifSound ? "" : "none";
@@ -4049,6 +4086,7 @@
4049
4086
  notifVolumeEl.addEventListener("input", function() {
4050
4087
  state.notifVolume = parseInt(notifVolumeEl.value, 10);
4051
4088
  if (notifVolumeVal) notifVolumeVal.textContent = state.notifVolume + "%";
4089
+ _syncRangeFill(notifVolumeEl);
4052
4090
  try { localStorage.setItem("wand-notif-volume", String(state.notifVolume)); } catch (e) {}
4053
4091
  // Also sync to native bridge if available
4054
4092
  if (_hasNativeBridge && typeof WandNative.setNotificationVolume === "function") {
@@ -6823,6 +6861,8 @@
6823
6861
  updateDrawerState();
6824
6862
  var modal = document.getElementById("session-modal");
6825
6863
  if (modal) {
6864
+ if (modal._wandCloseTimer) { clearTimeout(modal._wandCloseTimer); modal._wandCloseTimer = null; }
6865
+ modal.classList.remove("closing");
6826
6866
  modal.classList.remove("hidden");
6827
6867
  lastFocusedElement = document.activeElement;
6828
6868
  state.sessionTool = getPreferredTool();
@@ -6844,8 +6884,7 @@
6844
6884
  state.modalOpen = false;
6845
6885
  var modal = document.getElementById("session-modal");
6846
6886
  if (modal) {
6847
- modal.classList.add("hidden");
6848
- // Remove focus trap
6887
+ // Remove focus trap before kicking off the exit animation
6849
6888
  if (focusTrapHandler) {
6850
6889
  document.removeEventListener("keydown", focusTrapHandler);
6851
6890
  focusTrapHandler = null;
@@ -6854,10 +6893,38 @@
6854
6893
  if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
6855
6894
  lastFocusedElement.focus();
6856
6895
  }
6896
+ animateModalClose(modal);
6857
6897
  }
6858
6898
  hidePathSuggestions();
6859
6899
  }
6860
6900
 
6901
+ // Run the liquid-glass exit animation on a modal-backdrop, then mark it hidden.
6902
+ // Falls back to instant hide when reduced-motion is requested or a tab is in the background.
6903
+ function animateModalClose(modal) {
6904
+ if (!modal) return;
6905
+ if (modal.classList.contains("hidden")) return;
6906
+ var prefersReducedMotion = false;
6907
+ try {
6908
+ prefersReducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
6909
+ } catch (_e) {}
6910
+ if (prefersReducedMotion || document.hidden) {
6911
+ modal.classList.remove("closing");
6912
+ modal.classList.add("hidden");
6913
+ return;
6914
+ }
6915
+ // Cancel any outstanding pending hide on the same node
6916
+ if (modal._wandCloseTimer) {
6917
+ clearTimeout(modal._wandCloseTimer);
6918
+ modal._wandCloseTimer = null;
6919
+ }
6920
+ modal.classList.add("closing");
6921
+ modal._wandCloseTimer = setTimeout(function() {
6922
+ modal.classList.remove("closing");
6923
+ modal.classList.add("hidden");
6924
+ modal._wandCloseTimer = null;
6925
+ }, 170);
6926
+ }
6927
+
6861
6928
  function setupFocusTrap(modal) {
6862
6929
  if (focusTrapHandler) {
6863
6930
  document.removeEventListener("keydown", focusTrapHandler);
@@ -7054,6 +7121,8 @@
7054
7121
  closeSessionModal();
7055
7122
  var modal = document.getElementById("settings-modal");
7056
7123
  if (modal) {
7124
+ if (modal._wandCloseTimer) { clearTimeout(modal._wandCloseTimer); modal._wandCloseTimer = null; }
7125
+ modal.classList.remove("closing");
7057
7126
  modal.classList.remove("hidden");
7058
7127
  lastFocusedElement = document.activeElement;
7059
7128
  var passEl = document.getElementById("new-password");
@@ -7077,6 +7146,8 @@
7077
7146
  if (volEl) {
7078
7147
  volEl.value = state.notifVolume;
7079
7148
  if (volValEl) volValEl.textContent = state.notifVolume + "%";
7149
+ // Sync the iOS-style fill via the input listener (calls _syncRangeFill)
7150
+ try { volEl.dispatchEvent(new Event("input", { bubbles: true })); } catch (_e) {}
7080
7151
  }
7081
7152
  var volField = document.getElementById("notif-volume-field");
7082
7153
  if (volField) volField.style.display = state.notifSound ? "" : "none";
@@ -7098,6 +7169,8 @@
7098
7169
  state.notifVolume = nativeVol;
7099
7170
  if (volEl) volEl.value = nativeVol;
7100
7171
  if (volValEl) volValEl.textContent = nativeVol + "%";
7172
+ // Sync the iOS-style fill so the orange track matches
7173
+ if (volEl) { try { volEl.dispatchEvent(new Event("input", { bubbles: true })); } catch (_e) {} }
7101
7174
  try { localStorage.setItem("wand-notif-volume", String(nativeVol)); } catch (_e) {}
7102
7175
  } catch (_e) {}
7103
7176
  }
@@ -7113,8 +7186,7 @@
7113
7186
  function closeSettingsModal() {
7114
7187
  var modal = document.getElementById("settings-modal");
7115
7188
  if (modal) {
7116
- modal.classList.add("hidden");
7117
- // Remove focus trap
7189
+ // Remove focus trap before kicking off the exit animation
7118
7190
  if (focusTrapHandler) {
7119
7191
  document.removeEventListener("keydown", focusTrapHandler);
7120
7192
  focusTrapHandler = null;
@@ -7123,6 +7195,7 @@
7123
7195
  if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
7124
7196
  lastFocusedElement.focus();
7125
7197
  }
7198
+ animateModalClose(modal);
7126
7199
  }
7127
7200
  }
7128
7201
 
@@ -7543,7 +7616,9 @@
7543
7616
  msgEl.textContent = data.error;
7544
7617
  msgEl.style.color = "var(--error)";
7545
7618
  } else {
7546
- msgEl.textContent = "配置已保存,部分更改需要重启后生效。";
7619
+ msgEl.textContent = data.restartRequired
7620
+ ? "配置已保存,部分部署字段(host/port/https/shell)需要重启服务才生效。"
7621
+ : "配置已保存。";
7547
7622
  msgEl.style.color = "var(--success)";
7548
7623
  }
7549
7624
  msgEl.classList.remove("hidden");