@co0ontty/wand 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,8 +19,40 @@
19
19
  console.log('SW registration failed:', e.message || e);
20
20
  }
21
21
  });
22
+
23
+ // Auto-reload when a new service worker takes control (e.g. after update)
24
+ var reloading = false;
25
+ navigator.serviceWorker.addEventListener('controllerchange', function() {
26
+ if (reloading) return;
27
+ reloading = true;
28
+ location.reload();
29
+ });
22
30
  }
23
31
 
32
+ // PWA display mode detection
33
+ (function() {
34
+ function detectDisplayMode() {
35
+ var mode = 'browser';
36
+ if (window.matchMedia('(display-mode: window-controls-overlay)').matches) {
37
+ mode = 'window-controls-overlay';
38
+ } else if (window.matchMedia('(display-mode: standalone)').matches) {
39
+ mode = 'standalone';
40
+ } else if (window.matchMedia('(display-mode: fullscreen)').matches) {
41
+ mode = 'fullscreen';
42
+ } else if (navigator.standalone === true) {
43
+ mode = 'standalone'; // iOS Safari
44
+ }
45
+ document.documentElement.setAttribute('data-display-mode', mode);
46
+ document.documentElement.classList.toggle('is-pwa', mode !== 'browser');
47
+ return mode;
48
+ }
49
+ detectDisplayMode();
50
+ // Re-detect when display mode changes (e.g., user toggles WCO)
51
+ ['standalone', 'window-controls-overlay', 'fullscreen'].forEach(function(m) {
52
+ window.matchMedia('(display-mode: ' + m + ')').addEventListener('change', detectDisplayMode);
53
+ });
54
+ })();
55
+
24
56
  (function() {
25
57
  var configPath = "${escapeHtml(configPath)}";
26
58
 
@@ -36,6 +68,15 @@
36
68
  fitAddon: null,
37
69
  terminalSessionId: null,
38
70
  terminalOutput: "",
71
+ terminalViewportSize: { width: 0, height: 0 },
72
+ terminalAutoFollow: true,
73
+ terminalScrollIdleTimer: null,
74
+ terminalScrollIdleMs: 1800,
75
+ terminalScrollThreshold: 12,
76
+ showTerminalJumpToBottom: false,
77
+ terminalViewportEl: null,
78
+ terminalViewportScrollHandler: null,
79
+ terminalViewportTouchHandler: null,
39
80
  resizeObserver: null,
40
81
  resizeHandler: null,
41
82
  resizeTimer: null,
@@ -86,9 +127,17 @@
86
127
  currentTask: null, // Current task title from Claude
87
128
  terminalInteractive: false,
88
129
  miniKeyboardVisible: false,
130
+ shortcutsExpanded: false,
89
131
  modifiers: { ctrl: false, alt: false, shift: false },
90
132
  fileSearchQuery: "",
91
133
  allFiles: [],
134
+ claudeHistory: [],
135
+ claudeHistoryLoaded: false,
136
+ claudeHistoryExpanded: true,
137
+ claudeHistoryExpandedDirs: {},
138
+ sessionsManageMode: false,
139
+ selectedSessionIds: {},
140
+ selectedClaudeHistoryIds: {},
92
141
  // Load last used working directory from localStorage
93
142
  workingDir: (function() {
94
143
  try {
@@ -156,8 +205,21 @@
156
205
  }
157
206
  }
158
207
 
208
+ renderBootLoading();
159
209
  restoreLoginSession();
160
210
 
211
+ function renderBootLoading() {
212
+ var app = document.getElementById("app");
213
+ if (!app) return;
214
+ app.innerHTML =
215
+ '<div class="boot-loading">' +
216
+ '<div class="boot-loading-card">' +
217
+ '<div class="boot-loading-spinner"></div>' +
218
+ '<div class="boot-loading-text">正在恢复会话…</div>' +
219
+ '</div>' +
220
+ '</div>';
221
+ }
222
+
161
223
  function restoreLoginSession() {
162
224
  fetch("/api/config", { credentials: "same-origin" })
163
225
  .then(function(res) {
@@ -172,16 +234,23 @@
172
234
  if (!config) return;
173
235
  state.config = config;
174
236
  state.loginChecked = true;
175
- // Render the app shell first, THEN load session data into it.
176
- // This avoids refreshAll() rendering chat content that render() immediately destroys.
177
- try {
178
- render();
179
- } catch (_e) {
180
- // render() may fail if external scripts (xterm.js) failed to load;
181
- // continue with polling and session loading so the app remains functional
182
- }
183
- startPolling();
184
- return refreshAll();
237
+ requestAnimationFrame(function() {
238
+ // Render the app shell first, THEN load session data into it.
239
+ // Skip updateShellChrome() here — sessions aren't loaded yet.
240
+ // refreshAll() will call updateShellChrome() after sessions arrive.
241
+ try {
242
+ render({ skipShellChrome: true });
243
+ } catch (_e) {
244
+ // render() may fail if external scripts (xterm.js) failed to load;
245
+ // continue with polling and session loading so the app remains functional
246
+ }
247
+ startPolling();
248
+ refreshAll();
249
+ // Auto-load claude history since section defaults to expanded
250
+ if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
251
+ loadClaudeHistory();
252
+ }
253
+ });
185
254
  })
186
255
  .catch(function() {
187
256
  state.loginChecked = true;
@@ -189,14 +258,19 @@
189
258
  });
190
259
  }
191
260
 
192
- render();
193
-
194
- function render() {
261
+ function render(options) {
262
+ var skipShellChrome = options && options.skipShellChrome;
195
263
  var app = document.getElementById("app");
196
264
  var isLoggedIn = state.config !== null;
197
265
  var wasModalOpen = state.modalOpen;
266
+ var shouldResetShell = !isLoggedIn || !document.getElementById("output");
198
267
 
199
- teardownTerminal();
268
+ if (shouldResetShell) {
269
+ teardownTerminal();
270
+ }
271
+
272
+ // Suppress CSS transitions during initial DOM build
273
+ document.documentElement.classList.add("no-transition");
200
274
 
201
275
  app.innerHTML = isLoggedIn ? renderAppShell() : renderLogin();
202
276
  // Reset chat render tracking since DOM was fully replaced
@@ -207,7 +281,15 @@
207
281
  updateDrawerState();
208
282
  syncComposerModeSelect();
209
283
  applyCurrentView();
210
- updateShellChrome();
284
+ if (!skipShellChrome) {
285
+ updateShellChrome();
286
+ }
287
+
288
+ // Force reflow then re-enable transitions after layout settles
289
+ void document.body.offsetHeight;
290
+ requestAnimationFrame(function() {
291
+ document.documentElement.classList.remove("no-transition");
292
+ });
211
293
 
212
294
  // Restore modal state if it was open
213
295
  if (wasModalOpen && state.modalOpen) {
@@ -223,38 +305,25 @@
223
305
 
224
306
  function renderInlineKeyboard() {
225
307
  if (!state.selectedId) return "";
226
- // Keyboard toggle button + popup panel
227
- var isActive = state.currentView === "terminal";
228
- return '<button id="keyboard-toggle" class="keyboard-toggle-btn' + (isActive ? "" : " hidden") + '" type="button" title="快捷键盘">' +
229
- '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
230
- '<rect x="2" y="4" width="20" height="16" rx="2" ry="2"/>' +
231
- '<line x1="6" y1="8" x2="6.01" y2="8"/>' +
232
- '<line x1="10" y1="8" x2="10.01" y2="8"/>' +
233
- '<line x1="14" y1="8" x2="14.01" y2="8"/>' +
234
- '<line x1="18" y1="8" x2="18.01" y2="8"/>' +
235
- '<line x1="6" y1="12" x2="18" y2="12"/>' +
236
- '</svg>' +
237
- '</button>' +
238
- '<div id="keyboard-popup" class="keyboard-popup' + (state.keyboardPopupOpen ? '' : ' hidden') + '">' +
239
- '<div class="keyboard-popup-row modifiers">' +
240
- '<button class="kp-key' + (state.modifiers.ctrl ? ' active' : '') + '" data-key="ctrl" type="button">Ctrl</button>' +
241
- '<button class="kp-key' + (state.modifiers.alt ? ' active' : '') + '" data-key="alt" type="button">Alt</button>' +
242
- '</div>' +
243
- '<div class="keyboard-popup-row directions">' +
244
- '<div class="kp-dir-grid">' +
245
- '<div class="kp-dir-up"><button class="kp-key kp-dir" data-key="up" type="button">↑</button></div>' +
246
- '<div class="kp-dir-lr">' +
247
- '<button class="kp-key kp-dir" data-key="left" type="button">←</button>' +
248
- '<button class="kp-key kp-dir" data-key="down" type="button">↓</button>' +
249
- '<button class="kp-key kp-dir" data-key="right" type="button">→</button>' +
250
- '</div>' +
251
- '</div>' +
252
- '</div>' +
253
- '<div class="keyboard-popup-row actions">' +
254
- '<button class="kp-key kp-action" data-key="enter" type="button">↵ 回车</button>' +
255
- '<button class="kp-key kp-action" data-key="ctrl_enter" type="button">C-↵</button>' +
256
- '<button class="kp-key kp-action kp-escape" data-key="escape" type="button">Esc</button>' +
257
- '</div>' +
308
+ var isTerminal = state.currentView === "terminal";
309
+ if (!isTerminal) return "";
310
+ var keys =
311
+ '<button class="shortcut-key' + (state.modifiers.ctrl ? ' active' : '') + '" data-key="ctrl" type="button">Ctrl</button>' +
312
+ '<button class="shortcut-key' + (state.modifiers.alt ? ' active' : '') + '" data-key="alt" type="button">Alt</button>' +
313
+ '<span class="shortcut-sep">·</span>' +
314
+ '<button class="shortcut-key shortcut-dir" data-key="up" type="button">↑</button>' +
315
+ '<button class="shortcut-key shortcut-dir" data-key="down" type="button">↓</button>' +
316
+ '<button class="shortcut-key shortcut-dir" data-key="left" type="button">←</button>' +
317
+ '<button class="shortcut-key shortcut-dir" data-key="right" type="button">→</button>' +
318
+ '<span class="shortcut-sep">·</span>' +
319
+ '<button class="shortcut-key" data-key="enter" type="button">↵</button>' +
320
+ '<button class="shortcut-key" data-key="ctrl_enter" type="button">C-↵</button>' +
321
+ '<button class="shortcut-key" data-key="escape" type="button">Esc</button>';
322
+ var arrow = state.shortcutsExpanded ? '' : '';
323
+ return '<div class="inline-shortcuts-wrap' + (state.shortcutsExpanded ? ' expanded' : '') + '">' +
324
+ '<button class="shortcuts-toggle' + (state.shortcutsExpanded ? ' active' : '') + '" type="button" title="快捷键">' + arrow + '</button>' +
325
+ '<div class="inline-shortcuts-strip">' + keys + '</div>' +
326
+ '<div class="inline-shortcuts-inline">' + keys + '</div>' +
258
327
  '</div>';
259
328
  }
260
329
 
@@ -297,7 +366,6 @@
297
366
  '</div>' +
298
367
  '<div class="login-body">' +
299
368
  '<p class="login-hint">输入 Wand 访问密码以进入控制台。</p>' +
300
- '<p class="login-tip">如果页面是通过 <strong>https://</strong> 打开的,请改用 <strong>http://</strong> 访问本地服务。</p>' +
301
369
  '<div class="field">' +
302
370
  '<label class="field-label" for="password">密码</label>' +
303
371
  '<div class="password-field">' +
@@ -324,62 +392,45 @@
324
392
  var composerMode = getSafeModeForTool(preferredTool, state.chatMode);
325
393
 
326
394
  return '<div class="app-container">' +
327
- '<header class="topbar">' +
328
- '<div class="topbar-left">' +
329
- '<button id="sessions-toggle-button" class="btn btn-secondary btn-sm sidebar-toggle-btn' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="Toggle sidebar">' +
330
- '<span class="hamburger-icon">' +
331
- '<span></span><span></span><span></span>' +
332
- '</span>' +
333
- '</button>' +
334
- '<div class="topbar-logo">' +
335
- '<div class="topbar-logo-icon">W</div>' +
336
- '</div>' +
337
- '</div>' +
338
- '<div class="topbar-center">' +
339
- '<div class="topbar-session-meta">' +
340
- '<span class="topbar-title" id="terminal-title">' + escapeHtml(terminalTitle) + '</span>' +
341
- '<span class="terminal-info topbar-terminal-info" id="terminal-info">' + (selectedSession ? getSessionStatusLabel(selectedSession) : '开始对话') + '</span>' +
342
- '</div>' +
343
- '<span class="current-task hidden" id="current-task"></span>' +
344
- '<span class="permission-actions hidden" id="permission-actions"><button id="approve-permission-btn" class="btn btn-primary btn-small" type="button">批准</button><button id="deny-permission-btn" class="btn btn-ghost btn-small" type="button">拒绝</button></span>' +
345
- '</div>' +
346
- '<div class="topbar-right">' +
347
- '<button id="file-panel-toggle-btn" class="topbar-btn square' + (state.filePanelOpen ? " active" : "") + '" type="button" title="查看文件">📁</button>' +
348
- '<button id="topbar-new-session-button" class="topbar-new-btn" title="新对话">' +
349
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
350
- '新对话' +
351
- '</button>' +
352
- '<button id="terminal-interactive-toggle-top" class="topbar-btn' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨ ' + (state.terminalInteractive ? '交互开' : '交互关') + '</button>' +
353
- '<button id="pwa-install-button" class="topbar-btn square hidden" title="安装应用">' +
354
- '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>' +
355
- '</button>' +
356
- '<button id="logout-button" class="topbar-btn square" title="退出登录">' +
357
- '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>' +
358
- '</button>' +
359
- '</div>' +
360
- '</header>' +
395
+ '<button id="sessions-toggle-button" class="floating-sidebar-toggle' + (state.sessionsDrawerOpen ? ' active' : '') + '" aria-label="Toggle sidebar">' +
396
+ '<span class="hamburger-icon">' +
397
+ '<span></span><span></span><span></span>' +
398
+ '</span>' +
399
+ '</button>' +
361
400
  '<div id="sessions-drawer-backdrop" class="drawer-backdrop' + drawerClass + '"></div>' +
362
401
  '<div class="main-layout' + (state.sessionsDrawerOpen ? ' sidebar-open' : '') + '">' +
363
402
  '<aside id="sessions-drawer" class="sidebar' + drawerClass + '">' +
364
403
  '<div class="sidebar-header">' +
365
404
  '<div class="sidebar-header-main">' +
405
+ '<div class="topbar-logo-icon">W</div>' +
366
406
  '<span class="sidebar-title">会话</span>' +
367
407
  '<span class="session-count" id="session-count">' + String(state.sessions.length) + '</span>' +
368
408
  '</div>' +
369
- '<button id="close-drawer-button" class="btn btn-ghost btn-sm sidebar-close" type="button" aria-label="关闭菜单">×</button>' +
409
+ '<div class="sidebar-header-actions">' +
410
+ '<button id="close-drawer-button" class="btn btn-ghost btn-sm sidebar-close" type="button" aria-label="关闭菜单">×</button>' +
411
+ '</div>' +
370
412
  '</div>' +
371
413
  '<div class="sidebar-body">' +
372
414
  '<div id="sessions-panel">' +
373
- '<p class="sidebar-intro">最近的会话记录会显示在这里</p>' +
374
415
  '<div class="sessions-list" id="sessions-list">' + renderSessions() + '</div>' +
375
416
  '</div>' +
376
417
  '</div>' +
377
418
  '<div class="sidebar-footer">' +
378
419
  '<button id="drawer-new-session-button" class="btn btn-primary btn-block"><span>+</span> 新会话</button>' +
420
+ '<div class="sidebar-footer-actions">' +
421
+ '<button id="file-panel-toggle-btn" class="btn btn-ghost btn-sm' + (state.filePanelOpen ? " active" : "") + '" type="button" title="查看文件">📁 文件</button>' +
422
+ '<button id="pwa-install-button" class="btn btn-ghost btn-sm hidden" title="安装应用">' +
423
+ '<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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> 安装' +
424
+ '</button>' +
425
+ '<button id="logout-button" class="btn btn-ghost btn-sm sidebar-logout" type="button" title="退出登录">' +
426
+ '<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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> 退出' +
427
+ '</button>' +
428
+ '</div>' +
379
429
  '</div>' +
380
430
  '</aside>' +
381
431
  '<main class="main-content">' +
382
- '<div class="topbar-spacer"></div>' +
432
+ '<span class="current-task hidden" id="current-task"></span>' +
433
+ '' +
383
434
  // File panel backdrop (mobile)
384
435
  '<div id="file-panel-backdrop" class="file-panel-backdrop' + (state.filePanelOpen ? " open" : "") + '"></div>' +
385
436
  // File side panel
@@ -405,7 +456,10 @@
405
456
  '<button id="terminal-scale-down-top" class="terminal-scale-overlay-btn terminal-scale-btn" type="button" title="缩小">−</button>' +
406
457
  '<span class="terminal-scale-overlay-label terminal-scale-label" id="terminal-scale-label-top">' + Math.round(state.terminalScale * 100) + '%</span>' +
407
458
  '<button id="terminal-scale-up-top" class="terminal-scale-overlay-btn terminal-scale-btn" type="button" title="放大">+</button>' +
459
+ '<span class="terminal-scale-overlay-divider"></span>' +
460
+ '<button id="page-refresh-btn" class="terminal-scale-overlay-btn" type="button" title="刷新页面">↻</button>' +
408
461
  '</div>' +
462
+ '<button id="terminal-jump-bottom" class="terminal-jump-bottom' + (state.showTerminalJumpToBottom ? ' visible' : '') + '" type="button" title="回到底部">↓ 最新</button>' +
409
463
  '</div>' +
410
464
  '<div id="chat-output" class="chat-container hidden"></div>' +
411
465
  '<div id="blank-chat" class="blank-chat' + (state.selectedId ? " hidden" : "") + '">' +
@@ -423,7 +477,6 @@
423
477
  '</div>' +
424
478
  '</div>' +
425
479
  '</div>' +
426
- '<div id="chat-output" class="chat-container hidden"></div>' +
427
480
  '<div class="input-panel' + (state.selectedId ? "" : " hidden") + '">' +
428
481
  '<div id="todo-progress" class="todo-progress hidden">' +
429
482
  '<div class="todo-progress-header" id="todo-progress-toggle">' +
@@ -446,16 +499,23 @@
446
499
  '<select id="chat-mode-select" class="chat-mode-select" title="仅对新建会话生效">' +
447
500
  renderModeOptions(preferredTool, composerMode) +
448
501
  '</select>' +
502
+ '<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
503
+ '<span class="permission-actions hidden" id="permission-actions">' +
504
+ '<span class="permission-actions-divider"></span>' +
505
+ '<span class="permission-actions-label" id="permission-actions-label">等待授权</span>' +
506
+ '<button id="approve-permission-btn" class="btn btn-permission btn-permission-approve" type="button">批准</button>' +
507
+ '<button id="deny-permission-btn" class="btn btn-permission btn-permission-deny" type="button">拒绝</button>' +
508
+ '</span>' +
449
509
  '</div>' +
450
510
  '<div class="input-composer-right">' +
451
511
  '<span id="queue-counter" class="queue-counter hidden">队列: 0</span>' +
452
512
  '<span class="input-hint' + (state.terminalInteractive ? ' terminal-interactive-hint' : state.currentView === "terminal" ? " hidden" : "") + '">' + (state.terminalInteractive ? '终端交互中 · Ctrl+C 中断 · Ctrl+L 清屏' : 'Enter 发送 · Shift+Enter 换行') + '</span>' +
453
513
  renderInlineKeyboard() +
454
514
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
455
- '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="3" width="10" height="10" rx="2"/></svg>' +
515
+ '<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><rect x="3" y="3" width="10" height="10" rx="2"/></svg>' +
456
516
  '</button>' +
457
517
  '<button id="send-input-button" class="btn-circle btn-circle-send" title="发送">' +
458
- '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
518
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
459
519
  '</button>' +
460
520
  '</div>' +
461
521
  '</div>' +
@@ -466,6 +526,7 @@
466
526
  '<span id="session-mode-display" class="session-mode-display">' + (selectedSession ? getModeLabel(selectedSession.mode) : '默认') + '</span>' +
467
527
  '<span class="session-info-separator">|</span>' +
468
528
  '<span id="session-status-display" class="session-status-display">' + (selectedSession ? getSessionStatusLabel(selectedSession) : '-') + '</span>' +
529
+ (selectedSession && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
469
530
  '<span class="session-info-separator">|</span>' +
470
531
  '<span id="session-exit-display" class="session-exit-display">exit=' + (selectedSession && selectedSession.exitCode !== undefined ? selectedSession.exitCode : 'n/a') + '</span>' +
471
532
  '</div>' +
@@ -524,28 +585,357 @@
524
585
  }
525
586
 
526
587
  function renderSessions() {
527
- if (state.sessions.length === 0) {
528
- return '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>';
529
- }
530
588
  var activeSessions = state.sessions.filter(function(session) { return !session.archived; });
531
589
  var archivedSessions = state.sessions.filter(function(session) { return session.archived; });
532
590
  var groups = [];
533
- if (activeSessions.length > 0) {
534
- groups.push(renderSessionGroup("最近", activeSessions));
591
+ groups.push(renderSessionManageBar());
592
+
593
+ // Split claude history into recent (24h) and older
594
+ var recentHistorySessions = [];
595
+ if (state.claudeHistoryLoaded) {
596
+ var cutoff = Date.now() - 24 * 60 * 60 * 1000;
597
+ recentHistorySessions = getVisibleClaudeHistorySessions().filter(function(s) {
598
+ return s.timestamp && new Date(s.timestamp).getTime() > cutoff;
599
+ });
600
+ }
601
+
602
+ if (activeSessions.length > 0 || recentHistorySessions.length > 0) {
603
+ groups.push(renderRecentGroup(activeSessions, recentHistorySessions));
535
604
  }
536
605
  if (archivedSessions.length > 0) {
537
- groups.push(renderSessionGroup("已归档", archivedSessions));
606
+ groups.push(renderSessionGroup("已归档", archivedSessions, "sessions"));
607
+ }
608
+ groups.push(renderClaudeHistorySection());
609
+ if (activeSessions.length === 0 && archivedSessions.length === 0 && recentHistorySessions.length === 0) {
610
+ return renderSessionManageBar() + '<div class="empty-state"><strong>还没有会话记录</strong><br>点击上方「新对话」开始你的第一次对话。</div>' + renderClaudeHistorySection();
538
611
  }
539
612
  return groups.join("");
540
613
  }
541
614
 
542
- function renderSessionGroup(title, sessions) {
615
+ function renderSessionManageBar() {
616
+ if (!state.sessionsManageMode) {
617
+ return '<div class="session-manage-bar">' +
618
+ '<span class="sidebar-intro">最近的会话记录</span>' +
619
+ '<button class="session-manage-toggle" data-action="toggle-manage-mode" type="button">管理</button>' +
620
+ '</div>';
621
+ }
622
+
623
+ var sessionCount = getSelectedSessionIds().length;
624
+ var historyCount = getSelectedClaudeHistoryIds().length;
625
+ var totalCount = sessionCount + historyCount;
626
+ var hasAny = totalCount > 0;
627
+
628
+ return '<div class="session-manage-bar active">' +
629
+ '<div class="session-manage-summary">已选择 ' + totalCount + ' 项</div>' +
630
+ '<div class="session-manage-actions">' +
631
+ '<button class="session-manage-btn" data-action="select-all-visible" type="button">全选</button>' +
632
+ '<button class="session-manage-btn" data-action="clear-selection" type="button">清空</button>' +
633
+ '<button class="session-manage-btn danger" data-action="delete-selected" type="button"' + (hasAny ? '' : ' disabled') + '>删除所选</button>' +
634
+ '<button class="session-manage-btn" data-action="toggle-manage-mode" type="button">完成</button>' +
635
+ '</div>' +
636
+ '</div>';
637
+ }
638
+
639
+ function renderSessionGroup(title, sessions, kind) {
543
640
  return '<section class="session-group">' +
544
641
  '<div class="session-group-title">' + escapeHtml(title) + '</div>' +
545
- sessions.map(renderSessionItem).join("") +
642
+ sessions.map(function(session) { return renderSessionItem(session, kind); }).join("") +
546
643
  '</section>';
547
644
  }
548
645
 
646
+ function renderRecentGroup(activeSessions, recentHistorySessions) {
647
+ var html = '<section class="session-group">' +
648
+ '<div class="session-group-title">最近</div>';
649
+ html += activeSessions.map(function(session) { return renderSessionItem(session, "sessions"); }).join("");
650
+ html += recentHistorySessions.map(function(session) { return renderClaudeHistoryItem(session, "history"); }).join("");
651
+ html += '</section>';
652
+ return html;
653
+ }
654
+
655
+ function renderClaudeHistorySection() {
656
+ // Exclude recent 24h items from history section
657
+ var cutoff = Date.now() - 24 * 60 * 60 * 1000;
658
+ var visibleHistory = getVisibleClaudeHistorySessions().filter(function(s) {
659
+ return !s.timestamp || new Date(s.timestamp).getTime() <= cutoff;
660
+ });
661
+ var chevron = state.claudeHistoryExpanded ? "&#9662;" : "&#9656;";
662
+ var countBadge = state.claudeHistoryLoaded && visibleHistory.length > 0
663
+ ? ' <span class="history-count">' + visibleHistory.length + '</span>'
664
+ : '';
665
+ var clearAllButton = state.claudeHistoryExpanded && state.claudeHistoryLoaded && visibleHistory.length > 0
666
+ ? '<button class="session-manage-btn danger compact" data-action="clear-all-history" type="button">清空历史</button>'
667
+ : '';
668
+ var header = '<div class="session-group-title claude-history-toggle" id="claude-history-toggle">' +
669
+ '<span class="chevron">' + chevron + '</span> Claude 历史' + countBadge +
670
+ '</div>' + clearAllButton;
671
+
672
+ if (!state.claudeHistoryExpanded) {
673
+ return '<section class="session-group">' + header + '</section>';
674
+ }
675
+
676
+ if (!state.claudeHistoryLoaded) {
677
+ return '<section class="session-group">' + header +
678
+ '<div class="claude-history-loading">扫描历史会话中…</div></section>';
679
+ }
680
+
681
+ if (visibleHistory.length === 0) {
682
+ return '<section class="session-group">' + header +
683
+ '<div class="claude-history-loading">没有更早的 Claude 历史会话</div></section>';
684
+ }
685
+
686
+ var groups = {};
687
+ var groupOrder = [];
688
+ visibleHistory.forEach(function(s) {
689
+ if (!groups[s.cwd]) {
690
+ groups[s.cwd] = [];
691
+ groupOrder.push(s.cwd);
692
+ }
693
+ groups[s.cwd].push(s);
694
+ });
695
+
696
+ var html = '';
697
+ groupOrder.forEach(function(cwd) {
698
+ var cwdShort = cwd.split("/").filter(Boolean).slice(-3).join("/");
699
+ var isDirExpanded = !!state.claudeHistoryExpandedDirs[cwd];
700
+ html += renderClaudeHistoryDirectoryHeader(cwd, cwdShort, groups[cwd].length, isDirExpanded);
701
+ if (isDirExpanded) {
702
+ html += groups[cwd].map(function(session) { return renderClaudeHistoryItem(session, "history"); }).join("");
703
+ }
704
+ });
705
+
706
+ return '<section class="session-group">' + header + html + '</section>';
707
+ }
708
+
709
+ function getVisibleClaudeHistorySessions() {
710
+ return state.claudeHistory.filter(function(s) {
711
+ return s.hasConversation && !s.managedByWand;
712
+ });
713
+ }
714
+
715
+ function getSelectedSessionIds() {
716
+ return Object.keys(state.selectedSessionIds).filter(function(id) { return !!state.selectedSessionIds[id]; });
717
+ }
718
+
719
+ function getSelectedClaudeHistoryIds() {
720
+ return Object.keys(state.selectedClaudeHistoryIds).filter(function(id) { return !!state.selectedClaudeHistoryIds[id]; });
721
+ }
722
+
723
+ function clearManageSelections() {
724
+ state.selectedSessionIds = {};
725
+ state.selectedClaudeHistoryIds = {};
726
+ }
727
+
728
+ function toggleManageMode(force) {
729
+ state.sessionsManageMode = typeof force === "boolean" ? force : !state.sessionsManageMode;
730
+ if (!state.sessionsManageMode) {
731
+ clearManageSelections();
732
+ closeSwipedItem();
733
+ }
734
+ updateSessionsList();
735
+ }
736
+
737
+ function selectAllVisibleItems() {
738
+ var nextSessionIds = {};
739
+ state.sessions.forEach(function(session) {
740
+ nextSessionIds[session.id] = true;
741
+ });
742
+ var cutoff = Date.now() - 24 * 60 * 60 * 1000;
743
+ var nextHistoryIds = {};
744
+ getVisibleClaudeHistorySessions().filter(function(session) {
745
+ return !session.timestamp || new Date(session.timestamp).getTime() <= cutoff;
746
+ }).forEach(function(session) {
747
+ nextHistoryIds[session.claudeSessionId] = true;
748
+ });
749
+ state.selectedSessionIds = nextSessionIds;
750
+ state.selectedClaudeHistoryIds = nextHistoryIds;
751
+ updateSessionsList();
752
+ }
753
+
754
+ function clearSelections() {
755
+ clearManageSelections();
756
+ updateSessionsList();
757
+ }
758
+
759
+ function toggleManagedItemSelection(kind, id) {
760
+ if (!state.sessionsManageMode || !id) return;
761
+ var target = kind === "history" ? state.selectedClaudeHistoryIds : state.selectedSessionIds;
762
+ if (target[id]) {
763
+ delete target[id];
764
+ } else {
765
+ target[id] = true;
766
+ }
767
+ updateSessionsList();
768
+ }
769
+
770
+ function renderManageCheckbox(kind, id, label) {
771
+ if (!state.sessionsManageMode) return '';
772
+ var selected = kind === "history" ? !!state.selectedClaudeHistoryIds[id] : !!state.selectedSessionIds[id];
773
+ return '<label class="session-manage-check">' +
774
+ '<input type="checkbox" data-action="toggle-selection" data-kind="' + escapeHtml(kind) + '" data-id="' + escapeHtml(id) + '"' + (selected ? ' checked' : '') + ' aria-label="' + escapeHtml(label) + '">' +
775
+ '<span></span>' +
776
+ '</label>';
777
+ }
778
+
779
+ function confirmDelete(message) {
780
+ return window.confirm(message);
781
+ }
782
+
783
+ function batchDeleteSelected() {
784
+ var sessionIds = getSelectedSessionIds();
785
+ var historyIds = getSelectedClaudeHistoryIds();
786
+ var total = sessionIds.length + historyIds.length;
787
+ if (!total) return;
788
+ if (!confirmDelete('确认删除所选 ' + total + ' 项吗?')) {
789
+ return;
790
+ }
791
+
792
+ var requests = [];
793
+ if (sessionIds.length > 0) {
794
+ requests.push(fetch('/api/sessions/batch-delete', {
795
+ method: 'POST',
796
+ headers: { 'Content-Type': 'application/json' },
797
+ credentials: 'same-origin',
798
+ body: JSON.stringify({ sessionIds: sessionIds })
799
+ }).then(function(res) { return res.json(); }));
800
+ }
801
+ if (historyIds.length > 0) {
802
+ requests.push(fetch('/api/claude-history/batch-delete', {
803
+ method: 'POST',
804
+ headers: { 'Content-Type': 'application/json' },
805
+ credentials: 'same-origin',
806
+ body: JSON.stringify({ claudeSessionIds: historyIds })
807
+ }).then(function(res) { return res.json(); }));
808
+ }
809
+
810
+ Promise.all(requests)
811
+ .then(function() {
812
+ if (sessionIds.indexOf(state.selectedId) !== -1) {
813
+ state.selectedId = null;
814
+ persistSelectedId();
815
+ }
816
+ state.claudeHistory = state.claudeHistory.filter(function(session) {
817
+ return historyIds.indexOf(session.claudeSessionId) === -1;
818
+ });
819
+ clearManageSelections();
820
+ return refreshAll();
821
+ })
822
+ .catch(function() {
823
+ var errorEl = document.getElementById('action-error');
824
+ showError(errorEl, '无法批量删除所选项目。');
825
+ });
826
+ }
827
+
828
+ function clearAllClaudeHistory() {
829
+ var cutoff = Date.now() - 24 * 60 * 60 * 1000;
830
+ var visibleHistory = getVisibleClaudeHistorySessions().filter(function(s) {
831
+ return !s.timestamp || new Date(s.timestamp).getTime() <= cutoff;
832
+ });
833
+ if (!visibleHistory.length) return;
834
+ if (!confirmDelete('确认清空当前显示的 ' + visibleHistory.length + ' 条 Claude 历史吗?')) {
835
+ return;
836
+ }
837
+ var deleteIds = visibleHistory.map(function(session) { return session.claudeSessionId; });
838
+ return fetch('/api/claude-history/batch-delete', {
839
+ method: 'POST',
840
+ headers: { 'Content-Type': 'application/json' },
841
+ credentials: 'same-origin',
842
+ body: JSON.stringify({ claudeSessionIds: deleteIds })
843
+ })
844
+ .then(function(res) { return res.json(); })
845
+ .then(function(data) {
846
+ if (data && data.error) {
847
+ throw new Error(data.error);
848
+ }
849
+ state.claudeHistory = state.claudeHistory.filter(function(s) {
850
+ return deleteIds.indexOf(s.claudeSessionId) === -1;
851
+ });
852
+ clearManageSelections();
853
+ updateSessionsList();
854
+ })
855
+ .catch(function() {
856
+ var errorEl = document.getElementById('action-error');
857
+ showError(errorEl, '无法清空历史会话。');
858
+ });
859
+ }
860
+
861
+ function renderClaudeHistoryDirectoryHeader(cwd, cwdShort, count, isExpanded) {
862
+ var chevron = isExpanded ? "&#9662;" : "&#9656;";
863
+ return '<div class="claude-history-directory-header" data-action="toggle-history-directory" data-cwd="' + escapeHtml(cwd) + '" role="button" tabindex="0">' +
864
+ '<div class="session-group-title claude-history-directory-title">' +
865
+ '<span class="chevron">' + chevron + '</span>' +
866
+ '<span class="claude-history-directory-label">' + escapeHtml(cwdShort) + ' (' + count + ')</span>' +
867
+ '<button class="session-manage-btn danger compact claude-history-directory-clear-btn" data-action="delete-history-directory" data-cwd="' +
868
+ escapeHtml(cwd) + '" type="button" aria-label="清空此目录的历史会话" title="清空此目录的历史会话">清空此目录</button>' +
869
+ '</div>' +
870
+ '</div>';
871
+ }
872
+
873
+ function renderClaudeHistoryItem(session, kind) {
874
+ var shortId = session.claudeSessionId.slice(0, 8);
875
+ var preview = session.firstUserMessage || "(空会话)";
876
+ var timeStr = formatHistoryTime(session.timestamp);
877
+ var checkbox = renderManageCheckbox(kind, session.claudeSessionId, "选择历史会话 " + preview);
878
+ var deleteButton = state.sessionsManageMode ? '' :
879
+ '<button class="session-action-btn delete-btn" data-action="delete-history" data-claude-session-id="' +
880
+ session.claudeSessionId + '" 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>';
881
+ var resumeButton = state.sessionsManageMode ? '' :
882
+ '<button class="session-action-btn" data-action="resume-history" data-claude-session-id="' +
883
+ session.claudeSessionId + '" data-cwd="' + escapeHtml(session.cwd) +
884
+ '" 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>';
885
+
886
+ return '<div class="session-item claude-history-item' + (state.sessionsManageMode && state.selectedClaudeHistoryIds[session.claudeSessionId] ? ' selected' : '') + '" data-claude-history-id="' + session.claudeSessionId + '" data-cwd="' + escapeHtml(session.cwd) + '" role="button" tabindex="0">' +
887
+ '<div class="session-item-content">' +
888
+ '<div class="session-item-row">' +
889
+ checkbox +
890
+ '<div class="session-main">' +
891
+ '<div class="session-command claude-history-preview">' + escapeHtml(preview) + '</div>' +
892
+ '<div class="session-meta">' +
893
+ '<span class="session-id" title="' + escapeHtml(session.claudeSessionId) + '">' + escapeHtml(shortId) + '</span>' +
894
+ '<span>' + escapeHtml(timeStr) + '</span>' +
895
+ '</div>' +
896
+ '</div>' +
897
+ '<span class="session-actions">' + resumeButton + deleteButton + '</span>' +
898
+ '</div>' +
899
+ '</div>' +
900
+ '</div>';
901
+ }
902
+ function formatHistoryTime(isoStr) {
903
+ if (!isoStr) return "";
904
+ try {
905
+ var d = new Date(isoStr);
906
+ var now = new Date();
907
+ var diffMs = now - d;
908
+ var diffMin = Math.floor(diffMs / 60000);
909
+ if (diffMin < 1) return "刚刚";
910
+ if (diffMin < 60) return diffMin + " 分钟前";
911
+ var diffHr = Math.floor(diffMin / 60);
912
+ if (diffHr < 24) return diffHr + " 小时前";
913
+ var diffDay = Math.floor(diffHr / 24);
914
+ if (diffDay < 30) return diffDay + " 天前";
915
+ return d.toLocaleDateString();
916
+ } catch (e) {
917
+ return "";
918
+ }
919
+ }
920
+
921
+ function loadClaudeHistory() {
922
+ return fetch("/api/claude-history", { credentials: "same-origin" })
923
+ .then(function(res) {
924
+ if (!res.ok) return [];
925
+ return res.json();
926
+ })
927
+ .then(function(sessions) {
928
+ state.claudeHistory = sessions || [];
929
+ state.claudeHistoryLoaded = true;
930
+ updateSessionsList();
931
+ })
932
+ .catch(function() {
933
+ state.claudeHistoryLoaded = true;
934
+ state.claudeHistory = [];
935
+ updateSessionsList();
936
+ });
937
+ }
938
+
549
939
  function isMobileLayout() {
550
940
  return window.innerWidth <= 768;
551
941
  }
@@ -1059,37 +1449,52 @@
1059
1449
 
1060
1450
  function renderSessionItem(session) {
1061
1451
  var activeClass = session.id === state.selectedId ? " active" : "";
1452
+ var selectedClass = state.sessionsManageMode && state.selectedSessionIds[session.id] ? " selected" : "";
1062
1453
  var metaStatus = getSessionStatusLabel(session);
1063
1454
  var metaStatusClass = getSessionStatusClass(session);
1064
1455
  var modeName = session.mode === "full-access" ? "全权限" : session.mode === "default" ? "默认" : session.mode === "native" ? "原生" : session.mode === "auto-edit" ? "自动编辑" : session.mode;
1065
- var deleteButton = '<button class="btn btn-ghost btn-sm session-action-btn" data-action="delete" data-session-id="' + session.id + '" type="button" aria-label="删除会话">×</button>';
1066
1456
  var resumeButton = "";
1067
1457
  var sessionIdDisplay = "";
1458
+ var recoveryHint = "";
1459
+ var checkbox = renderManageCheckbox("sessions", session.id, "选择会话 " + session.command);
1068
1460
 
1069
- // 如果有 Claude 会话 ID,显示恢复按钮
1070
1461
  if (session.claudeSessionId) {
1071
1462
  var shortId = session.claudeSessionId.slice(0, 8);
1072
1463
  sessionIdDisplay = '<span class="session-id" title="' + escapeHtml(session.claudeSessionId) + '">' + escapeHtml(shortId) + '</span>';
1073
- if (session.status !== "running") {
1074
- resumeButton = '<button class="btn btn-secondary btn-sm session-action-btn" data-action="resume" data-claude-session-id="' + escapeHtml(session.claudeSessionId) + '" data-cwd="' + escapeHtml(session.cwd) + '" type="button" aria-label="恢复会话" title="恢复 Claude 会话">↻</button>';
1464
+ if (session.status !== "running" && !state.sessionsManageMode) {
1465
+ 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>';
1075
1466
  }
1076
1467
  }
1077
1468
 
1078
- return '<div class="session-item' + activeClass + '" data-session-id="' + session.id + '" role="button" tabindex="0">' +
1079
- '<div class="session-item-row">' +
1080
- '<div class="session-main">' +
1081
- '<div class="session-command">' + escapeHtml(session.command) + '</div>' +
1082
- '<div class="session-meta">' +
1083
- '<span>' + escapeHtml(modeName) + '</span>' +
1084
- '<span class="session-status ' + metaStatusClass + '">' + escapeHtml(metaStatus) + '</span>' +
1085
- sessionIdDisplay +
1469
+ if (session.autoRecovered) {
1470
+ recoveryHint = '<span class="session-id" title="自动恢复的会话">自动恢复</span>';
1471
+ } else if (session.resumedToSessionId) {
1472
+ recoveryHint = '<span class="session-id" title="已恢复到新会话">已恢复</span>';
1473
+ } else if (session.resumedFromSessionId) {
1474
+ recoveryHint = '<span class="session-id" title="从旧会话恢复而来">续接</span>';
1475
+ }
1476
+
1477
+ 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>';
1478
+ var actionsHtml = '<span class="session-actions">' + resumeButton + deleteButton + '</span>';
1479
+
1480
+ return '<div class="session-item' + activeClass + selectedClass + '" data-session-id="' + session.id + '" role="button" tabindex="0">' +
1481
+ '<div class="session-item-content">' +
1482
+ '<div class="session-item-row">' +
1483
+ checkbox +
1484
+ '<div class="session-main">' +
1485
+ '<div class="session-command">' + escapeHtml(session.command) + '</div>' +
1486
+ '<div class="session-meta">' +
1487
+ '<span>' + escapeHtml(modeName) + '</span>' +
1488
+ '<span class="session-status ' + metaStatusClass + '">' + escapeHtml(metaStatus) + '</span>' +
1489
+ sessionIdDisplay +
1490
+ recoveryHint +
1491
+ '</div>' +
1086
1492
  '</div>' +
1493
+ actionsHtml +
1087
1494
  '</div>' +
1088
- '<span class="session-actions">' + resumeButton + deleteButton + '</span>' +
1089
1495
  '</div>' +
1090
1496
  '</div>';
1091
1497
  }
1092
-
1093
1498
  function renderModeCards(selectedMode) {
1094
1499
  var modes = [
1095
1500
  { id: "default", label: "标准", desc: "逐步确认操作" },
@@ -1120,6 +1525,13 @@
1120
1525
  '<button id="close-modal-button" class="btn btn-ghost btn-icon">&times;</button>' +
1121
1526
  '</div>' +
1122
1527
  '<div class="modal-body">' +
1528
+ '<div class="field">' +
1529
+ '<label class="field-label">模式</label>' +
1530
+ '<div id="mode-cards" class="mode-cards">' +
1531
+ renderModeCards(modalMode) +
1532
+ '</div>' +
1533
+ '<p id="mode-description" class="field-hint">' + escapeHtml(getToolModeHint(modalTool, modalMode)) + '</p>' +
1534
+ '</div>' +
1123
1535
  '<div class="field">' +
1124
1536
  '<label class="field-label" for="cwd">工作目录</label>' +
1125
1537
  '<div class="suggestions-wrap">' +
@@ -1127,13 +1539,7 @@
1127
1539
  '<div id="cwd-suggestions" class="suggestions hidden"></div>' +
1128
1540
  '</div>' +
1129
1541
  '<p class="field-hint">会话将在此目录启动,支持路径自动补全。</p>' +
1130
- '</div>' +
1131
- '<div class="field">' +
1132
- '<label class="field-label">模式</label>' +
1133
- '<div id="mode-cards" class="mode-cards">' +
1134
- renderModeCards(modalMode) +
1135
- '</div>' +
1136
- '<p id="mode-description" class="field-hint">' + escapeHtml(getToolModeHint(modalTool, modalMode)) + '</p>' +
1542
+ '<div id="recent-paths-bubbles" class="recent-paths-bubbles"></div>' +
1137
1543
  '</div>' +
1138
1544
  '<button id="run-button" class="btn btn-primary btn-block">启动会话</button>' +
1139
1545
  '<p id="modal-error" class="error-message hidden"></p>' +
@@ -1300,8 +1706,12 @@
1300
1706
  if (sessionsList) {
1301
1707
  sessionsList.addEventListener("click", handleSessionItemClick);
1302
1708
  sessionsList.addEventListener("keydown", handleSessionItemKeydown);
1709
+ initSwipeToDelete(sessionsList);
1303
1710
  }
1304
1711
 
1712
+ // Claude session ID badge click-to-copy (event delegation on document)
1713
+ document.addEventListener("click", handleClaudeIdCopy);
1714
+
1305
1715
  var modeCardsEl = document.getElementById("mode-cards");
1306
1716
  if (modeCardsEl) modeCardsEl.addEventListener("click", function(e) {
1307
1717
  var card = e.target.closest(".mode-card");
@@ -1375,7 +1785,7 @@
1375
1785
  inputBox.addEventListener("paste", handleInputPaste);
1376
1786
  inputBox.addEventListener("input", function() {
1377
1787
  refreshInputBoxState(inputBox);
1378
- setDraftValue(inputBox.value);
1788
+ setDraftValue(inputBox.value, true);
1379
1789
  });
1380
1790
  inputBox.addEventListener("focus", function() {
1381
1791
  // Close drawer when user focuses input to avoid backdrop blocking clicks
@@ -1394,21 +1804,34 @@
1394
1804
  var toggle = document.getElementById(id);
1395
1805
  if (toggle) toggle.addEventListener("click", toggleTerminalInteractive);
1396
1806
  });
1397
- // Keyboard popup handlers
1398
- var keyboardToggle = document.getElementById("keyboard-toggle");
1399
- if (keyboardToggle) keyboardToggle.addEventListener("click", handleKeyboardToggle);
1400
- var keyboardPopup = document.getElementById("keyboard-popup");
1401
- if (keyboardPopup) keyboardPopup.addEventListener("click", handleInlineKeyboardClick);
1402
- // Close popup when clicking outside
1403
- document.addEventListener("click", function(event) {
1404
- var toggle = document.getElementById("keyboard-toggle");
1405
- var popup = document.getElementById("keyboard-popup");
1406
- var target = event.target;
1407
- if (!popup || popup.classList.contains("hidden") || !target) return;
1408
- var clickedPopup = popup.contains(target);
1409
- var clickedToggle = !!toggle && toggle.contains(target);
1410
- if (!clickedPopup && !clickedToggle) {
1411
- closeKeyboardPopup();
1807
+ // Inline shortcuts click handler
1808
+ var inlineShortcutsWrap = document.querySelector(".inline-shortcuts-wrap");
1809
+ if (inlineShortcutsWrap) inlineShortcutsWrap.addEventListener("click", handleInlineKeyboardClick);
1810
+ // Shortcuts toggle (mobile fold/unfold)
1811
+ var shortcutsToggleBtn = document.querySelector(".shortcuts-toggle");
1812
+ if (shortcutsToggleBtn) shortcutsToggleBtn.addEventListener("click", function(e) {
1813
+ e.stopPropagation();
1814
+ state.shortcutsExpanded = !state.shortcutsExpanded;
1815
+ var wrap = document.querySelector(".inline-shortcuts-wrap");
1816
+ var toggle = document.querySelector(".shortcuts-toggle");
1817
+ if (wrap) wrap.classList.toggle("expanded", state.shortcutsExpanded);
1818
+ if (toggle) {
1819
+ toggle.classList.toggle("active", state.shortcutsExpanded);
1820
+ toggle.textContent = state.shortcutsExpanded ? "\u203a" : "\u2039";
1821
+ }
1822
+ });
1823
+ // Close shortcuts strip on outside click
1824
+ document.addEventListener("click", function(e) {
1825
+ if (!state.shortcutsExpanded) return;
1826
+ var wrap = document.querySelector(".inline-shortcuts-wrap");
1827
+ if (wrap && !wrap.contains(e.target)) {
1828
+ state.shortcutsExpanded = false;
1829
+ wrap.classList.remove("expanded");
1830
+ var toggle = document.querySelector(".shortcuts-toggle");
1831
+ if (toggle) {
1832
+ toggle.classList.remove("active");
1833
+ toggle.textContent = "\u2039";
1834
+ }
1412
1835
  }
1413
1836
  });
1414
1837
 
@@ -1441,6 +1864,12 @@
1441
1864
  var scaleUpBtn = document.getElementById("terminal-scale-up-top");
1442
1865
  if (scaleDownBtn) scaleDownBtn.addEventListener("click", function() { adjustTerminalScale(-0.25); });
1443
1866
  if (scaleUpBtn) scaleUpBtn.addEventListener("click", function() { adjustTerminalScale(0.25); });
1867
+ var pageRefreshBtn = document.getElementById("page-refresh-btn");
1868
+ if (pageRefreshBtn) pageRefreshBtn.addEventListener("click", function() { location.reload(); });
1869
+ var jumpBottomBtn = document.getElementById("terminal-jump-bottom");
1870
+ if (jumpBottomBtn) jumpBottomBtn.addEventListener("click", function() {
1871
+ maybeScrollTerminalToBottom("force");
1872
+ });
1444
1873
 
1445
1874
  // File explorer
1446
1875
  var fileRefresh = document.getElementById("file-explorer-refresh");
@@ -1874,34 +2303,289 @@
1874
2303
  function handleSessionItemClick(event) {
1875
2304
  var target = event.target;
1876
2305
  if (!target || !(target instanceof Element)) return;
2306
+
2307
+ var historyToggle = target.closest("#claude-history-toggle");
2308
+ if (historyToggle) {
2309
+ event.preventDefault();
2310
+ event.stopPropagation();
2311
+ state.claudeHistoryExpanded = !state.claudeHistoryExpanded;
2312
+ if (state.claudeHistoryExpanded && !state.claudeHistoryLoaded) {
2313
+ loadClaudeHistory();
2314
+ }
2315
+ updateSessionsList();
2316
+ return;
2317
+ }
2318
+
1877
2319
  var actionButton = target.closest("[data-action]");
1878
2320
  if (actionButton && actionButton instanceof HTMLElement) {
1879
2321
  event.preventDefault();
1880
2322
  event.stopPropagation();
1881
- if (actionButton.dataset.action === "delete" && actionButton.dataset.sessionId) {
1882
- deleteSession(actionButton.dataset.sessionId);
1883
- } else if (actionButton.dataset.action === "resume" && actionButton.dataset.claudeSessionId) {
1884
- startCommand("claude --resume " + actionButton.dataset.claudeSessionId, actionButton.dataset.cwd || "");
2323
+ if (actionButton.dataset.action === "toggle-manage-mode") {
2324
+ toggleManageMode();
2325
+ } else if (actionButton.dataset.action === "select-all-visible") {
2326
+ selectAllVisibleItems();
2327
+ } else if (actionButton.dataset.action === "clear-selection") {
2328
+ clearSelections();
2329
+ } else if (actionButton.dataset.action === "delete-selected") {
2330
+ batchDeleteSelected();
2331
+ } else if (actionButton.dataset.action === "toggle-selection") {
2332
+ toggleManagedItemSelection(actionButton.dataset.kind, actionButton.dataset.id);
2333
+ } else if (actionButton.dataset.action === "delete-session" && actionButton.dataset.sessionId) {
2334
+ if (confirmDelete("确认删除这个会话吗?")) {
2335
+ deleteSession(actionButton.dataset.sessionId);
2336
+ }
2337
+ } else if (actionButton.dataset.action === "delete-history" && actionButton.dataset.claudeSessionId) {
2338
+ if (confirmDelete("确认隐藏这条 Claude 历史吗?")) {
2339
+ executeDeleteHistory(actionButton.dataset.claudeSessionId, actionButton.closest(".session-item"));
2340
+ }
2341
+ } else if (actionButton.dataset.action === "toggle-history-directory" && actionButton.dataset.cwd) {
2342
+ var dirCwd = actionButton.dataset.cwd;
2343
+ state.claudeHistoryExpandedDirs[dirCwd] = !state.claudeHistoryExpandedDirs[dirCwd];
2344
+ updateSessionsList();
2345
+ } else if (actionButton.dataset.action === "delete-history-directory" && actionButton.dataset.cwd) {
2346
+ var deleteCwd = actionButton.dataset.cwd;
2347
+ var items = getHistoryItemsByCwd(deleteCwd);
2348
+ var dirCount = getVisibleClaudeHistorySessions().filter(function(s) { return s.cwd === deleteCwd; }).length;
2349
+ if (confirmDelete("确认清空此目录下的 " + dirCount + " 条 Claude 历史吗?")) {
2350
+ setDeletingState(items, true);
2351
+ deleteClaudeHistoryDirectory(deleteCwd, actionButton, items);
2352
+ }
2353
+ } else if (actionButton.dataset.action === "clear-all-history") {
2354
+ clearAllClaudeHistory();
2355
+ } else if (actionButton.dataset.action === "resume" && actionButton.dataset.sessionId) {
2356
+ handleResumeAction(actionButton);
2357
+ } else if (actionButton.dataset.action === "resume-history" && actionButton.dataset.claudeSessionId) {
2358
+ handleResumeHistoryAction(actionButton);
1885
2359
  }
1886
2360
  return;
1887
2361
  }
2362
+
1888
2363
  var item = target.closest(".session-item");
1889
- if (item && item.dataset.sessionId) {
1890
- selectSession(item.dataset.sessionId);
1891
- closeSessionsDrawer();
2364
+ if (item) {
2365
+ if (state.sessionsManageMode) {
2366
+ if (item.dataset.sessionId) {
2367
+ toggleManagedItemSelection("sessions", item.dataset.sessionId);
2368
+ } else if (item.dataset.claudeHistoryId) {
2369
+ toggleManagedItemSelection("history", item.dataset.claudeHistoryId);
2370
+ }
2371
+ return;
2372
+ }
2373
+ if (item.classList.contains("swiped")) {
2374
+ closeSwipedItem();
2375
+ return;
2376
+ }
2377
+ if (_swipeState) return;
2378
+ if (item.dataset.sessionId) {
2379
+ selectSession(item.dataset.sessionId);
2380
+ closeSessionsDrawer();
2381
+ }
1892
2382
  }
1893
2383
  }
1894
2384
 
1895
2385
  function handleSessionItemKeydown(event) {
1896
2386
  if (event.key !== "Enter" && event.key !== " ") return;
1897
2387
  var item = event.target.closest(".session-item");
1898
- if (item && item.dataset.sessionId) {
1899
- event.preventDefault();
2388
+ if (!item) return;
2389
+ event.preventDefault();
2390
+ if (state.sessionsManageMode) {
2391
+ if (item.dataset.sessionId) {
2392
+ toggleManagedItemSelection("sessions", item.dataset.sessionId);
2393
+ } else if (item.dataset.claudeHistoryId) {
2394
+ toggleManagedItemSelection("history", item.dataset.claudeHistoryId);
2395
+ }
2396
+ return;
2397
+ }
2398
+ if (item.dataset.sessionId) {
1900
2399
  selectSession(item.dataset.sessionId);
1901
2400
  closeSessionsDrawer();
1902
2401
  }
1903
2402
  }
1904
2403
 
2404
+ /** Copy Claude session ID from badge to clipboard */
2405
+ function handleClaudeIdCopy(event) {
2406
+ var badge = event.target.closest("#claude-session-id-badge");
2407
+ if (!badge) return;
2408
+ var fullId = badge.dataset.claudeId;
2409
+ if (!fullId) return;
2410
+ navigator.clipboard.writeText(fullId).then(function() {
2411
+ var original = badge.textContent;
2412
+ badge.textContent = "\u2713 已复制";
2413
+ badge.classList.add("copied");
2414
+ setTimeout(function() {
2415
+ badge.textContent = original;
2416
+ badge.classList.remove("copied");
2417
+ }, 1200);
2418
+ }).catch(function() {
2419
+ showToast("复制失败", "error");
2420
+ });
2421
+ }
2422
+
2423
+ function getTerminalViewport() {
2424
+ if (!state.terminal || !state.terminal.element) return null;
2425
+ if (state.terminalViewportEl && state.terminal.element.contains(state.terminalViewportEl)) {
2426
+ return state.terminalViewportEl;
2427
+ }
2428
+ state.terminalViewportEl = state.terminal.element.querySelector(".xterm-viewport");
2429
+ return state.terminalViewportEl;
2430
+ }
2431
+
2432
+ function clearTerminalScrollIdleTimer() {
2433
+ if (state.terminalScrollIdleTimer) {
2434
+ clearTimeout(state.terminalScrollIdleTimer);
2435
+ state.terminalScrollIdleTimer = null;
2436
+ }
2437
+ }
2438
+
2439
+ function updateTerminalJumpToBottomButton() {
2440
+ var button = document.getElementById("terminal-jump-bottom");
2441
+ var shouldShow = !!state.selectedId
2442
+ && state.currentView === "terminal"
2443
+ && !state.terminalAutoFollow
2444
+ && !isTerminalNearBottom();
2445
+ state.showTerminalJumpToBottom = shouldShow;
2446
+ if (button) {
2447
+ button.classList.toggle("visible", shouldShow);
2448
+ }
2449
+ }
2450
+
2451
+ function isTerminalNearBottom() {
2452
+ var viewport = getTerminalViewport();
2453
+ if (!viewport) return true;
2454
+ var distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop;
2455
+ return distance <= state.terminalScrollThreshold;
2456
+ }
2457
+
2458
+ function scrollTerminalToBottom(smooth) {
2459
+ if (!state.terminal) return;
2460
+ if (smooth) {
2461
+ var viewport = getTerminalViewport();
2462
+ if (viewport) {
2463
+ viewport.scrollTo({ top: viewport.scrollHeight, behavior: "smooth" });
2464
+ setTimeout(function() {
2465
+ if (state.terminal) state.terminal.scrollToBottom();
2466
+ }, 160);
2467
+ return;
2468
+ }
2469
+ }
2470
+ state.terminal.scrollToBottom();
2471
+ }
2472
+
2473
+ function scheduleTerminalResumeFollow() {
2474
+ clearTerminalScrollIdleTimer();
2475
+ updateTerminalJumpToBottomButton();
2476
+ state.terminalScrollIdleTimer = setTimeout(function() {
2477
+ state.terminalScrollIdleTimer = null;
2478
+ state.terminalAutoFollow = true;
2479
+ if (!isTerminalNearBottom()) {
2480
+ scrollTerminalToBottom(true);
2481
+ }
2482
+ updateTerminalJumpToBottomButton();
2483
+ }, state.terminalScrollIdleMs);
2484
+ }
2485
+
2486
+ function setTerminalManualScrollActive() {
2487
+ state.terminalAutoFollow = false;
2488
+ updateTerminalJumpToBottomButton();
2489
+ scheduleTerminalResumeFollow();
2490
+ }
2491
+
2492
+ function maybeScrollTerminalToBottom(reason) {
2493
+ if (!state.terminal) return;
2494
+ var force = reason === "force";
2495
+ if (force) {
2496
+ state.terminalAutoFollow = true;
2497
+ clearTerminalScrollIdleTimer();
2498
+ scrollTerminalToBottom(false);
2499
+ updateTerminalJumpToBottomButton();
2500
+ return;
2501
+ }
2502
+ if (!state.terminalAutoFollow && !isTerminalNearBottom()) {
2503
+ updateTerminalJumpToBottomButton();
2504
+ return;
2505
+ }
2506
+ state.terminalAutoFollow = true;
2507
+ scrollTerminalToBottom(false);
2508
+ updateTerminalJumpToBottomButton();
2509
+ }
2510
+
2511
+ function syncTerminalBuffer(sessionId, output, options) {
2512
+ if (!state.terminal) return false;
2513
+ var normalizedOutput = normalizeTerminalOutput(output || "");
2514
+ var nextSessionId = sessionId || null;
2515
+ var opts = options || {};
2516
+ var mode = opts.mode || "append";
2517
+ var shouldScroll = opts.scroll !== false;
2518
+ var sessionChanged = state.terminalSessionId !== nextSessionId;
2519
+ var currentOutput = state.terminalOutput || "";
2520
+ var wrote = false;
2521
+
2522
+ if (normalizedOutput === currentOutput && !sessionChanged) {
2523
+ if (shouldScroll) maybeScrollTerminalToBottom("output");
2524
+ updateTerminalJumpToBottomButton();
2525
+ return false;
2526
+ }
2527
+
2528
+ if (sessionChanged) {
2529
+ state.terminal.reset();
2530
+ currentOutput = "";
2531
+ state.terminalOutput = "";
2532
+ state.terminalAutoFollow = true;
2533
+ clearTerminalScrollIdleTimer();
2534
+ updateTerminalJumpToBottomButton();
2535
+ }
2536
+
2537
+ if (mode === "replace") {
2538
+ if (normalizedOutput !== currentOutput) {
2539
+ state.terminal.reset();
2540
+ if (normalizedOutput) {
2541
+ state.terminal.write(normalizedOutput);
2542
+ }
2543
+ wrote = true;
2544
+ }
2545
+ } else if (normalizedOutput.length < currentOutput.length && !sessionChanged) {
2546
+ // Ignore regressive snapshots for the active session; wait for an explicit replace.
2547
+ return false;
2548
+ } else if (normalizedOutput.startsWith(currentOutput)) {
2549
+ var delta = normalizedOutput.slice(currentOutput.length);
2550
+ if (delta) {
2551
+ state.terminal.write(delta);
2552
+ wrote = true;
2553
+ }
2554
+ } else if (currentOutput && currentOutput.startsWith(normalizedOutput)) {
2555
+ // Ignore shorter/stale snapshots from polling or reconnect races.
2556
+ return false;
2557
+ } else {
2558
+ state.terminal.reset();
2559
+ if (normalizedOutput) {
2560
+ state.terminal.write(normalizedOutput);
2561
+ }
2562
+ wrote = true;
2563
+ }
2564
+
2565
+ state.terminalSessionId = nextSessionId;
2566
+ state.terminalOutput = normalizedOutput;
2567
+ if (shouldScroll && (wrote || sessionChanged || mode === "replace")) {
2568
+ maybeScrollTerminalToBottom(sessionChanged || mode === "replace" ? "force" : "output");
2569
+ } else {
2570
+ updateTerminalJumpToBottomButton();
2571
+ }
2572
+ return wrote || sessionChanged;
2573
+ }
2574
+
2575
+ function shouldResizeTerminalViewport() {
2576
+ var output = document.getElementById("output");
2577
+ if (!output) return false;
2578
+ var rect = output.getBoundingClientRect();
2579
+ var width = Math.round(rect.width);
2580
+ var height = Math.round(rect.height);
2581
+ if (!width || !height) return false;
2582
+ if (state.terminalViewportSize.width === width && state.terminalViewportSize.height === height) {
2583
+ return false;
2584
+ }
2585
+ state.terminalViewportSize = { width: width, height: height };
2586
+ return true;
2587
+ }
2588
+
1905
2589
  function initTerminal() {
1906
2590
  var container = document.getElementById("output");
1907
2591
  if (!container || state.terminal) return;
@@ -1913,7 +2597,7 @@
1913
2597
  state.terminal = new Terminal({
1914
2598
  cols: 120,
1915
2599
  rows: 36,
1916
- convertEol: false,
2600
+ convertEol: true,
1917
2601
  disableStdin: false,
1918
2602
  cursorBlink: false,
1919
2603
  fontFamily: '"Geist Mono", "SF Mono", monospace',
@@ -1946,19 +2630,62 @@
1946
2630
  }
1947
2631
  });
1948
2632
 
1949
- state.fitAddon = new FitAddon.FitAddon();
1950
- state.terminal.loadAddon(state.fitAddon);
2633
+ var fitAddonConstructor =
2634
+ typeof FitAddon !== "undefined" && FitAddon && typeof FitAddon.FitAddon === "function"
2635
+ ? FitAddon.FitAddon
2636
+ : null;
2637
+ state.fitAddon = fitAddonConstructor ? new fitAddonConstructor() : null;
2638
+ if (state.fitAddon) {
2639
+ state.terminal.loadAddon(state.fitAddon);
2640
+ } else {
2641
+ console.error("[wand] xterm fit addon failed to load; continuing without fit support.");
2642
+ }
1951
2643
 
1952
2644
  state.terminal.open(container);
1953
2645
  applyTerminalScale();
1954
- state.fitAddon.fit();
2646
+ state.terminalViewportSize = { width: 0, height: 0 };
2647
+ state.terminalAutoFollow = true;
2648
+ clearTerminalScrollIdleTimer();
2649
+ // Double-rAF: wait for browser to complete layout before measuring and fitting
2650
+ if (state.fitAddon) {
2651
+ requestAnimationFrame(function() {
2652
+ requestAnimationFrame(function() {
2653
+ if (state.fitAddon && shouldResizeTerminalViewport()) {
2654
+ state.fitAddon.fit();
2655
+ }
2656
+ });
2657
+ });
2658
+ }
2659
+
2660
+ var viewport = getTerminalViewport();
2661
+ if (viewport) {
2662
+ state.terminalViewportScrollHandler = function() {
2663
+ if (isTerminalNearBottom()) {
2664
+ state.terminalAutoFollow = true;
2665
+ clearTerminalScrollIdleTimer();
2666
+ updateTerminalJumpToBottomButton();
2667
+ return;
2668
+ }
2669
+ setTerminalManualScrollActive();
2670
+ };
2671
+ state.terminalViewportTouchHandler = function() {
2672
+ setTerminalManualScrollActive();
2673
+ };
2674
+ viewport.addEventListener("scroll", state.terminalViewportScrollHandler, { passive: true });
2675
+ viewport.addEventListener("touchmove", state.terminalViewportTouchHandler, { passive: true });
2676
+ }
2677
+
2678
+ container.addEventListener('wheel', function(e) {
2679
+ if (!isTerminalNearBottom() || e.deltaY < 0) {
2680
+ setTerminalManualScrollActive();
2681
+ }
2682
+ e.stopPropagation();
2683
+ }, { passive: true });
1955
2684
 
1956
2685
  if (state.selectedId) {
1957
2686
  var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
1958
- if (session && session.output) {
1959
- var normalizedOutput = normalizeTerminalOutput(session.output);
1960
- state.terminal.write(normalizedOutput);
1961
- state.terminalOutput = normalizedOutput;
2687
+ if (session) {
2688
+ syncTerminalBuffer(session.id, session.output || "", { mode: "replace", scroll: false });
1962
2689
  }
1963
2690
  } else {
1964
2691
  state.terminal.writeln("点击上方「新对话」开始你的第一次对话。");
@@ -1969,13 +2696,8 @@
1969
2696
  queueDirectInput(data);
1970
2697
  });
1971
2698
 
1972
- // 鼠标滚轮支持 - 在终端容器上滚动
1973
- container.addEventListener('wheel', function(e) {
1974
- // 总是允许滚动,让 xterm 处理滚轮事件
1975
- e.stopPropagation();
1976
- }, { passive: true });
1977
-
1978
2699
  container.addEventListener("click", focusInputBox);
2700
+ updateTerminalJumpToBottomButton();
1979
2701
 
1980
2702
  // 初始化拖动调整大小
1981
2703
  initTerminalResizeHandle();
@@ -2051,14 +2773,16 @@
2051
2773
  state.selectedId = null;
2052
2774
  persistSelectedId();
2053
2775
  state.sessions = [];
2776
+ state.claudeHistory = [];
2777
+ state.claudeHistoryLoaded = false;
2778
+ state.claudeHistoryExpanded = true;
2779
+ state.claudeHistoryExpandedDirs = {};
2054
2780
  state.sessionsDrawerOpen = false;
2055
2781
  render();
2056
2782
  }
2057
2783
 
2058
2784
  function refreshAll() {
2059
- return loadSessions().then(function() {
2060
- if (state.selectedId) return loadOutput(state.selectedId);
2061
- });
2785
+ return loadSessions();
2062
2786
  }
2063
2787
 
2064
2788
  function getModeLabel(mode) {
@@ -2250,9 +2974,6 @@
2250
2974
  }
2251
2975
  }
2252
2976
  updateShellChrome();
2253
- if (state.selectedId) {
2254
- loadOutput(state.selectedId);
2255
- }
2256
2977
  });
2257
2978
  }
2258
2979
 
@@ -2273,6 +2994,7 @@
2273
2994
  closeKeyboardPopup();
2274
2995
  }
2275
2996
  var terminalTitle = selectedSession ? shortCommand(selectedSession.command) : "Wand";
2997
+ var terminalInfo = selectedSession ? getSessionStatusLabel(selectedSession) : "开始对话";
2276
2998
  var summaryEl = document.querySelector(".session-summary-value");
2277
2999
  var titleEl = document.getElementById("terminal-title");
2278
3000
  var infoEl = document.getElementById("terminal-info");
@@ -2281,10 +3003,10 @@
2281
3003
  var chatContainer = document.getElementById("chat-output");
2282
3004
  var stopBtn = document.getElementById("stop-button");
2283
3005
 
2284
- if (summaryEl) summaryEl.textContent = terminalTitle;
2285
- if (titleEl) titleEl.textContent = terminalTitle;
2286
- if (infoEl) {
2287
- infoEl.textContent = selectedSession ? getSessionStatusLabel(selectedSession) : "开始对话";
3006
+ if (summaryEl && summaryEl.textContent !== terminalTitle) summaryEl.textContent = terminalTitle;
3007
+ if (titleEl && titleEl.textContent !== terminalTitle) titleEl.textContent = terminalTitle;
3008
+ if (infoEl && infoEl.textContent !== terminalInfo) {
3009
+ infoEl.textContent = terminalInfo;
2288
3010
  }
2289
3011
 
2290
3012
  // Update session info bar at bottom
@@ -2292,10 +3014,34 @@
2292
3014
  var modeEl = document.getElementById("session-mode-display");
2293
3015
  var statusEl = document.getElementById("session-status-display");
2294
3016
  var exitEl = document.getElementById("session-exit-display");
2295
- if (cwdEl) cwdEl.textContent = selectedSession && selectedSession.cwd ? escapeHtml(selectedSession.cwd) : '未设置目录';
2296
- if (modeEl) modeEl.textContent = selectedSession ? getModeLabel(selectedSession.mode) : '默认';
2297
- if (statusEl) statusEl.textContent = selectedSession ? getSessionStatusLabel(selectedSession) : '-';
2298
- if (exitEl) exitEl.textContent = 'exit=' + (selectedSession && selectedSession.exitCode !== undefined ? selectedSession.exitCode : 'n/a');
3017
+ var cwdText = selectedSession && selectedSession.cwd ? selectedSession.cwd : "未设置目录";
3018
+ var modeText = selectedSession ? getModeLabel(selectedSession.mode) : "默认";
3019
+ var exitText = "exit=" + (selectedSession && selectedSession.exitCode !== undefined ? selectedSession.exitCode : "n/a");
3020
+ if (cwdEl && cwdEl.textContent !== cwdText) cwdEl.textContent = cwdText;
3021
+ if (modeEl && modeEl.textContent !== modeText) modeEl.textContent = modeText;
3022
+ if (statusEl && statusEl.textContent !== terminalInfo) statusEl.textContent = terminalInfo;
3023
+ if (exitEl && exitEl.textContent !== exitText) exitEl.textContent = exitText;
3024
+
3025
+ if (!state.terminal && terminalContainer && selectedSession) {
3026
+ initTerminal();
3027
+ }
3028
+ if (state.terminal && terminalContainer && !terminalContainer.contains(state.terminal.element)) {
3029
+ state.terminal.open(terminalContainer);
3030
+ applyTerminalScale();
3031
+ state.terminalViewportSize = { width: 0, height: 0 };
3032
+ scheduleTerminalResize();
3033
+ }
3034
+
3035
+ if (selectedSession && state.terminal) {
3036
+ syncTerminalBuffer(selectedSession.id, selectedSession.output || "", { mode: "replace" });
3037
+ } else if (!selectedSession) {
3038
+ state.terminalSessionId = null;
3039
+ state.terminalOutput = "";
3040
+ }
3041
+
3042
+ if (state.terminal && selectedSession && state.currentView === "terminal") {
3043
+ maybeScrollTerminalToBottom("view");
3044
+ }
2299
3045
 
2300
3046
  var inputPanel = document.querySelector(".input-panel");
2301
3047
  if (selectedSession) {
@@ -2332,21 +3078,7 @@
2332
3078
  state.currentMessages = [];
2333
3079
 
2334
3080
  if (state.terminal) {
2335
- if (state.terminalSessionId !== id) {
2336
- state.terminal.reset();
2337
- state.terminalOutput = "";
2338
- }
2339
- var newOutput = normalizeTerminalOutput(data.output || "");
2340
- if (newOutput.startsWith(state.terminalOutput)) {
2341
- state.terminal.write(newOutput.slice(state.terminalOutput.length));
2342
- } else {
2343
- state.terminal.reset();
2344
- state.terminal.write(newOutput);
2345
- }
2346
- state.terminalSessionId = id;
2347
- state.terminalOutput = newOutput;
2348
- state.terminal.scrollToBottom();
2349
- scheduleTerminalResize();
3081
+ syncTerminalBuffer(id, data.output || "", { mode: "replace" });
2350
3082
  }
2351
3083
 
2352
3084
  renderChat(false);
@@ -2412,6 +3144,7 @@
2412
3144
 
2413
3145
  function closeSessionsDrawer() {
2414
3146
  if (!state.sessionsDrawerOpen) return;
3147
+ closeSwipedItem();
2415
3148
  state.sessionsDrawerOpen = false;
2416
3149
  updateLayoutState();
2417
3150
  }
@@ -2431,6 +3164,7 @@
2431
3164
  state.sessionTool = getPreferredTool();
2432
3165
  state.modeValue = getSafeModeForTool(state.sessionTool, state.modeValue || state.chatMode);
2433
3166
  syncSessionModalUI();
3167
+ loadRecentPathBubbles();
2434
3168
  setTimeout(function() {
2435
3169
  var modeCardsEl = document.getElementById("mode-cards");
2436
3170
  if (modeCardsEl) modeCardsEl.focus();
@@ -2458,6 +3192,10 @@
2458
3192
  }
2459
3193
 
2460
3194
  function setupFocusTrap(modal) {
3195
+ if (focusTrapHandler) {
3196
+ document.removeEventListener("keydown", focusTrapHandler);
3197
+ }
3198
+
2461
3199
  // Focusable elements selector
2462
3200
  var focusableSelector = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
2463
3201
 
@@ -2646,6 +3384,36 @@
2646
3384
  });
2647
3385
  }
2648
3386
 
3387
+ function loadRecentPathBubbles() {
3388
+ var container = document.getElementById("recent-paths-bubbles");
3389
+ if (!container) return;
3390
+ fetch("/api/recent-paths", { credentials: "same-origin" })
3391
+ .then(function(res) { return res.json(); })
3392
+ .then(function(items) {
3393
+ if (!items || !items.length) {
3394
+ container.innerHTML = "";
3395
+ return;
3396
+ }
3397
+ container.innerHTML = items.map(function(item) {
3398
+ return '<button class="recent-path-bubble" data-path="' + escapeHtml(item.path) + '" title="' + escapeHtml(item.path) + '">' +
3399
+ escapeHtml(item.name) +
3400
+ '</button>';
3401
+ }).join("");
3402
+ container.querySelectorAll(".recent-path-bubble").forEach(function(el) {
3403
+ el.addEventListener("click", function() {
3404
+ var cwdEl = document.getElementById("cwd");
3405
+ if (cwdEl) {
3406
+ cwdEl.value = el.dataset.path;
3407
+ state.cwdValue = el.dataset.path || "";
3408
+ }
3409
+ });
3410
+ });
3411
+ })
3412
+ .catch(function() {
3413
+ if (container) container.innerHTML = "";
3414
+ });
3415
+ }
3416
+
2649
3417
  function schedulePathSuggestions() {
2650
3418
  if (state.suggestionTimer) clearTimeout(state.suggestionTimer);
2651
3419
  state.suggestionTimer = setTimeout(loadPathSuggestions, 120);
@@ -2720,7 +3488,7 @@
2720
3488
  // Move cursor to after the inserted newline
2721
3489
  inputBox.selectionStart = start + 1;
2722
3490
  inputBox.selectionEnd = start + 1;
2723
- setDraftValue(newValue);
3491
+ setDraftValue(newValue, true);
2724
3492
  autoResizeInput(inputBox);
2725
3493
  }
2726
3494
  return;
@@ -2735,7 +3503,7 @@
2735
3503
  setTimeout(function() {
2736
3504
  var inputBox = document.getElementById("input-box");
2737
3505
  if (inputBox) {
2738
- setDraftValue(inputBox.value);
3506
+ setDraftValue(inputBox.value, true);
2739
3507
  }
2740
3508
  }, 0);
2741
3509
  return;
@@ -2749,7 +3517,7 @@
2749
3517
  var current = inputBox.value;
2750
3518
  var newValue = current.slice(0, start) + String.fromCharCode(9) + current.slice(start);
2751
3519
  inputBox.value = newValue;
2752
- setDraftValue(newValue);
3520
+ setDraftValue(newValue, true);
2753
3521
  }
2754
3522
  return;
2755
3523
  }
@@ -2862,15 +3630,17 @@
2862
3630
  return "";
2863
3631
  }
2864
3632
 
2865
- function setDraftValue(value) {
3633
+ function setDraftValue(value, skipDom) {
2866
3634
  if (!state.selectedId) return;
2867
3635
  state.drafts[state.selectedId] = value;
2868
3636
  // Persist to localStorage
2869
3637
  try {
2870
3638
  localStorage.setItem("wand-draft-" + state.selectedId, value);
2871
3639
  } catch (e) { /* ignore */ }
2872
- var inputBox = document.getElementById("input-box");
2873
- if (inputBox) inputBox.value = value;
3640
+ if (!skipDom) {
3641
+ var inputBox = document.getElementById("input-box");
3642
+ if (inputBox) inputBox.value = value;
3643
+ }
2874
3644
  }
2875
3645
 
2876
3646
  function autoResizeInput(el) {
@@ -3053,7 +3823,8 @@
3053
3823
  if (!state.terminal) initTerminal();
3054
3824
  applyCurrentView();
3055
3825
  if (state.currentView === "terminal") {
3056
- setTimeout(scheduleTerminalResize, 40);
3826
+ state.terminalViewportSize = { width: 0, height: 0 };
3827
+ scheduleTerminalResize(true);
3057
3828
  }
3058
3829
  // Don't call renderChat() here — loadOutput() always calls renderChat() after it resolves.
3059
3830
  // Calling renderChat() prematurely would render with stale/empty messages.
@@ -3079,26 +3850,42 @@
3079
3850
  terminalInteractive: state.terminalInteractive,
3080
3851
  inputLength: value.length
3081
3852
  });
3082
- if (!isSelectedSessionRunning()) {
3083
- console.warn("[wand] Prevented send because selected session is not running", {
3084
- sessionId: state.selectedId,
3085
- sessionStatus: selectedSession ? selectedSession.status : null
3086
- });
3087
- showToast("会话已结束,请重新启动会话。", "error");
3088
- return Promise.resolve();
3089
- }
3090
3853
  // Clear todo progress bar at the start of a new user turn
3091
3854
  var todoEl = document.getElementById("todo-progress");
3092
3855
  if (todoEl) todoEl.classList.add("hidden");
3093
3856
  // Send text + Enter as a single call to avoid race conditions
3094
3857
  var combinedInput = value + getControlInput("enter");
3095
- // Clear the input box immediately to prevent double-sending
3096
- if (inputBox) {
3097
- inputBox.value = "";
3098
- autoResizeInput(inputBox);
3858
+ var isOffline = !state.wsConnected;
3859
+
3860
+ if (isOffline) {
3861
+ // Offline: queue for flush on reconnect, clear input immediately
3862
+ if (state.pendingMessages.length >= 100) {
3863
+ state.pendingMessages.shift();
3864
+ }
3865
+ state.pendingMessages.push(combinedInput);
3866
+ if (inputBox) {
3867
+ inputBox.value = "";
3868
+ autoResizeInput(inputBox);
3869
+ }
3870
+ setDraftValue("");
3871
+ return Promise.resolve();
3099
3872
  }
3100
- setDraftValue("");
3101
- return queueDirectInput(combinedInput).catch(function(err) {
3873
+
3874
+ // Online: send via queue, only clear on success
3875
+ return ensureSessionReadyForInput(selectedSession).then(function(readySession) {
3876
+ if (!readySession) {
3877
+ showToast("会话未就绪,将稍后重试。", "info");
3878
+ return null;
3879
+ }
3880
+ return queueDirectInput(combinedInput).then(function() {
3881
+ // Clear input only after the send succeeds
3882
+ if (inputBox && inputBox.value === value) {
3883
+ inputBox.value = "";
3884
+ autoResizeInput(inputBox);
3885
+ }
3886
+ setDraftValue("");
3887
+ });
3888
+ }).catch(function(err) {
3102
3889
  showToast(getInputErrorMessage(err), "error");
3103
3890
  throw err;
3104
3891
  });
@@ -3108,12 +3895,12 @@
3108
3895
 
3109
3896
  function getInputErrorMessage(error) {
3110
3897
  if (error && (error.errorCode === "SESSION_NOT_RUNNING" || error.errorCode === "SESSION_NO_PTY")) {
3111
- return "会话已结束,请重新启动会话。";
3898
+ return "会话已结束;若存在 Claude 历史会话,将在你下次发送消息时自动恢复。";
3112
3899
  }
3113
3900
  if (error && error.errorCode === "SESSION_NOT_FOUND") {
3114
- return "会话不存在,请重新启动会话。";
3901
+ return "会话不存在,请重新选择或新建会话。";
3115
3902
  }
3116
- return (error && error.message) || "会话已结束,请重启会话。";
3903
+ return (error && error.message) || "会话暂不可用;若存在 Claude 历史会话,将自动尝试恢复。";
3117
3904
  }
3118
3905
 
3119
3906
  function buildInputError(payload) {
@@ -3135,6 +3922,56 @@
3135
3922
  updateSessionSnapshot({ id: sessionId, status: status || "exited" });
3136
3923
  }
3137
3924
 
3925
+ function hasRealConversationHistory(session) {
3926
+ if (!session || !Array.isArray(session.messages) || session.messages.length < 2) {
3927
+ return false;
3928
+ }
3929
+ var hasUser = session.messages.some(function(turn) {
3930
+ return turn && turn.role === "user" && Array.isArray(turn.content) && turn.content.some(function(block) {
3931
+ return block && block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0;
3932
+ });
3933
+ });
3934
+ var hasAssistant = session.messages.some(function(turn) {
3935
+ return turn && turn.role === "assistant" && Array.isArray(turn.content) && turn.content.some(function(block) {
3936
+ return block && block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0;
3937
+ });
3938
+ });
3939
+ return hasUser && hasAssistant;
3940
+ }
3941
+
3942
+ function canAutoResumeSession(session) {
3943
+ return !!(session && session.status === "exited" && session.claudeSessionId && hasRealConversationHistory(session));
3944
+ }
3945
+
3946
+ function ensureSessionReadyForInput(session, errorEl) {
3947
+ if (!session) {
3948
+ showToast("会话不存在,请重新选择或新建会话。", "error");
3949
+ return Promise.resolve(null);
3950
+ }
3951
+ if (session.status === "running") {
3952
+ return Promise.resolve(session);
3953
+ }
3954
+ if (!canAutoResumeSession(session)) {
3955
+ showToast("该会话没有可恢复的 Claude 历史上下文,请新建会话。", "error");
3956
+ return Promise.resolve(null);
3957
+ }
3958
+
3959
+ showToast("正在恢复历史会话…", "info");
3960
+ return resumeClaudeSessionById(session.claudeSessionId, errorEl).then(function(data) {
3961
+ if (!data) return null;
3962
+ updateSessionSnapshot(data);
3963
+ updateSessionsList();
3964
+ switchToSessionView(data.id);
3965
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
3966
+ state.ws.send(JSON.stringify({ type: "subscribe", sessionId: data.id }));
3967
+ }
3968
+ return loadOutput(data.id).then(function() {
3969
+ focusInputBox(true);
3970
+ return data;
3971
+ });
3972
+ });
3973
+ }
3974
+
3138
3975
  function queueDirectInput(input) {
3139
3976
  if (!input || !state.selectedId) return Promise.resolve();
3140
3977
  state.messageQueue.push(input);
@@ -3154,19 +3991,36 @@
3154
3991
 
3155
3992
  // Pre-check: don't send if session is not running
3156
3993
  if (!isSelectedSessionRunning()) {
3994
+ // If WebSocket is disconnected, queue for flush on reconnect
3995
+ if (!state.wsConnected) {
3996
+ if (state.pendingMessages.length >= 100) {
3997
+ state.pendingMessages.shift();
3998
+ }
3999
+ state.pendingMessages.push(input);
4000
+ console.log("[wand] postInput: session not running, queued for reconnect", {
4001
+ sessionId: state.selectedId,
4002
+ inputLength: input.length
4003
+ });
4004
+ return Promise.resolve();
4005
+ }
3157
4006
  console.warn("[wand] postInput: session not running, skipping send", {
3158
4007
  sessionId: state.selectedId
3159
4008
  });
3160
- showToast("会话已结束,请重新启动会话。", "error");
4009
+ showToast("会话未运行,正在等待自动恢复后重试。", "info");
3161
4010
  return Promise.resolve();
3162
4011
  }
3163
4012
 
3164
- // If WebSocket is disconnected, queue the message
4013
+ // If WebSocket is disconnected, queue the message (no HTTP fetch while offline)
3165
4014
  if (!state.wsConnected) {
3166
4015
  if (state.pendingMessages.length >= 100) {
3167
4016
  state.pendingMessages.shift();
3168
4017
  }
3169
4018
  state.pendingMessages.push(input);
4019
+ console.log("[wand] postInput: WebSocket disconnected, queued message", {
4020
+ sessionId: state.selectedId,
4021
+ inputLength: input.length
4022
+ });
4023
+ return Promise.resolve();
3170
4024
  }
3171
4025
 
3172
4026
  console.log("[wand] postInput: sending", {
@@ -3392,7 +4246,6 @@
3392
4246
  var toggle = document.getElementById(id);
3393
4247
  if (toggle) {
3394
4248
  toggle.classList.toggle("active", state.terminalInteractive);
3395
- toggle.textContent = state.terminalInteractive ? "⌨ 交互开" : "⌨ 交互关";
3396
4249
  }
3397
4250
  });
3398
4251
  // Inline keyboard visibility follows current view
@@ -3402,16 +4255,6 @@
3402
4255
  if (inputHint) inputHint.classList.toggle("hidden", state.currentView === "terminal");
3403
4256
  var container = document.getElementById("output");
3404
4257
  if (container) container.classList.toggle("interactive", state.terminalInteractive);
3405
- var keyboardToggle = document.getElementById("keyboard-toggle");
3406
- if (keyboardToggle) {
3407
- keyboardToggle.classList.toggle("hidden", state.currentView !== "terminal" || !state.selectedId);
3408
- keyboardToggle.classList.toggle("active", state.keyboardPopupOpen);
3409
- }
3410
- var popup = document.getElementById("keyboard-popup");
3411
- if (popup) {
3412
- var shouldShowPopup = state.keyboardPopupOpen && state.currentView === "terminal" && !!state.selectedId;
3413
- popup.classList.toggle("hidden", !shouldShowPopup);
3414
- }
3415
4258
  }
3416
4259
 
3417
4260
  function captureTerminalInput(event) {
@@ -3442,8 +4285,7 @@
3442
4285
  }
3443
4286
 
3444
4287
  function handleInlineKeyboardClick(event) {
3445
- // Support both old .ik-key and new .kp-key buttons
3446
- var btn = event.target.closest(".ik-key, .kp-key");
4288
+ var btn = event.target.closest(".shortcut-key");
3447
4289
  if (!btn) return;
3448
4290
  var key = btn.getAttribute("data-key");
3449
4291
  if (!key) return;
@@ -3454,7 +4296,6 @@
3454
4296
  return;
3455
4297
  }
3456
4298
  if (key === "ctrl_enter") {
3457
- // Ctrl+Enter for confirm/approve in terminal
3458
4299
  var sequence = buildPtySequence("enter", { ctrl: true, alt: false, shift: false });
3459
4300
  if (sequence) sendTerminalSequence(sequence);
3460
4301
  return;
@@ -3466,10 +4307,10 @@
3466
4307
  }
3467
4308
 
3468
4309
  function updateKeyboardPopupUI() {
3469
- var popup = document.getElementById("keyboard-popup");
3470
- if (!popup) return;
4310
+ var container = document.querySelector(".inline-shortcuts-wrap");
4311
+ if (!container) return;
3471
4312
  ["ctrl", "alt"].forEach(function(name) {
3472
- var btn = popup.querySelector('[data-key="' + name + '"]');
4313
+ var btn = container.querySelector('[data-key="' + name + '"]');
3473
4314
  if (btn) btn.classList.toggle("active", !!state.modifiers[name]);
3474
4315
  });
3475
4316
  }
@@ -3573,17 +4414,60 @@
3573
4414
  function flushPendingMessages() {
3574
4415
  if (state.pendingMessages.length === 0) return;
3575
4416
 
3576
- // Send queued messages in order
4417
+ // Send queued messages in order, bypassing the session-running check
4418
+ // since our local state may be stale right after reconnect
3577
4419
  var queue = state.pendingMessages.slice();
3578
4420
  state.pendingMessages = [];
3579
4421
 
4422
+ var sendPromise = Promise.resolve();
3580
4423
  queue.forEach(function(input) {
3581
- postInput(input).catch(function() {
3582
- // Ignore errors during flush
4424
+ sendPromise = sendPromise.then(function() {
4425
+ return sendInputDirect(input).catch(function() {
4426
+ // Ignore errors during flush
4427
+ });
3583
4428
  });
3584
4429
  });
3585
4430
  }
3586
4431
 
4432
+ function sendInputDirect(input) {
4433
+ if (!input || !state.selectedId) return Promise.resolve();
4434
+ return fetch("/api/sessions/" + state.selectedId + "/input", {
4435
+ method: "POST",
4436
+ headers: { "Content-Type": "application/json" },
4437
+ credentials: "same-origin",
4438
+ body: JSON.stringify({ input: input, view: state.currentView })
4439
+ })
4440
+ .then(function(res) {
4441
+ if (!res.ok) {
4442
+ return res.json().catch(function() { return { error: "请求失败" }; }).then(function(payload) {
4443
+ var error = buildInputError(payload);
4444
+ error.httpStatus = res.status;
4445
+ // Don't re-queue on session-unavailable — the session will auto-resume
4446
+ // on the user's next message, and stale queue items would cause duplicates
4447
+ if (isSessionUnavailableError(error)) {
4448
+ console.log("[wand] sendInputDirect: session unavailable, dropping", {
4449
+ sessionId: state.selectedId,
4450
+ errorCode: error.errorCode
4451
+ });
4452
+ return null;
4453
+ }
4454
+ throw error;
4455
+ });
4456
+ }
4457
+ return res.json();
4458
+ })
4459
+ .then(function(snapshot) {
4460
+ if (snapshot && snapshot.id) {
4461
+ updateSessionSnapshot(snapshot);
4462
+ if (snapshot.messages && snapshot.messages.length > 0) {
4463
+ state.currentMessages = snapshot.messages;
4464
+ }
4465
+ renderChat(true);
4466
+ }
4467
+ return snapshot;
4468
+ });
4469
+ }
4470
+
3587
4471
  function stopSession() {
3588
4472
  if (!state.selectedId) return;
3589
4473
  fetch("/api/sessions/" + state.selectedId + "/stop", { method: "POST", credentials: "same-origin" })
@@ -3591,28 +4475,112 @@
3591
4475
  }
3592
4476
 
3593
4477
  function deleteSession(id) {
3594
- // 二次确认
3595
- if (!confirm("确定要删除这个会话吗?此操作无法撤销。")) {
4478
+ var item = document.querySelector('.session-item[data-session-id="' + id + '"]');
4479
+ if (item) {
4480
+ item.classList.add("deleting");
4481
+ }
4482
+ setTimeout(function() {
4483
+ fetch("/api/sessions/" + id, { method: "DELETE", credentials: "same-origin" })
4484
+ .then(function(res) { return res.json(); })
4485
+ .then(function(data) {
4486
+ if (data && data.error) {
4487
+ throw new Error(data.error);
4488
+ }
4489
+ if (state.selectedId === id) {
4490
+ state.selectedId = null;
4491
+ persistSelectedId();
4492
+ }
4493
+ return refreshAll();
4494
+ })
4495
+ .catch(function() {
4496
+ // Remove deleting state on error so item reappears
4497
+ if (item) item.classList.remove("deleting");
4498
+ var errorEl = document.getElementById("action-error");
4499
+ showError(errorEl, "无法删除会话。");
4500
+ });
4501
+ }, 250);
4502
+ }
4503
+
4504
+ function executeDeleteHistory(claudeSessionId, item) {
4505
+ if (item) {
4506
+ item.classList.add("deleting");
4507
+ }
4508
+ setTimeout(function() {
4509
+ fetch("/api/claude-history/" + encodeURIComponent(claudeSessionId), { method: "DELETE", credentials: "same-origin" })
4510
+ .then(function(res) { return res.json(); })
4511
+ .then(function(data) {
4512
+ if (data && data.error) {
4513
+ throw new Error(data.error);
4514
+ }
4515
+ state.claudeHistory = state.claudeHistory.filter(function(s) {
4516
+ return s.claudeSessionId !== claudeSessionId;
4517
+ });
4518
+ delete state.selectedClaudeHistoryIds[claudeSessionId];
4519
+ updateSessionsList();
4520
+ })
4521
+ .catch(function() {
4522
+ if (item) item.classList.remove("deleting");
4523
+ var errorEl = document.getElementById("action-error");
4524
+ showError(errorEl, "无法删除历史会话。");
4525
+ });
4526
+ }, 250);
4527
+ }
4528
+
4529
+ function deleteClaudeHistorySession(claudeSessionId, item) {
4530
+ executeDeleteHistory(claudeSessionId, item);
4531
+ }
4532
+
4533
+ function deleteClaudeHistoryDirectory(cwd, btn, items) {
4534
+ if (!cwd) {
3596
4535
  return;
3597
4536
  }
3598
- fetch("/api/sessions/" + id, { method: "DELETE", credentials: "same-origin" })
4537
+ fetch("/api/claude-history?cwd=" + encodeURIComponent(cwd), { method: "DELETE", credentials: "same-origin" })
3599
4538
  .then(function(res) { return res.json(); })
3600
4539
  .then(function(data) {
3601
4540
  if (data && data.error) {
3602
4541
  throw new Error(data.error);
3603
4542
  }
3604
- if (state.selectedId === id) {
3605
- state.selectedId = null;
3606
- persistSelectedId();
3607
- }
3608
- return refreshAll();
4543
+ state.claudeHistory = state.claudeHistory.filter(function(s) {
4544
+ return s.cwd !== cwd;
4545
+ });
4546
+ updateSessionsList();
3609
4547
  })
3610
4548
  .catch(function() {
4549
+ setDeletingState(items, false);
3611
4550
  var errorEl = document.getElementById("action-error");
3612
- showError(errorEl, "无法删除会话。");
4551
+ showError(errorEl, "无法清理该目录的历史会话。");
3613
4552
  });
3614
4553
  }
3615
4554
 
4555
+ function setDeletingState(items, deleting) {
4556
+ items.forEach(function(item) {
4557
+ item.classList.toggle("deleting", deleting);
4558
+ });
4559
+ }
4560
+
4561
+ function getHistoryItemsByCwd(cwd) {
4562
+ return Array.prototype.slice.call(document.querySelectorAll('.claude-history-item[data-cwd="' + window.CSS.escape(String(cwd)) + '"]'));
4563
+ }
4564
+
4565
+ // ── Swipe-to-delete gesture ──
4566
+
4567
+ var _swipeState = null;
4568
+ var _swipedItem = null;
4569
+
4570
+ function closeSwipedItem() {
4571
+ if (_swipedItem) {
4572
+ _swipedItem.classList.remove("swiped");
4573
+ var content = _swipedItem.querySelector(".session-item-content");
4574
+ if (content) content.style.transform = "";
4575
+ _swipedItem = null;
4576
+ }
4577
+ }
4578
+
4579
+ function initSwipeToDelete() {
4580
+ _swipeState = null;
4581
+ _swipedItem = null;
4582
+ }
4583
+
3616
4584
  function startCommand(command, cwd, errorEl) {
3617
4585
  if (command === "claude") {
3618
4586
  state.preferredCommand = command;
@@ -3641,6 +4609,224 @@
3641
4609
  });
3642
4610
  }
3643
4611
 
4612
+ function resumeSession(sessionId, errorEl) {
4613
+ if (!sessionId) return Promise.resolve(null);
4614
+ return fetch("/api/sessions/" + encodeURIComponent(sessionId) + "/resume", {
4615
+ method: "POST",
4616
+ headers: { "Content-Type": "application/json" },
4617
+ credentials: "same-origin",
4618
+ body: JSON.stringify({
4619
+ mode: state.chatMode || state.config.defaultMode || "default"
4620
+ })
4621
+ })
4622
+ .then(function(res) { return res.json(); })
4623
+ .then(function(data) {
4624
+ if (data.error) {
4625
+ if (errorEl) showError(errorEl, data.error);
4626
+ else showToast(data.error, "error");
4627
+ return null;
4628
+ }
4629
+ state.selectedId = data.id;
4630
+ persistSelectedId();
4631
+ state.drafts[data.id] = "";
4632
+ return data;
4633
+ })
4634
+ .catch(function(error) {
4635
+ var message = (error && error.message) || "无法恢复会话。";
4636
+ if (errorEl) showError(errorEl, message);
4637
+ else showToast(message, "error");
4638
+ return null;
4639
+ });
4640
+ }
4641
+
4642
+ function resumeClaudeSessionById(claudeSessionId, errorEl) {
4643
+ if (!claudeSessionId) return Promise.resolve(null);
4644
+ return fetch("/api/claude-sessions/" + encodeURIComponent(claudeSessionId) + "/resume", {
4645
+ method: "POST",
4646
+ headers: { "Content-Type": "application/json" },
4647
+ credentials: "same-origin",
4648
+ body: JSON.stringify({
4649
+ mode: state.chatMode || state.config.defaultMode || "default"
4650
+ })
4651
+ })
4652
+ .then(function(res) { return res.json(); })
4653
+ .then(function(data) {
4654
+ if (data.error) {
4655
+ if (errorEl) showError(errorEl, data.error);
4656
+ else showToast(data.error, "error");
4657
+ return null;
4658
+ }
4659
+ state.selectedId = data.id;
4660
+ persistSelectedId();
4661
+ state.drafts[data.id] = "";
4662
+ return data;
4663
+ })
4664
+ .catch(function(error) {
4665
+ var message = (error && error.message) || "无法按 Claude 会话 ID 恢复会话。";
4666
+ if (errorEl) showError(errorEl, message);
4667
+ else showToast(message, "error");
4668
+ return null;
4669
+ });
4670
+ }
4671
+
4672
+ function activateSession(data) {
4673
+ if (!data || !data.id) return Promise.resolve();
4674
+ state.lastRenderedHash = 0;
4675
+ state.lastRenderedMsgCount = 0;
4676
+ state.lastRenderedEmpty = null;
4677
+ switchToSessionView(data.id);
4678
+ updateSessionSnapshot(data);
4679
+ updateSessionsList();
4680
+ if (state.ws && state.ws.readyState === WebSocket.OPEN) {
4681
+ state.ws.send(JSON.stringify({ type: "subscribe", sessionId: data.id }));
4682
+ }
4683
+ return loadOutput(data.id).then(function() {
4684
+ focusInputBox(true);
4685
+ });
4686
+ }
4687
+
4688
+ function resumeSessionFromList(sessionId) {
4689
+ return resumeSession(sessionId).then(function(data) {
4690
+ if (!data) return null;
4691
+ return activateSession(data).then(function() {
4692
+ return data;
4693
+ });
4694
+ });
4695
+ }
4696
+
4697
+ function startAndActivateCommand(command, cwd, errorEl) {
4698
+ return startCommand(command, cwd, errorEl).then(function(data) {
4699
+ if (!data) return null;
4700
+ return activateSession(data).then(function() {
4701
+ return data;
4702
+ });
4703
+ });
4704
+ }
4705
+
4706
+ function createSessionFromWelcomeInput(value) {
4707
+ var welcomeInput = document.getElementById("welcome-input");
4708
+ if (!welcomeInput) return;
4709
+ welcomeInput.placeholder = "Claude 正在思考,请稍候...";
4710
+ welcomeInput.disabled = true;
4711
+ var mode = state.chatMode || "full-access";
4712
+ var defaultCwd = state.workingDir || (state.config && state.config.defaultCwd ? state.config.defaultCwd : "");
4713
+ var preferredTool = getPreferredTool();
4714
+ fetch("/api/commands", {
4715
+ method: "POST",
4716
+ headers: { "Content-Type": "application/json" },
4717
+ credentials: "same-origin",
4718
+ body: JSON.stringify({
4719
+ command: preferredTool,
4720
+ cwd: defaultCwd,
4721
+ mode: mode,
4722
+ initialInput: value
4723
+ })
4724
+ })
4725
+ .then(function(res) { return res.json(); })
4726
+ .then(function(data) {
4727
+ if (data.error) {
4728
+ showToast(data.error, "error");
4729
+ welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
4730
+ welcomeInput.disabled = false;
4731
+ return null;
4732
+ }
4733
+ return activateSession(data);
4734
+ })
4735
+ .catch(function(error) {
4736
+ showToast((error && error.message) || "无法启动会话。", "error");
4737
+ welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
4738
+ welcomeInput.disabled = false;
4739
+ })
4740
+ .finally(function() {
4741
+ welcomeInput.placeholder = "输入你的问题,按 Enter 发送...";
4742
+ welcomeInput.disabled = false;
4743
+ });
4744
+ }
4745
+
4746
+ function createSessionFromInput(value, inputBox, welcomeInput) {
4747
+ var mode = state.chatMode || "full-access";
4748
+ var defaultCwd = state.workingDir || (state.config && state.config.defaultCwd ? state.config.defaultCwd : "");
4749
+ var preferredTool = getPreferredTool();
4750
+ fetch("/api/commands", {
4751
+ method: "POST",
4752
+ headers: { "Content-Type": "application/json" },
4753
+ credentials: "same-origin",
4754
+ body: JSON.stringify({
4755
+ command: preferredTool,
4756
+ cwd: defaultCwd,
4757
+ mode: mode,
4758
+ initialInput: value || undefined
4759
+ })
4760
+ })
4761
+ .then(function(res) { return res.json(); })
4762
+ .then(function(data) {
4763
+ if (data.error) {
4764
+ showToast(data.error, "error");
4765
+ return null;
4766
+ }
4767
+ if (inputBox) inputBox.value = "";
4768
+ if (welcomeInput) welcomeInput.value = "";
4769
+ return activateSession(data);
4770
+ })
4771
+ .catch(function(error) {
4772
+ showToast((error && error.message) || "无法启动会话。", "error");
4773
+ });
4774
+ }
4775
+
4776
+ function handleResumeAction(actionButton) {
4777
+ actionButton.disabled = true;
4778
+ resumeSessionFromList(actionButton.dataset.sessionId)
4779
+ .finally(function() {
4780
+ actionButton.disabled = false;
4781
+ });
4782
+ }
4783
+
4784
+ function handleResumeHistoryAction(actionButton) {
4785
+ var claudeSessionId = actionButton.dataset.claudeSessionId;
4786
+ var cwd = actionButton.dataset.cwd;
4787
+ if (!claudeSessionId) return;
4788
+ actionButton.disabled = true;
4789
+ resumeClaudeHistorySession(claudeSessionId, cwd)
4790
+ .then(function(data) {
4791
+ if (data && data.id) {
4792
+ state.selectedId = data.id;
4793
+ persistSelectedId();
4794
+ state.drafts[data.id] = "";
4795
+ loadSessions().then(function() {
4796
+ selectSession(data.id);
4797
+ closeSessionsDrawer();
4798
+ });
4799
+ }
4800
+ })
4801
+ .finally(function() {
4802
+ actionButton.disabled = false;
4803
+ });
4804
+ }
4805
+
4806
+ function resumeClaudeHistorySession(claudeSessionId, cwd) {
4807
+ return fetch("/api/claude-sessions/" + encodeURIComponent(claudeSessionId) + "/resume", {
4808
+ method: "POST",
4809
+ headers: { "Content-Type": "application/json" },
4810
+ credentials: "same-origin",
4811
+ body: JSON.stringify({
4812
+ mode: state.chatMode || (state.config && state.config.defaultMode) || "default",
4813
+ cwd: cwd
4814
+ })
4815
+ })
4816
+ .then(function(res) { return res.json(); })
4817
+ .then(function(data) {
4818
+ if (data.error) {
4819
+ showToast(data.error, "error");
4820
+ return null;
4821
+ }
4822
+ return data;
4823
+ })
4824
+ .catch(function(error) {
4825
+ showToast((error && error.message) || "无法恢复历史会话。", "error");
4826
+ return null;
4827
+ });
4828
+ }
4829
+
3644
4830
  function isTouchDevice() {
3645
4831
  return "ontouchstart" in window || navigator.maxTouchPoints > 0;
3646
4832
  }
@@ -4483,23 +5669,26 @@
4483
5669
  e.preventDefault();
4484
5670
  });
4485
5671
 
4486
- document.addEventListener("mousemove", function(e) {
5672
+ // Store document-level listeners so they can be removed in teardownTerminal
5673
+ state.resizeMouseMove = function(e) {
4487
5674
  if (!isResizing) return;
4488
5675
  var deltaY = e.clientY - startY;
4489
5676
  var newHeight = Math.max(200, Math.min(startHeight + deltaY, window.innerHeight - 200));
4490
5677
  container.style.height = newHeight + "px";
4491
5678
  container.style.flex = "none";
4492
5679
  scheduleTerminalResize();
4493
- });
5680
+ };
5681
+ document.addEventListener("mousemove", state.resizeMouseMove);
4494
5682
 
4495
- document.addEventListener("mouseup", function() {
5683
+ state.resizeMouseUp = function() {
4496
5684
  if (isResizing) {
4497
5685
  isResizing = false;
4498
5686
  document.body.style.cursor = "";
4499
5687
  document.body.style.userSelect = "";
4500
5688
  scheduleTerminalResize();
4501
5689
  }
4502
- });
5690
+ };
5691
+ document.addEventListener("mouseup", state.resizeMouseUp);
4503
5692
 
4504
5693
  // 触摸设备支持
4505
5694
  resizeHandle.addEventListener("touchstart", function(e) {
@@ -4509,7 +5698,7 @@
4509
5698
  e.preventDefault();
4510
5699
  }, { passive: false });
4511
5700
 
4512
- document.addEventListener("touchmove", function(e) {
5701
+ state.resizeTouchMove = function(e) {
4513
5702
  if (!isResizing) return;
4514
5703
  var deltaY = e.touches[0].clientY - startY;
4515
5704
  var newHeight = Math.max(200, Math.min(startHeight + deltaY, window.innerHeight - 200));
@@ -4517,14 +5706,16 @@
4517
5706
  container.style.flex = "none";
4518
5707
  scheduleTerminalResize();
4519
5708
  e.preventDefault();
4520
- }, { passive: false });
5709
+ };
5710
+ document.addEventListener("touchmove", state.resizeTouchMove, { passive: false });
4521
5711
 
4522
- document.addEventListener("touchend", function() {
5712
+ state.resizeTouchEnd = function() {
4523
5713
  if (isResizing) {
4524
5714
  isResizing = false;
4525
5715
  scheduleTerminalResize();
4526
5716
  }
4527
- });
5717
+ };
5718
+ document.addEventListener("touchend", state.resizeTouchEnd);
4528
5719
  }
4529
5720
 
4530
5721
  function observeTerminalResize() {
@@ -4532,15 +5723,19 @@
4532
5723
  if (!output) return;
4533
5724
 
4534
5725
  if (typeof ResizeObserver === "function") {
4535
- state.resizeObserver = new ResizeObserver(function() { scheduleTerminalResize(); });
5726
+ state.resizeObserver = new ResizeObserver(function() { scheduleTerminalResize(true); });
4536
5727
  state.resizeObserver.observe(output);
4537
5728
  }
4538
- state.resizeHandler = scheduleTerminalResize;
5729
+ state.resizeHandler = function() { scheduleTerminalResize(true); };
4539
5730
  window.addEventListener("resize", state.resizeHandler);
4540
- requestAnimationFrame(scheduleTerminalResize);
5731
+ requestAnimationFrame(function() { scheduleTerminalResize(true); });
4541
5732
  }
4542
5733
 
4543
5734
  function teardownTerminal() {
5735
+ if (state.resizeTimer) {
5736
+ clearTimeout(state.resizeTimer);
5737
+ state.resizeTimer = null;
5738
+ }
4544
5739
  if (state.resizeObserver) {
4545
5740
  state.resizeObserver.disconnect();
4546
5741
  state.resizeObserver = null;
@@ -4549,24 +5744,61 @@
4549
5744
  window.removeEventListener("resize", state.resizeHandler);
4550
5745
  state.resizeHandler = null;
4551
5746
  }
5747
+ [["mousemove", "resizeMouseMove"], ["mouseup", "resizeMouseUp"],
5748
+ ["touchmove", "resizeTouchMove"], ["touchend", "resizeTouchEnd"]
5749
+ ].forEach(function(pair) {
5750
+ if (state[pair[1]]) {
5751
+ document.removeEventListener(pair[0], state[pair[1]]);
5752
+ state[pair[1]] = null;
5753
+ }
5754
+ });
5755
+ clearTerminalScrollIdleTimer();
5756
+ if (state.terminalViewportEl) {
5757
+ if (state.terminalViewportScrollHandler) {
5758
+ state.terminalViewportEl.removeEventListener("scroll", state.terminalViewportScrollHandler);
5759
+ }
5760
+ if (state.terminalViewportTouchHandler) {
5761
+ state.terminalViewportEl.removeEventListener("touchmove", state.terminalViewportTouchHandler);
5762
+ }
5763
+ }
5764
+ state.terminalViewportEl = null;
5765
+ state.terminalViewportScrollHandler = null;
5766
+ state.terminalViewportTouchHandler = null;
4552
5767
  if (state.terminal) {
4553
5768
  state.terminal.dispose();
4554
5769
  state.terminal = null;
4555
5770
  }
4556
5771
  state.fitAddon = null;
4557
5772
  state.terminalSessionId = null;
5773
+ state.terminalOutput = "";
5774
+ state.terminalViewportSize = { width: 0, height: 0 };
5775
+ state.terminalAutoFollow = true;
5776
+ state.showTerminalJumpToBottom = false;
5777
+ updateTerminalJumpToBottomButton();
4558
5778
  }
4559
5779
 
4560
- function scheduleTerminalResize() {
4561
- if (state.resizeTimer) clearTimeout(state.resizeTimer);
4562
- state.resizeTimer = setTimeout(syncTerminalSize, 60);
5780
+ function scheduleTerminalResize(immediate) {
5781
+ if (state.resizeTimer) {
5782
+ clearTimeout(state.resizeTimer);
5783
+ state.resizeTimer = null;
5784
+ }
5785
+ var delay = immediate ? 0 : 100;
5786
+ state.resizeTimer = setTimeout(function() {
5787
+ state.resizeTimer = null;
5788
+ requestAnimationFrame(syncTerminalSize);
5789
+ }, delay);
4563
5790
  }
4564
5791
 
4565
5792
  function syncTerminalSize() {
4566
5793
  var output = document.getElementById("output");
4567
5794
  if (!state.terminal || !state.fitAddon || !output) return;
5795
+ if (!shouldResizeTerminalViewport()) return;
4568
5796
 
5797
+ var shouldFollow = state.terminalAutoFollow || isTerminalNearBottom();
4569
5798
  state.fitAddon.fit();
5799
+ if (shouldFollow) {
5800
+ maybeScrollTerminalToBottom("resize");
5801
+ }
4570
5802
 
4571
5803
  var nextSize = {
4572
5804
  cols: state.terminal.cols,
@@ -4602,6 +5834,12 @@
4602
5834
  function initWebSocket() {
4603
5835
  if (!window.WebSocket) return false;
4604
5836
 
5837
+ // Prevent duplicate connections
5838
+ if (state.ws) {
5839
+ try { state.ws.close(); } catch (e) { /* ignore */ }
5840
+ state.ws = null;
5841
+ }
5842
+
4605
5843
  var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
4606
5844
  var wsUrl = protocol + '//' + window.location.host + '/ws';
4607
5845
 
@@ -4669,16 +5907,21 @@
4669
5907
 
4670
5908
  }
4671
5909
  // Real-time terminal output
4672
- if (msg.sessionId === state.selectedId && state.terminal && msg.data && msg.data.output) {
4673
- var newOutput = normalizeTerminalOutput(msg.data.output || "");
4674
- if (newOutput.startsWith(state.terminalOutput)) {
4675
- state.terminal.write(newOutput.slice(state.terminalOutput.length));
4676
- } else {
4677
- state.terminal.reset();
4678
- state.terminal.write(newOutput);
5910
+ if (msg.sessionId === state.selectedId && state.terminal && msg.data) {
5911
+ if (msg.data.chunk && (!state.terminalSessionId || state.terminalSessionId === msg.sessionId)) {
5912
+ // Fast path: write chunk directly to avoid full-output comparison
5913
+ // which can trigger terminal.reset() and cause screen flicker.
5914
+ state.terminal.write(msg.data.chunk);
5915
+ state.terminalSessionId = msg.sessionId;
5916
+ if (msg.data.output) {
5917
+ state.terminalOutput = normalizeTerminalOutput(msg.data.output);
5918
+ }
5919
+ maybeScrollTerminalToBottom("output");
5920
+ updateTerminalJumpToBottomButton();
5921
+ } else if (Object.prototype.hasOwnProperty.call(msg.data, "output")) {
5922
+ // Fallback: no chunk available, use full-output comparison
5923
+ syncTerminalBuffer(msg.sessionId, msg.data.output || "", { mode: "append" });
4679
5924
  }
4680
- state.terminalOutput = newOutput;
4681
- state.terminal.scrollToBottom();
4682
5925
  }
4683
5926
  break;
4684
5927
  case 'started':
@@ -4727,8 +5970,7 @@
4727
5970
  // Initial state for subscribed session (after reconnect or subscription)
4728
5971
  if (msg.sessionId === state.selectedId && msg.data) {
4729
5972
  if (chatRenderTimer) { clearTimeout(chatRenderTimer); chatRenderTimer = null; }
4730
- updateTerminalOutput(msg.data.output || "");
4731
- scheduleTerminalResize();
5973
+ updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
4732
5974
  }
4733
5975
  break;
4734
5976
  case 'usage':
@@ -4741,31 +5983,59 @@
4741
5983
  updateTaskDisplay();
4742
5984
  }
4743
5985
  break;
5986
+ case 'status':
5987
+ if (msg.sessionId && msg.data) {
5988
+ var statusUpdate = { id: msg.sessionId };
5989
+ if (Object.prototype.hasOwnProperty.call(msg.data, 'permissionBlocked')) {
5990
+ statusUpdate.permissionBlocked = !!msg.data.permissionBlocked;
5991
+ }
5992
+ if (msg.data.permissionRequest) {
5993
+ statusUpdate.pendingEscalation = {
5994
+ scope: msg.data.permissionRequest.scope,
5995
+ target: msg.data.permissionRequest.target,
5996
+ reason: msg.data.permissionRequest.prompt
5997
+ };
5998
+ }
5999
+ if (msg.data.permissionBlocked === false) {
6000
+ statusUpdate.pendingEscalation = null;
6001
+ }
6002
+ updateSessionSnapshot(statusUpdate);
6003
+ if (msg.sessionId === state.selectedId) {
6004
+ updateTaskDisplay();
6005
+ }
6006
+ }
6007
+ break;
4744
6008
  }
4745
6009
  }
4746
6010
 
4747
6011
  function updateTaskDisplay() {
4748
6012
  var taskEl = document.getElementById("current-task");
4749
6013
  var permissionActionsEl = document.getElementById("permission-actions");
6014
+ var permissionLabel = document.getElementById("permission-actions-label");
4750
6015
  if (!taskEl) return;
4751
6016
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
4752
6017
  var pendingEscalation = selectedSession && selectedSession.pendingEscalation ? selectedSession.pendingEscalation : null;
4753
- if (pendingEscalation) {
4754
- var reason = pendingEscalation.reason || "等待 Claude 权限授权";
4755
- var target = pendingEscalation.target ? " · " + pendingEscalation.target : "";
4756
- taskEl.textContent = reason + target;
4757
- taskEl.classList.remove("hidden");
4758
- taskEl.classList.add("permission-blocked");
6018
+ var isBlocked = pendingEscalation || (selectedSession && selectedSession.permissionBlocked);
6019
+
6020
+ if (isBlocked) {
6021
+ // Show permission label in input composer area
6022
+ if (permissionLabel) {
6023
+ if (pendingEscalation) {
6024
+ var reason = pendingEscalation.reason || "等待授权";
6025
+ var target = pendingEscalation.target ? " · " + pendingEscalation.target : "";
6026
+ permissionLabel.textContent = reason + target;
6027
+ } else {
6028
+ permissionLabel.textContent = "等待授权";
6029
+ }
6030
+ }
4759
6031
  if (permissionActionsEl) permissionActionsEl.classList.remove("hidden");
4760
- return;
4761
- }
4762
- if (selectedSession && selectedSession.permissionBlocked) {
4763
- taskEl.textContent = "等待 Claude 权限授权";
6032
+ // Also show in task bar
6033
+ taskEl.textContent = pendingEscalation ? (pendingEscalation.reason || "等待 Claude 权限授权") : "等待 Claude 权限授权";
4764
6034
  taskEl.classList.remove("hidden");
4765
6035
  taskEl.classList.add("permission-blocked");
4766
- if (permissionActionsEl) permissionActionsEl.classList.remove("hidden");
4767
6036
  return;
4768
6037
  }
6038
+
4769
6039
  taskEl.classList.remove("permission-blocked");
4770
6040
  if (permissionActionsEl) permissionActionsEl.classList.add("hidden");
4771
6041
  var task = state.currentTask;
@@ -4780,6 +6050,10 @@
4780
6050
 
4781
6051
  function approvePermission() {
4782
6052
  if (!state.selectedId) return;
6053
+ var approveBtn = document.getElementById("approve-permission-btn");
6054
+ var denyBtn = document.getElementById("deny-permission-btn");
6055
+ if (approveBtn) approveBtn.disabled = true;
6056
+ if (denyBtn) denyBtn.disabled = true;
4783
6057
  fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/approve-permission", {
4784
6058
  method: "POST",
4785
6059
  credentials: "same-origin"
@@ -4795,11 +6069,19 @@
4795
6069
  })
4796
6070
  .catch(function(error) {
4797
6071
  showToast((error && error.message) || "无法批准授权。", "error");
6072
+ })
6073
+ .finally(function() {
6074
+ if (approveBtn) approveBtn.disabled = false;
6075
+ if (denyBtn) denyBtn.disabled = false;
4798
6076
  });
4799
6077
  }
4800
6078
 
4801
6079
  function denyPermission() {
4802
6080
  if (!state.selectedId) return;
6081
+ var approveBtn = document.getElementById("approve-permission-btn");
6082
+ var denyBtn = document.getElementById("deny-permission-btn");
6083
+ if (approveBtn) approveBtn.disabled = true;
6084
+ if (denyBtn) denyBtn.disabled = true;
4803
6085
  fetch("/api/sessions/" + encodeURIComponent(state.selectedId) + "/deny-permission", {
4804
6086
  method: "POST",
4805
6087
  credentials: "same-origin"
@@ -4815,20 +6097,16 @@
4815
6097
  })
4816
6098
  .catch(function(error) {
4817
6099
  showToast((error && error.message) || "无法拒绝授权。", "error");
6100
+ })
6101
+ .finally(function() {
6102
+ if (approveBtn) approveBtn.disabled = false;
6103
+ if (denyBtn) denyBtn.disabled = false;
4818
6104
  });
4819
6105
  }
4820
6106
 
4821
- function updateTerminalOutput(output) {
4822
- if (!state.terminal) return;
4823
- var normalized = normalizeTerminalOutput(output);
4824
- if (normalized.startsWith(state.terminalOutput)) {
4825
- state.terminal.write(normalized.slice(state.terminalOutput.length));
4826
- } else {
4827
- state.terminal.reset();
4828
- state.terminal.write(normalized);
4829
- }
4830
- state.terminalOutput = normalized;
4831
- state.terminal.scrollToBottom();
6107
+ function updateTerminalOutput(output, sessionId, mode) {
6108
+ if (!state.terminal) return false;
6109
+ return syncTerminalBuffer(sessionId || state.selectedId, output, { mode: mode || "append" });
4832
6110
  }
4833
6111
 
4834
6112
  function stopPolling() {
@@ -4846,8 +6124,10 @@
4846
6124
  }
4847
6125
  applyCurrentView();
4848
6126
  reconcileInteractiveState();
6127
+ updateTerminalJumpToBottomButton();
4849
6128
  if (state.currentView === "terminal") {
4850
- setTimeout(scheduleTerminalResize, 40);
6129
+ state.terminalViewportSize = { width: 0, height: 0 };
6130
+ scheduleTerminalResize(true);
4851
6131
  }
4852
6132
  }
4853
6133
 
@@ -6486,20 +7766,9 @@
6486
7766
  }
6487
7767
 
6488
7768
  function normalizeTerminalOutput(value) {
6489
- var text = String(value || "");
6490
- var normalized = "";
6491
- for (var i = 0; i < text.length; i += 1) {
6492
- var char = text.charAt(i);
6493
- if (char === String.fromCharCode(10)) {
6494
- if (i === 0 || text.charAt(i - 1) !== String.fromCharCode(13)) {
6495
- normalized += String.fromCharCode(13);
6496
- }
6497
- normalized += char;
6498
- continue;
6499
- }
6500
- normalized += char;
6501
- }
6502
- return normalized;
7769
+ return String(value || "")
7770
+ .replace(/\r\r\n/g, "\r\n")
7771
+ .replace(/\u0000/g, "");
6503
7772
  }
6504
7773
 
6505
7774
  function showError(el, msg) {