@co0ontty/wand 1.9.0 → 1.14.2

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.
@@ -60,335 +60,6 @@
60
60
  var configPath = "${escapeHtml(configPath)}";
61
61
  var CHAT_EXPAND_STATE_STORAGE_KEY = "wand-chat-expand-state-v1";
62
62
  var CHAT_AUTO_FOLLOW_STORAGE_KEY = "wand-chat-auto-follow";
63
- var DEFAULT_PANEL_STATE = {
64
- sessionsDrawerOpen: false,
65
- filePanelOpen: false,
66
- shortcutsExpanded: false,
67
- claudeHistoryExpanded: true,
68
- chatMessageExpanded: true,
69
- structuredThinkingExpanded: true,
70
- structuredToolGroupExpanded: false,
71
- structuredInlineToolExpanded: false,
72
- structuredTerminalExpanded: false,
73
- structuredToolCardExpanded: false,
74
- };
75
-
76
- function getConfiguredPanelDefaults(configOverride) {
77
- var currentConfig = configOverride;
78
- if (!currentConfig || typeof currentConfig !== "object") {
79
- return {
80
- sessionsDrawerOpen: DEFAULT_PANEL_STATE.sessionsDrawerOpen,
81
- filePanelOpen: DEFAULT_PANEL_STATE.filePanelOpen,
82
- shortcutsExpanded: DEFAULT_PANEL_STATE.shortcutsExpanded,
83
- claudeHistoryExpanded: DEFAULT_PANEL_STATE.claudeHistoryExpanded,
84
- chatMessageExpanded: DEFAULT_PANEL_STATE.chatMessageExpanded,
85
- structuredThinkingExpanded: DEFAULT_PANEL_STATE.structuredThinkingExpanded,
86
- structuredToolGroupExpanded: DEFAULT_PANEL_STATE.structuredToolGroupExpanded,
87
- structuredInlineToolExpanded: DEFAULT_PANEL_STATE.structuredInlineToolExpanded,
88
- structuredTerminalExpanded: DEFAULT_PANEL_STATE.structuredTerminalExpanded,
89
- structuredToolCardExpanded: DEFAULT_PANEL_STATE.structuredToolCardExpanded,
90
- };
91
- }
92
- var preferences = currentConfig.uiPreferences;
93
- var configured = preferences && typeof preferences === "object" ? preferences.defaultPanelState : null;
94
- return {
95
- sessionsDrawerOpen: configured && typeof configured.sessionsDrawerOpen === "boolean" ? configured.sessionsDrawerOpen : DEFAULT_PANEL_STATE.sessionsDrawerOpen,
96
- filePanelOpen: configured && typeof configured.filePanelOpen === "boolean" ? configured.filePanelOpen : DEFAULT_PANEL_STATE.filePanelOpen,
97
- shortcutsExpanded: configured && typeof configured.shortcutsExpanded === "boolean" ? configured.shortcutsExpanded : DEFAULT_PANEL_STATE.shortcutsExpanded,
98
- claudeHistoryExpanded: configured && typeof configured.claudeHistoryExpanded === "boolean" ? configured.claudeHistoryExpanded : DEFAULT_PANEL_STATE.claudeHistoryExpanded,
99
- chatMessageExpanded: configured && typeof configured.chatMessageExpanded === "boolean" ? configured.chatMessageExpanded : DEFAULT_PANEL_STATE.chatMessageExpanded,
100
- structuredThinkingExpanded: configured && typeof configured.structuredThinkingExpanded === "boolean" ? configured.structuredThinkingExpanded : DEFAULT_PANEL_STATE.structuredThinkingExpanded,
101
- structuredToolGroupExpanded: configured && typeof configured.structuredToolGroupExpanded === "boolean" ? configured.structuredToolGroupExpanded : DEFAULT_PANEL_STATE.structuredToolGroupExpanded,
102
- structuredInlineToolExpanded: configured && typeof configured.structuredInlineToolExpanded === "boolean" ? configured.structuredInlineToolExpanded : DEFAULT_PANEL_STATE.structuredInlineToolExpanded,
103
- structuredTerminalExpanded: configured && typeof configured.structuredTerminalExpanded === "boolean" ? configured.structuredTerminalExpanded : DEFAULT_PANEL_STATE.structuredTerminalExpanded,
104
- structuredToolCardExpanded: configured && typeof configured.structuredToolCardExpanded === "boolean" ? configured.structuredToolCardExpanded : DEFAULT_PANEL_STATE.structuredToolCardExpanded,
105
- };
106
- }
107
-
108
- function getStoredBoolean(key) {
109
- try {
110
- var saved = localStorage.getItem(key);
111
- if (saved === "true") return true;
112
- if (saved === "false") return false;
113
- return null;
114
- } catch (e) {
115
- return null;
116
- }
117
- }
118
-
119
- function getInitialPanelBoolean(key, fieldName) {
120
- var stored = getStoredBoolean(key);
121
- if (stored !== null) return stored;
122
- return DEFAULT_PANEL_STATE[fieldName];
123
- }
124
-
125
- function persistPanelBoolean(key, value) {
126
- try {
127
- localStorage.setItem(key, String(!!value));
128
- } catch (e) {}
129
- }
130
-
131
- function applyConfiguredPanelDefaults() {
132
- var defaults = getConfiguredPanelDefaults((typeof state !== "undefined" && state) ? state.config : null);
133
- if (getStoredBoolean("wand-sessions-drawer-open") === null) {
134
- state.sessionsDrawerOpen = defaults.sessionsDrawerOpen;
135
- }
136
- if (getStoredBoolean("wand-file-panel-open") === null) {
137
- state.filePanelOpen = defaults.filePanelOpen;
138
- }
139
- if (getStoredBoolean("wand-shortcuts-expanded") === null) {
140
- state.shortcutsExpanded = defaults.shortcutsExpanded;
141
- }
142
- if (getStoredBoolean("wand-claude-history-expanded") === null) {
143
- state.claudeHistoryExpanded = defaults.claudeHistoryExpanded;
144
- }
145
- }
146
-
147
- function getPanelStateSettingsFormValues() {
148
- return {
149
- sessionsDrawerOpen: !!((document.getElementById("cfg-panel-sessions-drawer") || {}).checked),
150
- filePanelOpen: !!((document.getElementById("cfg-panel-file") || {}).checked),
151
- shortcutsExpanded: !!((document.getElementById("cfg-panel-shortcuts") || {}).checked),
152
- claudeHistoryExpanded: !!((document.getElementById("cfg-panel-history") || {}).checked),
153
- chatMessageExpanded: !!((document.getElementById("cfg-panel-chat-message") || {}).checked),
154
- structuredThinkingExpanded: !!((document.getElementById("cfg-panel-structured-thinking") || {}).checked),
155
- structuredToolGroupExpanded: !!((document.getElementById("cfg-panel-structured-tool-group") || {}).checked),
156
- structuredInlineToolExpanded: !!((document.getElementById("cfg-panel-structured-inline-tool") || {}).checked),
157
- structuredTerminalExpanded: !!((document.getElementById("cfg-panel-structured-terminal") || {}).checked),
158
- structuredToolCardExpanded: !!((document.getElementById("cfg-panel-structured-tool-card") || {}).checked),
159
- };
160
- }
161
-
162
- function syncPanelStateSettingsForm(panelDefaults) {
163
- var defaults = panelDefaults || getConfiguredPanelDefaults();
164
- var sessionsDrawerEl = document.getElementById("cfg-panel-sessions-drawer");
165
- var filePanelEl = document.getElementById("cfg-panel-file");
166
- var shortcutsEl = document.getElementById("cfg-panel-shortcuts");
167
- var historyEl = document.getElementById("cfg-panel-history");
168
- var chatMessageEl = document.getElementById("cfg-panel-chat-message");
169
- var structuredThinkingEl = document.getElementById("cfg-panel-structured-thinking");
170
- var structuredToolGroupEl = document.getElementById("cfg-panel-structured-tool-group");
171
- var structuredInlineToolEl = document.getElementById("cfg-panel-structured-inline-tool");
172
- var structuredTerminalEl = document.getElementById("cfg-panel-structured-terminal");
173
- var structuredToolCardEl = document.getElementById("cfg-panel-structured-tool-card");
174
- if (sessionsDrawerEl) sessionsDrawerEl.checked = !!defaults.sessionsDrawerOpen;
175
- if (filePanelEl) filePanelEl.checked = !!defaults.filePanelOpen;
176
- if (shortcutsEl) shortcutsEl.checked = !!defaults.shortcutsExpanded;
177
- if (historyEl) historyEl.checked = !!defaults.claudeHistoryExpanded;
178
- if (chatMessageEl) chatMessageEl.checked = !!defaults.chatMessageExpanded;
179
- if (structuredThinkingEl) structuredThinkingEl.checked = !!defaults.structuredThinkingExpanded;
180
- if (structuredToolGroupEl) structuredToolGroupEl.checked = !!defaults.structuredToolGroupExpanded;
181
- if (structuredInlineToolEl) structuredInlineToolEl.checked = !!defaults.structuredInlineToolExpanded;
182
- if (structuredTerminalEl) structuredTerminalEl.checked = !!defaults.structuredTerminalExpanded;
183
- if (structuredToolCardEl) structuredToolCardEl.checked = !!defaults.structuredToolCardExpanded;
184
- }
185
-
186
- function applySettingsConfig(config) {
187
- state.config = config || null;
188
- applyConfiguredPanelDefaults();
189
- updatePanelDefaultControls();
190
- }
191
-
192
- function renderSettingsNav() {
193
- return '<div class="settings-nav">' +
194
- '<button class="settings-tab active" data-tab="about">关于</button>' +
195
- '<button class="settings-tab" data-tab="general">基本配置</button>' +
196
- '<button class="settings-tab" data-tab="security">安全</button>' +
197
- '<button class="settings-tab" data-tab="presets">命令预设</button>' +
198
- '</div>';
199
- }
200
-
201
- function renderAppearanceSettingsCard() {
202
- return '<div class="settings-card settings-card-accent">' +
203
- '<div class="settings-card-header">' +
204
- '<div>' +
205
- '<h3 class="settings-section-title">界面偏好</h3>' +
206
- '<p class="settings-hint">这些选项决定新页面或未保存本地偏好的默认展开状态。</p>' +
207
- '</div>' +
208
- '</div>' +
209
- '<div class="settings-toggle-list">' +
210
- '<label class="settings-toggle-item" for="cfg-panel-sessions-drawer">' +
211
- '<div><span class="settings-toggle-title">默认展开会话侧栏</span><span class="settings-toggle-desc">进入页面时左侧会话列表默认展开。</span></div>' +
212
- '<input id="cfg-panel-sessions-drawer" type="checkbox" class="field-checkbox" />' +
213
- '</label>' +
214
- '<label class="settings-toggle-item" for="cfg-panel-file">' +
215
- '<div><span class="settings-toggle-title">默认展开文件面板</span><span class="settings-toggle-desc">右侧文件浏览器在初始状态下打开。</span></div>' +
216
- '<input id="cfg-panel-file" type="checkbox" class="field-checkbox" />' +
217
- '</label>' +
218
- '<label class="settings-toggle-item" for="cfg-panel-shortcuts">' +
219
- '<div><span class="settings-toggle-title">默认展开快捷键栏</span><span class="settings-toggle-desc">移动端快捷键面板首次显示时展开完整行。</span></div>' +
220
- '<input id="cfg-panel-shortcuts" type="checkbox" class="field-checkbox" />' +
221
- '</label>' +
222
- '<label class="settings-toggle-item" for="cfg-panel-history">' +
223
- '<div><span class="settings-toggle-title">默认展开 Claude 历史</span><span class="settings-toggle-desc">侧栏里的 Claude 历史分组默认展开。</span></div>' +
224
- '<input id="cfg-panel-history" type="checkbox" class="field-checkbox" />' +
225
- '</label>' +
226
- '<label class="settings-toggle-item" for="cfg-panel-chat-message">' +
227
- '<div><span class="settings-toggle-title">默认展开聊天详情</span><span class="settings-toggle-desc">当某条消息没有本地展开记录时,采用这里的默认值。</span></div>' +
228
- '<input id="cfg-panel-chat-message" type="checkbox" class="field-checkbox" />' +
229
- '</label>' +
230
- '<label class="settings-toggle-item" for="cfg-panel-structured-thinking">' +
231
- '<div><span class="settings-toggle-title">默认展开思考卡片</span><span class="settings-toggle-desc">结构化模式中的 thinking 块默认展开。</span></div>' +
232
- '<input id="cfg-panel-structured-thinking" type="checkbox" class="field-checkbox" />' +
233
- '</label>' +
234
- '<label class="settings-toggle-item" for="cfg-panel-structured-tool-group">' +
235
- '<div><span class="settings-toggle-title">默认展开工具组</span><span class="settings-toggle-desc">连续工具调用合并后的工具组默认展开。</span></div>' +
236
- '<input id="cfg-panel-structured-tool-group" type="checkbox" class="field-checkbox" />' +
237
- '</label>' +
238
- '<label class="settings-toggle-item" for="cfg-panel-structured-inline-tool">' +
239
- '<div><span class="settings-toggle-title">默认展开内联工具</span><span class="settings-toggle-desc">Read、Grep、Glob 等内联工具结果默认展开。</span></div>' +
240
- '<input id="cfg-panel-structured-inline-tool" type="checkbox" class="field-checkbox" />' +
241
- '</label>' +
242
- '<label class="settings-toggle-item" for="cfg-panel-structured-terminal">' +
243
- '<div><span class="settings-toggle-title">默认展开终端卡片</span><span class="settings-toggle-desc">Bash 等终端输出卡片默认展开。</span></div>' +
244
- '<input id="cfg-panel-structured-terminal" type="checkbox" class="field-checkbox" />' +
245
- '</label>' +
246
- '<label class="settings-toggle-item" for="cfg-panel-structured-tool-card">' +
247
- '<div><span class="settings-toggle-title">默认展开通用工具卡</span><span class="settings-toggle-desc">工具调用、计划类卡片等通用卡片默认展开。</span></div>' +
248
- '<input id="cfg-panel-structured-tool-card" type="checkbox" class="field-checkbox" />' +
249
- '</label>' +
250
- '</div>' +
251
- '</div>';
252
- }
253
-
254
- function buildSettingsGeneralPanel() {
255
- return '<div class="settings-panel" id="settings-tab-general">' +
256
- '<div class="settings-card settings-card-accent">' +
257
- '<div class="settings-card-header">' +
258
- '<div>' +
259
- '<h3 class="settings-section-title">基础运行配置</h3>' +
260
- '<p class="settings-hint">影响服务器监听、默认模式和 CLI 行为。</p>' +
261
- '</div>' +
262
- '</div>' +
263
- '<div class="field-row">' +
264
- '<div class="field">' +
265
- '<label class="field-label" for="cfg-host">监听地址 (host)</label>' +
266
- '<input id="cfg-host" type="text" class="field-input" placeholder="127.0.0.1" />' +
267
- '</div>' +
268
- '<div class="field">' +
269
- '<label class="field-label" for="cfg-port">端口 (port)</label>' +
270
- '<input id="cfg-port" type="number" class="field-input" placeholder="8443" min="1" max="65535" />' +
271
- '</div>' +
272
- '</div>' +
273
- '<div class="field field-inline">' +
274
- '<input id="cfg-https" type="checkbox" class="field-checkbox" />' +
275
- '<label class="field-label" for="cfg-https">启用 HTTPS</label>' +
276
- '</div>' +
277
- '<div class="field-row">' +
278
- '<div class="field">' +
279
- '<label class="field-label" for="cfg-mode">默认执行模式</label>' +
280
- '<select id="cfg-mode" class="field-input">' +
281
- '<option value="default">default</option>' +
282
- '<option value="assist">assist</option>' +
283
- '<option value="agent">agent</option>' +
284
- '<option value="agent-max">agent-max</option>' +
285
- '<option value="auto-edit">auto-edit</option>' +
286
- '<option value="full-access">full-access</option>' +
287
- '<option value="native">native</option>' +
288
- '<option value="managed">managed</option>' +
289
- '</select>' +
290
- '</div>' +
291
- '<div class="field">' +
292
- '<label class="field-label" for="cfg-language">回复语言</label>' +
293
- '<select id="cfg-language" class="field-input">' +
294
- '<option value="">自动(不指定)</option>' +
295
- '<option value="中文">中文</option>' +
296
- '<option value="English">English</option>' +
297
- '<option value="日本語">日本語</option>' +
298
- '<option value="한국어">한국어</option>' +
299
- '<option value="Español">Español</option>' +
300
- '<option value="Français">Français</option>' +
301
- '<option value="Deutsch">Deutsch</option>' +
302
- '<option value="Русский">Русский</option>' +
303
- '</select>' +
304
- '</div>' +
305
- '</div>' +
306
- '<p class="field-hint settings-inline-hint">设置回复语言后,Claude 将尽量使用指定语言回复。</p>' +
307
- '<div class="field">' +
308
- '<label class="field-label" for="cfg-cwd">默认工作目录</label>' +
309
- '<input id="cfg-cwd" type="text" class="field-input" placeholder="/home/user" />' +
310
- '</div>' +
311
- '<div class="field">' +
312
- '<label class="field-label" for="cfg-shell">Shell</label>' +
313
- '<input id="cfg-shell" type="text" class="field-input" placeholder="/bin/bash" />' +
314
- '</div>' +
315
- '</div>' +
316
- renderAppearanceSettingsCard() +
317
- '<button id="save-config-button" class="btn btn-primary btn-block">保存配置</button>' +
318
- '<p id="config-message" class="hint hidden"></p>' +
319
- '</div>';
320
- }
321
-
322
- function persistSettingsBackedUiState() {
323
- persistPanelBoolean("wand-sessions-drawer-open", state.sessionsDrawerOpen);
324
- persistPanelBoolean("wand-file-panel-open", state.filePanelOpen);
325
- persistPanelBoolean("wand-shortcuts-expanded", state.shortcutsExpanded);
326
- persistPanelBoolean("wand-claude-history-expanded", state.claudeHistoryExpanded);
327
- }
328
-
329
- function resetSettingsBackedUiStateToDefaults() {
330
- var defaults = getConfiguredPanelDefaults();
331
- state.sessionsDrawerOpen = defaults.sessionsDrawerOpen;
332
- state.filePanelOpen = defaults.filePanelOpen;
333
- state.shortcutsExpanded = defaults.shortcutsExpanded;
334
- state.claudeHistoryExpanded = defaults.claudeHistoryExpanded;
335
- persistSettingsBackedUiState();
336
- }
337
-
338
- function handleSettingsConfigSaved(nextConfig) {
339
- applySettingsConfig(nextConfig || state.config);
340
- syncPanelStateSettingsForm();
341
- resetSettingsBackedUiStateToDefaults();
342
- updateLayoutState();
343
- updateSessionsList();
344
- updateCurrentSession();
345
- }
346
-
347
- function updateSettingsActiveNav() {
348
- var activeTab = document.querySelector(".settings-tab.active");
349
- var nav = document.querySelector(".settings-nav");
350
- if (!nav) return;
351
- if (activeTab) {
352
- nav.setAttribute("data-active-tab", activeTab.getAttribute("data-tab") || "");
353
- }
354
- }
355
-
356
- function updateCollapsedShortcutsUi() {
357
- var wrap = document.querySelector(".inline-shortcuts-wrap");
358
- var row = document.querySelector(".inline-shortcuts-expanded-row");
359
- var toggle = document.querySelector(".shortcuts-toggle");
360
- if (wrap) wrap.classList.toggle("expanded", state.shortcutsExpanded);
361
- if (row) row.classList.toggle("visible", state.shortcutsExpanded);
362
- if (toggle) {
363
- toggle.classList.toggle("active", state.shortcutsExpanded);
364
- toggle.textContent = state.shortcutsExpanded ? "\u203a" : "\u2039";
365
- }
366
- }
367
-
368
- function updatePanelDefaultControls() {
369
- syncPanelStateSettingsForm();
370
- }
371
-
372
- function persistDrawerState() {
373
- persistPanelBoolean("wand-sessions-drawer-open", state.sessionsDrawerOpen);
374
- }
375
-
376
- function persistHistoryPanelState() {
377
- persistPanelBoolean("wand-claude-history-expanded", state.claudeHistoryExpanded);
378
- }
379
-
380
- function persistShortcutsExpandedState() {
381
- persistPanelBoolean("wand-shortcuts-expanded", state.shortcutsExpanded);
382
- }
383
-
384
- function persistFilePanelState() {
385
- persistPanelBoolean("wand-file-panel-open", state.filePanelOpen);
386
- }
387
-
388
- function refreshUiAfterPanelStateChange() {
389
- updateLayoutState();
390
- updateSessionsList();
391
- }
392
63
 
393
64
  var state = {
394
65
  selectedId: (function() {
@@ -437,7 +108,7 @@
437
108
  loginPending: false,
438
109
  loginChecked: false,
439
110
  bootstrapping: true,
440
- sessionsDrawerOpen: getInitialPanelBoolean("wand-sessions-drawer-open", "sessionsDrawerOpen"),
111
+ sessionsDrawerOpen: false,
441
112
  modalOpen: false,
442
113
  presetValue: "",
443
114
  cwdValue: "",
@@ -459,6 +130,14 @@
459
130
  showInstallPrompt: false,
460
131
  ws: null,
461
132
  wsConnected: false,
133
+ _updateBubbleShown: false,
134
+ notifSound: (function() {
135
+ try { var v = localStorage.getItem("wand-notif-sound"); return v === null ? true : v === "true"; } catch (e) { return true; }
136
+ })(),
137
+ notifBubble: (function() {
138
+ try { var v = localStorage.getItem("wand-notif-bubble"); return v === null ? true : v === "true"; } catch (e) { return true; }
139
+ })(),
140
+ toolContentCache: {},
462
141
  currentView: "terminal",
463
142
  terminalScale: (function() {
464
143
  try {
@@ -470,7 +149,13 @@
470
149
  })(),
471
150
  terminalBaseFontSize: 13,
472
151
  keyboardPopupOpen: false,
473
- filePanelOpen: getInitialPanelBoolean("wand-file-panel-open", "filePanelOpen"),
152
+ filePanelOpen: (function() {
153
+ try {
154
+ return localStorage.getItem("wand-file-panel-open") === "true";
155
+ } catch (e) {
156
+ return false;
157
+ }
158
+ })(),
474
159
  chatAutoFollow: (function() {
475
160
  try {
476
161
  var saved = localStorage.getItem(CHAT_AUTO_FOLLOW_STORAGE_KEY);
@@ -491,21 +176,25 @@
491
176
  lastRenderedMsgCount: 0,
492
177
  lastRenderedEmpty: null,
493
178
  renderPending: false,
179
+ chatPageSize: 20,
180
+ chatRenderedCount: 20,
494
181
  currentTask: null, // Current task title from Claude
495
182
  terminalInteractive: false,
496
183
  miniKeyboardVisible: false,
497
- shortcutsExpanded: getInitialPanelBoolean("wand-shortcuts-expanded", "shortcutsExpanded"),
184
+ shortcutsExpanded: false,
498
185
  modifiers: { ctrl: false, alt: false, shift: false },
499
186
  fileSearchQuery: "",
500
187
  fileExplorerLoading: false,
501
188
  allFiles: [],
502
189
  claudeHistory: [],
503
190
  claudeHistoryLoaded: false,
504
- claudeHistoryExpanded: getInitialPanelBoolean("wand-claude-history-expanded", "claudeHistoryExpanded"),
191
+ claudeHistoryExpanded: true,
505
192
  claudeHistoryExpandedDirs: {},
506
193
  sessionsManageMode: false,
507
194
  selectedSessionIds: {},
508
195
  selectedClaudeHistoryIds: {},
196
+ askUserSelections: {}, // { toolUseId: { 0: [optIdx...], submitted: false } }
197
+ queueEpoch: 0, // Monotonic counter for queue state freshness
509
198
  // Load last used working directory from localStorage
510
199
  workingDir: (function() {
511
200
  try {
@@ -650,6 +339,8 @@
650
339
  if (button) {
651
340
  button.classList.toggle("visible", shouldShow);
652
341
  }
342
+ var chatContainer = document.getElementById("chat-output");
343
+ if (chatContainer) chatContainer.classList.toggle("has-jump-btn", shouldShow);
653
344
  }
654
345
 
655
346
  function scrollChatToBottom(smooth) {
@@ -714,6 +405,35 @@
714
405
  updateChatJumpToBottomButton();
715
406
  }
716
407
 
408
+ /** Load older messages by expanding the visible window */
409
+ function loadMoreChatMessages() {
410
+ if (state.chatRenderedCount >= state.currentMessages.length) return;
411
+ state.chatRenderedCount += state.chatPageSize;
412
+ renderChat(true);
413
+ }
414
+
415
+ // Observe the "load more" sentinel for auto-loading when scrolled into view
416
+ var _loadMoreObserver = null;
417
+ function observeLoadMoreSentinel() {
418
+ if (_loadMoreObserver) { _loadMoreObserver.disconnect(); _loadMoreObserver = null; }
419
+ var sentinel = document.getElementById("chat-load-more-sentinel");
420
+ if (!sentinel) return;
421
+ // Click handler for the button
422
+ var btn = sentinel.querySelector(".chat-load-more-btn");
423
+ if (btn) btn.onclick = function() { loadMoreChatMessages(); };
424
+ // IntersectionObserver for auto-load on scroll
425
+ if (typeof IntersectionObserver === "undefined") return;
426
+ _loadMoreObserver = new IntersectionObserver(function(entries) {
427
+ for (var i = 0; i < entries.length; i++) {
428
+ if (entries[i].isIntersecting) {
429
+ loadMoreChatMessages();
430
+ break;
431
+ }
432
+ }
433
+ }, { root: getChatScrollElement(), rootMargin: "200px" });
434
+ _loadMoreObserver.observe(sentinel);
435
+ }
436
+
717
437
  // Helper function to persist selected session ID to localStorage
718
438
  function persistSelectedId() {
719
439
  try {
@@ -863,56 +583,6 @@
863
583
  }
864
584
  }
865
585
 
866
- function getDefaultChatMessageExpanded() {
867
- return getConfiguredPanelDefaults().chatMessageExpanded;
868
- }
869
-
870
- function getDefaultStructuredCardExpanded(cardType, fallbackValue) {
871
- var defaults = getConfiguredPanelDefaults();
872
- if (cardType === "thinking") {
873
- return defaults.structuredThinkingExpanded;
874
- }
875
- if (cardType === "tool-group") {
876
- return defaults.structuredToolGroupExpanded;
877
- }
878
- if (cardType === "inline-tool") {
879
- return defaults.structuredInlineToolExpanded;
880
- }
881
- if (cardType === "terminal") {
882
- return defaults.structuredTerminalExpanded;
883
- }
884
- if (cardType === "tool-card") {
885
- return defaults.structuredToolCardExpanded;
886
- }
887
- if (typeof fallbackValue === "boolean") {
888
- return fallbackValue;
889
- }
890
- return defaults.chatMessageExpanded;
891
- }
892
-
893
- function hasPersistedExpandState(itemKey) {
894
- if (!itemKey || !state.selectedId) return false;
895
- var sessionState = getCurrentChatExpandState();
896
- return typeof sessionState[itemKey] === "boolean";
897
- }
898
-
899
- function getExpandState(itemKey, cardType, fallbackValue) {
900
- if (!itemKey || !state.selectedId) {
901
- if (typeof fallbackValue === "boolean") return fallbackValue;
902
- return getDefaultStructuredCardExpanded(cardType, fallbackValue);
903
- }
904
- var sessionState = getCurrentChatExpandState();
905
- if (typeof sessionState[itemKey] === "boolean") return sessionState[itemKey];
906
- return getDefaultStructuredCardExpanded(cardType, fallbackValue);
907
- }
908
-
909
- function getPersistedExpandState(itemKey) {
910
- if (!itemKey || !state.selectedId) return null;
911
- var sessionState = getCurrentChatExpandState();
912
- if (typeof sessionState[itemKey] === "boolean") return sessionState[itemKey];
913
- return null;
914
- }
915
-
916
586
  function saveChatExpandStateMap(map) {
917
587
  try {
918
588
  if (!map || Object.keys(map).length === 0) {
@@ -933,6 +603,12 @@
933
603
  return sessionState && typeof sessionState === "object" ? sessionState : {};
934
604
  }
935
605
 
606
+ function getPersistedExpandState(itemKey) {
607
+ if (!itemKey || !state.selectedId) return null;
608
+ var sessionState = getCurrentChatExpandState();
609
+ return typeof sessionState[itemKey] === "boolean" ? sessionState[itemKey] : null;
610
+ }
611
+
936
612
  function setPersistedExpandState(itemKey, expanded) {
937
613
  if (!itemKey || !state.selectedId) return;
938
614
  var map = loadChatExpandStateMap();
@@ -1049,8 +725,9 @@
1049
725
  container.querySelectorAll("[data-expand-key]").forEach(function(el) {
1050
726
  var itemKey = getElementExpandKey(el);
1051
727
  var kind = el.dataset.expandKind || "";
1052
- if (!kind || !hasPersistedExpandState(itemKey)) return;
1053
- applyExpandedState(el, kind, getPersistedExpandState(itemKey));
728
+ var persisted = getPersistedExpandState(itemKey);
729
+ if (persisted === null || !kind) return;
730
+ applyExpandedState(el, kind, persisted);
1054
731
  });
1055
732
  }
1056
733
 
@@ -1059,6 +736,8 @@
1059
736
  state.lastRenderedMsgCount = 0;
1060
737
  state.lastRenderedEmpty = null;
1061
738
  state.renderPending = false;
739
+ state.chatRenderedCount = state.chatPageSize;
740
+ state.askUserSelections = {};
1062
741
  if (state.chatScrollElement && state.chatScrollHandler) {
1063
742
  state.chatScrollElement.removeEventListener("scroll", state.chatScrollHandler);
1064
743
  }
@@ -1193,7 +872,7 @@
1193
872
  })
1194
873
  .then(function(config) {
1195
874
  if (!config) return;
1196
- applySettingsConfig(config);
875
+ state.config = config;
1197
876
  state.loginChecked = true;
1198
877
  requestAnimationFrame(function() {
1199
878
  try {
@@ -1207,18 +886,7 @@
1207
886
  refreshAll();
1208
887
  requestNotificationPermission();
1209
888
  if (config.updateAvailable && config.latestVersion) {
1210
- showNotificationBubble({
1211
- title: "\u53d1\u73b0\u65b0\u7248\u672c",
1212
- body: "\u5f53\u524d " + (config.currentVersion || "-") + " \u2192 \u6700\u65b0 " + config.latestVersion,
1213
- type: "info",
1214
- icon: "\u2191",
1215
- duration: 10000,
1216
- actionLabel: "\u53bb\u66f4\u65b0",
1217
- action: function() {
1218
- var settingsBtn = document.getElementById("open-settings-btn") || document.querySelector("[data-action='settings']");
1219
- if (settingsBtn) settingsBtn.click();
1220
- }
1221
- });
889
+ showUpdateBubble(config.currentVersion || "-", config.latestVersion);
1222
890
  sendBrowserNotification("Wand \u53d1\u73b0\u65b0\u7248\u672c", "\u5f53\u524d " + (config.currentVersion || "-") + " \u2192 \u6700\u65b0 " + config.latestVersion, { tag: "wand-update" });
1223
891
  }
1224
892
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
@@ -1413,8 +1081,6 @@
1413
1081
  var preferredTool = getComposerTool();
1414
1082
  var composerMode = getSafeModeForTool(preferredTool, state.chatMode);
1415
1083
 
1416
- var showTerminalHeaderControls = !!selectedSession && state.currentView === "terminal";
1417
- var showChatHeaderControls = !!selectedSession && state.currentView !== "terminal";
1418
1084
  return '<div class="app-container">' +
1419
1085
  '<div id="sessions-drawer-backdrop" class="drawer-backdrop' + drawerClass + '"></div>' +
1420
1086
  '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + '">' +
@@ -1457,37 +1123,14 @@
1457
1123
  '</div>' +
1458
1124
  '</aside>' +
1459
1125
  '<main class="main-content">' +
1460
- '<div class="main-content-header">' +
1461
- '<div class="main-content-header-left">' +
1462
- '<button id="sessions-toggle-button" class="main-header-btn menu-toggle-btn' + (state.sessionsDrawerOpen ? ' active' : '') + '" type="button" aria-label="打开菜单" title="菜单">' +
1463
- '<span class="hamburger-icon">' +
1464
- '<span></span><span></span><span></span>' +
1465
- '</span>' +
1466
- '</button>' +
1467
- '<span class="current-task hidden" id="current-task"></span>' +
1468
- '</div>' +
1469
- '<div class="main-content-header-center">' +
1470
- '<div class="view-toggle-bar' + (state.selectedId ? '' : ' hidden') + '" id="view-toggle-bar">' +
1471
- '<button id="view-terminal-btn" class="topbar-btn' + (state.currentView === "terminal" ? ' active' : '') + '" type="button" title="查看原始终端输出">终端</button>' +
1472
- '<button id="view-chat-btn" class="topbar-btn' + (state.currentView !== "terminal" ? ' active' : '') + '" type="button" title="查看聊天解析视图">聊天</button>' +
1473
- '</div>' +
1474
- '</div>' +
1475
- '<div class="main-content-header-right">' +
1476
- '<div class="main-header-controls' + (showTerminalHeaderControls ? '' : ' hidden') + '" id="terminal-header-controls">' +
1477
- '<button id="terminal-scale-down-top" class="main-header-btn terminal-scale-btn" type="button" title="缩小">−</button>' +
1478
- '<span class="main-header-label terminal-scale-label" id="terminal-scale-label-top">' + Math.round(state.terminalScale * 100) + '%</span>' +
1479
- '<button id="terminal-scale-up-top" class="main-header-btn terminal-scale-btn" type="button" title="放大">+</button>' +
1480
- '<button id="page-refresh-btn" class="main-header-btn" type="button" title="刷新页面">↻</button>' +
1481
- '<button id="terminal-jump-bottom" class="main-header-btn jump-latest-btn' + (state.showTerminalJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部">↓ 最新</button>' +
1482
- '</div>' +
1483
- '<div class="main-header-controls' + (showChatHeaderControls ? '' : ' hidden') + '" id="chat-header-controls">' +
1484
- '<button id="chat-follow-toggle" class="chat-follow-toggle topbar-btn' + (state.chatAutoFollow ? ' active' : '') + '" type="button" aria-pressed="' + (state.chatAutoFollow ? 'true' : 'false') + '" title="' + (state.chatAutoFollow ? '追踪底部:开启' : '追踪底部:已暂停') + '">' + (state.chatAutoFollow ? '追底' : '暂停') + '</button>' +
1485
- '<button id="chat-jump-bottom" class="main-header-btn jump-latest-btn' + (state.showChatJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部并继续追底">↓ 最新</button>' +
1486
- '</div>' +
1487
- '<button id="topbar-new-session-button" class="main-header-btn main-header-new-session" type="button" title="新对话">+ 新对话</button>' +
1488
- '</div>' +
1126
+ '<div class="main-header-row">' +
1127
+ '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="切换会话侧栏" type="button">' +
1128
+ '<span class="hamburger-icon">' +
1129
+ '<span></span><span></span><span></span>' +
1130
+ '</span>' +
1131
+ '</button>' +
1132
+ '<span class="current-task hidden" id="current-task"></span>' +
1489
1133
  '</div>' +
1490
- '<div class="main-content-body">' +
1491
1134
  // File panel backdrop (mobile)
1492
1135
  '<div id="file-panel-backdrop" class="file-panel-backdrop' + (state.filePanelOpen ? " open" : "") + '"></div>' +
1493
1136
  // File side panel
@@ -1508,8 +1151,22 @@
1508
1151
  '<div class="file-explorer" id="file-explorer">' + renderFileExplorer(selectedSession && selectedSession.cwd ? selectedSession.cwd : getConfigCwd()) + '</div>' +
1509
1152
  '</div>' +
1510
1153
  '</div>' +
1511
- '<div id="output" class="terminal-container' + (state.selectedId ? "" : " hidden") + ' active"></div>' +
1512
- '<div id="chat-output" class="chat-container hidden"></div>' +
1154
+ '<div id="output" class="terminal-container' + (state.selectedId ? "" : " hidden") + ' active">' +
1155
+ '<div class="terminal-scale-overlay" aria-label="终端缩放控件">' +
1156
+ '<button id="terminal-scale-down-top" class="terminal-scale-overlay-btn terminal-scale-btn" type="button" title="缩小">−</button>' +
1157
+ '<span class="terminal-scale-overlay-label terminal-scale-label" id="terminal-scale-label-top">' + Math.round(state.terminalScale * 100) + '%</span>' +
1158
+ '<button id="terminal-scale-up-top" class="terminal-scale-overlay-btn terminal-scale-btn" type="button" title="放大">+</button>' +
1159
+ '<span class="terminal-scale-overlay-divider"></span>' +
1160
+ '<button id="page-refresh-btn" class="terminal-scale-overlay-btn" type="button" title="刷新页面">↻</button>' +
1161
+ '</div>' +
1162
+ '<button id="terminal-jump-bottom" class="terminal-jump-bottom' + (state.showTerminalJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部">↓ 最新</button>' +
1163
+ '</div>' +
1164
+ '<div id="chat-output" class="chat-container hidden">' +
1165
+ '<div class="chat-overlay-controls">' +
1166
+ '<button id="chat-follow-toggle" class="chat-follow-toggle topbar-btn' + (state.chatAutoFollow ? ' active' : '') + '" type="button" aria-pressed="' + (state.chatAutoFollow ? 'true' : 'false') + '" title="' + (state.chatAutoFollow ? '追踪底部:开启' : '追踪底部:已暂停') + '">' + (state.chatAutoFollow ? '追底' : '暂停') + '</button>' +
1167
+ '</div>' +
1168
+ '<button id="chat-jump-bottom" class="chat-jump-bottom' + (state.showChatJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部并继续追底">↓ 最新</button>' +
1169
+ '</div>' +
1513
1170
  '<div id="blank-chat" class="blank-chat' + (state.selectedId ? " hidden" : "") + '">' +
1514
1171
  '<div class="blank-chat-inner">' +
1515
1172
  '<div class="blank-chat-logo">W</div>' +
@@ -1651,9 +1308,18 @@
1651
1308
  '<h2 class="modal-title">设置</h2>' +
1652
1309
  '<button id="close-settings-button" class="btn btn-ghost btn-icon">×</button>' +
1653
1310
  '</div>' +
1654
- '<div class="modal-body settings-layout">' +
1655
- renderSettingsNav() +
1656
- '<div class="settings-content">' +
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>' +
1321
+
1322
+ // About tab
1657
1323
  '<div class="settings-panel active" id="settings-tab-about">' +
1658
1324
  '<div class="settings-about-info">' +
1659
1325
  '<div class="settings-about-row"><span class="settings-label">包名</span><span class="settings-value" id="settings-pkg-name">-</span></div>' +
@@ -1667,26 +1333,145 @@
1667
1333
  '<span class="settings-value" id="settings-latest-version">-</span>' +
1668
1334
  '</div>' +
1669
1335
  '<div class="settings-update-actions">' +
1670
- '<button id="check-update-button" class="btn btn-ghost btn-sm">检查更新</button>' +
1671
- '<button id="do-update-button" class="btn btn-primary btn-sm hidden">更新到最新版</button>' +
1336
+ '<button id="check-update-button" class="btn btn-ghost btn-sm">\u68c0\u67e5\u66f4\u65b0</button>' +
1337
+ '<button id="do-update-button" class="btn btn-primary btn-sm hidden">\u66f4\u65b0\u5230\u6700\u65b0\u7248</button>' +
1338
+ '<button id="do-restart-button" class="btn btn-success btn-sm hidden">\u91cd\u542f\u751f\u6548</button>' +
1672
1339
  '</div>' +
1673
1340
  '<p id="update-message" class="hint hidden"></p>' +
1674
1341
  '</div>' +
1675
- '<div class="settings-notification-section">' +
1676
- '<div class="settings-section-title">\u901a\u77e5\u72b6\u6001</div>' +
1342
+ '<div class="settings-update-section" id="android-apk-section">' +
1343
+ '<div id="android-apk-current-row" class="settings-about-row hidden">' +
1344
+ '<span class="settings-label">当前版本</span>' +
1345
+ '<span class="settings-value" id="settings-android-apk-current">-</span>' +
1346
+ '</div>' +
1347
+ '<div id="android-apk-github-row" class="settings-about-row hidden">' +
1348
+ '<span class="settings-label">线上版本</span>' +
1349
+ '<span class="settings-value" id="settings-android-apk-github" style="flex:1">-</span>' +
1350
+ '<button id="download-github-apk-btn" class="btn btn-ghost btn-sm hidden" type="button" style="margin-left:8px;flex-shrink:0">下载</button>' +
1351
+ '</div>' +
1352
+ '<div id="android-apk-local-row" class="settings-about-row hidden">' +
1353
+ '<span class="settings-label">本地版本</span>' +
1354
+ '<span class="settings-value" id="settings-android-apk-local" style="flex:1">-</span>' +
1355
+ '<button id="download-local-apk-btn" class="btn btn-ghost btn-sm hidden" type="button" style="margin-left:8px;flex-shrink:0">下载</button>' +
1356
+ '</div>' +
1357
+ '<p id="android-apk-message" class="hint hidden"></p>' +
1358
+ '</div>' +
1359
+ '<div class="settings-update-section" id="android-connect-section">' +
1360
+ '<div class="settings-section-title" style="margin-bottom:8px">App 连接码</div>' +
1361
+ '<div class="settings-connect-url-box">' +
1362
+ '<code id="android-connect-code" class="settings-connect-url-text" style="font-size:12px;word-break:break-all">-</code>' +
1363
+ '<button id="copy-connect-code-button" class="btn btn-ghost btn-sm" type="button" title="复制连接码">复制</button>' +
1364
+ '</div>' +
1365
+ '<p class="hint">复制此连接码粘贴到 Android App 即可自动连接,无需输入密码。修改密码后连接码自动失效。</p>' +
1366
+ '</div>' +
1367
+ '</div>' +
1368
+
1369
+ // Notifications tab
1370
+ '<div class="settings-panel" id="settings-tab-notifications">' +
1371
+ '<div class="settings-section-title">\u901a\u77e5\u504f\u597d</div>' +
1372
+ '<div class="field field-inline">' +
1373
+ '<input id="cfg-notif-sound" type="checkbox" class="field-checkbox" />' +
1374
+ '<label class="field-label" for="cfg-notif-sound">\u64ad\u653e\u63d0\u793a\u97f3</label>' +
1375
+ '</div>' +
1376
+ '<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>' +
1377
+ '<div class="field field-inline">' +
1378
+ '<input id="cfg-notif-bubble" type="checkbox" class="field-checkbox" />' +
1379
+ '<label class="field-label" for="cfg-notif-bubble">\u5e94\u7528\u5185\u901a\u77e5\u6c14\u6ce1</label>' +
1380
+ '</div>' +
1381
+ '<p class="hint" style="margin-top:0;margin-bottom:10px">\u5728\u9875\u9762\u9876\u90e8\u5f39\u51fa\u6d6e\u52a8\u901a\u77e5\u6c14\u6ce1</p>' +
1382
+ '<div class="settings-notification-section" style="margin-top:6px">' +
1383
+ '<div class="settings-section-title">\u6d4f\u89c8\u5668\u901a\u77e5</div>' +
1677
1384
  '<div class="settings-about-row">' +
1678
- '<span class="settings-label">\u6d4f\u89c8\u5668\u901a\u77e5</span>' +
1385
+ '<span class="settings-label">\u6388\u6743\u72b6\u6001</span>' +
1679
1386
  '<span class="settings-value" id="notification-permission-status">-</span>' +
1680
1387
  '</div>' +
1681
1388
  '<div class="settings-update-actions">' +
1682
1389
  '<button id="notification-request-btn" class="btn btn-ghost btn-sm hidden">\u6388\u6743\u901a\u77e5</button>' +
1390
+ '<button id="notification-reset-btn" class="btn btn-ghost btn-sm hidden">\u91cd\u65b0\u6388\u6743</button>' +
1683
1391
  '<button id="notification-test-btn" class="btn btn-ghost btn-sm">\u53d1\u9001\u6d4b\u8bd5\u901a\u77e5</button>' +
1684
1392
  '</div>' +
1685
1393
  '<p id="notification-test-message" class="hint hidden"></p>' +
1686
1394
  '</div>' +
1687
1395
  '</div>' +
1688
1396
 
1689
- buildSettingsGeneralPanel() +
1397
+ // General config tab
1398
+ '<div class="settings-panel" id="settings-tab-general">' +
1399
+ '<div class="field-row">' +
1400
+ '<div class="field">' +
1401
+ '<label class="field-label" for="cfg-host">监听地址 (host)</label>' +
1402
+ '<input id="cfg-host" type="text" class="field-input" placeholder="127.0.0.1" />' +
1403
+ '</div>' +
1404
+ '<div class="field">' +
1405
+ '<label class="field-label" for="cfg-port">端口 (port)</label>' +
1406
+ '<input id="cfg-port" type="number" class="field-input" placeholder="8443" min="1" max="65535" />' +
1407
+ '</div>' +
1408
+ '</div>' +
1409
+ '<div class="field field-inline">' +
1410
+ '<input id="cfg-https" type="checkbox" class="field-checkbox" />' +
1411
+ '<label class="field-label" for="cfg-https">启用 HTTPS</label>' +
1412
+ '</div>' +
1413
+ '<div class="field-row">' +
1414
+ '<div class="field">' +
1415
+ '<label class="field-label" for="cfg-mode">默认执行模式</label>' +
1416
+ '<select id="cfg-mode" class="field-input">' +
1417
+ '<option value="default">default</option>' +
1418
+ '<option value="assist">assist</option>' +
1419
+ '<option value="agent">agent</option>' +
1420
+ '<option value="agent-max">agent-max</option>' +
1421
+ '<option value="auto-edit">auto-edit</option>' +
1422
+ '<option value="full-access">full-access</option>' +
1423
+ '<option value="native">native</option>' +
1424
+ '<option value="managed">managed</option>' +
1425
+ '</select>' +
1426
+ '</div>' +
1427
+ '<div class="field">' +
1428
+ '<label class="field-label" for="cfg-language">回复语言</label>' +
1429
+ '<select id="cfg-language" class="field-input">' +
1430
+ '<option value="">自动(不指定)</option>' +
1431
+ '<option value="中文">中文</option>' +
1432
+ '<option value="English">English</option>' +
1433
+ '<option value="日本語">日本語</option>' +
1434
+ '<option value="한국어">한국어</option>' +
1435
+ '<option value="Español">Español</option>' +
1436
+ '<option value="Français">Français</option>' +
1437
+ '<option value="Deutsch">Deutsch</option>' +
1438
+ '<option value="Русский">Русский</option>' +
1439
+ '</select>' +
1440
+ '</div>' +
1441
+ '</div>' +
1442
+ '<p class="field-hint" style="margin-top:-4px;">设置回复语言后,Claude 将尽量使用指定语言回复。</p>' +
1443
+ '<div class="field">' +
1444
+ '<label class="field-label" for="cfg-cwd">默认工作目录</label>' +
1445
+ '<input id="cfg-cwd" type="text" class="field-input" placeholder="/home/user" />' +
1446
+ '</div>' +
1447
+ '<div class="field">' +
1448
+ '<label class="field-label" for="cfg-shell">Shell</label>' +
1449
+ '<input id="cfg-shell" type="text" class="field-input" placeholder="/bin/bash" />' +
1450
+ '</div>' +
1451
+ (typeof WandNative !== "undefined" && typeof WandNative.getAppIcon === "function" ?
1452
+ '<div style="margin-bottom:16px">' +
1453
+ '<div class="settings-section-title">应用图标</div>' +
1454
+ '<p class="hint" style="margin-top:-4px;margin-bottom:10px">选择 App 启动器图标,返回桌面后生效</p>' +
1455
+ '<div id="app-icon-picker" style="display:flex;gap:16px">' +
1456
+ '<div class="app-icon-option" data-icon="shorthair" style="cursor:pointer;text-align:center">' +
1457
+ '<div class="app-icon-preview" style="width:56px;height:56px;border-radius:12px;overflow:hidden;border:3px solid transparent;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;margin-bottom:4px">' +
1458
+ PIXEL_AVATAR.user +
1459
+ '</div>' +
1460
+ '<span style="font-size:0.72rem;color:var(--text-secondary)">赛博虎妞</span>' +
1461
+ '</div>' +
1462
+ '<div class="app-icon-option" data-icon="garfield" style="cursor:pointer;text-align:center">' +
1463
+ '<div class="app-icon-preview" style="width:56px;height:56px;border-radius:12px;overflow:hidden;border:3px solid transparent;background:var(--bg-tertiary);display:flex;align-items:center;justify-content:center;margin-bottom:4px">' +
1464
+ PIXEL_AVATAR.assistant +
1465
+ '</div>' +
1466
+ '<span style="font-size:0.72rem;color:var(--text-secondary)">勤劳初二</span>' +
1467
+ '</div>' +
1468
+ '</div>' +
1469
+ '<p id="app-icon-message" class="hint hidden" style="margin-top:8px"></p>' +
1470
+ '</div>'
1471
+ : '') +
1472
+ '<button id="save-config-button" class="btn btn-primary btn-block">保存配置</button>' +
1473
+ '<p id="config-message" class="hint hidden"></p>' +
1474
+ '</div>' +
1690
1475
 
1691
1476
  // Security tab
1692
1477
  '<div class="settings-panel" id="settings-tab-security">' +
@@ -1724,6 +1509,38 @@
1724
1509
  '<div class="settings-panel" id="settings-tab-presets">' +
1725
1510
  '<div id="presets-list" class="presets-list"></div>' +
1726
1511
  '</div>' +
1512
+
1513
+ // Display settings tab
1514
+ '<div class="settings-panel" id="settings-tab-display">' +
1515
+ '<div class="settings-section-title">卡片默认展开状态</div>' +
1516
+ '<p class="hint" style="margin-top:-4px;margin-bottom:12px">设置结构化聊天视图中各类卡片的默认展开/折叠状态。手动操作的展开状态优先于此默认设置。</p>' +
1517
+ '<div class="field field-inline">' +
1518
+ '<input id="cfg-card-edit" type="checkbox" class="field-checkbox" />' +
1519
+ '<label class="field-label" for="cfg-card-edit">编辑卡片 (Edit/Write)</label>' +
1520
+ '</div>' +
1521
+ '<p class="hint" style="margin-top:0;margin-bottom:10px">文件编辑和写入操作的 diff 视图</p>' +
1522
+ '<div class="field field-inline">' +
1523
+ '<input id="cfg-card-inline" type="checkbox" class="field-checkbox" />' +
1524
+ '<label class="field-label" for="cfg-card-inline">内联工具 (Read/Glob/Grep)</label>' +
1525
+ '</div>' +
1526
+ '<p class="hint" style="margin-top:0;margin-bottom:10px">文件读取、搜索等工具的结果</p>' +
1527
+ '<div class="field field-inline">' +
1528
+ '<input id="cfg-card-terminal" type="checkbox" class="field-checkbox" />' +
1529
+ '<label class="field-label" for="cfg-card-terminal">终端输出 (Bash)</label>' +
1530
+ '</div>' +
1531
+ '<p class="hint" style="margin-top:0;margin-bottom:10px">命令行执行结果</p>' +
1532
+ '<div class="field field-inline">' +
1533
+ '<input id="cfg-card-thinking" type="checkbox" class="field-checkbox" />' +
1534
+ '<label class="field-label" for="cfg-card-thinking">思考过程 (Thinking)</label>' +
1535
+ '</div>' +
1536
+ '<p class="hint" style="margin-top:0;margin-bottom:10px">Claude 的思考过程块</p>' +
1537
+ '<div class="field field-inline">' +
1538
+ '<input id="cfg-card-toolgroup" type="checkbox" class="field-checkbox" />' +
1539
+ '<label class="field-label" for="cfg-card-toolgroup">工具组</label>' +
1540
+ '</div>' +
1541
+ '<p class="hint" style="margin-top:0;margin-bottom:10px">连续同类工具调用的折叠组</p>' +
1542
+ '<button id="save-display-button" class="btn btn-primary btn-block">保存显示设置</button>' +
1543
+ '<p id="display-message" class="hint hidden"></p>' +
1727
1544
  '</div>' +
1728
1545
  '</div>' +
1729
1546
  '</div>' +
@@ -2087,7 +1904,9 @@
2087
1904
 
2088
1905
  function setFilePanelOpen(nextOpen) {
2089
1906
  state.filePanelOpen = nextOpen;
2090
- persistFilePanelState();
1907
+ try {
1908
+ localStorage.setItem("wand-file-panel-open", String(state.filePanelOpen));
1909
+ } catch (e) {}
2091
1910
  if (state.filePanelOpen && isMobileLayout()) {
2092
1911
  state.sessionsDrawerOpen = false;
2093
1912
  }
@@ -2150,7 +1969,10 @@
2150
1969
  } catch (e) {}
2151
1970
  applyTerminalScale();
2152
1971
  updateScaleLabel();
2153
- scheduleTerminalResize();
1972
+ // Force refit: font size changed but container dimensions didn't,
1973
+ // so ensureTerminalFit (which resets viewport tracking) is needed
1974
+ // instead of scheduleTerminalResize (which skips when size unchanged).
1975
+ ensureTerminalFit();
2154
1976
  }
2155
1977
 
2156
1978
  function applyTerminalScale() {
@@ -2589,6 +2411,35 @@
2589
2411
  '</div>';
2590
2412
  }
2591
2413
 
2414
+ function timeAgo(isoString) {
2415
+ if (!isoString) return "";
2416
+ var now = Date.now();
2417
+ var then = new Date(isoString).getTime();
2418
+ var diff = Math.max(0, now - then);
2419
+ var seconds = Math.floor(diff / 1000);
2420
+ if (seconds < 60) return "刚刚";
2421
+ var minutes = Math.floor(seconds / 60);
2422
+ if (minutes < 60) return minutes + "分钟前";
2423
+ var hours = Math.floor(minutes / 60);
2424
+ if (hours < 24) return hours + "小时前";
2425
+ var days = Math.floor(hours / 24);
2426
+ if (days < 30) return days + "天前";
2427
+ return Math.floor(days / 30) + "个月前";
2428
+ }
2429
+
2430
+ function elapsedTime(isoString) {
2431
+ if (!isoString) return "";
2432
+ var now = Date.now();
2433
+ var then = new Date(isoString).getTime();
2434
+ var diff = Math.max(0, now - then);
2435
+ var seconds = Math.floor(diff / 1000);
2436
+ var minutes = Math.floor(seconds / 60);
2437
+ var hours = Math.floor(minutes / 60);
2438
+ if (hours > 0) return hours + "h" + (minutes % 60 > 0 ? (minutes % 60) + "m" : "");
2439
+ if (minutes > 0) return minutes + "m";
2440
+ return seconds + "s";
2441
+ }
2442
+
2592
2443
  function getSessionStatusLabel(session) {
2593
2444
  if (!session) return "";
2594
2445
  if (session.archived) return "已归档";
@@ -2611,29 +2462,55 @@
2611
2462
  return session.status || "";
2612
2463
  }
2613
2464
 
2465
+ /** Get a human-readable activity description for a running session */
2466
+ function getSessionActivityDesc(session) {
2467
+ if (!session) return "";
2468
+ if (session.permissionBlocked) return "等待你的授权";
2469
+ if (session.status !== "running") return "";
2470
+ // Check WebSocket-delivered currentTask first
2471
+ if (session.id === state.selectedId && state.currentTask && state.currentTask.title) {
2472
+ return state.currentTask.title;
2473
+ }
2474
+ // Fall back to snapshot-delivered currentTaskTitle
2475
+ if (session.currentTaskTitle) return session.currentTaskTitle;
2476
+ return "";
2477
+ }
2478
+
2479
+ /** Get the last meaningful assistant text from messages for notification/display */
2480
+ function getLastAssistantSummary(session) {
2481
+ var msgs = session && session.messages;
2482
+ if (!msgs || msgs.length === 0) return "";
2483
+ for (var i = msgs.length - 1; i >= 0; i--) {
2484
+ var msg = msgs[i];
2485
+ if (msg.role !== "assistant") continue;
2486
+ var blocks = msg.content || [];
2487
+ for (var j = 0; j < blocks.length; j++) {
2488
+ if (blocks[j].type === "text" && blocks[j].text && blocks[j].text.trim()) {
2489
+ var text = blocks[j].text.trim();
2490
+ // Strip markdown formatting for compact display
2491
+ text = text.replace(/^#+\s+/gm, "").replace(/\*\*/g, "").replace(/`/g, "");
2492
+ var firstLine = text.split("\n")[0].trim();
2493
+ return firstLine.slice(0, 100);
2494
+ }
2495
+ }
2496
+ }
2497
+ return "";
2498
+ }
2499
+
2614
2500
  function renderSessionItem(session) {
2615
2501
  var activeClass = session.id === state.selectedId ? " active" : "";
2616
2502
  var selectedClass = state.sessionsManageMode && state.selectedSessionIds[session.id] ? " selected" : "";
2617
2503
  var metaStatus = getSessionStatusLabel(session);
2618
2504
  var metaStatusClass = getSessionStatusClass(session);
2619
- var modeName = session.mode === "full-access" ? "全权限" : session.mode === "default" ? "默认" : session.mode === "native" ? "原生" : session.mode === "auto-edit" ? "自动编辑" : session.mode;
2620
2505
  var resumeButton = "";
2621
- var sessionIdDisplay = "";
2622
- var recoveryHint = "";
2623
2506
  var checkbox = renderManageCheckbox("sessions", session.id, "选择会话 " + session.command);
2624
2507
 
2625
2508
  if (session.provider === "claude" && session.claudeSessionId) {
2626
- var shortId = session.claudeSessionId.slice(0, 8);
2627
- sessionIdDisplay = '<span class="session-id" title="' + escapeHtml(session.claudeSessionId) + '">' + escapeHtml(shortId) + '</span>';
2628
2509
  if (session.status !== "running" && !state.sessionsManageMode && !isStructuredSession(session)) {
2629
2510
  resumeButton = '<button class="session-action-btn" data-action="resume" data-session-id="' + session.id + '" type="button" aria-label="恢复会话" title="恢复 Claude 会话"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-11.36L3 10"/></svg></button>';
2630
2511
  }
2631
2512
  }
2632
2513
 
2633
- if (session.autoRecovered) {
2634
- recoveryHint = '<span class="session-id" title="自动恢复的会话">自动恢复</span>';
2635
- }
2636
-
2637
2514
  var canOpenMerge = !state.sessionsManageMode && session.worktreeEnabled && session.worktree && session.worktree.branch && session.worktree.path;
2638
2515
  var needsCleanup = session.worktreeMergeStatus === "merged" && session.worktreeMergeInfo && session.worktreeMergeInfo.cleanupDone === false;
2639
2516
  var mergeDisabled = session.status === "running" || session.worktreeMergeStatus === "merging";
@@ -2644,23 +2521,50 @@
2644
2521
  ? '<button class="session-action-btn merge-btn" data-action="worktree-cleanup" data-session-id="' + session.id + '" type="button" aria-label="重试清理 worktree" title="重试清理 worktree"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></button>'
2645
2522
  : "";
2646
2523
  var deleteButton = state.sessionsManageMode ? '' : '<button class="session-action-btn delete-btn" data-action="delete-session" data-session-id="' + session.id + '" type="button" aria-label="删除会话" title="删除此会话"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></button>';
2647
- var modeBadge = renderSessionKindBadge(session);
2648
2524
  var actionsHtml = '<span class="session-actions">' + resumeButton + mergeButton + deleteButton + '</span>';
2649
2525
 
2526
+ // Title: summary or command
2527
+ var titleHtml = session.summary
2528
+ ? '<div class="session-title">' + escapeHtml(session.summary) + '</div>'
2529
+ : '<div class="session-command">' + escapeHtml(session.resumedFromSessionId ? session.command.replace(/\s+--resume\s+\S+/, '') : session.command) + '</div>';
2530
+
2531
+ // Activity description for running sessions
2532
+ var activityDesc = getSessionActivityDesc(session);
2533
+ var activityHtml = "";
2534
+ if (session.status === "running" && activityDesc) {
2535
+ activityHtml = '<div class="session-activity">' + escapeHtml(activityDesc) + '</div>';
2536
+ }
2537
+
2538
+ // Time display
2539
+ var timeDisplay = "";
2540
+ if (session.status === "running") {
2541
+ timeDisplay = '<span class="session-time" title="已运行 ' + escapeHtml(elapsedTime(session.startedAt)) + '">' + escapeHtml(elapsedTime(session.startedAt)) + '</span>';
2542
+ } else if (session.endedAt) {
2543
+ timeDisplay = '<span class="session-time" title="' + escapeHtml(new Date(session.endedAt).toLocaleString()) + '">' + escapeHtml(timeAgo(session.endedAt)) + '</span>';
2544
+ } else if (session.startedAt) {
2545
+ timeDisplay = '<span class="session-time" title="' + escapeHtml(new Date(session.startedAt).toLocaleString()) + '">' + escapeHtml(timeAgo(session.startedAt)) + '</span>';
2546
+ }
2547
+
2548
+ // Badges: worktree only (removed PTY/Structured and mode badges for cleaner look)
2549
+ var badgesHtml = renderWorktreeBadge(session);
2550
+
2551
+ // Recovery hint
2552
+ var recoveryHtml = session.autoRecovered ? '<span class="session-recovery-hint">自动恢复</span>' : '';
2553
+
2650
2554
  return '<div class="session-item' + activeClass + selectedClass + '" data-session-id="' + session.id + '" role="button" tabindex="0">' +
2651
2555
  '<div class="session-item-content">' +
2652
2556
  '<div class="session-item-row">' +
2653
2557
  checkbox +
2654
2558
  '<div class="session-main">' +
2655
- (session.summary
2656
- ? '<div class="session-title">' + escapeHtml(session.summary) + '</div>'
2657
- : '<div class="session-command">' + escapeHtml(session.resumedFromSessionId ? session.command.replace(/\s+--resume\s+\S+/, '') : session.command) + '</div>') +
2559
+ '<div class="session-title-row">' +
2560
+ titleHtml +
2561
+ timeDisplay +
2562
+ '</div>' +
2563
+ activityHtml +
2658
2564
  '<div class="session-meta">' +
2659
- modeBadge +
2660
- '<span>' + escapeHtml(modeName) + '</span>' +
2661
2565
  '<span class="session-status ' + metaStatusClass + '">' + escapeHtml(metaStatus) + '</span>' +
2662
- sessionIdDisplay +
2663
- recoveryHint +
2566
+ badgesHtml +
2567
+ recoveryHtml +
2664
2568
  '</div>' +
2665
2569
  '</div>' +
2666
2570
  actionsHtml +
@@ -2819,6 +2723,8 @@
2819
2723
  '<p class="field-hint">留空则使用上方目录,支持路径自动补全。</p>' +
2820
2724
  '<div id="recent-paths-bubbles" class="recent-paths-bubbles"></div>' +
2821
2725
  '</div>' +
2726
+ '</div>' +
2727
+ '<div class="modal-footer">' +
2822
2728
  '<button id="run-button" class="btn btn-primary btn-block">启动会话</button>' +
2823
2729
  '<p id="modal-error" class="error-message hidden"></p>' +
2824
2730
  '</div>' +
@@ -2827,11 +2733,53 @@
2827
2733
  }
2828
2734
 
2829
2735
  // Global toggle function for tool card headers — called via onclick attribute
2736
+ // Lazy-load tool content for truncated results
2737
+ function __fetchToolContent(toolUseId, callback) {
2738
+ if (!state.selectedId || !toolUseId) return;
2739
+ var cacheKey = state.selectedId + ":" + toolUseId;
2740
+ if (state.toolContentCache[cacheKey]) {
2741
+ callback(null, state.toolContentCache[cacheKey]);
2742
+ return;
2743
+ }
2744
+ fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/tool-content/" + encodeURIComponent(toolUseId), { credentials: "same-origin" })
2745
+ .then(function(res) { return res.json(); })
2746
+ .then(function(data) {
2747
+ if (data.error) {
2748
+ callback(data.error, null);
2749
+ } else {
2750
+ state.toolContentCache[cacheKey] = data;
2751
+ callback(null, data);
2752
+ }
2753
+ })
2754
+ .catch(function() {
2755
+ callback("加载失败", null);
2756
+ });
2757
+ }
2758
+
2830
2759
  window.__tcToggle = function(e, headerEl) {
2831
2760
  var card = headerEl.closest(".tool-use-card");
2832
2761
  if (card) {
2762
+ var wasCollapsed = card.classList.contains("collapsed");
2833
2763
  card.classList.toggle("collapsed");
2834
2764
  persistElementExpandState(card, "tool-card");
2765
+ // Lazy-load truncated content on expand
2766
+ if (wasCollapsed && card.dataset.truncated === "true" && card.dataset.loaded !== "true") {
2767
+ var toolUseId = card.dataset.toolUseId;
2768
+ var resultDiv = card.querySelector(".tool-use-result");
2769
+ if (resultDiv) resultDiv.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2770
+ card.dataset.loaded = "loading";
2771
+ __fetchToolContent(toolUseId, function(err, data) {
2772
+ if (err) {
2773
+ if (resultDiv) resultDiv.innerHTML = '<div class="tool-content-error" onclick="__tcToggle(null, card.querySelector(\'.tool-use-header\'))">加载失败,点击重试</div>';
2774
+ card.dataset.loaded = "";
2775
+ } else {
2776
+ card.dataset.truncated = "false";
2777
+ card.dataset.loaded = "true";
2778
+ var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2779
+ if (resultDiv) resultDiv.innerHTML = '<pre class="tool-use-result-content">' + escapeHtml(content) + '</pre>';
2780
+ }
2781
+ });
2782
+ }
2835
2783
  }
2836
2784
  if (e) { e.preventDefault(); e.stopPropagation(); }
2837
2785
  };
@@ -2870,6 +2818,24 @@
2870
2818
  statusSpan.textContent = "✓";
2871
2819
  }
2872
2820
  }
2821
+ // Lazy-load truncated content on expand
2822
+ if (expanded && el.dataset.truncated === "true" && el.dataset.loaded !== "true") {
2823
+ var toolUseId = el.dataset.toolUseId;
2824
+ if (body) body.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2825
+ el.dataset.loaded = "loading";
2826
+ __fetchToolContent(toolUseId, function(err, data) {
2827
+ if (err) {
2828
+ if (body) body.innerHTML = '<div class="tool-content-error">加载失败,点击重试</div>';
2829
+ el.dataset.loaded = "";
2830
+ } else {
2831
+ el.dataset.truncated = "false";
2832
+ el.dataset.loaded = "true";
2833
+ var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2834
+ el.dataset.result = content;
2835
+ if (body) body.innerHTML = '<div class="inline-tool-result">' + formatInlineResult(content, "") + '</div>';
2836
+ }
2837
+ });
2838
+ }
2873
2839
  persistElementExpandState(el, "inline-tool");
2874
2840
  };
2875
2841
  // Toggle function for terminal tool blocks
@@ -2884,7 +2850,32 @@
2884
2850
  var toggleIcon = el.querySelector(".term-toggle-icon");
2885
2851
  if (toggleIcon) toggleIcon.textContent = isHidden ? "▼" : "▶";
2886
2852
  persistElementExpandState(container, "terminal");
2887
- }
2853
+ // Lazy-load truncated content on expand
2854
+ if (isHidden && container.dataset.truncated === "true" && container.dataset.loaded !== "true") {
2855
+ var toolUseId = container.dataset.toolUseId;
2856
+ var termOutput = body.querySelector(".term-output");
2857
+ if (termOutput) termOutput.innerHTML = '<div class="tool-content-loading">加载中…</div>';
2858
+ container.dataset.loaded = "loading";
2859
+ __fetchToolContent(toolUseId, function(err, data) {
2860
+ if (err) {
2861
+ if (termOutput) termOutput.innerHTML = '<div class="tool-content-error">加载失败,点击重试</div>';
2862
+ container.dataset.loaded = "";
2863
+ } else {
2864
+ container.dataset.truncated = "false";
2865
+ container.dataset.loaded = "true";
2866
+ var content = typeof data.content === "string" ? data.content : JSON.stringify(data.content);
2867
+ if (termOutput) {
2868
+ var lines = content.split("\n");
2869
+ var html = "";
2870
+ for (var i = 0; i < lines.length; i++) {
2871
+ if (!lines[i] && i === lines.length - 1) continue;
2872
+ html += '<div class="term-line">' + escapeHtml(lines[i]) + '</div>';
2873
+ }
2874
+ termOutput.innerHTML = html;
2875
+ }
2876
+ }
2877
+ });
2878
+ }
2888
2879
  };
2889
2880
  // Update streaming thinking content (called from WebSocket handler)
2890
2881
  function updateStreamingThinking(text) {
@@ -2901,33 +2892,85 @@
2901
2892
  }
2902
2893
  }
2903
2894
  }
2904
- // Global handler for ask-user option buttons called via onclick
2905
- window.__askOption = function(btnEl) {
2906
- var optionLabel = btnEl.dataset.optionLabel;
2907
- if (optionLabel && state.selectedId) {
2908
- btnEl.classList.add("selected");
2909
- // Only disable options within the same question group, not globally
2910
- var questionGroup = btnEl.closest(".ask-user-question-group");
2911
- if (questionGroup) {
2912
- questionGroup.querySelectorAll(".ask-user-option").forEach(function(opt) {
2913
- opt.classList.add("selected");
2914
- opt.style.pointerEvents = "none";
2915
- });
2916
- var sentDiv = document.createElement("div");
2917
- sentDiv.className = "ask-user-answer-sent";
2918
- sentDiv.innerHTML = "\u2713 \u5df2\u53d1\u9001: " + escapeHtml(optionLabel);
2919
- questionGroup.appendChild(sentDiv);
2920
- }
2921
- fetch("/api/sessions/" + state.selectedId + "/input", {
2922
- method: "POST",
2923
- headers: { "Content-Type": "application/json" },
2924
- credentials: "same-origin",
2925
- body: JSON.stringify({ input: optionLabel + "\n", view: state.currentView })
2926
- }).catch(function(err) {
2927
- console.error("[wand] Error sending answer:", err);
2895
+ // ── AskUserQuestion handlers: select render submit ──
2896
+ window.__askSelect = function(toolUseId, qIdx, optIdx, isMulti) {
2897
+ var sel = state.askUserSelections[toolUseId];
2898
+ if (!sel) {
2899
+ sel = { submitted: false };
2900
+ state.askUserSelections[toolUseId] = sel;
2901
+ }
2902
+ if (sel.submitted) return;
2903
+ var current = sel[qIdx] || [];
2904
+ if (isMulti) {
2905
+ var pos = current.indexOf(optIdx);
2906
+ if (pos === -1) { current.push(optIdx); } else { current.splice(pos, 1); }
2907
+ } else {
2908
+ current = current[0] === optIdx ? [] : [optIdx];
2909
+ }
2910
+ sel[qIdx] = current;
2911
+ window.__askRender(toolUseId);
2912
+ };
2913
+
2914
+ window.__askRender = function(toolUseId) {
2915
+ var card = document.querySelector('[data-tool-use-id="' + toolUseId + '"]');
2916
+ if (!card) return;
2917
+ var sel = state.askUserSelections[toolUseId] || {};
2918
+ // Update option selected states
2919
+ card.querySelectorAll(".ask-user-option").forEach(function(btn) {
2920
+ var qIdx = parseInt(btn.dataset.questionIndex, 10);
2921
+ var oIdx = parseInt(btn.dataset.optionIndex, 10);
2922
+ var chosen = (sel[qIdx] || []).indexOf(oIdx) !== -1;
2923
+ btn.classList.toggle("selected", chosen);
2924
+ });
2925
+ // Update submit button: enabled only when every question has at least one selection
2926
+ var submitBtn = card.querySelector(".ask-user-submit");
2927
+ if (submitBtn) {
2928
+ var groups = card.querySelectorAll(".ask-user-question-group");
2929
+ var allAnswered = true;
2930
+ groups.forEach(function(g, i) {
2931
+ if (!sel[i] || sel[i].length === 0) allAnswered = false;
2928
2932
  });
2933
+ submitBtn.disabled = !allAnswered || !!sel.submitted;
2934
+ if (sel.submitted) {
2935
+ submitBtn.textContent = "已提交...";
2936
+ submitBtn.classList.add("ask-user-submitted");
2937
+ }
2929
2938
  }
2930
2939
  };
2940
+
2941
+ window.__askSubmit = function(toolUseId) {
2942
+ var sel = state.askUserSelections[toolUseId];
2943
+ if (!sel || sel.submitted || !state.selectedId) return;
2944
+ var card = document.querySelector('[data-tool-use-id="' + toolUseId + '"]');
2945
+ if (!card) return;
2946
+ var groups = card.querySelectorAll(".ask-user-question-group");
2947
+ var lines = [];
2948
+ var allAnswered = true;
2949
+ groups.forEach(function(group, qIdx) {
2950
+ var selected = sel[qIdx] || [];
2951
+ if (selected.length === 0) { allAnswered = false; return; }
2952
+ var labels = [];
2953
+ selected.forEach(function(optIdx) {
2954
+ var btn = group.querySelector('[data-option-index="' + optIdx + '"]');
2955
+ if (btn) labels.push(btn.dataset.optionLabel);
2956
+ });
2957
+ lines.push(labels.join(", "));
2958
+ });
2959
+ if (!allAnswered) return;
2960
+ sel.submitted = true;
2961
+ window.__askRender(toolUseId);
2962
+ var answerText = lines.join("\n");
2963
+ fetch("/api/sessions/" + state.selectedId + "/input", {
2964
+ method: "POST",
2965
+ headers: { "Content-Type": "application/json" },
2966
+ credentials: "same-origin",
2967
+ body: JSON.stringify({ input: answerText + "\n", view: state.currentView })
2968
+ }).catch(function(err) {
2969
+ console.error("[wand] Error sending answer:", err);
2970
+ sel.submitted = false;
2971
+ window.__askRender(toolUseId);
2972
+ });
2973
+ };
2931
2974
  function attachEventListeners() {
2932
2975
 
2933
2976
  var loginButton = document.getElementById("login-button");
@@ -3093,20 +3136,86 @@
3093
3136
  });
3094
3137
  var savePassBtn = document.getElementById("save-password-button");
3095
3138
  if (savePassBtn) savePassBtn.addEventListener("click", savePassword);
3096
- bindSettingsModalEvents();
3139
+ // Settings tab clicks
3140
+ var settingsTabs = document.querySelectorAll(".settings-tab");
3141
+ for (var ti = 0; ti < settingsTabs.length; ti++) {
3142
+ settingsTabs[ti].addEventListener("click", function(e) {
3143
+ switchSettingsTab(e.target.getAttribute("data-tab"));
3144
+ });
3145
+ }
3146
+ var saveConfigBtn = document.getElementById("save-config-button");
3147
+ if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
3148
+ var saveDisplayBtn = document.getElementById("save-display-button");
3149
+ if (saveDisplayBtn) saveDisplayBtn.addEventListener("click", saveDisplaySettings);
3150
+ // App icon picker (APK only)
3151
+ var appIconPicker = document.getElementById("app-icon-picker");
3152
+ if (appIconPicker) {
3153
+ var appIconOpts = appIconPicker.querySelectorAll(".app-icon-option");
3154
+ for (var ai = 0; ai < appIconOpts.length; ai++) {
3155
+ appIconOpts[ai].addEventListener("click", function() {
3156
+ var iconName = this.getAttribute("data-icon");
3157
+ if (!iconName || typeof WandNative === "undefined" || typeof WandNative.setAppIcon !== "function") return;
3158
+ try {
3159
+ WandNative.setAppIcon(iconName);
3160
+ _updateAppIconSelection(iconName);
3161
+ var msgEl = document.getElementById("app-icon-message");
3162
+ if (msgEl) {
3163
+ msgEl.textContent = "图标已切换,返回桌面后生效";
3164
+ msgEl.style.color = "var(--success)";
3165
+ msgEl.classList.remove("hidden");
3166
+ setTimeout(function() { msgEl.classList.add("hidden"); }, 3000);
3167
+ }
3168
+ } catch (_e) {}
3169
+ });
3170
+ }
3171
+ }
3097
3172
  var uploadCertBtn = document.getElementById("upload-cert-button");
3098
3173
  if (uploadCertBtn) uploadCertBtn.addEventListener("click", uploadCertificates);
3099
3174
  var checkUpdateBtn = document.getElementById("check-update-button");
3100
3175
  if (checkUpdateBtn) checkUpdateBtn.addEventListener("click", checkForUpdate);
3101
3176
  var doUpdateBtn = document.getElementById("do-update-button");
3102
3177
  if (doUpdateBtn) doUpdateBtn.addEventListener("click", performUpdate);
3103
- // Notification test section
3178
+ var doRestartBtn = document.getElementById("do-restart-button");
3179
+ if (doRestartBtn) doRestartBtn.addEventListener("click", performSettingsRestart);
3180
+ var copyConnectCodeBtn = document.getElementById("copy-connect-code-button");
3181
+ if (copyConnectCodeBtn) copyConnectCodeBtn.addEventListener("click", function() {
3182
+ var text = document.getElementById("android-connect-code");
3183
+ if (text) copyToClipboard(text.textContent, copyConnectCodeBtn);
3184
+ });
3185
+ // Notification preferences
3186
+ var notifSoundEl = document.getElementById("cfg-notif-sound");
3187
+ if (notifSoundEl) {
3188
+ notifSoundEl.checked = state.notifSound;
3189
+ notifSoundEl.addEventListener("change", function() {
3190
+ state.notifSound = notifSoundEl.checked;
3191
+ try { localStorage.setItem("wand-notif-sound", String(state.notifSound)); } catch (e) {}
3192
+ // Preview sound when toggling on
3193
+ if (state.notifSound) _doPlaySound();
3194
+ });
3195
+ }
3196
+ var notifBubbleEl = document.getElementById("cfg-notif-bubble");
3197
+ if (notifBubbleEl) {
3198
+ notifBubbleEl.checked = state.notifBubble;
3199
+ notifBubbleEl.addEventListener("change", function() {
3200
+ state.notifBubble = notifBubbleEl.checked;
3201
+ try { localStorage.setItem("wand-notif-bubble", String(state.notifBubble)); } catch (e) {}
3202
+ });
3203
+ }
3204
+ // Browser notification section
3104
3205
  var notifRequestBtn = document.getElementById("notification-request-btn");
3105
3206
  if (notifRequestBtn) notifRequestBtn.addEventListener("click", function() {
3106
- if (typeof Notification !== "undefined") {
3207
+ if (_hasNativeBridge) {
3208
+ window._onNativePermissionResult = function() {
3209
+ updateNotificationStatus();
3210
+ delete window._onNativePermissionResult;
3211
+ };
3212
+ try { WandNative.requestPermission(); } catch (_e) {}
3213
+ } else if (typeof Notification !== "undefined") {
3107
3214
  Notification.requestPermission().then(function() { updateNotificationStatus(); });
3108
3215
  }
3109
3216
  });
3217
+ var notifResetBtn = document.getElementById("notification-reset-btn");
3218
+ if (notifResetBtn) notifResetBtn.addEventListener("click", resetNotificationPermission);
3110
3219
  var notifTestBtn = document.getElementById("notification-test-btn");
3111
3220
  if (notifTestBtn) notifTestBtn.addEventListener("click", testNotification);
3112
3221
  updateNotificationStatus();
@@ -3169,11 +3278,6 @@
3169
3278
  inputBox.addEventListener("blur", handleInputBoxBlur);
3170
3279
  }
3171
3280
 
3172
- // View toggle handlers
3173
- var viewTermBtn = document.getElementById("view-terminal-btn");
3174
- if (viewTermBtn) viewTermBtn.addEventListener("click", function() { setView("terminal"); });
3175
- var viewChatBtn = document.getElementById("view-chat-btn");
3176
- if (viewChatBtn) viewChatBtn.addEventListener("click", function() { setView("chat"); });
3177
3281
  // Terminal interactive toggle (both topbar and terminal-header)
3178
3282
  var terminalInteractiveToggles = ["terminal-interactive-toggle-top"];
3179
3283
  terminalInteractiveToggles.forEach(function(id) {
@@ -3190,8 +3294,15 @@
3190
3294
  if (shortcutsToggleBtn) shortcutsToggleBtn.addEventListener("click", function(e) {
3191
3295
  e.stopPropagation();
3192
3296
  state.shortcutsExpanded = !state.shortcutsExpanded;
3193
- persistShortcutsExpandedState();
3194
- updateCollapsedShortcutsUi();
3297
+ var wrap = document.querySelector(".inline-shortcuts-wrap");
3298
+ var toggle = document.querySelector(".shortcuts-toggle");
3299
+ var row = document.querySelector(".inline-shortcuts-expanded-row");
3300
+ if (wrap) wrap.classList.toggle("expanded", state.shortcutsExpanded);
3301
+ if (row) row.classList.toggle("visible", state.shortcutsExpanded);
3302
+ if (toggle) {
3303
+ toggle.classList.toggle("active", state.shortcutsExpanded);
3304
+ toggle.textContent = state.shortcutsExpanded ? "\u203a" : "\u2039";
3305
+ }
3195
3306
  });
3196
3307
  // Close shortcuts strip on outside click
3197
3308
  document.addEventListener("click", function(e) {
@@ -3201,8 +3312,13 @@
3201
3312
  var clickedInsideRow = expandedRow && expandedRow.contains(e.target);
3202
3313
  if (wrap && !wrap.contains(e.target) && !clickedInsideRow) {
3203
3314
  state.shortcutsExpanded = false;
3204
- persistShortcutsExpandedState();
3205
- updateCollapsedShortcutsUi();
3315
+ wrap.classList.remove("expanded");
3316
+ if (expandedRow) expandedRow.classList.remove("visible");
3317
+ var toggle = document.querySelector(".shortcuts-toggle");
3318
+ if (toggle) {
3319
+ toggle.classList.remove("active");
3320
+ toggle.textContent = "\u2039";
3321
+ }
3206
3322
  }
3207
3323
  });
3208
3324
 
@@ -3681,7 +3797,6 @@
3681
3797
  event.preventDefault();
3682
3798
  event.stopPropagation();
3683
3799
  state.claudeHistoryExpanded = !state.claudeHistoryExpanded;
3684
- persistHistoryPanelState();
3685
3800
  if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
3686
3801
  loadClaudeHistory();
3687
3802
  }
@@ -3851,6 +3966,8 @@
3851
3966
  if (button) {
3852
3967
  button.classList.toggle("visible", shouldShow);
3853
3968
  }
3969
+ var termContainer = document.getElementById("output");
3970
+ if (termContainer) termContainer.classList.toggle("has-jump-btn", shouldShow);
3854
3971
  }
3855
3972
 
3856
3973
  function isTerminalNearBottom() {
@@ -4353,7 +4470,7 @@
4353
4470
  })
4354
4471
  .then(function(res) { return res.json(); })
4355
4472
  .then(function(config) {
4356
- applySettingsConfig(config);
4473
+ state.config = config;
4357
4474
  var statusDot = document.getElementById("status-dot");
4358
4475
  var statusText = document.getElementById("status-text");
4359
4476
  if (statusDot) statusDot.classList.add("active");
@@ -4391,16 +4508,9 @@
4391
4508
  state.sessions = [];
4392
4509
  state.claudeHistory = [];
4393
4510
  state.claudeHistoryLoaded = false;
4394
- state.claudeHistoryExpanded = getConfiguredPanelDefaults().claudeHistoryExpanded;
4395
- persistHistoryPanelState();
4511
+ state.claudeHistoryExpanded = true;
4396
4512
  state.claudeHistoryExpandedDirs = {};
4397
- state.sessionsDrawerOpen = getConfiguredPanelDefaults().sessionsDrawerOpen;
4398
- persistDrawerState();
4399
- state.filePanelOpen = getConfiguredPanelDefaults().filePanelOpen;
4400
- persistFilePanelState();
4401
- state.shortcutsExpanded = getConfiguredPanelDefaults().shortcutsExpanded;
4402
- persistShortcutsExpandedState();
4403
- updateCollapsedShortcutsUi();
4513
+ state.sessionsDrawerOpen = false;
4404
4514
  render();
4405
4515
  }
4406
4516
 
@@ -4588,9 +4698,6 @@
4588
4698
 
4589
4699
  function applyCurrentView() {
4590
4700
  var hasSession = !!state.selectedId;
4591
- var terminalBtn = document.getElementById("view-terminal-btn");
4592
- var chatBtn = document.getElementById("view-chat-btn");
4593
- var toggleBar = document.getElementById("view-toggle-bar");
4594
4701
  var terminalContainer = document.getElementById("output");
4595
4702
  var chatContainer = document.getElementById("chat-output");
4596
4703
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
@@ -4603,17 +4710,6 @@
4603
4710
  state.currentView = "terminal";
4604
4711
  }
4605
4712
 
4606
- if (toggleBar) {
4607
- toggleBar.classList.toggle("hidden", !hasSession);
4608
- }
4609
- if (terminalBtn) {
4610
- terminalBtn.classList.toggle("hidden", structured || !hasSession);
4611
- terminalBtn.classList.toggle("active", showTerminal);
4612
- }
4613
- if (chatBtn) {
4614
- chatBtn.classList.toggle("hidden", !hasSession);
4615
- chatBtn.classList.toggle("active", showChat);
4616
- }
4617
4713
  if (terminalContainer) {
4618
4714
  terminalContainer.classList.toggle("active", showTerminal);
4619
4715
  terminalContainer.classList.toggle("hidden", !showTerminal);
@@ -4888,6 +4984,15 @@
4888
4984
  });
4889
4985
  }
4890
4986
 
4987
+ var _sessionListUpdateTimer = null;
4988
+ function scheduleSessionListUpdate() {
4989
+ if (_sessionListUpdateTimer) return;
4990
+ _sessionListUpdateTimer = setTimeout(function() {
4991
+ _sessionListUpdateTimer = null;
4992
+ updateSessionsList();
4993
+ }, 200);
4994
+ }
4995
+
4891
4996
  function updateSessionsList() {
4892
4997
  var listEl = document.getElementById("sessions-list");
4893
4998
  var countEl = document.getElementById("session-count");
@@ -5016,6 +5121,8 @@
5016
5121
  }
5017
5122
  state.selectedId = id;
5018
5123
  persistSelectedId();
5124
+ // Clear tool content cache on session switch
5125
+ state.toolContentCache = {};
5019
5126
  // Clear queued inputs from the previous session to prevent cross-session leaks
5020
5127
  state.messageQueue = [];
5021
5128
  state.pendingMessages = [];
@@ -5066,10 +5173,11 @@
5066
5173
 
5067
5174
  function toggleSessionsDrawer() {
5068
5175
  state.sessionsDrawerOpen = !state.sessionsDrawerOpen;
5069
- persistDrawerState();
5070
5176
  if (state.sessionsDrawerOpen && isMobileLayout()) {
5071
5177
  state.filePanelOpen = false;
5072
- persistFilePanelState();
5178
+ try {
5179
+ localStorage.setItem("wand-file-panel-open", "false");
5180
+ } catch (e) {}
5073
5181
  }
5074
5182
  updateLayoutState();
5075
5183
  }
@@ -5078,7 +5186,6 @@
5078
5186
  if (!state.sessionsDrawerOpen) return;
5079
5187
  closeSwipedItem();
5080
5188
  state.sessionsDrawerOpen = false;
5081
- persistDrawerState();
5082
5189
  updateLayoutState();
5083
5190
  }
5084
5191
 
@@ -5323,7 +5430,6 @@
5323
5430
  function openSettingsModal() {
5324
5431
  // Close session modal first if open (mutual exclusion)
5325
5432
  closeSessionModal();
5326
- refreshSettingsModalUi();
5327
5433
  var modal = document.getElementById("settings-modal");
5328
5434
  if (modal) {
5329
5435
  modal.classList.remove("hidden");
@@ -5334,9 +5440,20 @@
5334
5440
  if (confirmEl) confirmEl.value = "";
5335
5441
  hideSettingsMessages();
5336
5442
  setupFocusTrap(modal);
5337
- bindSettingsModalEvents();
5443
+ // Activate first tab
5338
5444
  switchSettingsTab("about");
5445
+ // Load settings data
5339
5446
  loadSettingsData();
5447
+ // Sync notification preferences
5448
+ var soundEl = document.getElementById("cfg-notif-sound");
5449
+ var bubbleEl = document.getElementById("cfg-notif-bubble");
5450
+ if (soundEl) soundEl.checked = state.notifSound;
5451
+ if (bubbleEl) bubbleEl.checked = state.notifBubble;
5452
+ updateNotificationStatus();
5453
+ // Load current app icon selection (APK only)
5454
+ if (typeof WandNative !== "undefined" && typeof WandNative.getAppIcon === "function") {
5455
+ try { _updateAppIconSelection(WandNative.getAppIcon() || "shorthair"); } catch (_e) {}
5456
+ }
5340
5457
  }
5341
5458
  }
5342
5459
 
@@ -5426,138 +5543,273 @@
5426
5543
  panels[j].classList.remove("active");
5427
5544
  }
5428
5545
  }
5429
- updateSettingsActiveNav();
5430
5546
  }
5431
5547
 
5432
- function refreshSettingsGeneralPanel() {
5433
- var existingPanel = document.getElementById("settings-tab-general");
5434
- if (!existingPanel) return;
5435
- var wrapper = document.createElement("div");
5436
- wrapper.innerHTML = buildSettingsGeneralPanel();
5437
- var nextPanel = wrapper.firstChild;
5438
- if (!nextPanel) return;
5439
- existingPanel.replaceWith(nextPanel);
5440
- var saveConfigBtn = document.getElementById("save-config-button");
5441
- if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
5442
- syncPanelStateSettingsForm();
5548
+ function copyToClipboard(text, triggerBtn) {
5549
+ if (!text) return;
5550
+ navigator.clipboard.writeText(text).then(function() {
5551
+ if (triggerBtn) {
5552
+ var orig = triggerBtn.textContent;
5553
+ triggerBtn.textContent = "已复制";
5554
+ setTimeout(function() { triggerBtn.textContent = orig; }, 1500);
5555
+ }
5556
+ }).catch(function() {
5557
+ // Fallback for non-secure contexts
5558
+ var ta = document.createElement("textarea");
5559
+ ta.value = text;
5560
+ ta.style.position = "fixed";
5561
+ ta.style.opacity = "0";
5562
+ document.body.appendChild(ta);
5563
+ ta.select();
5564
+ document.execCommand("copy");
5565
+ document.body.removeChild(ta);
5566
+ if (triggerBtn) {
5567
+ var orig = triggerBtn.textContent;
5568
+ triggerBtn.textContent = "已复制";
5569
+ setTimeout(function() { triggerBtn.textContent = orig; }, 1500);
5570
+ }
5571
+ });
5443
5572
  }
5444
5573
 
5445
- function bindSettingsTabEvents() {
5446
- var settingsTabs = document.querySelectorAll(".settings-tab");
5447
- for (var ti = 0; ti < settingsTabs.length; ti++) {
5448
- settingsTabs[ti].addEventListener("click", function(e) {
5449
- switchSettingsTab(e.target.getAttribute("data-tab"));
5450
- });
5574
+ function formatBytes(value) {
5575
+ if (typeof value !== "number" || !isFinite(value) || value < 0) return "-";
5576
+ if (value < 1024) return value + " B";
5577
+ var units = ["KB", "MB", "GB", "TB"];
5578
+ var size = value / 1024;
5579
+ var unitIndex = 0;
5580
+ while (size >= 1024 && unitIndex < units.length - 1) {
5581
+ size = size / 1024;
5582
+ unitIndex += 1;
5451
5583
  }
5584
+ var display = size >= 10 ? size.toFixed(0) : size.toFixed(1);
5585
+ return display + " " + units[unitIndex];
5452
5586
  }
5453
5587
 
5454
- function bindSettingsConfigActions() {
5455
- var saveConfigBtn = document.getElementById("save-config-button");
5456
- if (saveConfigBtn) saveConfigBtn.addEventListener("click", saveConfigSettings);
5457
- }
5588
+ function loadSettingsData() {
5589
+ fetch("/api/settings", { credentials: "same-origin" })
5590
+ .then(function(res) { return res.json(); })
5591
+ .then(function(data) {
5592
+ // About
5593
+ var nameEl = document.getElementById("settings-pkg-name");
5594
+ var verEl = document.getElementById("settings-version");
5595
+ var nodeEl = document.getElementById("settings-node-req");
5596
+ var repoEl = document.getElementById("settings-repo-url");
5597
+ if (nameEl) nameEl.textContent = data.packageName || "-";
5598
+ if (verEl) verEl.textContent = data.version || "-";
5599
+ if (nodeEl) nodeEl.textContent = data.nodeVersion || "-";
5600
+ if (repoEl && data.repoUrl) {
5601
+ repoEl.innerHTML = '<a href="' + escapeHtml(data.repoUrl) + '" target="_blank" rel="noopener">' + escapeHtml(data.repoUrl) + '</a>';
5602
+ }
5603
+
5604
+ // Prefill update info if available
5605
+ var latestEl = document.getElementById("settings-latest-version");
5606
+ var updateBtn = document.getElementById("do-update-button");
5607
+ if (data.latestVersion && latestEl) {
5608
+ latestEl.textContent = data.latestVersion;
5609
+ if (data.updateAvailable && updateBtn) {
5610
+ updateBtn.classList.remove("hidden");
5611
+ }
5612
+ }
5458
5613
 
5459
- function bindSettingsModalEvents() {
5460
- bindSettingsTabEvents();
5461
- bindSettingsConfigActions();
5462
- }
5614
+ // ── Android APK version display ──
5615
+ var apkSection = document.getElementById("android-apk-section");
5616
+ var apkCurrentRow = document.getElementById("android-apk-current-row");
5617
+ var apkCurrentEl = document.getElementById("settings-android-apk-current");
5618
+ var apkGithubRow = document.getElementById("android-apk-github-row");
5619
+ var apkGithubEl = document.getElementById("settings-android-apk-github");
5620
+ var apkGithubBtn = document.getElementById("download-github-apk-btn");
5621
+ var apkLocalRow = document.getElementById("android-apk-local-row");
5622
+ var apkLocalEl = document.getElementById("settings-android-apk-local");
5623
+ var apkLocalBtn = document.getElementById("download-local-apk-btn");
5624
+ var apkMessageEl = document.getElementById("android-apk-message");
5625
+ var androidApk = data.androidApk || {};
5626
+ var isInApk = !!_apkVersion;
5627
+
5628
+ if (isInApk) {
5629
+ // ── APK 内模式:显示当前版本 + 线上版本 + 本地版本 ──
5630
+ if (apkCurrentRow && apkCurrentEl) {
5631
+ apkCurrentEl.textContent = "v" + _apkVersion;
5632
+ apkCurrentRow.classList.remove("hidden");
5633
+ }
5634
+ // 线上版本
5635
+ if (androidApk.github && apkGithubRow && apkGithubEl) {
5636
+ var ghLabel = androidApk.github.version ? ("v" + androidApk.github.version) : androidApk.github.fileName;
5637
+ if (typeof androidApk.github.size === "number") ghLabel += " · " + formatBytes(androidApk.github.size);
5638
+ apkGithubEl.textContent = ghLabel;
5639
+ apkGithubRow.classList.remove("hidden");
5640
+ if (apkGithubBtn) {
5641
+ apkGithubBtn.textContent = "下载安装";
5642
+ apkGithubBtn.classList.remove("hidden");
5643
+ apkGithubBtn.onclick = function() {
5644
+ try {
5645
+ WandNative.downloadUpdate(androidApk.github.downloadUrl, androidApk.github.fileName || "wand-update.apk", "github");
5646
+ } catch (e) {
5647
+ alert("调用下载失败: " + e.message);
5648
+ }
5649
+ };
5650
+ }
5651
+ }
5652
+ // 本地版本
5653
+ if (androidApk.local && apkLocalRow && apkLocalEl) {
5654
+ var lcLabel = androidApk.local.version ? ("v" + androidApk.local.version) : androidApk.local.fileName;
5655
+ if (typeof androidApk.local.size === "number") lcLabel += " · " + formatBytes(androidApk.local.size);
5656
+ apkLocalEl.textContent = lcLabel;
5657
+ apkLocalRow.classList.remove("hidden");
5658
+ if (apkLocalBtn) {
5659
+ apkLocalBtn.textContent = "下载安装";
5660
+ apkLocalBtn.classList.remove("hidden");
5661
+ apkLocalBtn.onclick = function() {
5662
+ try {
5663
+ WandNative.downloadUpdate(androidApk.local.downloadUrl, androidApk.local.fileName || "wand-update.apk", "local");
5664
+ } catch (e) {
5665
+ alert("调用下载失败: " + e.message);
5666
+ }
5667
+ };
5668
+ }
5669
+ }
5670
+ // 都没有时
5671
+ if (!androidApk.github && !androidApk.local && apkMessageEl) {
5672
+ apkMessageEl.textContent = "暂无可用更新";
5673
+ apkMessageEl.classList.remove("hidden");
5674
+ }
5675
+ } else {
5676
+ // ── 浏览器模式:只显示线上版本 + 下载按钮 ──
5677
+ if (androidApk.github && apkGithubRow && apkGithubEl) {
5678
+ var ghLabel2 = androidApk.github.version ? ("v" + androidApk.github.version) : androidApk.github.fileName;
5679
+ if (typeof androidApk.github.size === "number") ghLabel2 += " · " + formatBytes(androidApk.github.size);
5680
+ apkGithubEl.textContent = ghLabel2;
5681
+ apkGithubRow.classList.remove("hidden");
5682
+ if (apkGithubBtn) {
5683
+ apkGithubBtn.textContent = "下载";
5684
+ apkGithubBtn.classList.remove("hidden");
5685
+ apkGithubBtn.onclick = function() {
5686
+ window.open(androidApk.github.downloadUrl, "_blank");
5687
+ };
5688
+ }
5689
+ } else if (apkMessageEl) {
5690
+ apkMessageEl.textContent = "暂未提供";
5691
+ apkMessageEl.classList.remove("hidden");
5692
+ }
5693
+ }
5463
5694
 
5464
- function refreshSettingsModalUi() {
5465
- refreshSettingsGeneralPanel();
5466
- updateSettingsActiveNav();
5695
+ // App connect code (encrypted)
5696
+ var connectCodeEl = document.getElementById("android-connect-code");
5697
+ if (connectCodeEl) {
5698
+ connectCodeEl.textContent = "加载中...";
5699
+ fetch("/api/app-connect-code").then(function(r) { return r.json(); }).then(function(d) {
5700
+ if (d.code) connectCodeEl.textContent = d.code;
5701
+ else connectCodeEl.textContent = "生成失败";
5702
+ }).catch(function() { connectCodeEl.textContent = "获取失败"; });
5703
+ }
5704
+
5705
+ // Config fields
5706
+ var cfg = data.config || {};
5707
+ var hostEl = document.getElementById("cfg-host");
5708
+ var portEl = document.getElementById("cfg-port");
5709
+ var httpsEl = document.getElementById("cfg-https");
5710
+ var modeEl = document.getElementById("cfg-mode");
5711
+ var cwdEl = document.getElementById("cfg-cwd");
5712
+ var shellEl = document.getElementById("cfg-shell");
5713
+ if (hostEl) hostEl.value = cfg.host || "";
5714
+ if (portEl) portEl.value = cfg.port || "";
5715
+ if (httpsEl) httpsEl.checked = cfg.https === true;
5716
+ if (modeEl) modeEl.value = cfg.defaultMode || "default";
5717
+ if (cwdEl) cwdEl.value = cfg.defaultCwd || "";
5718
+ if (shellEl) shellEl.value = cfg.shell || "";
5719
+ var langEl = document.getElementById("cfg-language");
5720
+ if (langEl) langEl.value = cfg.language || "";
5721
+
5722
+ // Cert status
5723
+ var certStatus = document.getElementById("cert-status");
5724
+ if (certStatus) {
5725
+ certStatus.textContent = data.hasCert ? "已安装 SSL 证书" : "未安装证书(使用自签名或 HTTP)";
5726
+ certStatus.style.color = data.hasCert ? "var(--success)" : "var(--text-secondary)";
5727
+ }
5728
+
5729
+ // Presets
5730
+ var presetsList = document.getElementById("presets-list");
5731
+ if (presetsList && cfg.commandPresets) {
5732
+ var html = "";
5733
+ for (var i = 0; i < cfg.commandPresets.length; i++) {
5734
+ var p = cfg.commandPresets[i];
5735
+ html += '<div class="preset-item">' +
5736
+ '<span class="preset-label">' + escapeHtml(p.label) + '</span>' +
5737
+ '<span class="preset-detail">' + escapeHtml(p.command) + (p.mode ? ' (' + escapeHtml(p.mode) + ')' : '') + '</span>' +
5738
+ '</div>';
5739
+ }
5740
+ if (!html) html = '<div class="empty-state-compact"><span class="empty-icon">\u2699</span><span>\u6ca1\u6709\u547d\u4ee4\u9884\u8bbe</span><span class="hint">\u5728 config.json \u7684 commandPresets \u4e2d\u914d\u7f6e</span></div>';
5741
+ presetsList.innerHTML = html;
5742
+ }
5743
+
5744
+ // Card expand defaults
5745
+ var cd = cfg.cardDefaults || {};
5746
+ var cdEditEl = document.getElementById("cfg-card-edit");
5747
+ var cdInlineEl = document.getElementById("cfg-card-inline");
5748
+ var cdTerminalEl = document.getElementById("cfg-card-terminal");
5749
+ var cdThinkingEl = document.getElementById("cfg-card-thinking");
5750
+ var cdToolgroupEl = document.getElementById("cfg-card-toolgroup");
5751
+ if (cdEditEl) cdEditEl.checked = cd.editCards === true;
5752
+ if (cdInlineEl) cdInlineEl.checked = cd.inlineTools === true;
5753
+ if (cdTerminalEl) cdTerminalEl.checked = cd.terminal === true;
5754
+ if (cdThinkingEl) cdThinkingEl.checked = cd.thinking === true;
5755
+ if (cdToolgroupEl) cdToolgroupEl.checked = cd.toolGroup === true;
5756
+ })
5757
+ .catch(function() {});
5467
5758
  }
5468
5759
 
5469
- function normalizePanelStateSettings(cfg) {
5470
- var panelState = cfg && cfg.uiPreferences && cfg.uiPreferences.defaultPanelState;
5471
- var defaults = getConfiguredPanelDefaults(cfg);
5472
- return {
5473
- sessionsDrawerOpen: panelState && typeof panelState.sessionsDrawerOpen === "boolean" ? panelState.sessionsDrawerOpen : defaults.sessionsDrawerOpen,
5474
- filePanelOpen: panelState && typeof panelState.filePanelOpen === "boolean" ? panelState.filePanelOpen : defaults.filePanelOpen,
5475
- shortcutsExpanded: panelState && typeof panelState.shortcutsExpanded === "boolean" ? panelState.shortcutsExpanded : defaults.shortcutsExpanded,
5476
- claudeHistoryExpanded: panelState && typeof panelState.claudeHistoryExpanded === "boolean" ? panelState.claudeHistoryExpanded : defaults.claudeHistoryExpanded,
5477
- chatMessageExpanded: panelState && typeof panelState.chatMessageExpanded === "boolean" ? panelState.chatMessageExpanded : defaults.chatMessageExpanded,
5478
- structuredThinkingExpanded: panelState && typeof panelState.structuredThinkingExpanded === "boolean" ? panelState.structuredThinkingExpanded : defaults.structuredThinkingExpanded,
5479
- structuredToolGroupExpanded: panelState && typeof panelState.structuredToolGroupExpanded === "boolean" ? panelState.structuredToolGroupExpanded : defaults.structuredToolGroupExpanded,
5480
- structuredInlineToolExpanded: panelState && typeof panelState.structuredInlineToolExpanded === "boolean" ? panelState.structuredInlineToolExpanded : defaults.structuredInlineToolExpanded,
5481
- structuredTerminalExpanded: panelState && typeof panelState.structuredTerminalExpanded === "boolean" ? panelState.structuredTerminalExpanded : defaults.structuredTerminalExpanded,
5482
- structuredToolCardExpanded: panelState && typeof panelState.structuredToolCardExpanded === "boolean" ? panelState.structuredToolCardExpanded : defaults.structuredToolCardExpanded,
5760
+ function saveConfigSettings() {
5761
+ var msgEl = document.getElementById("config-message");
5762
+ if (msgEl) { msgEl.classList.add("hidden"); msgEl.textContent = ""; }
5763
+
5764
+ var body = {
5765
+ host: (document.getElementById("cfg-host") || {}).value,
5766
+ port: Number((document.getElementById("cfg-port") || {}).value),
5767
+ https: (document.getElementById("cfg-https") || {}).checked,
5768
+ defaultMode: (document.getElementById("cfg-mode") || {}).value,
5769
+ defaultCwd: (document.getElementById("cfg-cwd") || {}).value,
5770
+ shell: (document.getElementById("cfg-shell") || {}).value,
5771
+ language: (document.getElementById("cfg-language") || {}).value || "",
5483
5772
  };
5484
- }
5485
5773
 
5486
- function applyLoadedSettingsData(data) {
5487
- var cfg = data.config || {};
5488
- applySettingsConfig(cfg);
5489
-
5490
- var nameEl = document.getElementById("settings-pkg-name");
5491
- var verEl = document.getElementById("settings-version");
5492
- var nodeEl = document.getElementById("settings-node-req");
5493
- var repoEl = document.getElementById("settings-repo-url");
5494
- if (nameEl) nameEl.textContent = data.packageName || "-";
5495
- if (verEl) verEl.textContent = data.version || "-";
5496
- if (nodeEl) nodeEl.textContent = data.nodeVersion || "-";
5497
- if (repoEl && data.repoUrl) {
5498
- repoEl.innerHTML = '<a href="' + escapeHtml(data.repoUrl) + '" target="_blank" rel="noopener">' + escapeHtml(data.repoUrl) + '</a>';
5499
- }
5500
-
5501
- var hostEl = document.getElementById("cfg-host");
5502
- var portEl = document.getElementById("cfg-port");
5503
- var httpsEl = document.getElementById("cfg-https");
5504
- var modeEl = document.getElementById("cfg-mode");
5505
- var cwdEl = document.getElementById("cfg-cwd");
5506
- var shellEl = document.getElementById("cfg-shell");
5507
- if (hostEl) hostEl.value = cfg.host || "";
5508
- if (portEl) portEl.value = cfg.port || "";
5509
- if (httpsEl) httpsEl.checked = cfg.https === true;
5510
- if (modeEl) modeEl.value = cfg.defaultMode || "default";
5511
- if (cwdEl) cwdEl.value = cfg.defaultCwd || "";
5512
- if (shellEl) shellEl.value = cfg.shell || "";
5513
- var langEl = document.getElementById("cfg-language");
5514
- if (langEl) langEl.value = cfg.language || "";
5515
- syncPanelStateSettingsForm(normalizePanelStateSettings(cfg));
5516
-
5517
- var certStatus = document.getElementById("cert-status");
5518
- if (certStatus) {
5519
- certStatus.textContent = data.hasCert ? "已安装 SSL 证书" : "未安装证书(使用自签名或 HTTP)";
5520
- certStatus.style.color = data.hasCert ? "var(--success)" : "var(--text-secondary)";
5521
- }
5522
-
5523
- var presetsList = document.getElementById("presets-list");
5524
- if (presetsList && cfg.commandPresets) {
5525
- var html = "";
5526
- for (var i = 0; i < cfg.commandPresets.length; i++) {
5527
- var p = cfg.commandPresets[i];
5528
- html += '<div class="preset-item">' +
5529
- '<span class="preset-label">' + escapeHtml(p.label) + '</span>' +
5530
- '<span class="preset-detail">' + escapeHtml(p.command) + (p.mode ? ' (' + escapeHtml(p.mode) + ')' : '') + '</span>' +
5531
- '</div>';
5774
+ fetch("/api/settings/config", {
5775
+ method: "POST",
5776
+ headers: { "Content-Type": "application/json" },
5777
+ credentials: "same-origin",
5778
+ body: JSON.stringify(body)
5779
+ })
5780
+ .then(function(res) { return res.json(); })
5781
+ .then(function(data) {
5782
+ if (msgEl) {
5783
+ if (data.error) {
5784
+ msgEl.textContent = data.error;
5785
+ msgEl.style.color = "var(--error)";
5786
+ } else {
5787
+ msgEl.textContent = "配置已保存,部分更改需要重启后生效。";
5788
+ msgEl.style.color = "var(--success)";
5789
+ }
5790
+ msgEl.classList.remove("hidden");
5791
+ }
5792
+ })
5793
+ .catch(function() {
5794
+ if (msgEl) {
5795
+ msgEl.textContent = "保存失败。";
5796
+ msgEl.style.color = "var(--error)";
5797
+ msgEl.classList.remove("hidden");
5532
5798
  }
5533
- if (!html) html = '<div class="empty-state-compact"><span class="empty-icon">\u2699</span><span>没有命令预设</span><span class="hint">在 config.json 的 commandPresets 中配置</span></div>';
5534
- presetsList.innerHTML = html;
5535
- }
5536
- }
5537
-
5538
- function loadSettingsData() {
5539
- fetch("/api/settings", { credentials: "same-origin" })
5540
- .then(function(res) { return res.json(); })
5541
- .then(function(data) {
5542
- applyLoadedSettingsData(data);
5543
- })
5544
- .catch(function() {});
5799
+ });
5545
5800
  }
5546
5801
 
5547
- function saveConfigSettings() {
5548
- var msgEl = document.getElementById("config-message");
5802
+ function saveDisplaySettings() {
5803
+ var msgEl = document.getElementById("display-message");
5549
5804
  if (msgEl) { msgEl.classList.add("hidden"); msgEl.textContent = ""; }
5550
5805
 
5551
5806
  var body = {
5552
- host: (document.getElementById("cfg-host") || {}).value,
5553
- port: Number((document.getElementById("cfg-port") || {}).value),
5554
- https: (document.getElementById("cfg-https") || {}).checked,
5555
- defaultMode: (document.getElementById("cfg-mode") || {}).value,
5556
- defaultCwd: (document.getElementById("cfg-cwd") || {}).value,
5557
- shell: (document.getElementById("cfg-shell") || {}).value,
5558
- language: (document.getElementById("cfg-language") || {}).value || "",
5559
- uiPreferences: {
5560
- defaultPanelState: getPanelStateSettingsFormValues(),
5807
+ cardDefaults: {
5808
+ editCards: !!(document.getElementById("cfg-card-edit") || {}).checked,
5809
+ inlineTools: !!(document.getElementById("cfg-card-inline") || {}).checked,
5810
+ terminal: !!(document.getElementById("cfg-card-terminal") || {}).checked,
5811
+ thinking: !!(document.getElementById("cfg-card-thinking") || {}).checked,
5812
+ toolGroup: !!(document.getElementById("cfg-card-toolgroup") || {}).checked,
5561
5813
  }
5562
5814
  };
5563
5815
 
@@ -5574,12 +5826,15 @@
5574
5826
  msgEl.textContent = data.error;
5575
5827
  msgEl.style.color = "var(--error)";
5576
5828
  } else {
5577
- msgEl.textContent = "配置已保存,部分更改需要重启后生效。";
5829
+ msgEl.textContent = "显示设置已保存";
5578
5830
  msgEl.style.color = "var(--success)";
5579
- handleSettingsConfigSaved(data.config);
5580
5831
  }
5581
5832
  msgEl.classList.remove("hidden");
5582
5833
  }
5834
+ // Update local config so card defaults take effect immediately
5835
+ if (!data.error && state.config) {
5836
+ state.config.cardDefaults = body.cardDefaults;
5837
+ }
5583
5838
  })
5584
5839
  .catch(function() {
5585
5840
  if (msgEl) {
@@ -5590,7 +5845,6 @@
5590
5845
  });
5591
5846
  }
5592
5847
 
5593
-
5594
5848
  function uploadCertificates() {
5595
5849
  var keyFile = document.getElementById("cert-key-file");
5596
5850
  var certFile = document.getElementById("cert-cert-file");
@@ -5699,7 +5953,7 @@
5699
5953
  .then(function(res) { return res.json(); })
5700
5954
  .then(function(data) {
5701
5955
  if (msgEl) {
5702
- msgEl.textContent = data.message || data.error || "更新完成。";
5956
+ msgEl.textContent = data.message || data.error || "\u66f4\u65b0\u5b8c\u6210\u3002";
5703
5957
  msgEl.style.color = data.error ? "var(--error)" : "var(--success)";
5704
5958
  msgEl.classList.remove("hidden");
5705
5959
  }
@@ -5707,11 +5961,14 @@
5707
5961
  updateBtn.disabled = false;
5708
5962
  } else {
5709
5963
  updateBtn.classList.add("hidden");
5964
+ // Show restart button
5965
+ var restartBtn = document.getElementById("do-restart-button");
5966
+ if (restartBtn) restartBtn.classList.remove("hidden");
5710
5967
  }
5711
5968
  })
5712
5969
  .catch(function() {
5713
5970
  if (msgEl) {
5714
- msgEl.textContent = "更新失败。";
5971
+ msgEl.textContent = "\u66f4\u65b0\u5931\u8d25\u3002";
5715
5972
  msgEl.style.color = "var(--error)";
5716
5973
  msgEl.classList.remove("hidden");
5717
5974
  }
@@ -5719,101 +5976,224 @@
5719
5976
  });
5720
5977
  }
5721
5978
 
5979
+ function performSettingsRestart() {
5980
+ var restartBtn = document.getElementById("do-restart-button");
5981
+ var msgEl = document.getElementById("update-message");
5982
+ performRestart(restartBtn, msgEl);
5983
+ }
5984
+
5722
5985
  // ── Notification Settings Helpers ──
5723
5986
 
5987
+ function _updateAppIconSelection(activeIcon) {
5988
+ var opts = document.querySelectorAll(".app-icon-option");
5989
+ for (var i = 0; i < opts.length; i++) {
5990
+ var preview = opts[i].querySelector(".app-icon-preview");
5991
+ if (preview) {
5992
+ preview.style.borderColor = opts[i].getAttribute("data-icon") === activeIcon ? "var(--accent)" : "transparent";
5993
+ }
5994
+ }
5995
+ }
5996
+
5724
5997
  function updateNotificationStatus() {
5725
5998
  var statusEl = document.getElementById("notification-permission-status");
5726
5999
  var requestBtn = document.getElementById("notification-request-btn");
6000
+ var resetBtn = document.getElementById("notification-reset-btn");
5727
6001
  var testMsgEl = document.getElementById("notification-test-message");
5728
6002
  if (!statusEl) return;
5729
6003
 
5730
- if (typeof Notification === "undefined") {
5731
- statusEl.textContent = "\u4e0d\u652f\u6301";
5732
- statusEl.style.color = "var(--fg-muted)";
5733
- if (requestBtn) requestBtn.classList.add("hidden");
5734
- return;
6004
+ // Determine permission state: native bridge or browser API
6005
+ var perm = _getNativePermission();
6006
+ if (perm === null) {
6007
+ // No native bridge — fall back to browser Notification API
6008
+ if (typeof Notification === "undefined") {
6009
+ statusEl.textContent = "\u4e0d\u652f\u6301";
6010
+ statusEl.style.color = "var(--fg-muted)";
6011
+ if (requestBtn) requestBtn.classList.add("hidden");
6012
+ if (resetBtn) resetBtn.classList.add("hidden");
6013
+ return;
6014
+ }
6015
+ perm = Notification.permission;
5735
6016
  }
5736
6017
 
5737
- var perm = Notification.permission;
5738
6018
  if (perm === "granted") {
5739
6019
  statusEl.textContent = "\u5df2\u6388\u6743 \u2713";
5740
6020
  statusEl.style.color = "var(--success)";
5741
6021
  if (requestBtn) requestBtn.classList.add("hidden");
6022
+ if (resetBtn) resetBtn.classList.add("hidden");
5742
6023
  } else if (perm === "denied") {
5743
6024
  statusEl.textContent = "\u5df2\u62d2\u7edd";
5744
6025
  statusEl.style.color = "var(--danger)";
5745
6026
  if (requestBtn) requestBtn.classList.add("hidden");
5746
- if (testMsgEl) {
5747
- testMsgEl.textContent = "\u6d4f\u89c8\u5668\u5df2\u62d2\u7edd\u901a\u77e5\u6743\u9650\uff0c\u8bf7\u5728\u6d4f\u89c8\u5668\u8bbe\u7f6e\u4e2d\u624b\u52a8\u5f00\u542f";
5748
- testMsgEl.style.color = "var(--fg-muted)";
5749
- testMsgEl.classList.remove("hidden");
5750
- }
6027
+ if (resetBtn) resetBtn.classList.remove("hidden");
5751
6028
  } else {
5752
6029
  statusEl.textContent = "\u672a\u6388\u6743";
5753
6030
  statusEl.style.color = "var(--warning)";
5754
6031
  if (requestBtn) requestBtn.classList.remove("hidden");
6032
+ if (resetBtn) resetBtn.classList.remove("hidden");
6033
+ }
6034
+ }
6035
+
6036
+ function resetNotificationPermission() {
6037
+ var testMsgEl = document.getElementById("notification-test-message");
6038
+
6039
+ // Native bridge path — trigger Android system permission dialog
6040
+ if (_hasNativeBridge) {
6041
+ // Listen for permission result callback from native
6042
+ window._onNativePermissionResult = function(result) {
6043
+ updateNotificationStatus();
6044
+ if (testMsgEl) {
6045
+ if (result === "granted") {
6046
+ testMsgEl.textContent = "\u2713 \u5df2\u6388\u6743";
6047
+ testMsgEl.style.color = "var(--success)";
6048
+ } else {
6049
+ testMsgEl.textContent = "\u2717 \u672a\u6388\u6743\uff0c\u8bf7\u5728\u7cfb\u7edf\u8bbe\u7f6e\u4e2d\u5f00\u542f Wand \u7684\u901a\u77e5\u6743\u9650";
6050
+ testMsgEl.style.color = "var(--danger)";
6051
+ }
6052
+ testMsgEl.classList.remove("hidden");
6053
+ }
6054
+ delete window._onNativePermissionResult;
6055
+ };
6056
+ try { WandNative.requestPermission(); } catch (_e) {}
6057
+ return;
5755
6058
  }
6059
+
6060
+ if (typeof Notification === "undefined") return;
6061
+
6062
+ // Always call requestPermission — this triggers the browser's native
6063
+ // permission dialog when allowed. In "default" state it always works.
6064
+ // In "denied" state, some browsers (newer Chrome) re-prompt, others don't.
6065
+ Notification.requestPermission().then(function(result) {
6066
+ updateNotificationStatus();
6067
+ if (result === "granted") {
6068
+ if (testMsgEl) {
6069
+ testMsgEl.textContent = "\u2713 \u5df2\u6388\u6743";
6070
+ testMsgEl.style.color = "var(--success)";
6071
+ testMsgEl.classList.remove("hidden");
6072
+ }
6073
+ } else if (result === "denied") {
6074
+ // Browser blocked re-prompting — show inline guide with site-settings shortcut
6075
+ if (testMsgEl) {
6076
+ var origin = location.origin;
6077
+ testMsgEl.innerHTML =
6078
+ "\u6d4f\u89c8\u5668\u5df2\u62e6\u622a\u6388\u6743\u5f39\u7a97\uff0c\u8bf7\u624b\u52a8\u91cd\u7f6e\uff1a<br>" +
6079
+ '<span style="display:inline-flex;align-items:center;gap:4px;margin:4px 0">' +
6080
+ "\u2460 \u70b9\u51fb\u5730\u5740\u680f\u5de6\u4fa7\u7684 " +
6081
+ '<span style="display:inline-flex;align-items:center;justify-content:center;' +
6082
+ "width:16px;height:16px;border-radius:50%;border:1px solid var(--border);" +
6083
+ 'font-size:11px;vertical-align:middle">i</span>' +
6084
+ " \u6216\u9501\u56fe\u6807" +
6085
+ "</span><br>" +
6086
+ "\u2461 \u627e\u5230\u300c\u901a\u77e5\u300d\u2192 \u6539\u4e3a\u300c\u5141\u8bb8\u300d<br>" +
6087
+ "\u2462 \u5237\u65b0\u9875\u9762\u5373\u53ef";
6088
+ testMsgEl.style.color = "var(--fg-muted)";
6089
+ testMsgEl.classList.remove("hidden");
6090
+ }
6091
+ }
6092
+ });
5756
6093
  }
5757
6094
 
5758
6095
  function testNotification() {
5759
6096
  var testMsgEl = document.getElementById("notification-test-message");
6097
+ var results = [];
5760
6098
 
5761
- // Always show in-app bubble
6099
+ // 1. Test sound playback
6100
+ var soundOk = tryPlayNotificationSound();
6101
+ results.push(soundOk ? "\u2713 \u63d0\u793a\u97f3" : "\u2717 \u63d0\u793a\u97f3\uff08\u65e0\u6cd5\u64ad\u653e\uff09");
6102
+
6103
+ // 2. Test in-app bubble
6104
+ var bubbleEnabled = state.notifBubble;
5762
6105
  showNotificationBubble({
5763
6106
  title: "\u6d4b\u8bd5\u901a\u77e5",
5764
- body: "\u8fd9\u662f\u4e00\u6761\u6d4b\u8bd5\u901a\u77e5\uff0c\u5e94\u7528\u5185\u6c14\u6ce1\u5df2\u6b63\u5e38\u5de5\u4f5c\u3002",
6107
+ body: "\u8fd9\u662f\u4e00\u6761\u6d4b\u8bd5\u901a\u77e5\u3002",
5765
6108
  type: "info",
5766
6109
  icon: "\u266a",
5767
6110
  duration: 5000,
6111
+ playSound: false, // sound already played above
5768
6112
  });
6113
+ results.push(bubbleEnabled ? "\u2713 \u5e94\u7528\u5185\u6c14\u6ce1" : "\u2013 \u5e94\u7528\u5185\u6c14\u6ce1\uff08\u5df2\u5173\u95ed\uff09");
5769
6114
 
5770
- // Test browser notification
5771
- if (typeof Notification === "undefined") {
5772
- if (testMsgEl) {
5773
- testMsgEl.textContent = "\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u901a\u77e5 API\uff0c\u4ec5\u53ef\u4f7f\u7528\u5e94\u7528\u5185\u6c14\u6ce1\u901a\u77e5\u3002";
5774
- testMsgEl.style.color = "var(--fg-muted)";
5775
- testMsgEl.classList.remove("hidden");
6115
+ // 3. Test system notification (native bridge or browser API)
6116
+ if (_hasNativeBridge) {
6117
+ var nativePerm = _getNativePermission();
6118
+ if (nativePerm === "granted") {
6119
+ try {
6120
+ WandNative.sendNotification("Wand \u6d4b\u8bd5\u901a\u77e5", "\u7cfb\u7edf\u901a\u77e5\u5df2\u6b63\u5e38\u5de5\u4f5c\u3002", "wand-test");
6121
+ results.push("\u2713 \u7cfb\u7edf\u901a\u77e5");
6122
+ } catch (_e) {
6123
+ results.push("\u2717 \u7cfb\u7edf\u901a\u77e5\uff08\u53d1\u9001\u5931\u8d25\uff09");
6124
+ }
6125
+ } else if (nativePerm === "denied") {
6126
+ results.push("\u2717 \u7cfb\u7edf\u901a\u77e5\uff08\u5df2\u62d2\u7edd\uff0c\u8bf7\u5728\u7cfb\u7edf\u8bbe\u7f6e\u4e2d\u5f00\u542f\uff09");
6127
+ } else {
6128
+ // "default" — request permission, then report
6129
+ window._onNativePermissionResult = function(result) {
6130
+ updateNotificationStatus();
6131
+ if (result === "granted") {
6132
+ try {
6133
+ WandNative.sendNotification("Wand \u6d4b\u8bd5\u901a\u77e5", "\u7cfb\u7edf\u901a\u77e5\u5df2\u6b63\u5e38\u5de5\u4f5c\u3002", "wand-test");
6134
+ results.push("\u2713 \u7cfb\u7edf\u901a\u77e5\uff08\u5df2\u6388\u6743\uff09");
6135
+ } catch (_e2) {
6136
+ results.push("\u2717 \u7cfb\u7edf\u901a\u77e5\uff08\u53d1\u9001\u5931\u8d25\uff09");
6137
+ }
6138
+ } else {
6139
+ results.push("\u2717 \u7cfb\u7edf\u901a\u77e5\uff08\u672a\u6388\u6743\uff09");
6140
+ }
6141
+ showTestResults(testMsgEl, results);
6142
+ delete window._onNativePermissionResult;
6143
+ };
6144
+ try { WandNative.requestPermission(); } catch (_e) {}
6145
+ return; // async — results shown in callback
5776
6146
  }
6147
+ showTestResults(testMsgEl, results);
6148
+ return;
6149
+ }
6150
+
6151
+ if (typeof Notification === "undefined") {
6152
+ results.push("\u2013 \u7cfb\u7edf\u901a\u77e5\uff08\u4e0d\u652f\u6301\uff09");
6153
+ showTestResults(testMsgEl, results);
5777
6154
  return;
5778
6155
  }
5779
6156
 
5780
- if (Notification.permission === "granted") {
6157
+ var perm = Notification.permission;
6158
+ if (perm === "granted") {
5781
6159
  try {
5782
6160
  var n = new Notification("Wand \u6d4b\u8bd5\u901a\u77e5", {
5783
- body: "\u6d4f\u89c8\u5668\u901a\u77e5\u5df2\u6b63\u5e38\u5de5\u4f5c\u3002",
6161
+ body: "\u7cfb\u7edf\u901a\u77e5\u5df2\u6b63\u5e38\u5de5\u4f5c\u3002",
5784
6162
  icon: "/favicon.ico",
5785
6163
  tag: "wand-test",
5786
6164
  });
5787
6165
  setTimeout(function() { n.close(); }, 5000);
5788
- if (testMsgEl) {
5789
- testMsgEl.textContent = "\u2713 \u6d4f\u89c8\u5668\u901a\u77e5 + \u5e94\u7528\u5185\u6c14\u6ce1\u5747\u5df2\u53d1\u9001";
5790
- testMsgEl.style.color = "var(--success)";
5791
- testMsgEl.classList.remove("hidden");
5792
- }
6166
+ results.push("\u2713 \u7cfb\u7edf\u901a\u77e5");
5793
6167
  } catch (_e) {
5794
- if (testMsgEl) {
5795
- testMsgEl.textContent = "\u6d4f\u89c8\u5668\u901a\u77e5\u53d1\u9001\u5931\u8d25\uff0c\u53ef\u80fd\u9700\u8981 HTTPS";
5796
- testMsgEl.style.color = "var(--warning)";
5797
- testMsgEl.classList.remove("hidden");
5798
- }
6168
+ results.push("\u2717 \u7cfb\u7edf\u901a\u77e5\uff08\u53d1\u9001\u5931\u8d25\uff0c\u53ef\u80fd\u9700\u8981 HTTPS\uff09");
5799
6169
  }
6170
+ showTestResults(testMsgEl, results);
6171
+ } else if (perm === "denied") {
6172
+ results.push("\u2717 \u7cfb\u7edf\u901a\u77e5\uff08\u5df2\u62d2\u7edd\uff09");
6173
+ showTestResults(testMsgEl, results);
5800
6174
  } else {
5801
- // permission is "default" or "denied" always try requesting
6175
+ // "default" — try requesting
5802
6176
  Notification.requestPermission().then(function(result) {
5803
6177
  updateNotificationStatus();
5804
6178
  if (result === "granted") {
5805
- testNotification();
5806
- } else if (result === "denied") {
5807
- if (testMsgEl) {
5808
- testMsgEl.textContent = "\u6d4f\u89c8\u5668\u5df2\u62d2\u7edd\u901a\u77e5\u6743\u9650\uff0c\u8bf7\u70b9\u51fb\u5730\u5740\u680f\u5de6\u4fa7\u9501\u56fe\u6807\u6216\u5728\u6d4f\u89c8\u5668\u8bbe\u7f6e\u4e2d\u624b\u52a8\u5f00\u542f";
5809
- testMsgEl.style.color = "var(--fg-muted)";
5810
- testMsgEl.classList.remove("hidden");
5811
- }
6179
+ results.push("\u2713 \u7cfb\u7edf\u901a\u77e5\uff08\u5df2\u6388\u6743\uff09");
6180
+ } else {
6181
+ results.push("\u2717 \u7cfb\u7edf\u901a\u77e5\uff08\u672a\u6388\u6743\uff09");
5812
6182
  }
6183
+ showTestResults(testMsgEl, results);
5813
6184
  });
5814
6185
  }
5815
6186
  }
5816
6187
 
6188
+ function showTestResults(el, results) {
6189
+ if (!el) return;
6190
+ el.innerHTML = results.map(function(r) { return escapeHtml(r); }).join("<br>");
6191
+ // color based on whether all passed
6192
+ var allOk = results.every(function(r) { return r.indexOf("\u2713") === 0 || r.indexOf("\u2013") === 0; });
6193
+ el.style.color = allOk ? "var(--success)" : "var(--warning)";
6194
+ el.classList.remove("hidden");
6195
+ }
6196
+
5817
6197
  function quickStartSession() {
5818
6198
  var command = getPreferredTool();
5819
6199
  var defaultCwd = getEffectiveCwd();
@@ -6813,13 +7193,17 @@
6813
7193
  var userMsgs = stripRenderOnlyStructuredMessages(Array.isArray(session.messages) ? session.messages.slice() : []);
6814
7194
  userMsgs.push(userTurn);
6815
7195
  var optimisticStructuredState = Object.assign({}, session.structuredState || {}, { inFlight: true });
7196
+ // Write optimistic user turn into session.messages so WS updates
7197
+ // that arrive before the HTTP response don't erase it.
6816
7198
  updateSessionSnapshot({
6817
7199
  id: session.id,
6818
7200
  status: "running",
7201
+ messages: userMsgs,
6819
7202
  structuredState: optimisticStructuredState,
6820
7203
  });
6821
7204
  state.currentMessages = buildMessagesForRender(Object.assign({}, session, {
6822
7205
  status: "running",
7206
+ messages: userMsgs,
6823
7207
  structuredState: optimisticStructuredState,
6824
7208
  }), userMsgs);
6825
7209
  updateInputHint("思考中…");
@@ -6832,6 +7216,11 @@
6832
7216
  }
6833
7217
  setDraftValue("");
6834
7218
 
7219
+ // Capture queue epoch before the POST so we can detect whether
7220
+ // a newer WS update has already refreshed the queue by the time
7221
+ // the HTTP response arrives.
7222
+ var epochBeforePost = state.queueEpoch;
7223
+
6835
7224
  return fetch("/api/structured-sessions/" + state.selectedId + "/messages", {
6836
7225
  method: "POST",
6837
7226
  headers: { "Content-Type": "application/json" },
@@ -6844,13 +7233,21 @@
6844
7233
  throw new Error(snapshot.error);
6845
7234
  }
6846
7235
  if (snapshot && snapshot.id) {
7236
+ // If a WS update has already bumped the queue epoch, the HTTP
7237
+ // response's queuedMessages is stale — drop it to avoid
7238
+ // re-introducing already-dequeued items.
7239
+ if (state.queueEpoch > epochBeforePost && snapshot.queuedMessages) {
7240
+ delete snapshot.queuedMessages;
7241
+ }
6847
7242
  updateSessionSnapshot(snapshot);
6848
7243
  var refreshedSession = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
6849
7244
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
6850
7245
  renderChat(true);
6851
7246
  if (isQueueing) {
6852
7247
  var queuedCount = getStructuredQueuedInputs(refreshedSession).length;
6853
- showToast("已排队(第 " + queuedCount + " 条),将在当前消息处理完成后自动发送。", "info");
7248
+ if (queuedCount > 0) {
7249
+ showToast("已排队(第 " + queuedCount + " 条),将在当前消息处理完成后自动发送。", "info");
7250
+ }
6854
7251
  } else {
6855
7252
  updateInputHint("Enter 发送 · Shift+Enter 换行");
6856
7253
  }
@@ -6902,7 +7299,25 @@
6902
7299
  }
6903
7300
  var queued = getStructuredQueuedInputs(session);
6904
7301
  if (queued && queued.length > 0) {
7302
+ // Collect recent user message texts to deduplicate against queued items.
7303
+ // A queued message that already appears as a real user turn should not
7304
+ // be rendered a second time with the "排队中" badge.
7305
+ var existingUserTexts = {};
7306
+ for (var ei = base.length - 1; ei >= 0 && Object.keys(existingUserTexts).length < queued.length + 5; ei--) {
7307
+ var em = base[ei];
7308
+ if (em && em.role === "user" && Array.isArray(em.content)) {
7309
+ for (var ej = 0; ej < em.content.length; ej++) {
7310
+ if (em.content[ej] && em.content[ej].type === "text" && em.content[ej].text) {
7311
+ existingUserTexts[em.content[ej].text] = (existingUserTexts[em.content[ej].text] || 0) + 1;
7312
+ }
7313
+ }
7314
+ }
7315
+ }
6905
7316
  for (var qi = 0; qi < queued.length; qi++) {
7317
+ if (existingUserTexts[queued[qi]]) {
7318
+ existingUserTexts[queued[qi]]--;
7319
+ continue; // Skip — this queued text is already shown as a real message
7320
+ }
6906
7321
  base.push({ role: "user", content: [{ type: "text", text: queued[qi], __queued: true }] });
6907
7322
  }
6908
7323
  }
@@ -8017,25 +8432,15 @@
8017
8432
 
8018
8433
  function handleInputBoxBlur() {
8019
8434
  resetInputPanelViewportSpacing();
8020
- // Restore app container height when keyboard closes.
8021
- // Use a short delay because on iOS the visualViewport may not
8022
- // have updated yet at the moment blur fires.
8023
8435
  setTimeout(function() {
8024
- var appContainer = document.querySelector('.app-container');
8025
- if (appContainer) {
8026
- // Only clear if keyboard is actually closed now
8027
- var vv = window.visualViewport;
8028
- if (vv) {
8029
- var offsetBottom = window.innerHeight - vv.height - vv.offsetTop;
8030
- if (offsetBottom <= 50) {
8031
- appContainer.style.height = '';
8032
- }
8033
- } else {
8034
- appContainer.style.height = '';
8035
- }
8036
- }
8037
- // Scroll the window back to top to fix any residual offset
8038
8436
  window.scrollTo(0, 0);
8437
+ // On mobile, force terminal refit + scroll after keyboard dismissal.
8438
+ // The container height restores but xterm needs an explicit refit to
8439
+ // fill the expanded space, and the scroll position needs resetting.
8440
+ if (isTouchDevice()) {
8441
+ ensureTerminalFit();
8442
+ maybeScrollTerminalToBottom("force");
8443
+ }
8039
8444
  }, 100);
8040
8445
  }
8041
8446
 
@@ -8757,24 +9162,19 @@
8757
9162
  var isKeyboardOpen = offsetBottom > 50;
8758
9163
  var heightChanged = Math.abs(vv.height - lastHeight) > 8;
8759
9164
 
8760
- // Dynamically resize the app container to match visible viewport.
8761
- // This is needed because 100dvh does NOT shrink when the keyboard
8762
- // appears in PWA standalone mode, and on some browsers the layout
8763
- // viewport doesn't update on keyboard dismiss without this.
8764
- var appContainer = document.querySelector('.app-container');
8765
- if (appContainer) {
8766
- if (isKeyboardOpen) {
8767
- appContainer.style.height = vv.height + 'px';
8768
- } else if (keyboardOpen) {
8769
- // Keyboard just closed — clear forced height
8770
- appContainer.style.height = '';
8771
- }
8772
- }
8773
-
8774
9165
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
8775
9166
  syncInputBoxScroll(inputBox);
8776
9167
  }
8777
9168
 
9169
+ // Keyboard just closed — force terminal refit and scroll to bottom
9170
+ // after a delay so the keyboard dismiss animation and layout settle.
9171
+ if (keyboardOpen && !isKeyboardOpen) {
9172
+ setTimeout(function() {
9173
+ ensureTerminalFit();
9174
+ maybeScrollTerminalToBottom("force");
9175
+ }, 200);
9176
+ }
9177
+
8778
9178
  keyboardOpen = isKeyboardOpen;
8779
9179
  lastHeight = vv.height;
8780
9180
  }
@@ -8879,6 +9279,11 @@
8879
9279
  }
8880
9280
  state.resizeHandler = function() { scheduleTerminalResize(true); };
8881
9281
  window.addEventListener("resize", state.resizeHandler);
9282
+ // Also listen to visualViewport resize for pinch-zoom / browser zoom
9283
+ if (window.visualViewport) {
9284
+ state.visualViewportHandler = function() { scheduleTerminalResize(true); };
9285
+ window.visualViewport.addEventListener("resize", state.visualViewportHandler);
9286
+ }
8882
9287
  requestAnimationFrame(function() { scheduleTerminalResize(true); });
8883
9288
  }
8884
9289
 
@@ -8895,6 +9300,10 @@
8895
9300
  window.removeEventListener("resize", state.resizeHandler);
8896
9301
  state.resizeHandler = null;
8897
9302
  }
9303
+ if (state.visualViewportHandler && window.visualViewport) {
9304
+ window.visualViewport.removeEventListener("resize", state.visualViewportHandler);
9305
+ state.visualViewportHandler = null;
9306
+ }
8898
9307
  [["mousemove", "resizeMouseMove"], ["mouseup", "resizeMouseUp"],
8899
9308
  ["touchmove", "resizeTouchMove"], ["touchend", "resizeTouchEnd"]
8900
9309
  ].forEach(function(pair) {
@@ -9027,6 +9436,12 @@
9027
9436
  state.pollTimer = setInterval(refreshAll, 1600);
9028
9437
  }
9029
9438
 
9439
+ // Periodically refresh session time displays (30s)
9440
+ setInterval(function() {
9441
+ var timeEls = document.querySelectorAll(".session-time");
9442
+ if (timeEls.length > 0) scheduleSessionListUpdate();
9443
+ }, 30000);
9444
+
9030
9445
  function initWebSocket() {
9031
9446
  if (!window.WebSocket) return false;
9032
9447
 
@@ -9095,8 +9510,9 @@
9095
9510
  if (msg.data.messages) {
9096
9511
  snapshot.messages = msg.data.messages;
9097
9512
  }
9098
- if (msg.data.queuedMessages) {
9099
- snapshot.queuedMessages = msg.data.queuedMessages;
9513
+ if (Object.prototype.hasOwnProperty.call(msg.data, 'queuedMessages')) {
9514
+ snapshot.queuedMessages = msg.data.queuedMessages || [];
9515
+ state.queueEpoch++;
9100
9516
  }
9101
9517
  if (msg.data.structuredState) {
9102
9518
  snapshot.structuredState = msg.data.structuredState;
@@ -9164,14 +9580,26 @@
9164
9580
  // Trigger status bar completion animation
9165
9581
  scheduleChatRender(true);
9166
9582
  }
9167
- // Notify user when a session completes (browser + in-app if backgrounded or not viewing)
9583
+ // Notify user when a session completes show what was accomplished
9168
9584
  var endedSession = state.sessions.find(function(s) { return s.id === msg.sessionId; });
9169
- var endedName = endedSession ? (endedSession.label || endedSession.command || msg.sessionId) : msg.sessionId;
9170
9585
  var endedExitCode = msg.data && msg.data.exitCode;
9171
9586
  var endedIsError = endedExitCode !== null && endedExitCode !== undefined && endedExitCode !== 0;
9587
+ // Build meaningful notification body
9588
+ var endedTaskSummary = endedSession ? (endedSession.summary || "") : "";
9589
+ var endedLastReply = endedSession ? getLastAssistantSummary(endedSession) : "";
9590
+ var endedNotifTitle = endedIsError ? "任务异常结束" : "任务已完成";
9591
+ var endedNotifBody = "";
9592
+ if (endedTaskSummary) {
9593
+ endedNotifBody = endedTaskSummary;
9594
+ if (endedLastReply && !endedIsError) {
9595
+ endedNotifBody += "\n" + endedLastReply;
9596
+ }
9597
+ } else {
9598
+ endedNotifBody = endedSession ? (endedSession.command || msg.sessionId) : msg.sessionId;
9599
+ }
9172
9600
  sendBrowserNotification(
9173
- endedIsError ? "\u4f1a\u8bdd\u5f02\u5e38\u7ed3\u675f" : "\u4f1a\u8bdd\u5df2\u5b8c\u6210",
9174
- endedName,
9601
+ endedNotifTitle,
9602
+ endedNotifBody,
9175
9603
  {
9176
9604
  tag: "wand-ended-" + msg.sessionId,
9177
9605
  onClick: function() {
@@ -9181,8 +9609,8 @@
9181
9609
  );
9182
9610
  if (msg.sessionId !== state.selectedId || document.hidden) {
9183
9611
  showNotificationBubble({
9184
- title: endedIsError ? "\u4f1a\u8bdd\u5f02\u5e38\u7ed3\u675f" : "\u4f1a\u8bdd\u5df2\u5b8c\u6210",
9185
- body: endedName,
9612
+ title: endedNotifTitle,
9613
+ body: endedNotifBody,
9186
9614
  type: endedIsError ? "warning" : "success",
9187
9615
  icon: endedIsError ? "!" : "\u2713",
9188
9616
  duration: 6000,
@@ -9264,6 +9692,8 @@
9264
9692
  state.currentTask = msg.data || null;
9265
9693
  updateTaskDisplay();
9266
9694
  }
9695
+ // Update session list to reflect current activity (debounced)
9696
+ scheduleSessionListUpdate();
9267
9697
  break;
9268
9698
  case 'status':
9269
9699
  if (msg.sessionId && msg.data) {
@@ -9284,6 +9714,10 @@
9284
9714
  });
9285
9715
  }
9286
9716
  }
9717
+ if (Object.prototype.hasOwnProperty.call(msg.data, 'queuedMessages')) {
9718
+ statusUpdate.queuedMessages = msg.data.queuedMessages || [];
9719
+ state.queueEpoch++;
9720
+ }
9287
9721
  if (Object.prototype.hasOwnProperty.call(msg.data, 'permissionBlocked')) {
9288
9722
  statusUpdate.permissionBlocked = !!msg.data.permissionBlocked;
9289
9723
  }
@@ -9295,10 +9729,18 @@
9295
9729
  };
9296
9730
  // Browser notification for permission waiting (background tab)
9297
9731
  var permSession = state.sessions.find(function(s) { return s.id === msg.sessionId; });
9298
- var permSessionName = permSession ? (permSession.label || permSession.command || msg.sessionId) : msg.sessionId;
9732
+ var permTaskName = permSession ? (permSession.summary || permSession.command || msg.sessionId) : msg.sessionId;
9733
+ var permDetail = msg.data.permissionRequest.prompt || "需要权限审批";
9734
+ var permTarget = msg.data.permissionRequest.target;
9735
+ var permBody = permTaskName;
9736
+ if (permTarget) {
9737
+ permBody += "\n" + permDetail + " · " + permTarget;
9738
+ } else {
9739
+ permBody += "\n" + permDetail;
9740
+ }
9299
9741
  sendBrowserNotification(
9300
- "\u4f1a\u8bdd\u7b49\u5f85\u6388\u6743",
9301
- permSessionName + " \u2014 " + (msg.data.permissionRequest.prompt || "\u9700\u8981\u6743\u9650\u5ba1\u6279"),
9742
+ "需要你的授权",
9743
+ permBody,
9302
9744
  {
9303
9745
  tag: "wand-perm-" + msg.sessionId,
9304
9746
  onClick: function() {
@@ -9311,12 +9753,12 @@
9311
9753
  // In-app bubble if not currently viewing this session
9312
9754
  if (msg.sessionId !== state.selectedId) {
9313
9755
  showNotificationBubble({
9314
- title: "\u4f1a\u8bdd\u7b49\u5f85\u6388\u6743",
9315
- body: permSessionName + " \u2014 " + (msg.data.permissionRequest.prompt || "\u9700\u8981\u6743\u9650\u5ba1\u6279"),
9756
+ title: "需要你的授权",
9757
+ body: permBody,
9316
9758
  type: "warning",
9317
9759
  icon: "!",
9318
9760
  duration: 0,
9319
- actionLabel: "\u67e5\u770b",
9761
+ actionLabel: "去处理",
9320
9762
  action: function() {
9321
9763
  selectSession(msg.sessionId);
9322
9764
  }
@@ -9337,12 +9779,13 @@
9337
9779
  }
9338
9780
  // Re-render chat when structured session inFlight state changes
9339
9781
  if (statusUpdate.structuredState) {
9340
- scheduleChatRender();
9341
- // Flush queued structured messages when inFlight clears
9782
+ // Flush queued structured messages synchronously before render
9783
+ // so the chat view uses up-to-date queue state.
9342
9784
  if (!statusUpdate.structuredState.inFlight) {
9343
9785
  updateInputHint("Enter 发送 · Shift+Enter 换行");
9344
- setTimeout(flushStructuredInputQueue, 50);
9786
+ flushStructuredInputQueue();
9345
9787
  }
9788
+ scheduleChatRender();
9346
9789
  }
9347
9790
  }
9348
9791
  }
@@ -9350,23 +9793,14 @@
9350
9793
  case 'notification':
9351
9794
  if (msg.data) {
9352
9795
  if (msg.data.kind === "update") {
9353
- showNotificationBubble({
9354
- title: "\u53d1\u73b0\u65b0\u7248\u672c",
9355
- body: "\u5f53\u524d " + (msg.data.current || "-") + " \u2192 \u6700\u65b0 " + (msg.data.latest || "-"),
9356
- type: "info",
9357
- icon: "\u2191",
9358
- duration: 0,
9359
- actionLabel: "\u53bb\u66f4\u65b0",
9360
- action: function() {
9361
- var settingsBtn = document.getElementById("open-settings-btn") || document.querySelector("[data-action='settings']");
9362
- if (settingsBtn) settingsBtn.click();
9363
- }
9364
- });
9796
+ showUpdateBubble(msg.data.current || "-", msg.data.latest || "-");
9365
9797
  sendBrowserNotification(
9366
9798
  "Wand \u53d1\u73b0\u65b0\u7248\u672c",
9367
9799
  "\u5f53\u524d " + (msg.data.current || "-") + " \u2192 \u6700\u65b0 " + (msg.data.latest || "-"),
9368
9800
  { tag: "wand-update" }
9369
9801
  );
9802
+ } else if (msg.data.kind === "restart") {
9803
+ showRestartOverlay();
9370
9804
  }
9371
9805
  }
9372
9806
  break;
@@ -9758,9 +10192,9 @@
9758
10192
  return;
9759
10193
  }
9760
10194
 
9761
- var messages = state.currentMessages;
10195
+ var allMessages = state.currentMessages;
9762
10196
 
9763
- if (messages.length === 0) {
10197
+ if (allMessages.length === 0) {
9764
10198
  if (state.lastRenderedEmpty !== "empty") {
9765
10199
  renderChatEmptyState(chatOutput, '<div class="empty-state"><strong>对话已开始</strong><br>在下方输入框发送消息,Claude 会自动回复。</div>');
9766
10200
  state.lastRenderedEmpty = "empty";
@@ -9769,6 +10203,16 @@
9769
10203
  return;
9770
10204
  }
9771
10205
 
10206
+ // Lazy loading: only render the most recent chatRenderedCount messages.
10207
+ // Auto-expand when new messages arrive during active streaming to avoid hiding them.
10208
+ var totalMsgCount = allMessages.length;
10209
+ if (totalMsgCount > state.chatRenderedCount && state.chatAutoFollow) {
10210
+ state.chatRenderedCount = totalMsgCount;
10211
+ }
10212
+ var visibleOffset = Math.max(0, totalMsgCount - state.chatRenderedCount);
10213
+ var messages = visibleOffset > 0 ? allMessages.slice(visibleOffset) : allMessages;
10214
+ var hasOlderMessages = visibleOffset > 0;
10215
+
9772
10216
  // Check if messages actually changed
9773
10217
  var msgCount = messages.length;
9774
10218
  var outputHash = selectedSession.output ? selectedSession.output.length : 0;
@@ -9829,16 +10273,17 @@
9829
10273
  // Build HTML with system info cards interleaved
9830
10274
  var html = '';
9831
10275
  var reversedMessages = messages.slice().reverse();
9832
- var msgCount = messages.length;
10276
+ var visibleCount = messages.length;
9833
10277
 
9834
10278
  for (var i = 0; i < reversedMessages.length; i++) {
9835
10279
  var msg = reversedMessages[i];
9836
- var originalIndex = msgCount - 1 - i; // Original index in messages array
10280
+ var localIndex = visibleCount - 1 - i; // Index within visible slice
10281
+ var originalIndex = localIndex + visibleOffset; // Index in full messages array
9837
10282
 
9838
10283
  // Find system info for this message position
9839
10284
  var sysInfo = null;
9840
10285
  for (var j = 0; j < systemInfo.length; j++) {
9841
- if (systemInfo[j].beforeMessage === originalIndex) {
10286
+ if (systemInfo[j].beforeMessage === localIndex) {
9842
10287
  sysInfo = systemInfo[j];
9843
10288
  break;
9844
10289
  }
@@ -9858,6 +10303,13 @@
9858
10303
  html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null, originalIndex);
9859
10304
  }
9860
10305
 
10306
+ // Add sentinel for loading older messages (DOM end = visual top in column-reverse)
10307
+ if (hasOlderMessages) {
10308
+ html += '<div class="chat-load-more" id="chat-load-more-sentinel">' +
10309
+ '<button class="chat-load-more-btn" type="button">加载更早的 ' + Math.min(state.chatPageSize, visibleOffset) + ' 条消息</button>' +
10310
+ '</div>';
10311
+ }
10312
+
9861
10313
  chatMessages.innerHTML = html;
9862
10314
  attachAllCopyHandlers(chatMessages);
9863
10315
  bindChatScrollListener();
@@ -9869,12 +10321,12 @@
9869
10321
  if (cards.length > 0) {
9870
10322
  var firstCard = cards[0];
9871
10323
  var firstCardKey = getElementExpandKey(firstCard);
9872
- if (!hasPersistedExpandState(firstCardKey) && !getConfiguredPanelDefaults().structuredToolCardExpanded) {
10324
+ if (getPersistedExpandState(firstCardKey) === null) {
9873
10325
  firstCard.classList.remove("collapsed");
9874
10326
  }
9875
10327
  for (var ci = 1; ci < cards.length; ci++) {
9876
10328
  var cardKey = getElementExpandKey(cards[ci]);
9877
- if (!hasPersistedExpandState(cardKey) && !getConfiguredPanelDefaults().structuredToolCardExpanded) {
10329
+ if (getPersistedExpandState(cardKey) === null) {
9878
10330
  cards[ci].classList.add("collapsed");
9879
10331
  }
9880
10332
  }
@@ -9883,6 +10335,7 @@
9883
10335
  // Scroll to bottom (newest message) - column-reverse: scrollTop=0 is visual bottom
9884
10336
  requestAnimationFrame(function() {
9885
10337
  smartScrollToBottom(chatMessages);
10338
+ observeLoadMoreSentinel();
9886
10339
  });
9887
10340
  }
9888
10341
 
@@ -9892,7 +10345,7 @@
9892
10345
  var allCards = container.querySelectorAll(".tool-use-card");
9893
10346
  allCards.forEach(function(c) {
9894
10347
  var cardKey = getElementExpandKey(c);
9895
- if (hasPersistedExpandState(cardKey) || getConfiguredPanelDefaults().structuredToolCardExpanded) return;
10348
+ if (getPersistedExpandState(cardKey) !== null) return;
9896
10349
  // Keep expanded if this card is inside a newly added message
9897
10350
  if (newEls) {
9898
10351
  for (var i = 0; i < newEls.length; i++) {
@@ -9903,15 +10356,15 @@
9903
10356
  });
9904
10357
  }
9905
10358
 
9906
- // Pre-compute per-round cumulative usage.
10359
+ // Pre-compute per-round cumulative usage using original (full array) indices.
9907
10360
  // A "round" starts at a user message and includes all subsequent assistant turns
9908
10361
  // until the next user message. Only the last assistant in each round shows the total.
9909
10362
  var roundUsageByIndex = {};
9910
10363
  (function() {
9911
10364
  var acc = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, totalCostUsd: 0 };
9912
10365
  var lastAssistantIdx = -1;
9913
- for (var mi = 0; mi < messages.length; mi++) {
9914
- var m = messages[mi];
10366
+ for (var mi = 0; mi < allMessages.length; mi++) {
10367
+ var m = allMessages[mi];
9915
10368
  if (m.role === "user") {
9916
10369
  if (lastAssistantIdx >= 0 && (acc.inputTokens > 0 || acc.outputTokens > 0 || acc.totalCostUsd > 0)) {
9917
10370
  roundUsageByIndex[lastAssistantIdx] = {
@@ -9955,7 +10408,7 @@
9955
10408
  var insertedEls = [];
9956
10409
  for (var i = 0; i < newMessages.length; i++) {
9957
10410
  var div = document.createElement("div");
9958
- var nmOrigIdx = existingCount + (newMessages.length - 1 - i);
10411
+ var nmOrigIdx = visibleOffset + existingCount + (newMessages.length - 1 - i);
9959
10412
  div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null, nmOrigIdx);
9960
10413
  var el = div.firstElementChild;
9961
10414
  if (el) {
@@ -9987,7 +10440,7 @@
9987
10440
  for (var mi = 0; mi < MAX_STREAMING_SCAN; mi++) {
9988
10441
  var currentEl = existingEls[mi];
9989
10442
  var tmpWrap = document.createElement("div");
9990
- var srOrigIdx = reversedMessages.length - 1 - mi;
10443
+ var srOrigIdx = visibleOffset + reversedMessages.length - 1 - mi;
9991
10444
  tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null, srOrigIdx);
9992
10445
  var replacementEl = tmpWrap.firstElementChild;
9993
10446
  if (!replacementEl) continue;
@@ -10016,7 +10469,7 @@
10016
10469
  var newestCard = null;
10017
10470
  allCards.forEach(function(c) {
10018
10471
  var cardKey = getElementExpandKey(c);
10019
- if (hasPersistedExpandState(cardKey) || getConfiguredPanelDefaults().structuredToolCardExpanded) return;
10472
+ if (getPersistedExpandState(cardKey) !== null) return;
10020
10473
  if (newestMsgEl && newestMsgEl.contains(c)) {
10021
10474
  if (!newestCard) newestCard = c;
10022
10475
  else c.classList.add("collapsed");
@@ -10033,7 +10486,7 @@
10033
10486
  renderStructuredStatusBar(chatMessages, selectedSession);
10034
10487
 
10035
10488
  // Update todo progress bar from latest messages
10036
- updateTodoProgress(messages);
10489
+ updateTodoProgress(allMessages);
10037
10490
  }
10038
10491
 
10039
10492
  // Smart scroll: only auto-scroll if user is near bottom
@@ -10043,7 +10496,7 @@
10043
10496
  updateChatJumpToBottomButton();
10044
10497
  return;
10045
10498
  }
10046
- var chatMsgs = container && container.classList && container.classList.contains("chat-messages")
10499
+ var chatMsgs = (container && container.classList && container.classList.contains("chat-messages"))
10047
10500
  ? container
10048
10501
  : getChatScrollElement();
10049
10502
  if (!chatMsgs || !chatMsgs.isConnected) return;
@@ -11106,7 +11559,8 @@
11106
11559
  // Thinking card (deep thought) — from PTY parsing
11107
11560
  if (msg.role === "thinking") {
11108
11561
  var thinkingKey = buildExpandKey("thinking", [getMessageKey(msg, messageIndex), "pty"]);
11109
- var thinkingExpanded = getExpandState(thinkingKey, "thinking");
11562
+ var thinkingPersisted = getPersistedExpandState(thinkingKey);
11563
+ var thinkingExpanded = thinkingPersisted === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.thinking) : thinkingPersisted;
11110
11564
  return '<div class="chat-message thinking">' +
11111
11565
  '<div class="thinking-inline thinking-pty ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="" onclick="__thinkingToggle(this)">' +
11112
11566
  '<span class="thinking-inline-icon">⦿</span>' +
@@ -11243,7 +11697,8 @@
11243
11697
  }
11244
11698
  var summaryText = parts.join(" · ");
11245
11699
  var groupKey = buildExpandKey("tool-group", [messageKey, items[0] && items[0].index, items.length]);
11246
- var shouldExpand = getExpandState(groupKey, "tool-group");
11700
+ var persistedExpanded = getPersistedExpandState(groupKey);
11701
+ var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.toolGroup) : persistedExpanded;
11247
11702
 
11248
11703
  // Render each item's inline-tool card
11249
11704
  var innerHtml = "";
@@ -11354,7 +11809,8 @@
11354
11809
  '</div>';
11355
11810
  }
11356
11811
  var thinkingKey = buildExpandKey("thinking", [messageKey, index]);
11357
- var thinkingExpanded = getExpandState(thinkingKey, "thinking");
11812
+ var thinkingPersisted = getPersistedExpandState(thinkingKey);
11813
+ var thinkingExpanded = thinkingPersisted === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.thinking) : thinkingPersisted;
11358
11814
  return '<div class="thinking-inline ' + (thinkingExpanded ? 'expanded' : 'collapsed') + '" data-expand-kind="thinking" data-expand-key="' + escapeHtml(thinkingKey) + '" data-thinking="' + escapeHtml(thinkingText) + '" onclick="__thinkingToggle(this)">' +
11359
11815
  '<span class="thinking-inline-icon">⦿</span>' +
11360
11816
  '<span class="thinking-inline-preview">' + escapeHtml(thinkingExpanded ? thinkingText : preview) + '</span>' +
@@ -11380,6 +11836,7 @@
11380
11836
  function renderInlineTool(block, toolResult, toolName, fileInfo, extraInfo, messageKey, index) {
11381
11837
  var toolId = block.id || "tool-" + toolName;
11382
11838
  var expandKey = buildExpandKey("inline-tool", [messageKey, toolId || index, index]);
11839
+ var persistedExpanded = getPersistedExpandState(expandKey);
11383
11840
  var inputData = block.input || {};
11384
11841
  var resultContent = extractToolResultText(toolResult && toolResult.content);
11385
11842
 
@@ -11450,7 +11907,7 @@
11450
11907
  var fullResult = resultContent;
11451
11908
 
11452
11909
  var expandedHtml = "";
11453
- var shouldExpand = getExpandState(expandKey, "inline-tool");
11910
+ var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.inlineTools) : persistedExpanded;
11454
11911
  if (hasResult) {
11455
11912
  expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';">' +
11456
11913
  '<div class="inline-tool-result">' + formatInlineResult(resultContent, toolName) + '</div>' +
@@ -11462,16 +11919,23 @@
11462
11919
  expandedHtml = '<div class="inline-tool-expanded" style="display: ' + (shouldExpand ? 'block' : 'none') + ';"><div class="inline-tool-loading">等待响应…</div></div>';
11463
11920
  }
11464
11921
 
11922
+ var isTruncated = toolResult && toolResult._truncated === true;
11923
+
11465
11924
  var extraInfoHtml = meta ? '<span class="inline-tool-meta">' + escapeHtml(meta) + '</span>' : '';
11466
11925
  var extraClass = isError ? 'inline-tool-error-inline' : '';
11467
11926
  if (shouldExpand) extraClass += ' inline-tool-open';
11468
11927
 
11928
+ var truncatedAttrs = isTruncated
11929
+ ? 'data-truncated="true" data-tool-use-id="' + escapeHtml(block.id || "") + '" '
11930
+ : '';
11931
+
11469
11932
  return '<div class="inline-tool ' + extraClass + '" ' +
11470
11933
  'data-expand-kind="inline-tool" ' +
11471
11934
  'data-expand-key="' + escapeHtml(expandKey) + '" ' +
11472
11935
  'data-result="' + escapeHtml(fullResult) + '" ' +
11473
11936
  'data-preview="' + previewDataAttr + '" ' +
11474
11937
  'data-status="' + (isError ? 'error' : (hasResult ? 'done' : 'pending')) + '" ' +
11938
+ truncatedAttrs +
11475
11939
  'onclick="__inlineToolToggle(this)">' +
11476
11940
  '<div class="inline-tool-row">' +
11477
11941
  '<span class="inline-tool-status">' + statusIcon + '</span>' +
@@ -11490,6 +11954,7 @@
11490
11954
  var resultContent = extractToolResultText(toolResult && toolResult.content);
11491
11955
  var toolId = block.id || "tool-" + toolName;
11492
11956
  var expandKey = buildExpandKey("terminal", [messageKey, toolId || index, index]);
11957
+ var persistedExpanded = getPersistedExpandState(expandKey);
11493
11958
 
11494
11959
  var isError = toolResult && toolResult.is_error;
11495
11960
  var exitCode = inputData.exitCode;
@@ -11527,9 +11992,14 @@
11527
11992
 
11528
11993
  // Show command preview in header (truncate long commands)
11529
11994
  var cmdPreview = command.length > 80 ? command.slice(0, 77) + "…" : command;
11530
- var shouldExpand = getExpandState(expandKey, "terminal");
11995
+ var shouldExpand = persistedExpanded === null ? !!(state.config && state.config.cardDefaults && state.config.cardDefaults.terminal) : persistedExpanded;
11996
+
11997
+ var termTruncated = toolResult && toolResult._truncated === true;
11998
+ var termTruncAttrs = termTruncated
11999
+ ? ' data-truncated="true" data-tool-use-id="' + escapeHtml(block.id || "") + '"'
12000
+ : '';
11531
12001
 
11532
- return '<div class="inline-terminal" data-expand-kind="terminal" data-expand-key="' + escapeHtml(expandKey) + '" data-expanded="' + (shouldExpand ? 'true' : 'false') + '">' +
12002
+ return '<div class="inline-terminal" data-expand-kind="terminal" data-expand-key="' + escapeHtml(expandKey) + '" data-expanded="' + (shouldExpand ? 'true' : 'false') + '"' + termTruncAttrs + '>' +
11533
12003
  '<div class="term-header" onclick="__terminalExpand(this)">' +
11534
12004
  statusDot +
11535
12005
  '<span class="term-cmd-preview"><span class="term-prompt">$</span> ' + escapeHtml(cmdPreview) + '</span>' +
@@ -11653,33 +12123,116 @@
11653
12123
  return renderDiffTool(block, toolResult, toolName);
11654
12124
  }
11655
12125
 
11656
- // ── AskUserQuestion tool — special card
12126
+ // ── AskUserQuestion tool — special card with batch submit
11657
12127
  if (toolName === "AskUserQuestion" && block.input && block.input.questions) {
11658
12128
  var questions = block.input.questions;
11659
12129
  if (questions && questions.length > 0) {
12130
+ var isAnswered = !!toolResult;
12131
+ var sel = state.askUserSelections[toolId] || {};
12132
+ var isSubmitted = !!sel.submitted;
12133
+ var answerText = isAnswered ? extractToolResultText(toolResult.content) : "";
12134
+ var answerLines = answerText ? answerText.trim().split("\n") : [];
12135
+
12136
+ // Build header summary
12137
+ var headerLabel = "";
12138
+ for (var hi = 0; hi < questions.length; hi++) {
12139
+ if (questions[hi].header) { headerLabel = questions[hi].header; break; }
12140
+ }
12141
+ var headerSummary = headerLabel ? '<span class="tool-use-summary">' + escapeHtml(headerLabel) + '</span>' : "";
12142
+
11660
12143
  var questionsHtml = "";
11661
12144
  questions.forEach(function(question, qIdx) {
12145
+ var isMulti = !!question.multiSelect;
11662
12146
  var questionText = question.question ? '<div class="ask-user-title">' + escapeHtml(question.question) + '</div>' : "";
11663
12147
  var optionsHtml = "";
11664
12148
  if (question.options && question.options.length > 0) {
11665
- optionsHtml = '<div class="ask-user-options">';
12149
+ optionsHtml = '<div class="ask-user-options" data-multi-select="' + isMulti + '">';
11666
12150
  question.options.forEach(function(opt, idx) {
11667
12151
  var label = opt.label ? escapeHtml(opt.label) : "选项 " + (idx + 1);
11668
- optionsHtml += '<button class="ask-user-option" data-option-index="' + idx + '" data-question-index="' + qIdx + '" data-option-label="' + escapeHtml(label) + '" onclick="__askOption(this)">' +
11669
- '<div class="ask-user-option-label">' + label + '</div>' +
11670
- '</button>';
12152
+ var descHtml = opt.description ? '<div class="ask-user-option-desc">' + escapeHtml(opt.description) + '</div>' : "";
12153
+
12154
+ if (isAnswered) {
12155
+ // Read-only: check if this option was the chosen answer
12156
+ var answerLine = answerLines[qIdx] || answerLines[0] || "";
12157
+ var chosenLabels = answerLine.split(",").map(function(s) { return s.trim(); });
12158
+ var isChosen = chosenLabels.indexOf(opt.label || "") !== -1;
12159
+ optionsHtml += '<div class="ask-user-option ask-user-option-readonly' + (isChosen ? ' ask-user-option-chosen' : '') + '">' +
12160
+ '<span class="ask-user-indicator"></span>' +
12161
+ '<div class="ask-user-option-content">' +
12162
+ '<div class="ask-user-option-label">' + label + '</div>' +
12163
+ descHtml +
12164
+ '</div>' +
12165
+ '</div>';
12166
+ } else {
12167
+ // Interactive: selection state from askUserSelections
12168
+ var isSelected = (sel[qIdx] || []).indexOf(idx) !== -1;
12169
+ var disabledAttr = isSubmitted ? ' disabled' : '';
12170
+ optionsHtml += '<button class="ask-user-option' + (isSelected ? ' selected' : '') + '"' +
12171
+ ' data-option-index="' + idx + '"' +
12172
+ ' data-question-index="' + qIdx + '"' +
12173
+ ' data-option-label="' + escapeHtml(opt.label || "选项 " + (idx + 1)) + '"' +
12174
+ ' onclick="__askSelect(\'' + escapeHtml(toolId) + '\',' + qIdx + ',' + idx + ',' + isMulti + ')"' +
12175
+ disabledAttr + '>' +
12176
+ '<span class="ask-user-indicator"></span>' +
12177
+ '<div class="ask-user-option-content">' +
12178
+ '<div class="ask-user-option-label">' + label + '</div>' +
12179
+ descHtml +
12180
+ '</div>' +
12181
+ '</button>';
12182
+ }
11671
12183
  });
11672
12184
  optionsHtml += '</div>';
11673
12185
  }
11674
- questionsHtml += '<div class="ask-user-question-group">' + questionText + optionsHtml + '</div>';
12186
+ questionsHtml += '<div class="ask-user-question-group" data-question-index="' + qIdx + '">' + questionText + optionsHtml + '</div>';
11675
12187
  });
11676
- return '<div class="tool-use-card ask-user" data-tool-use-id="' + escapeHtml(toolId) + '">' +
12188
+
12189
+ // Submit button (only for interactive state)
12190
+ var actionsHtml = "";
12191
+ if (!isAnswered) {
12192
+ var allAnsweredCheck = true;
12193
+ for (var qi = 0; qi < questions.length; qi++) {
12194
+ if (!sel[qi] || sel[qi].length === 0) { allAnsweredCheck = false; break; }
12195
+ }
12196
+ var submitDisabled = (!allAnsweredCheck || isSubmitted) ? " disabled" : "";
12197
+ var submitClass = isSubmitted ? " ask-user-submitted" : "";
12198
+ var submitText = isSubmitted ? "已提交..." : "确认提交";
12199
+ actionsHtml = '<div class="ask-user-actions">' +
12200
+ '<button class="ask-user-submit' + submitClass + '" data-tool-use-id="' + escapeHtml(toolId) + '"' +
12201
+ ' onclick="__askSubmit(\'' + escapeHtml(toolId) + '\')"' + submitDisabled + '>' +
12202
+ submitText +
12203
+ '</button>' +
12204
+ '</div>';
12205
+ }
12206
+
12207
+ // Answered summary for header
12208
+ var answeredSummary = "";
12209
+ if (isAnswered && answerText) {
12210
+ var shortAnswer = answerText.trim().replace(/\n/g, ", ");
12211
+ if (shortAnswer.length > 40) shortAnswer = shortAnswer.slice(0, 37) + "...";
12212
+ answeredSummary = '<span class="tool-use-file">' + escapeHtml(shortAnswer) + '</span>';
12213
+ }
12214
+
12215
+ // Expand state: default expanded when unanswered, collapsed when answered
12216
+ var askExpandKey = buildExpandKey("tool-card", [messageKey, toolId]);
12217
+ var askPersisted = getPersistedExpandState(askExpandKey);
12218
+ var askShouldExpand = askPersisted === null ? !isAnswered : askPersisted;
12219
+ var askCollapsed = askShouldExpand ? "" : " collapsed";
12220
+ var answeredClass = isAnswered ? " ask-user-answered" : "";
12221
+
12222
+ return '<div class="tool-use-card ask-user' + answeredClass + askCollapsed + '"' +
12223
+ ' data-tool-use-id="' + escapeHtml(toolId) + '"' +
12224
+ ' data-expand-kind="tool-card"' +
12225
+ ' data-expand-key="' + escapeHtml(askExpandKey) + '">' +
11677
12226
  '<div class="tool-use-header" data-tool-toggle onclick="__tcToggle(event,this)">' +
11678
- '<span class="tool-use-icon">?</span>' +
12227
+ '<span class="tool-use-icon">' + (isAnswered ? '✓' : '?') + '</span>' +
11679
12228
  '<span class="tool-use-name">提问</span>' +
12229
+ headerSummary +
12230
+ answeredSummary +
12231
+ '<span class="tool-use-toggle">▼</span>' +
11680
12232
  '</div>' +
11681
12233
  '<div class="tool-use-body ask-user-body">' +
11682
12234
  questionsHtml +
12235
+ actionsHtml +
11683
12236
  '</div>' +
11684
12237
  '</div>';
11685
12238
  }
@@ -11725,10 +12278,13 @@
11725
12278
  }
11726
12279
 
11727
12280
  var expandKey = buildExpandKey("tool-card", [messageKey, toolId]);
11728
- var shouldExpand = getExpandState(expandKey, "tool-card", statusClass === "loading");
12281
+ var persistedExpanded = getPersistedExpandState(expandKey);
12282
+ var cardDefaultExpand = !!(state.config && state.config.cardDefaults && state.config.cardDefaults.editCards);
12283
+ var shouldExpand = persistedExpanded === null ? (statusClass === "loading" || cardDefaultExpand) : persistedExpanded;
12284
+ var tcTruncated = toolResult && toolResult._truncated === true;
11729
12285
  var collapsedClass = shouldExpand ? "" : " collapsed";
11730
12286
  var toggleHtml = '<span class="tool-use-toggle">▼</span>';
11731
- return '<div class="tool-use-card ' + statusClass + collapsedClass + '" data-expand-kind="tool-card" data-expand-key="' + escapeHtml(expandKey) + '" data-tool-use-id="' + escapeHtml(toolId) + '">' +
12287
+ return '<div class="tool-use-card ' + statusClass + collapsedClass + '" data-expand-kind="tool-card" data-expand-key="' + escapeHtml(expandKey) + '" data-tool-use-id="' + escapeHtml(toolId) + '"' + (tcTruncated ? ' data-truncated="true"' : '') + '>' +
11732
12288
  '<div class="tool-use-header" data-tool-toggle onclick="__tcToggle(event,this)">' +
11733
12289
  '<span class="tool-use-icon">' + headerIcon + '</span>' +
11734
12290
  '<span class="tool-use-name">' + escapeHtml(titleText) + '</span>' +
@@ -12149,8 +12705,8 @@
12149
12705
 
12150
12706
  var notificationStack = [];
12151
12707
  var notificationIdCounter = 0;
12152
- var NOTIFICATION_GAP = 8;
12153
- var NOTIFICATION_TOP = 24;
12708
+ var NOTIFICATION_GAP = 6;
12709
+ var NOTIFICATION_TOP = 16;
12154
12710
 
12155
12711
  /**
12156
12712
  * Show an in-app notification bubble at bottom-right.
@@ -12165,6 +12721,12 @@
12165
12721
  * @returns {{ dismiss: function }} handle
12166
12722
  */
12167
12723
  function showNotificationBubble(opts) {
12724
+ // Play sound for important notifications — independent of bubble setting
12725
+ if (opts.actionLabel || opts.playSound) playNotificationSound();
12726
+
12727
+ // Respect user preference (skip if bubbles disabled)
12728
+ if (!state.notifBubble) return { dismiss: function() {} };
12729
+
12168
12730
  var id = ++notificationIdCounter;
12169
12731
  var type = opts.type || "info";
12170
12732
  var icon = opts.icon || (type === "warning" ? "!" : type === "success" ? "\u2713" : "i");
@@ -12182,7 +12744,7 @@
12182
12744
  '</div>';
12183
12745
 
12184
12746
  var bodyHtml = opts.body
12185
- ? '<div class="notification-bubble-body">' + escapeHtml(opts.body) + '</div>'
12747
+ ? '<div class="notification-bubble-body">' + escapeHtml(opts.body).replace(/\n/g, '<br>') + '</div>'
12186
12748
  : '';
12187
12749
 
12188
12750
  var actionsHtml = opts.actionLabel
@@ -12248,13 +12810,43 @@
12248
12810
 
12249
12811
  // ── Browser Notification API ──
12250
12812
 
12813
+ // Detect Android APK native bridge
12814
+ var _hasNativeBridge = typeof WandNative !== "undefined" && typeof WandNative.sendNotification === "function";
12815
+ // Detect if running inside APK and extract installed version from User-Agent
12816
+ var _apkVersionMatch = navigator.userAgent.match(/WandApp\/([^\s]+)/);
12817
+ var _apkVersion = _apkVersionMatch ? _apkVersionMatch[1] : null;
12818
+
12819
+ function _getNativePermission() {
12820
+ if (_hasNativeBridge && typeof WandNative.getPermission === "function") {
12821
+ try { return WandNative.getPermission(); } catch (_e) {}
12822
+ }
12823
+ return null;
12824
+ }
12825
+
12251
12826
  function requestNotificationPermission() {
12827
+ if (_hasNativeBridge) {
12828
+ var perm = _getNativePermission();
12829
+ if (perm === "default" || perm === "denied") {
12830
+ try { WandNative.requestPermission(); } catch (_e) {}
12831
+ }
12832
+ return;
12833
+ }
12252
12834
  if (typeof Notification !== "undefined" && Notification.permission === "default") {
12253
12835
  Notification.requestPermission();
12254
12836
  }
12255
12837
  }
12256
12838
 
12257
12839
  function sendBrowserNotification(title, body, opts) {
12840
+ // Native Android bridge path
12841
+ if (_hasNativeBridge) {
12842
+ var perm = _getNativePermission();
12843
+ if (perm !== "granted") return;
12844
+ try {
12845
+ WandNative.sendNotification(title || "Wand", body || "", (opts && opts.tag) || "");
12846
+ } catch (_e) {}
12847
+ return;
12848
+ }
12849
+ // Browser Notification API path
12258
12850
  if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
12259
12851
  if (!document.hidden) return; // Only notify when tab is in background
12260
12852
  try {
@@ -12275,6 +12867,221 @@
12275
12867
  }
12276
12868
  }
12277
12869
 
12870
+ /**
12871
+ * Play a soft, rounded notification chime using Web Audio API.
12872
+ * Two ascending sine tones with smooth gain envelope — gentle on the ears.
12873
+ */
12874
+ function playNotificationSound() {
12875
+ if (!state.notifSound) return;
12876
+ _doPlaySound();
12877
+ }
12878
+
12879
+ /**
12880
+ * Try to play the notification sound regardless of user preference.
12881
+ * Returns true if playback was initiated successfully.
12882
+ * Used by the test function to always attempt playback.
12883
+ */
12884
+ function tryPlayNotificationSound() {
12885
+ return _doPlaySound();
12886
+ }
12887
+
12888
+ function _doPlaySound() {
12889
+ try {
12890
+ var AudioCtx = window.AudioContext || window.webkitAudioContext;
12891
+ if (!AudioCtx) return false;
12892
+ var ctx = new AudioCtx();
12893
+
12894
+ // Some browsers suspend AudioContext until user gesture — resume it
12895
+ if (ctx.state === "suspended") ctx.resume();
12896
+
12897
+ function tone(freq, start, dur) {
12898
+ var osc = ctx.createOscillator();
12899
+ var gain = ctx.createGain();
12900
+ osc.type = "sine";
12901
+ osc.frequency.value = freq;
12902
+ gain.gain.setValueAtTime(0, ctx.currentTime + start);
12903
+ gain.gain.linearRampToValueAtTime(0.18, ctx.currentTime + start + 0.04);
12904
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
12905
+ osc.connect(gain);
12906
+ gain.connect(ctx.destination);
12907
+ osc.start(ctx.currentTime + start);
12908
+ osc.stop(ctx.currentTime + start + dur);
12909
+ }
12910
+
12911
+ // Two-tone ascending chime: C5 → E5, soft and brief
12912
+ tone(523, 0, 0.25);
12913
+ tone(659, 0.12, 0.3);
12914
+
12915
+ // Clean up context after playback
12916
+ setTimeout(function() { ctx.close(); }, 600);
12917
+ return true;
12918
+ } catch (_e) {
12919
+ // Web Audio not available or blocked
12920
+ return false;
12921
+ }
12922
+ }
12923
+
12924
+ /**
12925
+ * Show an interactive update bubble that allows updating and restarting
12926
+ * directly from the notification, without navigating to settings.
12927
+ */
12928
+ function showUpdateBubble(currentVer, latestVer) {
12929
+ // Prevent duplicate bubbles
12930
+ if (state._updateBubbleShown) return;
12931
+ state._updateBubbleShown = true;
12932
+
12933
+ playNotificationSound();
12934
+
12935
+ var id = ++notificationIdCounter;
12936
+ var bubble = document.createElement("div");
12937
+ bubble.className = "notification-bubble";
12938
+ bubble.setAttribute("data-nid", id);
12939
+
12940
+ bubble.innerHTML =
12941
+ '<div class="notification-bubble-header">' +
12942
+ '<span class="notification-bubble-icon info">\u2191</span>' +
12943
+ '<span class="notification-bubble-title">\u53d1\u73b0\u65b0\u7248\u672c</span>' +
12944
+ '<button class="notification-bubble-close" title="\u5173\u95ed">\u00d7</button>' +
12945
+ '</div>' +
12946
+ '<div class="notification-bubble-body">' +
12947
+ escapeHtml(currentVer) + ' \u2192 ' + escapeHtml(latestVer) +
12948
+ '</div>' +
12949
+ '<div class="notification-bubble-actions">' +
12950
+ '<button class="primary" id="update-bubble-action">\u7acb\u5373\u66f4\u65b0</button>' +
12951
+ '</div>';
12952
+
12953
+ document.body.appendChild(bubble);
12954
+
12955
+ var entry = { id: id, el: bubble };
12956
+ notificationStack.push(entry);
12957
+ repositionNotifications();
12958
+
12959
+ var closeBtn = bubble.querySelector(".notification-bubble-close");
12960
+ if (closeBtn) closeBtn.onclick = function() {
12961
+ dismissNotification(id);
12962
+ state._updateBubbleShown = false;
12963
+ };
12964
+
12965
+ var actionBtn = bubble.querySelector("#update-bubble-action");
12966
+ var bodyEl = bubble.querySelector(".notification-bubble-body");
12967
+
12968
+ if (actionBtn) actionBtn.onclick = function() {
12969
+ // Phase 1: Performing update
12970
+ actionBtn.disabled = true;
12971
+ actionBtn.textContent = "\u66f4\u65b0\u4e2d\u2026";
12972
+ if (bodyEl) bodyEl.textContent = "\u6b63\u5728\u4e0b\u8f7d\u5e76\u5b89\u88c5\u65b0\u7248\u672c\u2026";
12973
+
12974
+ fetch("/api/update", {
12975
+ method: "POST",
12976
+ headers: { "Content-Type": "application/json" },
12977
+ credentials: "same-origin"
12978
+ })
12979
+ .then(function(res) { return res.json(); })
12980
+ .then(function(data) {
12981
+ if (data.error) {
12982
+ // Update failed
12983
+ if (bodyEl) {
12984
+ bodyEl.textContent = data.error;
12985
+ bodyEl.style.color = "var(--error)";
12986
+ }
12987
+ actionBtn.disabled = false;
12988
+ actionBtn.textContent = "\u91cd\u8bd5";
12989
+ return;
12990
+ }
12991
+ // Phase 2: Update succeeded, show restart button
12992
+ if (bodyEl) {
12993
+ bodyEl.textContent = data.message || "\u66f4\u65b0\u5b8c\u6210";
12994
+ bodyEl.style.color = "var(--success)";
12995
+ }
12996
+ actionBtn.textContent = "\u91cd\u542f\u751f\u6548";
12997
+ actionBtn.disabled = false;
12998
+ actionBtn.className = "primary success";
12999
+ actionBtn.onclick = function() {
13000
+ performRestart(actionBtn, bodyEl);
13001
+ };
13002
+ })
13003
+ .catch(function() {
13004
+ if (bodyEl) {
13005
+ bodyEl.textContent = "\u66f4\u65b0\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u7f51\u7edc\u8fde\u63a5\u3002";
13006
+ bodyEl.style.color = "var(--error)";
13007
+ }
13008
+ actionBtn.disabled = false;
13009
+ actionBtn.textContent = "\u91cd\u8bd5";
13010
+ });
13011
+ };
13012
+ }
13013
+
13014
+ /**
13015
+ * Call POST /api/restart and show the restart overlay.
13016
+ */
13017
+ function performRestart(btn, msgEl) {
13018
+ if (btn) {
13019
+ btn.disabled = true;
13020
+ btn.textContent = "\u6b63\u5728\u91cd\u542f\u2026";
13021
+ }
13022
+ if (msgEl) {
13023
+ msgEl.textContent = "\u670d\u52a1\u6b63\u5728\u91cd\u542f\u2026";
13024
+ msgEl.style.color = "var(--text-secondary)";
13025
+ }
13026
+
13027
+ fetch("/api/restart", {
13028
+ method: "POST",
13029
+ headers: { "Content-Type": "application/json" },
13030
+ credentials: "same-origin"
13031
+ })
13032
+ .then(function(res) { return res.json(); })
13033
+ .then(function() {
13034
+ showRestartOverlay();
13035
+ })
13036
+ .catch(function() {
13037
+ // Network error likely means server already shut down — show overlay anyway
13038
+ showRestartOverlay();
13039
+ });
13040
+ }
13041
+
13042
+ /**
13043
+ * Full-screen overlay shown during server restart.
13044
+ * Polls /api/config until the server comes back, then reloads the page.
13045
+ */
13046
+ function showRestartOverlay() {
13047
+ // Avoid duplicates
13048
+ if (document.getElementById("restart-overlay")) return;
13049
+
13050
+ var overlay = document.createElement("div");
13051
+ overlay.id = "restart-overlay";
13052
+ overlay.className = "restart-overlay";
13053
+ overlay.innerHTML =
13054
+ '<div class="restart-overlay-content">' +
13055
+ '<div class="restart-spinner"></div>' +
13056
+ '<div class="restart-title">\u670d\u52a1\u6b63\u5728\u91cd\u542f</div>' +
13057
+ '<div class="restart-subtitle">\u7a0d\u540e\u5c06\u81ea\u52a8\u5237\u65b0\u9875\u9762\u2026</div>' +
13058
+ '</div>';
13059
+ document.body.appendChild(overlay);
13060
+
13061
+ var attempts = 0;
13062
+ var maxAttempts = 20; // 20 * 2s = 40s
13063
+ var timer = setInterval(function() {
13064
+ attempts++;
13065
+ fetch("/api/config", { credentials: "same-origin" })
13066
+ .then(function(res) {
13067
+ if (res.ok) {
13068
+ clearInterval(timer);
13069
+ location.reload();
13070
+ }
13071
+ })
13072
+ .catch(function() {
13073
+ // Server not ready yet
13074
+ });
13075
+ if (attempts >= maxAttempts) {
13076
+ clearInterval(timer);
13077
+ var subtitle = overlay.querySelector(".restart-subtitle");
13078
+ if (subtitle) {
13079
+ subtitle.innerHTML = '\u91cd\u542f\u8d85\u65f6\uff0c\u8bf7 <a href="javascript:location.reload()" style="color:var(--accent);text-decoration:underline">\u624b\u52a8\u5237\u65b0</a> \u9875\u9762\u3002';
13080
+ }
13081
+ }
13082
+ }, 2000);
13083
+ }
13084
+
12278
13085
  function escapeHtml(value) {
12279
13086
  return String(value)
12280
13087
  .replace(/&/g, "&amp;")