agentgui 1.0.984 → 1.0.986

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.
@@ -45,9 +45,13 @@ const state = {
45
45
  files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
46
46
  };
47
47
 
48
+ // Two-step arm controls auto-reset after this delay so an accidental first click
49
+ // doesn't leave a "armed" button forever.
50
+ const ARM_RESET_MS = 4000;
51
+
48
52
  // Full routable param set. Every view-defining piece of state round-trips
49
53
  // through the hash so reload and Back/forward restore the exact view.
50
- const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section'];
54
+ const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter'];
51
55
  function readHash() {
52
56
  const hash = location.hash || '';
53
57
  const out = {};
@@ -70,6 +74,7 @@ function buildHash() {
70
74
  if (tab === 'files' && state.files) {
71
75
  if (state.files.path) parts.push('dir=' + encodeURIComponent(state.files.path));
72
76
  if (state.files.preview && state.files.preview.path) parts.push('file=' + encodeURIComponent(state.files.preview.path));
77
+ if (state.files.filter) parts.push('filter=' + encodeURIComponent(state.files.filter));
73
78
  }
74
79
  if (tab === 'history') {
75
80
  const q = (state.searchQ || '').trim();
@@ -171,6 +176,9 @@ function announce(msg) {
171
176
  requestAnimationFrame(() => { if (_announcer) _announcer.textContent = msg; });
172
177
  }
173
178
 
179
+ // Extract the last path segment from a file-system path (cross-platform / or \).
180
+ function pathBasename(p) { return p ? p.split(/[/\\]/).filter(Boolean).slice(-1)[0] || '' : ''; }
181
+
174
182
  function pillButton(key, label, active, title, onClick) {
175
183
  return h('button', {
176
184
  key,
@@ -244,7 +252,9 @@ function agentAvailable(id) { const a = agentById(id); return !a || a.available
244
252
  // Protocol ids are plumbing vocabulary; rows speak product words.
245
253
  const PROTOCOL_WORDS = { acp: 'managed server', cli: 'local CLI', direct: 'local CLI' };
246
254
  const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
247
- function sortedAgents() {
255
+ // Memoized: recomputed only when state.agents changes (in loadAgents), not on
256
+ // every chatMain() render. Stored in state.sortedAgentsCache.
257
+ function computeSortedAgents() {
248
258
  const rank = (a) => {
249
259
  const primary = PRIMARY_AGENTS.indexOf(a.id);
250
260
  const avail = a.available !== false;
@@ -258,6 +268,7 @@ function sortedAgents() {
258
268
  .sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
259
269
  .map(({ a }) => a);
260
270
  }
271
+ function sortedAgents() { return state.sortedAgentsCache || (state.sortedAgentsCache = computeSortedAgents()); }
261
272
 
262
273
  function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
263
274
  const prev = state.tab;
@@ -338,6 +349,7 @@ async function refreshActive() {
338
349
  try { next = await B.listActiveChats(state.backend); } catch { return; }
339
350
  const changed = activeSig(next) !== activeSig(state.active);
340
351
  state.active = next;
352
+ if (changed) state._sessionGroupsCache = null;
341
353
  // A stopping sid that left the active set has genuinely stopped - clear it
342
354
  // so the per-card 'stopping' state resolves.
343
355
  const st = state.live.stopping;
@@ -424,7 +436,11 @@ function openLiveStream() {
424
436
  // reconnect or overlap would otherwise double-append the same event.
425
437
  if (!state.events._seen) state.events._seen = new Set();
426
438
  if (ev.i == null || !state.events._seen.has(ev.i)) {
427
- if (ev.i != null) state.events._seen.add(ev.i);
439
+ if (ev.i != null) {
440
+ state.events._seen.add(ev.i);
441
+ // Cap the seen-set so a very long session doesn't grow unbounded.
442
+ if (state.events._seen.size > 5000) state.events._seen = new Set([...state.events._seen].slice(-2500));
443
+ }
428
444
  ev._idx = state.events.length;
429
445
  state.events.push(ev);
430
446
  // Cap retained events so a long live session can't grow unbounded.
@@ -449,6 +465,7 @@ function openLiveStream() {
449
465
  // only the one resumed in the in-page chat.
450
466
  if (ev.type === 'tool_use') { t.tools++; t.toolRunning = true; t.toolName = ev.tool || ev.name || ''; }
451
467
  if (ev.type === 'tool_result') { t.toolRunning = false; t.toolName = ''; }
468
+ if (ev.type === 'result' && ev.usage) { t.tokens = (t.tokens || 0) + (ev.usage.input_tokens || 0) + (ev.usage.output_tokens || 0); }
452
469
  if (ev.isError) { t.errors++; t.lastErrorTs = t.last; }
453
470
  }
454
471
  state.live.tally.set(data.sid, t);
@@ -576,8 +593,11 @@ function view() {
576
593
  });
577
594
 
578
595
  const shortcutsHint = state.showShortcuts
579
- ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
580
- children: ShortcutList({ shortcuts: SHORTCUTS }) })
596
+ ? h('div', { key: 'sc', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Keyboard shortcuts', class: 'ds-alert ds-alert--info shortcuts-dialog' },
597
+ h('div', { key: 'sc-head', class: 'ds-alert-head' },
598
+ h('span', { key: 'sc-title', class: 'ds-alert-title' }, 'Keyboard shortcuts'),
599
+ h('button', { key: 'sc-close', type: 'button', class: 'ds-btn', 'aria-label': 'Close keyboard shortcuts', ref: (el) => { if (el) requestAnimationFrame(() => el.focus()); }, onClick: () => { state.showShortcuts = false; render(); announce('shortcuts closed'); } }, 'close')),
600
+ h('div', { key: 'sc-body', class: 'ds-alert-body' }, ShortcutList({ shortcuts: SHORTCUTS })))
581
601
  : null;
582
602
  const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab }, [shortcutsHint, ...mainContent()].filter(Boolean));
583
603
 
@@ -606,7 +626,7 @@ function view() {
606
626
  // One affordance per action: the cwd row opens the SAME inline editor as
607
627
  // the composer context line (it validates via /api/stat) instead of
608
628
  // navigating away to the Files tab.
609
- onSetCwd: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
629
+ onSetCwd: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); requestAnimationFrame(() => { const inp = document.querySelector('.agentchat-cwd-input'); if (inp) inp.focus(); }); },
610
630
  });
611
631
  } else if (state.tab === 'files' && state.files && state.files.preview && !isNarrow()) {
612
632
  pane = filePreviewPane();
@@ -619,8 +639,11 @@ function view() {
619
639
  // The left workspace rail: brand, New chat action, and the primary view nav.
620
640
  function workspaceRail() {
621
641
  const liveCount = (Array.isArray(state.active) ? state.active.length : 0);
642
+ // Show a live pulse on the chat rail item when a stream is in progress but the
643
+ // user has navigated to a different tab - so the background stream stays visible.
644
+ const chatStreaming = state.chat.busy && state.tab !== 'chat';
622
645
  const items = [
623
- { key: 'chat', label: 'Chat', icon: 'forum', active: state.tab === 'chat', onClick: () => navTo('chat') },
646
+ { key: 'chat', label: 'Chat', icon: 'forum', active: state.tab === 'chat', count: chatStreaming ? 1 : null, onClick: () => navTo('chat') },
624
647
  { key: 'history', label: 'History', icon: 'thread', active: state.tab === 'history', onClick: () => navTo('history') },
625
648
  { key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
626
649
  { key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, onClick: () => navTo('live') },
@@ -655,6 +678,9 @@ const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier
655
678
  // chats are pinned to a "Running" section at the top (a live workspace surfaces
656
679
  // in-flight work first), the rest bucket by recency. Returns { items, groups }.
657
680
  function sessionGroups(sessionsView) {
681
+ // Cache key: sessions + active combo (both affect group membership).
682
+ const key = sessionsView.map(s => s.sid).join(',') + '|' + (Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId).join(',');
683
+ if (state._sessionGroupsCache && state._sessionGroupsCacheKey === key) return state._sessionGroupsCache;
658
684
  // Join on the REAL claude/ccsniff sid when known (chat.active rows carry the
659
685
  // ephemeral chat- id; claudeSessionId lands once streaming_session arrives).
660
686
  const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
@@ -667,6 +693,8 @@ function sessionGroups(sessionsView) {
667
693
  const groups = DATE_GROUP_ORDER
668
694
  .filter(l => buckets.has(l))
669
695
  .map(l => ({ label: l, sids: buckets.get(l) }));
696
+ state._sessionGroupsCacheKey = key;
697
+ state._sessionGroupsCache = groups;
670
698
  return groups;
671
699
  }
672
700
 
@@ -704,7 +732,7 @@ function sessionsColumn() {
704
732
  selected: state.selectedSid,
705
733
  search: {
706
734
  value: state.searchQ,
707
- placeholder: 'Search conversations',
735
+ placeholder: 'Search conversations (2+ chars)',
708
736
  onInput: (v) => { state.searchQ = v; if (v.trim().length >= 2) debouncedSearch(); else { state.searchHits = null; } render(); },
709
737
  },
710
738
  onNew: () => { navTo('chat'); newChat(); },
@@ -1100,12 +1128,30 @@ function fileDialog() {
1100
1128
  }
1101
1129
  if (d.kind === 'bulk-move') {
1102
1130
  const n = filesMarked().size;
1131
+ // Debounced /api/stat validation on the destination input (mirrors cwd field).
1132
+ if (!d._validateDest) {
1133
+ d._validateDest = debounce(async (v) => {
1134
+ if (!v || v === state.files.path) return;
1135
+ try {
1136
+ const st = await B.statPath(state.backend, v);
1137
+ if (state.files.dialog !== d) return;
1138
+ d.error = (!st || st.ok === false) ? 'folder not found on the server'
1139
+ : (!st.dir ? 'that path is not a directory' : null);
1140
+ } catch (e) {
1141
+ if (state.files.dialog !== d) return;
1142
+ d.error = e.status === 403 ? 'outside the allowed roots'
1143
+ : (e.status === 404 ? 'folder not found on the server' : null);
1144
+ }
1145
+ render();
1146
+ }, 400);
1147
+ }
1103
1148
  return PromptDialog({
1104
1149
  title: 'Move ' + n + ' selected ' + (n === 1 ? 'entry' : 'entries'),
1105
1150
  value: state.files.path || '', placeholder: 'destination folder path',
1106
1151
  error: d.error || null, busy: !!d.busy,
1107
1152
  confirmLabel: d.busy ? 'moving…' : 'move ' + n, cancelLabel: 'cancel',
1108
1153
  onCancel: closeFileDialog,
1154
+ onInput: (v) => { d.error = null; d._validateDest(v); },
1109
1155
  onConfirm: (v) => {
1110
1156
  if (!v) { d.error = 'enter a destination folder'; render(); return; }
1111
1157
  if (v === state.files.path) { d.error = 'already in that folder - enter a different destination'; render(); return; }
@@ -1272,9 +1318,10 @@ function filesMain() {
1272
1318
  persistFilesPrefs();
1273
1319
  render();
1274
1320
  } },
1275
- filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: (v) => { state.files.filter = v; state.files.shown = null; render(); } },
1321
+ filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: debouncedFilesFilter },
1276
1322
  onUp: fileUp,
1277
1323
  onOpen: (file) => {
1324
+ if (file.permissions === 'EACCES') { announce('no access to ' + file.name); return; }
1278
1325
  if (file.type === 'dir') loadDir(file.path);
1279
1326
  else openPreview(file);
1280
1327
  },
@@ -1315,7 +1362,9 @@ function filesMain() {
1315
1362
  f.path ? Btn({ key: 'upload', onClick: () => {
1316
1363
  const inp = document.createElement('input');
1317
1364
  inp.type = 'file'; inp.multiple = true;
1318
- inp.onchange = () => uploadFiles(inp.files);
1365
+ inp.style.position = 'fixed'; inp.style.opacity = '0';
1366
+ document.body.appendChild(inp);
1367
+ inp.onchange = () => { uploadFiles(inp.files); inp.remove(); };
1319
1368
  inp.click();
1320
1369
  }, children: 'upload' }) : null,
1321
1370
  targetCwd
@@ -1331,7 +1380,19 @@ function filesMain() {
1331
1380
  label: 'drop files to upload to this folder',
1332
1381
  onDragOver: () => { if (!state.files.dragover) { state.files.dragover = true; render(); } },
1333
1382
  onDragLeave: () => { if (state.files.dragover) { state.files.dragover = false; render(); } },
1334
- onDrop: (files) => { state.files.dragover = false; uploadFiles(files); },
1383
+ onDrop: (files, ev) => {
1384
+ state.files.dragover = false;
1385
+ // Detect directory drops: browsers report an empty FileList for dirs.
1386
+ // Check DataTransferItems when available; fall back to empty files list.
1387
+ const items = ev && ev.dataTransfer && ev.dataTransfer.items;
1388
+ if (items) {
1389
+ const hasDir = Array.from(items).some(it => { try { return it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory; } catch { return false; } });
1390
+ if (hasDir) { announce('Folders cannot be dropped — use the New Folder button to create directories'); return; }
1391
+ } else if (!files || !files.length) {
1392
+ announce('Folders cannot be dropped — use the New Folder button to create directories'); return;
1393
+ }
1394
+ uploadFiles(files);
1395
+ },
1335
1396
  children: body,
1336
1397
  })
1337
1398
  : body;
@@ -1405,13 +1466,13 @@ let _stopSelArmTimer = null;
1405
1466
  function armStopAll() {
1406
1467
  state.live.confirmingStopAll = true;
1407
1468
  clearTimeout(_stopAllArmTimer);
1408
- _stopAllArmTimer = setTimeout(() => { state.live.confirmingStopAll = false; render(); }, 4000);
1469
+ _stopAllArmTimer = setTimeout(() => { state.live.confirmingStopAll = false; render(); }, ARM_RESET_MS);
1409
1470
  render();
1410
1471
  }
1411
1472
  function armStopSelected() {
1412
1473
  state.live.confirmingStopSelected = true;
1413
1474
  clearTimeout(_stopSelArmTimer);
1414
- _stopSelArmTimer = setTimeout(() => { state.live.confirmingStopSelected = false; render(); }, 4000);
1475
+ _stopSelArmTimer = setTimeout(() => { state.live.confirmingStopSelected = false; render(); }, ARM_RESET_MS);
1415
1476
  render();
1416
1477
  }
1417
1478
 
@@ -1506,7 +1567,7 @@ function liveMain() {
1506
1567
  title: title || undefined,
1507
1568
  agent: agentById(r.agentId)?.name || r.agentId || 'agent',
1508
1569
  model: r.model || '',
1509
- cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
1570
+ cwd: pathBasename(r.cwd),
1510
1571
  elapsed: elapsedMs ? fmtDuration(elapsedMs) : '',
1511
1572
  elapsedMs,
1512
1573
  startedTs,
@@ -1519,10 +1580,12 @@ function liveMain() {
1519
1580
  stopping: stoppingSet.has(r.sessionId),
1520
1581
  // Arrival cue for a freshly-started session (a brief enter animation).
1521
1582
  isNew: startedTs ? (now - startedTs < 3000) : false,
1522
- // Surface the in-page chat's own running cost on its card (the only
1523
- // session we hold a reliable per-session cost for; others omit it).
1583
+ // Surface the in-page chat's own running cost and token count on its card
1584
+ // (the only session we hold reliable per-session data for; others omit it).
1524
1585
  cost: (state.chat.resumeSid && (r.claudeSessionId === state.chat.resumeSid || r.sessionId === state.chat.resumeSid))
1525
1586
  ? (state.chat.totalCost || null) : null,
1587
+ tokens: (state.chat.resumeSid && (r.claudeSessionId === state.chat.resumeSid || r.sessionId === state.chat.resumeSid) && state.chat.usage)
1588
+ ? ((state.chat.usage.inputTokens || 0) + (state.chat.usage.outputTokens || 0)) : (t && t.tokens ? t.tokens : undefined),
1526
1589
  };
1527
1590
  });
1528
1591
  // External sessions (a claude CLI in a terminal, etc.): live SSE motion that
@@ -1549,7 +1612,7 @@ function liveMain() {
1549
1612
  title: sess ? (projectLabel(sess.title) || projectLabel(sess.project) || sid) : sid,
1550
1613
  agent: 'external session',
1551
1614
  model: '',
1552
- cwd: sess && sess.cwd ? sess.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
1615
+ cwd: pathBasename(sess && sess.cwd),
1553
1616
  elapsed: '',
1554
1617
  elapsedMs: 0,
1555
1618
  startedTs: 0,
@@ -1558,7 +1621,9 @@ function liveMain() {
1558
1621
  lastTs: t.last,
1559
1622
  errors,
1560
1623
  currentTool: t.toolRunning ? (t.toolName || 'tool') : '',
1561
- status: (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) ? 'error' : 'running',
1624
+ // Apply stale detection to external sessions too: no recent activity + no running tool = stale.
1625
+ status: (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) ? 'error'
1626
+ : (!t.toolRunning && t.last && (nowS - t.last) > STALE_AFTER_MS * 0.6 ? 'stale' : 'running'),
1562
1627
  stopping: false,
1563
1628
  });
1564
1629
  }
@@ -1636,7 +1701,7 @@ function liveMain() {
1636
1701
  state.live.selected = sel;
1637
1702
  render();
1638
1703
  },
1639
- emptyText: 'No live sessions — agents you start (or run locally) appear here.',
1704
+ emptyText: (!sessions.length && (lv.filter || lv.errorsOnly)) ? 'No sessions match the current filter' : 'No live sessions — agents you start (or run locally) appear here.',
1640
1705
  emptyAction: { label: 'start a chat', onClick: () => { navTo('chat'); } },
1641
1706
  onStop: (s) => { if (!s.external) stopActiveChat(s.sid); },
1642
1707
  onStopAll: async (all) => {
@@ -1759,8 +1824,7 @@ function chatMain() {
1759
1824
  // silently loses prior context. Warn once the conversation is past its first
1760
1825
  // turn so the user knows this agent is not carrying history forward.
1761
1826
  {
1762
- const userTurns = state.chat.messages.filter(m => m.role === 'user').length;
1763
- if (state.selectedAgent && state.selectedAgent !== 'claude-code' && userTurns > 1 && !state.chat.resumeSid) {
1827
+ if (state.selectedAgent && state.selectedAgent !== 'claude-code' && userTurnCount > 1 && !state.chat.resumeSid) {
1764
1828
  banners.push(Alert({ key: 'nocontinuity', kind: 'info', title: (agentById(state.selectedAgent)?.name || state.selectedAgent) + ' does not resume across turns',
1765
1829
  children: 'This agent starts fresh each message - it will not remember earlier turns in this chat. Use Claude Code for a continuous conversation.' }));
1766
1830
  }
@@ -1812,6 +1876,10 @@ function chatMain() {
1812
1876
  h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load. '),
1813
1877
  Btn({ key: 'agretry', onClick: () => loadAgents(), children: 'retry' })] }));
1814
1878
  }
1879
+ if (state.chat.loadingTranscript) {
1880
+ banners.push(Alert({ key: 'transcriptload', kind: 'info', title: 'Loading prior conversation...',
1881
+ children: [Spinner({ key: 'trspin', size: 'sm' })] }));
1882
+ }
1815
1883
  if (state.chat.confirmingEdit) {
1816
1884
  banners.push(Alert({ key: 'confedit', kind: 'warn', title: 'Edit this message?',
1817
1885
  children: [
@@ -1878,9 +1946,9 @@ function chatMain() {
1878
1946
  agentName,
1879
1947
  state.selectedModel || null,
1880
1948
  {
1881
- label: state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default',
1949
+ label: state.chatCwd ? pathBasename(state.chatCwd) : 'server default',
1882
1950
  title: 'change working directory',
1883
- onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
1951
+ onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); requestAnimationFrame(() => { const inp = document.querySelector('.chat-cwd-input, .agentchat-cwd-input'); if (inp) inp.focus(); }); },
1884
1952
  },
1885
1953
  userTurnCount > 0 ? plural(userTurnCount, 'turn') : null,
1886
1954
  (state.chat.resumeSid && state.selectedAgent === 'claude-code')
@@ -1940,7 +2008,7 @@ function chatMain() {
1940
2008
  if (was !== now) render();
1941
2009
  },
1942
2010
  onSend: (v) => { state.chat.draft = v; sendChat(); },
1943
- onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
2011
+ onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); requestAnimationFrame(() => { const inp = document.querySelector('.agentchat-cwd-input'); if (inp) inp.focus(); }); },
1944
2012
  onCwdSave: async () => {
1945
2013
  const path = (state.cwdDraft ?? '').trim();
1946
2014
  // A relative cwd would resolve against the server process dir, not what
@@ -1970,7 +2038,7 @@ function chatMain() {
1970
2038
  if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
1971
2039
  state.cwdEditing = false; state.cwdDraft = undefined; render();
1972
2040
  },
1973
- onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false; render(); },
2041
+ onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false; render(); requestAnimationFrame(() => { const btn = document.querySelector('.agentchat-cwd-btn'); if (btn) btn.focus(); }); },
1974
2042
  onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
1975
2043
  onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
1976
2044
  }),
@@ -1997,7 +2065,7 @@ function newChat() {
1997
2065
  if (state.confirmingNewChat) { render(); return; } // armed: repeat press is a no-op
1998
2066
  state.confirmingNewChat = true;
1999
2067
  clearTimeout(_newChatArmTimer);
2000
- _newChatArmTimer = setTimeout(() => { state.confirmingNewChat = false; render(); }, 4000);
2068
+ _newChatArmTimer = setTimeout(() => { state.confirmingNewChat = false; render(); }, ARM_RESET_MS);
2001
2069
  render();
2002
2070
  return;
2003
2071
  }
@@ -2126,6 +2194,7 @@ function messageToText(m) {
2126
2194
  return m.parts.map((p) => {
2127
2195
  if (typeof p === 'string') return p;
2128
2196
  if (p.kind === 'md' || p.kind === 'text') return p.text || '';
2197
+ if (p.kind === 'thinking') return '[thinking: ' + (p.text || '') + ']';
2129
2198
  if (p.kind === 'tool') return '[tool: ' + (p.name || '') + (p.label ? ' ' + p.label : '') + ']';
2130
2199
  return '';
2131
2200
  }).filter(Boolean).join('\n');
@@ -2161,6 +2230,7 @@ function transcriptToMarkdown(messages) {
2161
2230
  ? parts.map((p) => {
2162
2231
  if (typeof p === 'string') return p;
2163
2232
  if (p.kind === 'md' || p.kind === 'text') return p.text || '';
2233
+ if (p.kind === 'thinking') return '> thinking: ' + (p.text || '');
2164
2234
  if (p.kind === 'tool' || p.kind === 'tool_result') {
2165
2235
  const bits = ['## tool: ' + (p.name || 'tool')];
2166
2236
  if (p.args && Object.keys(p.args).length) bits.push('```json\n' + JSON.stringify(p.args, null, 2) + '\n```');
@@ -2180,6 +2250,8 @@ const FOLLOWUP_SEEDS = ['Explain that in more detail', 'Show me the diff', 'Run
2180
2250
  function chatFollowups() {
2181
2251
  const msgs = state.chat.messages || [];
2182
2252
  if (!msgs.length || state.chat.busy) return [];
2253
+ // Memoize: recompute only when the messages array reference changes.
2254
+ if (state._followupsCache && state._followupsMsgs === msgs) return state._followupsCache;
2183
2255
  const last = [...msgs].reverse().find(m => m.role === 'assistant');
2184
2256
  if (!last) return [];
2185
2257
  const out = [];
@@ -2192,7 +2264,10 @@ function chatFollowups() {
2192
2264
  const base = fm[1].split(/[/\\]/).filter(Boolean).pop();
2193
2265
  if (base) out.push('Open ' + base.replace(/[^\w.\-]/g, '') + ' in files');
2194
2266
  }
2195
- return out.length ? out.slice(0, 3) : FOLLOWUP_SEEDS;
2267
+ const result = out.length ? out.slice(0, 3) : FOLLOWUP_SEEDS;
2268
+ state._followupsMsgs = msgs;
2269
+ state._followupsCache = result;
2270
+ return result;
2196
2271
  }
2197
2272
  // Retry the last assistant turn: drop it and re-send the preceding user message.
2198
2273
  function retryLastTurn() {
@@ -2281,6 +2356,11 @@ async function sendChat(textArg) {
2281
2356
  agentId: state.selectedAgent,
2282
2357
  model: state.selectedModel || undefined,
2283
2358
  cwd: state.chatCwd || undefined,
2359
+ // Non-resume agents receive a flattened transcript (tool parts become text summaries).
2360
+ // Claude-code uses --resume and ignores this array; it is only used by direct/ACP agents.
2361
+ // NOTE: only m.content (or its text equivalent) is sent here - tool_use/tool_result
2362
+ // parts are intentionally flattened to text so non-claude-code agents receive a
2363
+ // readable transcript rather than raw API objects they cannot process.
2284
2364
  messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content || messageToText(m) })),
2285
2365
  signal: ctrl.signal,
2286
2366
  // Only claude-code consumes a resume sid; never forward a stale one to
@@ -2382,7 +2462,8 @@ function reconnectAlert() {
2382
2462
  });
2383
2463
  }
2384
2464
 
2385
- // Humanize a millisecond span (1m 30s scale words, no glyphs).
2465
+ // Inline fallback for the fmtDuration kit export (line 11 references humanizeMs
2466
+ // as the local fallback when the kit does not export fmtDuration yet).
2386
2467
  function humanizeMs(ms) {
2387
2468
  if (ms == null || !isFinite(ms) || ms < 0) return '';
2388
2469
  const s = Math.round(ms / 1000);
@@ -2395,14 +2476,16 @@ function humanizeMs(ms) {
2395
2476
  function sessionDuration() {
2396
2477
  const ts = (state.events || []).map(e => e.ts).filter(Boolean);
2397
2478
  if (ts.length < 2) return '';
2398
- return humanizeMs(ts.reduce((a, b) => b > a ? b : a, ts[0]) - ts.reduce((a, b) => b < a ? b : a, ts[0]));
2479
+ return fmtDuration(ts.reduce((a, b) => b > a ? b : a, ts[0]) - ts.reduce((a, b) => b < a ? b : a, ts[0]));
2399
2480
  }
2400
2481
 
2401
- // Event-type filter predicate (all | text | tool | errors).
2482
+ // Event-type filter predicate (all | text | tool | errors | thinking).
2402
2483
  function eventMatchesFilter(e, f) {
2403
2484
  if (f === 'tool') return e.type === 'tool_use' || e.type === 'tool_result';
2404
2485
  if (f === 'errors') return !!e.isError;
2405
- if (f === 'text') return !e.isError && e.type !== 'tool_use' && e.type !== 'tool_result';
2486
+ if (f === 'thinking') return e.type === 'thinking';
2487
+ // text excludes thinking events so they don't appear under a non-dedicated filter.
2488
+ if (f === 'text') return !e.isError && e.type !== 'tool_use' && e.type !== 'tool_result' && e.type !== 'thinking';
2406
2489
  return true;
2407
2490
  }
2408
2491
 
@@ -2493,6 +2576,7 @@ function historyMain() {
2493
2576
  { id: 'text', label: 'text' },
2494
2577
  { id: 'tool', label: 'tools' },
2495
2578
  { id: 'errors', label: 'errors' },
2579
+ { id: 'thinking', label: 'thinking' },
2496
2580
  ],
2497
2581
  selected: ef,
2498
2582
  onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
@@ -2516,6 +2600,7 @@ function historyMain() {
2516
2600
  { label: 'turns', value: String(sess?.userTurns ?? evCounters.turns) },
2517
2601
  { label: 'tools', value: String(evCounters.tools) },
2518
2602
  { label: 'errors', value: String(evCounters.errors) },
2603
+ sess && sess.cost != null ? { label: 'cost', value: '$' + Number(sess.cost).toFixed(4) } : null,
2519
2604
  ].filter(Boolean),
2520
2605
  });
2521
2606
  if (filteredEvents.length === 0) {
@@ -2541,7 +2626,7 @@ function historyMain() {
2541
2626
  render();
2542
2627
  }, children: allExpanded ? 'collapse shown' : 'expand shown' }),
2543
2628
  hiddenCount > 0
2544
- ? Btn({ key: 'older', onClick: () => { state.eventsLimit += 300; render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
2629
+ ? Btn({ key: 'older', onClick: () => { const added = Math.min(300, hiddenCount); state.eventsLimit += 300; announce('loaded ' + added + ' more events'); render(); }, children: 'load ' + Math.min(300, hiddenCount) + ' older (' + hiddenCount + ' hidden)' })
2545
2630
  : null,
2546
2631
  );
2547
2632
  return [
@@ -2564,16 +2649,17 @@ function historyMain() {
2564
2649
  const tool = e.tool ? ' · tool: ' + e.tool : '';
2565
2650
  const errMark = e.isError ? ' · error' : '';
2566
2651
  const raw = e.text || '';
2567
- const text = raw.replace(/\s+/g, ' ').trim();
2568
- const typePrefix = e.type === 'tool_result' ? '(result) ' : (e.type === 'tool_use' ? '(tool call) ' : '');
2652
+ const text = raw.replace(/\s+/g, ' ').trim() || (e.type === 'tool_use' && e.toolInput ? toolLabel(e.toolInput) : '');
2653
+ const toolNamePrefix = (e.type === 'tool_use' && e.tool) ? e.tool + ': ' : '';
2654
+ const typePrefix = e.type === 'tool_result' ? '(result) ' : (e.type === 'tool_use' ? ('(tool call) ' + toolNamePrefix) : '');
2569
2655
  const expanded = state.expandedEvents.has(key);
2570
2656
  // Only build the expanded body (JSON.stringify tool input) when the row is
2571
2657
  // expanded - doing it for all ~300 rows every frame wastes work mid-stream.
2572
2658
  const full = expanded ? (e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw) : '';
2573
2659
  // Rail tone matches the session/agents rail semantics so an event's
2574
2660
  // kind is visible at a glance, consistent across the GUI:
2575
- // flame = error, purple = tool_use, green = normal turn.
2576
- const rail = e.isError ? 'flame' : (e.type === 'tool_use' ? 'flame' : 'green');
2661
+ // flame = error, purple = tool activity, green = normal turn.
2662
+ const rail = e.isError ? 'flame' : (e.type === 'tool_use' || e.type === 'tool_result' ? 'purple' : 'green');
2577
2663
  // When the session was opened from a search hit, window the collapsed
2578
2664
  // title AROUND the first query match (a match at char 5000 would
2579
2665
  // otherwise be invisible behind the 0-220 slice).
@@ -2657,6 +2743,33 @@ function resumeInChat(sess, { fromHash = false } = {}) {
2657
2743
  }
2658
2744
  if (state.selectedAgent !== 'claude-code') selectAgent('claude-code');
2659
2745
  render();
2746
+ // Load prior turns from history so the user can re-read context inline.
2747
+ const sidToLoad = state.chat.resumeSid;
2748
+ if (sidToLoad) {
2749
+ state.chat.loadingTranscript = true;
2750
+ render();
2751
+ B.getSessionEvents(state.backend, sidToLoad).then(evs => {
2752
+ // Only populate if still on the same resume (user may have switched).
2753
+ if (state.chat.resumeSid !== sidToLoad || state.chat.messages.length) return;
2754
+ const msgs = [];
2755
+ for (const e of (evs || []).slice(-50)) {
2756
+ if (e.type === 'human' || e.role === 'user') {
2757
+ const text = e.text || e.content || '';
2758
+ if (text) msgs.push({ id: 'rh' + e.ts, role: 'user', content: text, time: e.ts, historical: true });
2759
+ } else if (e.type === 'assistant' || e.role === 'assistant') {
2760
+ const text = e.text || '';
2761
+ if (text) msgs.push({ id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [{ kind: 'md', text }], historical: true });
2762
+ }
2763
+ }
2764
+ if (state.chat.resumeSid === sidToLoad && !state.chat.messages.length) {
2765
+ state.chat.messages = msgs;
2766
+ }
2767
+ }).catch(() => {}) // history may not be available; silent fail
2768
+ .finally(() => {
2769
+ if (state.chat.resumeSid === sidToLoad) state.chat.loadingTranscript = false;
2770
+ render();
2771
+ });
2772
+ }
2660
2773
  }
2661
2774
 
2662
2775
  function visibleSessions() {
@@ -2712,7 +2825,7 @@ function runningPanel() {
2712
2825
  // unkeyed one crashes webjsx applyDiff "reading 'key'").
2713
2826
  return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
2714
2827
  h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc ' + (isStopping ? 'status-dot-connecting' : 'status-dot-live'), 'aria-hidden': 'true' }),
2715
- h('span', { key: 'rl-' + r.sessionId, class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + (elapsedMs ? ' · ' + fmtDuration(elapsedMs) : '') + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
2828
+ h('span', { key: 'rl-' + r.sessionId, class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + (elapsedMs ? ' · ' + fmtDuration(elapsedMs) : '') + (r.cwd ? ' · ' + pathBasename(r.cwd) : '')),
2716
2829
  Btn({ key: 'open' + r.sessionId, onClick: () => navTo('live'), children: 'open in live' }),
2717
2830
  Btn({ key: 'stop' + r.sessionId, disabled: isStopping, onClick: () => stopActiveChat(r.sessionId), children: isStopping ? 'stopping…' : 'stop' }));
2718
2831
  }),
@@ -2720,107 +2833,6 @@ function runningPanel() {
2720
2833
  });
2721
2834
  }
2722
2835
 
2723
- function historySide() {
2724
- const searching = !!state.searchHits;
2725
- const sessionsView = visibleSessions();
2726
- const limit = state.sessionsLimit;
2727
- const visible = searching ? state.searchHits.results.slice(0, 60) : sessionsView.slice(0, limit);
2728
- const truncatedBy = searching ? Math.max(0, state.searchHits.results.length - 60) : Math.max(0, sessionsView.length - limit);
2729
- const rows = searching
2730
- ? visible.map((r, i) =>
2731
- Row({
2732
- // sid can repeat across hits, so key on sid+position; rank is the
2733
- // absolute result position (stable across the 60-row slice).
2734
- key: 'sr-' + (r.sid || '?') + '-' + i,
2735
- rank: String(i + 1).padStart(3, '0'),
2736
- title: r.snippet || '(no snippet)',
2737
- highlight: state.searchQ || undefined,
2738
- sub: (projectLabel(r.project) || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
2739
- // Rail carries the same semantics as session rows: error > subagent > normal.
2740
- rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
2741
- // Carry the matched event's index so loadSession scrolls to + flashes
2742
- // the match, instead of dropping the user at the top of the session.
2743
- onClick: () => loadSession(r.sid, { focusEventI: r.i, focusEventTs: r.ts }),
2744
- })
2745
- )
2746
- : visible.map((s, i) =>
2747
- Row({
2748
- key: 'sess' + s.sid,
2749
- rank: String(i + 1).padStart(3, '0'),
2750
- // Subagent is conveyed by the purple rail, not a "- " text prefix.
2751
- title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
2752
- // Always show the error count so 0 reads as "no errors", not "untracked".
2753
- sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools · ' + (s.errors || 0) + ' err',
2754
- rail: sessionErrorDense(s) ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
2755
- active: s.sid === state.selectedSid,
2756
- onClick: () => loadSession(s.sid),
2757
- })
2758
- );
2759
- const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
2760
- const projects = uniqueProjects();
2761
-
2762
- return [
2763
- runningPanel(),
2764
- Panel({
2765
- title: searching
2766
- ? 'matches · ' + (state.searchHits.results?.length || 0)
2767
- : ('sessions · ' + sessionsView.length + (subagentCount && !state.showSubagents ? ' (+'+subagentCount+' sub)' : '')),
2768
- children: [
2769
- SearchInput({
2770
- key: 'searchInput',
2771
- // The DS SearchInput reads `label` (not aria-label) for the accessible
2772
- // name, falling back to placeholder; pass label so AT announces it.
2773
- placeholder: 'Search event text across sessions',
2774
- label: 'Search event text across sessions',
2775
- 'aria-label': 'Search event text across sessions',
2776
- value: state.searchQ,
2777
- onInput: (v) => { state.searchQ = v; debouncedSearch(); },
2778
- }),
2779
- state.searchBusy
2780
- ? h('div', { key: 'searchbusy', class: 'lede empty-state empty-state--inline', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching…')
2781
- : null,
2782
- searching && state.searchHits.error
2783
- ? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
2784
- : null,
2785
- searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
2786
- ? h('p', { key: 'nomatch', class: 'lede empty-state empty-state--inline' }, 'no matches for "' + state.searchQ + '"')
2787
- : null,
2788
- state.searchQ.trim().length === 1
2789
- ? h('p', { key: 'min2', class: 'lede empty-state empty-state--inline' }, 'type at least 2 characters to search')
2790
- : null,
2791
- state.searchQ
2792
- ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; writeHash(); render(); }, children: 'clear search' })
2793
- : null,
2794
- !searching && projects.length > 1
2795
- ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
2796
- pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; writeHash(); render(); }),
2797
- ...projects.slice(0, 8).map(([name, count]) =>
2798
- pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; writeHash(); render(); })))
2799
- : null,
2800
- !searching && subagentCount
2801
- ? h('div', { key: 'subtog', class: 'lede subagent-toggle' },
2802
- Checkbox({
2803
- checked: state.showSubagents,
2804
- label: 'show subagents (' + (state.showSubagents ? subagentCount + ' shown' : subagentCount + ' hidden') + ')',
2805
- onChange: (v) => { state.showSubagents = v; render(); },
2806
- }))
2807
- : null,
2808
- state.historyError
2809
- ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
2810
- : (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede empty-state' }, 'no sessions yet')),
2811
- !searching && truncatedBy > 0
2812
- ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
2813
- : null,
2814
- // Search is server-capped at 60 hits; there is no deeper page, so tell
2815
- // the user the result set is truncated rather than silently hiding it.
2816
- searching && truncatedBy > 0
2817
- ? h('p', { key: 'searchmore', class: 'lede empty-state' }, 'showing first 60 matches (' + truncatedBy + ' more - refine your search)')
2818
- : null,
2819
- ],
2820
- }),
2821
- ].filter(Boolean);
2822
- }
2823
-
2824
2836
  // --- settings ---
2825
2837
  function isValidUrl(s) {
2826
2838
  if (!s) return true; // blank = same-origin is valid
@@ -2843,6 +2855,13 @@ function normalizeBackend(s) {
2843
2855
 
2844
2856
  async function saveBackend() {
2845
2857
  if (!isValidUrl(state.backendDraft)) return;
2858
+ // Block a mid-stream backend switch: the in-flight fetch is bound to the old
2859
+ // origin and will error or produce split state if the backend changes under it.
2860
+ if (state.chat.busy) {
2861
+ state.backendError = 'A chat is in progress - stop it before switching backends.';
2862
+ render();
2863
+ return;
2864
+ }
2846
2865
  // Re-submitting the current URL (e.g. after a failed health check) re-runs
2847
2866
  // the health probe and shows connecting… so the user gets visible feedback.
2848
2867
  if (state.backendDraft === state.backend) { state.backendStatus = 'connecting'; render(); await recheckHealth(); return; }
@@ -2949,6 +2968,9 @@ function settingsMain() {
2949
2968
  state.backendDraft = v;
2950
2969
  // The armed confirmation refers to a different value now - disarm.
2951
2970
  if (state.confirmingBackend !== undefined && state.confirmingBackend !== v) state.confirmingBackend = undefined;
2971
+ // Clear stale probe results so the old error doesn't persist while typing.
2972
+ state.backendError = undefined;
2973
+ if (state.backendStatus === 'failed') state.backendStatus = undefined;
2952
2974
  render();
2953
2975
  },
2954
2976
  }),
@@ -2999,7 +3021,7 @@ function serverPanel() {
2999
3021
  title: 'server',
3000
3022
  children: [
3001
3023
  h('div', { key: 'sv', class: 'lede' }, 'version: ' + (hh.version ? 'v' + hh.version : 'unknown')),
3002
- h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ? humanizeMs(upMs) : 'unknown')),
3024
+ h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ? fmtDuration(upMs) : 'unknown')),
3003
3025
  h('div', { key: 'swc', class: 'lede' }, 'connected clients: ' + (wsClients != null ? wsClients : 'unknown')),
3004
3026
  h('div', { key: 'spd', class: 'lede' }, 'projects folder: ' + (hh.projectsDir || 'unknown')),
3005
3027
  roots.length
@@ -3091,6 +3113,7 @@ function agentsPanel() {
3091
3113
  // manual start/stop controls by design. This note makes that explicit so
3092
3114
  // a 'stopped' row doesn't read as a missing action.
3093
3115
  hasAcp ? h('p', { key: 'acpnote', class: 'lede agentgui-field-mb' }, 'ACP agents start on demand and restart automatically; selecting one launches it.') : null,
3116
+ h('div', { key: 'agrefreshrow', class: 'agentgui-field-mb' }, Btn({ key: 'agrefresh', onClick: () => loadAgents(), children: 'refresh' })),
3094
3117
  ...(state.agents.length
3095
3118
  ? state.agents.map((a, i) => {
3096
3119
  const acp = acpStatusFor(a.id);
@@ -3118,6 +3141,9 @@ function agentsPanel() {
3118
3141
  // click, no button role) instead of looking clickable but doing nothing.
3119
3142
  state: usable ? 'default' : 'disabled',
3120
3143
  onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
3144
+ right: (acp && !acp.healthy)
3145
+ ? [Btn({ key: 'acprestart', onClick: (e) => { e.stopPropagation(); B.restartAcpAgent(state.backend, a.id).then(() => loadAgents()); }, children: 'restart' })]
3146
+ : undefined,
3121
3147
  });
3122
3148
  })
3123
3149
  // The empty array means one of three things; never let an in-flight load
@@ -3136,6 +3162,11 @@ function agentsPanel() {
3136
3162
 
3137
3163
  // --- data ---
3138
3164
  async function refreshHistory() {
3165
+ // Guard against concurrent calls: a slow first fetch followed by a polling
3166
+ // trigger would otherwise stack two in-flight requests; the second would
3167
+ // overwrite state mid-render with a stale response.
3168
+ if (state._historyFetching) return;
3169
+ state._historyFetching = true;
3139
3170
  // Warmup copy: the FIRST sessions fetch can sit behind ccsniff's 30-90s
3140
3171
  // JSONL walk; after 5s swap the loading copy to indexing language.
3141
3172
  const firstLoad = !state._historyLoadedOnce;
@@ -3149,6 +3180,7 @@ async function refreshHistory() {
3149
3180
  // Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
3150
3181
  // linear scan per event during a burst load.
3151
3182
  state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
3183
+ state._sessionGroupsCache = null;
3152
3184
  // Bound the live tally: drop entries with no activity in 24h and cap the
3153
3185
  // Map at ~200 most-recent sids (a long-lived tab otherwise accumulates
3154
3186
  // every sid ever seen, and dead entries could resurrect wrong externals).
@@ -3179,11 +3211,16 @@ async function refreshHistory() {
3179
3211
  // render-stack string and never clear), so render() lives outside this try.
3180
3212
  state.historyError = errText(e);
3181
3213
  console.warn('history fetch failed:', e.message);
3214
+ } finally {
3215
+ state._historyFetching = false;
3216
+ if (slowTimer) clearTimeout(slowTimer);
3217
+ render();
3182
3218
  }
3183
- if (slowTimer) clearTimeout(slowTimer);
3184
- render();
3185
3219
  }
3186
3220
  const debouncedRefreshHistory = debounce(refreshHistory, 500);
3221
+ // Debounced files filter: toLowerCase() on every entry runs on every keystroke;
3222
+ // 150ms coalesces rapid typing into one filter pass (perf-003).
3223
+ const debouncedFilesFilter = debounce((v) => { state.files.filter = v; state.files.shown = null; if (state.tab === 'files') writeHash(); render(); }, 150);
3187
3224
 
3188
3225
  async function runSearch() {
3189
3226
  const q = state.searchQ.trim();
@@ -3300,6 +3337,7 @@ async function loadAgents() {
3300
3337
  state.agentsLoading = true;
3301
3338
  try {
3302
3339
  state.agents = await B.listAgents(state.backend);
3340
+ state.sortedAgentsCache = null; // invalidate memoized sort when agents list changes
3303
3341
  state.agentsLoading = false;
3304
3342
  // Agent selection priority: the agent a restored transcript belongs to (so
3305
3343
  // the chat isn't shown under the wrong agent), else the saved picker agent,
@@ -3383,7 +3421,10 @@ async function init() {
3383
3421
  // navTo - loadDir sets loading=true synchronously, so navTo's default
3384
3422
  // loadDir('') guard skips and the two never race. Once the listing
3385
3423
  // resolves, restore the file= preview on top of it.
3386
- if (bootTab === 'files' && hp.dir) loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
3424
+ if (bootTab === 'files' && hp.dir) {
3425
+ if (hp.filter) state.files.filter = hp.filter;
3426
+ loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
3427
+ }
3387
3428
  if (bootTab === 'history' && hp.q) state.searchQ = hp.q;
3388
3429
  if (bootTab === 'history' && hp.project) state.projectFilter = hp.project;
3389
3430
  navTo(bootTab, { push: false });
@@ -3463,6 +3504,7 @@ window.addEventListener('popstate', () => {
3463
3504
  }
3464
3505
  if (tab === 'files') {
3465
3506
  const cur = state.files && state.files.path;
3507
+ if (hp.filter !== undefined && hp.filter !== null) state.files.filter = hp.filter || '';
3466
3508
  if (hp.dir && hp.dir !== cur) {
3467
3509
  // Restore the file= preview only once the directory listing resolves
3468
3510
  // (the entry object lives in state.files.entries).
@@ -3564,6 +3606,13 @@ window.addEventListener('keydown', (e) => {
3564
3606
  return;
3565
3607
  }
3566
3608
  if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
3609
+ // Left/Right: step through file previews (documented in SHORTCUTS).
3610
+ if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && state.tab === 'files' && state.files.preview) {
3611
+ const { prev, next } = previewNeighbours();
3612
+ const target = e.key === 'ArrowLeft' ? prev : next;
3613
+ if (target) { e.preventDefault(); openPreview(target); }
3614
+ return;
3615
+ }
3567
3616
  });
3568
3617
 
3569
3618
  // A file dropped anywhere outside a DropZone must never navigate the browser