agentgui 1.0.958 → 1.0.960

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.
@@ -3,7 +3,13 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
6
- const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane } = C;
6
+ const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta } = C;
7
+
8
+ // One duration/bytes vocabulary across every surface: prefer the kit's shared
9
+ // formatters (exported alongside the components), fall back to the local
10
+ // equivalents so the app keeps working against an older vendored kit build.
11
+ const fmtDuration = C.fmtDuration || ((ms) => humanizeMs(ms));
12
+ const fmtBytes = C.fmtBytes || ((n) => (Math.round((n || 0) / 102.4) / 10) + ' KB');
7
13
 
8
14
  const state = {
9
15
  backend: B.getBackend(),
@@ -15,7 +21,13 @@ const state = {
15
21
  agentModels: [],
16
22
  selectedModel: lsGet('agentgui.model') || '',
17
23
  chatCwd: lsGet('agentgui.cwd') || '',
18
- chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
24
+ chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 },
25
+ agentsError: null,
26
+ settingsSection: null,
27
+ eventFilter: 'all', // history event-type filter: all | text | tool | errors
28
+ sessionSearchQ: null, // the query the selected session was opened from (search-hit highlight)
29
+ historySlow: false, // first history fetch unresolved after 5s -> indexing copy
30
+ eventsSlow: false, // first events fetch unresolved after 5s -> indexing copy
19
31
  sessions: [],
20
32
  selectedSid: null,
21
33
  events: [],
@@ -25,39 +37,62 @@ const state = {
25
37
  showSubagents: false,
26
38
  sessionsLimit: 60,
27
39
  projectFilter: '',
28
- live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0 },
40
+ live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0, stopping: new Set(), clockSkew: null },
29
41
  active: [],
30
42
  activeTimer: null,
31
43
  eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
32
44
  files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
33
45
  };
34
46
 
47
+ // Full routable param set. Every view-defining piece of state round-trips
48
+ // through the hash so reload and Back/forward restore the exact view.
49
+ const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section'];
35
50
  function readHash() {
36
51
  const hash = location.hash || '';
37
- const sidM = hash.match(/sid=([^&]+)/);
38
- const tabM = hash.match(/tab=([^&]+)/);
39
- return {
40
- sid: sidM ? decodeURIComponent(sidM[1]) : null,
41
- tab: tabM ? decodeURIComponent(tabM[1]) : null,
42
- };
52
+ const out = {};
53
+ for (const k of HASH_KEYS) {
54
+ const m = hash.match(new RegExp('(?:^#|&)' + k + '=([^&]*)'));
55
+ out[k] = m ? decodeURIComponent(m[1]) : null;
56
+ }
57
+ return out;
43
58
  }
44
- function buildHash(tab, sid) {
59
+ function buildHash() {
45
60
  const parts = [];
46
- if (tab && tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
47
- if (sid) parts.push('sid=' + encodeURIComponent(sid));
61
+ const tab = state.tab || 'chat';
62
+ // tab is omitted for the default chat tab, EXCEPT when a session id rides
63
+ // along - a bare #sid= historically meant history, so chat+sid must name its
64
+ // tab explicitly or the deep-link restores the wrong surface.
65
+ if (tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
66
+ else if (state.selectedSid) parts.push('tab=chat');
67
+ // Keep sid whenever set (regardless of tab) so Back restores the selection.
68
+ if (state.selectedSid) parts.push('sid=' + encodeURIComponent(state.selectedSid));
69
+ if (tab === 'files' && state.files) {
70
+ if (state.files.path) parts.push('dir=' + encodeURIComponent(state.files.path));
71
+ if (state.files.preview && state.files.preview.path) parts.push('file=' + encodeURIComponent(state.files.preview.path));
72
+ }
73
+ if (tab === 'history') {
74
+ const q = (state.searchQ || '').trim();
75
+ if (q.length >= 2) parts.push('q=' + encodeURIComponent(q));
76
+ if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
77
+ }
78
+ if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
48
79
  return parts.length ? '#' + parts.join('&') : '';
49
80
  }
50
- function writeHash(sid, { push = false } = {}) {
51
- const h = buildHash(state.tab, sid);
81
+ function writeHash({ push = false } = {}) {
82
+ const h = buildHash();
52
83
  const url = location.pathname + location.search + h;
53
- if (location.hash === h) return;
54
- // pushState for session selection so Back returns to the session list;
55
- // replaceState for tab-only changes.
84
+ if (location.hash === h || (!location.hash && !h)) return;
85
+ // pushState for user navigation steps (tab visits, session selection,
86
+ // directory walks, preview opens) so Back retraces them; replaceState for
87
+ // passive state sync (search text, filter resets).
56
88
  (push ? history.pushState : history.replaceState).call(history, null, '', url);
57
89
  }
58
90
  function fmtRelTime(ts) {
59
91
  if (!ts) return '';
60
92
  const s = Math.round((Date.now() - ts) / 1000);
93
+ // Clamp negative spans (server clock ahead of the client) to "just now" -
94
+ // "-12s ago" is clock skew, not information.
95
+ if (s <= 0) return 'just now';
61
96
  if (s < 60) return s + 's ago';
62
97
  if (s < 3600) return Math.round(s/60) + 'm ago';
63
98
  if (s < 86400) return Math.round(s/3600) + 'h ago';
@@ -78,6 +113,18 @@ function scheduleRender() {
78
113
  let streamRenderScheduled = false;
79
114
  function scheduleStreamRender() {
80
115
  if (streamRenderScheduled) return;
116
+ // Don't wipe an active text selection inside the streaming thread: a
117
+ // re-render replaces the live bubble's text nodes every frame, destroying a
118
+ // select-and-copy in progress. The stream's settle path (finally) still
119
+ // renders unconditionally, so nothing is lost - rendering just pauses while
120
+ // the selection is held.
121
+ try {
122
+ const sel = document.getSelection();
123
+ if (sel && !sel.isCollapsed && sel.anchorNode) {
124
+ const node = sel.anchorNode.nodeType === 1 ? sel.anchorNode : sel.anchorNode.parentElement;
125
+ if (node && node.closest && node.closest('.agentchat-thread, .chat-thread')) return;
126
+ }
127
+ } catch {}
81
128
  streamRenderScheduled = true;
82
129
  requestAnimationFrame(() => {
83
130
  streamRenderScheduled = false;
@@ -206,7 +253,7 @@ function sortedAgents() {
206
253
  .map(({ a }) => a);
207
254
  }
208
255
 
209
- function navTo(tab) {
256
+ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
210
257
  const prev = state.tab;
211
258
  // Leaving chat clears any pending new-chat confirmation so it doesn't linger
212
259
  // as a stale banner when the user returns.
@@ -215,7 +262,11 @@ function navTo(tab) {
215
262
  // Live history SSE feeds both the History tab (event log) and the Live
216
263
  // dashboard (per-session activity tally + stream-health signal); open it on
217
264
  // either, close it when leaving both. Active-chat polling runs globally.
218
- const wantsStream = (t) => t === 'history' || t === 'live';
265
+ // The chat tab keeps the stream too while a chat is busy or sessions are in
266
+ // flight, so the rail's counters/timestamps don't freeze on the very tab
267
+ // where the streaming happens.
268
+ const wantsStream = (t) => t === 'history' || t === 'live'
269
+ || (t === 'chat' && (state.chat.busy || (Array.isArray(state.active) && state.active.length > 0)));
219
270
  if (wantsStream(tab)) {
220
271
  if (tab === 'history') refreshHistory();
221
272
  openLiveStream();
@@ -230,7 +281,9 @@ function navTo(tab) {
230
281
  // filesMain (which runs during render) - a render-time fetch is fragile under
231
282
  // a double-render and re-enters render() while building the tree.
232
283
  if (tab === 'files' && !state.files.path && !state.files.loading && !state.files.error) loadDir('');
233
- writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
284
+ // popstate calls navTo with writeHash:false so it never replaceState-clobbers
285
+ // the entry it popped; user-initiated navigation pushes so Back walks tabs.
286
+ if (doWriteHash) writeHash({ push });
234
287
  announce('Now on ' + tab + ' tab');
235
288
  render();
236
289
  // Move focus into the new region for keyboard/AT users.
@@ -268,13 +321,22 @@ function syncAriaCurrent() {
268
321
  // re-renders every row on each render). Only re-render when the set actually
269
322
  // changed (or while on the live tab, where elapsed advances every second).
270
323
  function activeSig(list) {
271
- return (Array.isArray(list) ? list : []).map(a => a.sessionId + ':' + (a.model || '') + ':' + (a.startedAt || '')).sort().join('|');
324
+ // claudeSessionId arrives mid-turn (streaming_session) - it must be part of
325
+ // the signature so the rail/dashboard re-render when the real sid lands.
326
+ return (Array.isArray(list) ? list : []).map(a => a.sessionId + ':' + (a.claudeSessionId || '') + ':' + (a.model || '') + ':' + (a.startedAt || '')).sort().join('|');
272
327
  }
273
328
  async function refreshActive() {
274
329
  let next;
275
330
  try { next = await B.listActiveChats(state.backend); } catch { return; }
276
331
  const changed = activeSig(next) !== activeSig(state.active);
277
332
  state.active = next;
333
+ // A stopping sid that left the active set has genuinely stopped - clear it
334
+ // so the per-card 'stopping' state resolves.
335
+ const st = state.live.stopping;
336
+ if (st && st.size) {
337
+ const present = new Set(next.map(a => a.sessionId));
338
+ for (const sid of [...st]) if (!present.has(sid)) st.delete(sid);
339
+ }
278
340
  // The live tab needs the steady elapsed tick regardless; elsewhere only
279
341
  // re-render when the active set genuinely changed.
280
342
  if (changed || state.tab === 'live') render();
@@ -305,8 +367,16 @@ function startLiveTick() {
305
367
  }, 1000);
306
368
  }
307
369
  async function stopActiveChat(sid) {
370
+ const st = state.live.stopping = state.live.stopping || new Set();
371
+ if (st.has(sid)) return; // re-entry guard: one cancel per sid in flight
372
+ st.add(sid);
373
+ render();
308
374
  try { await B.cancelChat(state.backend, sid); } catch {}
309
- refreshActive();
375
+ // Optimistically drop the row so the card doesn't read 'running' for up to
376
+ // 3s after the click; the immediate refresh confirms.
377
+ state.active = (Array.isArray(state.active) ? state.active : []).filter(a => a.sessionId !== sid);
378
+ await refreshActive();
379
+ render();
310
380
  }
311
381
 
312
382
  function openLiveStream() {
@@ -316,16 +386,27 @@ function openLiveStream() {
316
386
  try {
317
387
  state.live.es = B.streamHistory(state.backend, (kind, data) => {
318
388
  state.live.lastEventTs = Date.now();
319
- state.live.eventCount++;
389
+ // Only real event frames count - hello/error frames inflated the number.
390
+ if (kind === 'event') state.live.eventCount++;
320
391
  if (kind === 'hello') {
321
392
  if (!state.live.connected) state.live.connected = true;
322
- if (state.live.error) { state.live.error = null; state.live.reconnects++; }
393
+ if (state.live.error) {
394
+ state.live.error = null; state.live.reconnects++;
395
+ // After a disconnect there is no way to know what was missed or
396
+ // replayed: re-baseline. Drop the tally (the index owns truth) and
397
+ // refetch the session list so counters never double-count a replay.
398
+ if (state.live.tally) state.live.tally.clear();
399
+ debouncedRefreshHistory();
400
+ }
323
401
  } else if (kind === 'event' && data) {
324
402
  // ccsniff's stream frame is { sid, payload: <flattened event> } - the
325
403
  // real event (type/isError/i/ts) lives under .payload, not on the
326
404
  // wrapper. Reading the wrapper gave every counter `undefined`, so live
327
405
  // tool/error tallies never moved. Route by data.sid, read ev for the rest.
328
406
  const ev = data.payload || data;
407
+ // Estimate server-vs-client clock skew once from the first near-realtime
408
+ // event; staleness comparisons apply it so pure skew never reads stale.
409
+ if (state.live.clockSkew == null && ev.ts) state.live.clockSkew = Date.now() - ev.ts;
329
410
  if (state.selectedSid && data.sid === state.selectedSid) {
330
411
  // Dedupe against the snapshot/prior pushes by event index - a
331
412
  // reconnect or overlap would otherwise double-append the same event.
@@ -341,18 +422,32 @@ function openLiveStream() {
341
422
  // exists yet.
342
423
  if (data.sid) {
343
424
  state.live.tally = state.live.tally || new Map();
344
- const t = state.live.tally.get(data.sid) || { events: 0, tools: 0, errors: 0, last: 0 };
345
- t.events++; t.last = ev.ts || Date.now();
346
- if (ev.type === 'tool_use') t.tools++;
347
- if (ev.isError) t.errors++;
425
+ const t = state.live.tally.get(data.sid) || { events: 0, tools: 0, errors: 0, last: 0, maxI: null, toolRunning: false, toolName: '', lastErrorTs: 0 };
426
+ // Dedupe by event index: a reconnect replay/overlap re-delivers the
427
+ // same events and would double-count tools/errors otherwise.
428
+ const dup = ev.i != null && t.maxI != null && ev.i <= t.maxI;
429
+ if (!dup) {
430
+ if (ev.i != null) t.maxI = ev.i;
431
+ t.events++; t.last = ev.ts || Date.now();
432
+ // Per-sid running-tool truth: an unresolved tool_use means this
433
+ // session is busy-with-tool, not stalled - for EVERY session, not
434
+ // only the one resumed in the in-page chat.
435
+ if (ev.type === 'tool_use') { t.tools++; t.toolRunning = true; t.toolName = ev.tool || ev.name || ''; }
436
+ if (ev.type === 'tool_result') { t.toolRunning = false; t.toolName = ''; }
437
+ if (ev.isError) { t.errors++; t.lastErrorTs = t.last; }
438
+ }
348
439
  state.live.tally.set(data.sid, t);
349
440
  }
350
441
  const sess = state.sessionsBySid ? state.sessionsBySid.get(data.sid) : null;
351
442
  if (sess) {
352
- sess.events = (sess.events || 0) + 1;
353
- sess.last = ev.ts || Date.now();
354
- if (ev.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
355
- if (ev.isError) sess.errors = (sess.errors || 0) + 1;
443
+ const dupS = ev.i != null && sess._maxI != null && ev.i <= sess._maxI;
444
+ if (!dupS) {
445
+ if (ev.i != null) sess._maxI = ev.i;
446
+ sess.events = (sess.events || 0) + 1;
447
+ sess.last = ev.ts || Date.now();
448
+ if (ev.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
449
+ if (ev.isError) { sess.errors = (sess.errors || 0) + 1; sess.lastErrorTs = sess.last; }
450
+ }
356
451
  } else {
357
452
  // Unknown session: a burst of events for a new session would trigger
358
453
  // a full session-list refetch per event - debounce it into one. The
@@ -390,6 +485,19 @@ function closeLiveStream() {
390
485
  state.live.connected = false;
391
486
  }
392
487
 
488
+ // ONE shortcuts definition consumed by BOTH the ?-overlay and the settings
489
+ // keyboard panel, so the two surfaces cannot drift from the keydown handler
490
+ // below (which implements exactly these: g+c/h/f/l/s, n, /, ?, Esc).
491
+ const SHORTCUTS = [
492
+ { keys: 'g then c / h / f / l / s', desc: 'switch tabs (chat / history / files / live / settings)' },
493
+ { keys: 'n', desc: 'new chat (on the chat tab)' },
494
+ { keys: '/', desc: 'focus search / filter / composer' },
495
+ { keys: 'Ctrl/Cmd+Shift+L', desc: 'focus the composer from anywhere' },
496
+ { keys: 'Enter / Shift+Enter', desc: 'send / new line (in the composer)' },
497
+ { keys: '?', desc: 'show shortcuts' },
498
+ { keys: 'Esc', desc: 'close overlays, cancel confirms, stop generation, or blur the field' },
499
+ ];
500
+
393
501
  function view() {
394
502
  const ok = state.health.status === 'ok';
395
503
  const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
@@ -397,7 +505,7 @@ function view() {
397
505
  ? (state.live.error
398
506
  ? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
399
507
  : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
400
- : (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
508
+ : (ok ? (state.health.ws === 'reconnecting' ? 'connecting' : 'connected') : 'offline');
401
509
  const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
402
510
  // The status dot is drawn entirely by CSS (.status-dot::before) - a small
403
511
  // colored disc, real product design, not a text glyph. State drives its colour
@@ -415,6 +523,7 @@ function view() {
415
523
  // only the dot: on history/chat it names the selected session/agent, on files
416
524
  // the current dir, on live the live count, on settings "configuration".
417
525
  let crumbLeaf = '';
526
+ let crumbTrail = [];
418
527
  if (state.tab === 'history') {
419
528
  const sel = state.selectedSid && (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
420
529
  crumbLeaf = state.selectedSid
@@ -423,13 +532,17 @@ function view() {
423
532
  } else if (state.tab === 'chat') {
424
533
  crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
425
534
  } else if (state.tab === 'files') {
426
- crumbLeaf = truncate(state.files?.path || 'files', 24, 64);
535
+ // Location reads as hierarchy (parents / dir), not a mid-path ellipsis.
536
+ const segs = state.files?.segments || [];
537
+ crumbLeaf = segs.length ? truncate(segs[segs.length - 1], 18, 32) : 'files';
538
+ crumbTrail = segs.slice(Math.max(0, segs.length - 3), segs.length - 1).map(s => truncate(s, 12, 20));
427
539
  } else if (state.tab === 'live') {
428
540
  crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
429
541
  } else if (state.tab === 'settings') {
430
- crumbLeaf = 'configuration';
542
+ // Same word as the rail item - location chrome must not fork vocabulary.
543
+ crumbLeaf = 'settings';
431
544
  }
432
- const crumb = Crumb({ trail: [], leaf: crumbLeaf, right: [dot] });
545
+ const crumb = Crumb({ trail: crumbTrail, leaf: crumbLeaf, right: [dot] });
433
546
 
434
547
  const agentLabel = state.selectedAgent
435
548
  ? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
@@ -444,7 +557,7 @@ function view() {
444
557
  : 'min-height:0';
445
558
  const shortcutsHint = state.showShortcuts
446
559
  ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
447
- children: 'g then c/h/f/l/s - chat/history/files/live/settings · n - new chat · / - focus search/composer · ? - toggle this · Esc - blur field' })
560
+ children: SHORTCUTS.map(s => s.keys + ' - ' + s.desc).join(' · ') })
448
561
  : null;
449
562
  const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
450
563
 
@@ -466,7 +579,14 @@ function view() {
466
579
  cwd: state.chatCwd || '',
467
580
  toolCount: runningToolCount(),
468
581
  usage: state.chat.usage || null,
469
- onSetCwd: () => { navTo('files'); },
582
+ session: {
583
+ turns: state.chat.messages.filter(m => m.role === 'user').length,
584
+ cost: state.chat.totalCost || null,
585
+ },
586
+ // One affordance per action: the cwd row opens the SAME inline editor as
587
+ // the composer context line (it validates via /api/stat) instead of
588
+ // navigating away to the Files tab.
589
+ onSetCwd: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
470
590
  });
471
591
  } else if (state.tab === 'files' && state.files && state.files.preview && !isNarrow()) {
472
592
  pane = filePreviewPane();
@@ -513,7 +633,9 @@ const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier
513
633
  // chats are pinned to a "Running" section at the top (a live workspace surfaces
514
634
  // in-flight work first), the rest bucket by recency. Returns { items, groups }.
515
635
  function sessionGroups(sessionsView) {
516
- const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
636
+ // Join on the REAL claude/ccsniff sid when known (chat.active rows carry the
637
+ // ephemeral chat- id; claudeSessionId lands once streaming_session arrives).
638
+ const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
517
639
  const buckets = new Map();
518
640
  for (const s of sessionsView) {
519
641
  const label = runningSids.has(s.sid) ? 'Running' : dateGroupLabel(s.last);
@@ -565,7 +687,7 @@ function sessionsColumn() {
565
687
  }
566
688
  const sessionsView = visibleSessions();
567
689
  const sliced = sessionsView.slice(0, state.sessionsLimit);
568
- const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
690
+ const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
569
691
  const items = sliced.map((s) => ({
570
692
  sid: s.sid,
571
693
  title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
@@ -593,6 +715,7 @@ function sessionsColumn() {
593
715
  onNew: () => { navTo('chat'); newChat(); },
594
716
  onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
595
717
  loading: state.tab === 'history' && !state.sessions.length && !state.historyError,
718
+ loadingText: state.historySlow ? 'Indexing your Claude history — the first load can take a minute…' : undefined,
596
719
  error: state.historyError,
597
720
  emptyText: 'No conversations yet',
598
721
  });
@@ -607,16 +730,25 @@ function mainContent() {
607
730
  }
608
731
 
609
732
  // --- files (folder browser) ---
610
- async function loadDir(dirPath) {
733
+ async function loadDir(dirPath, { fromHash = false } = {}) {
611
734
  state.files = state.files || {};
612
735
  state.files.loading = true; state.files.error = null; render();
613
736
  try {
614
737
  const j = await B.listDir(state.backend, dirPath || '');
738
+ // The filter text and show-more cap are per-directory state: keep them
739
+ // across an in-place refresh (same path after a mutation), reset them when
740
+ // the resolved directory actually changed.
741
+ if (j.path !== state.files.path) { state.files.filter = ''; state.files.shown = null; }
615
742
  state.files.path = j.path;
616
743
  state.files.segments = j.segments || [];
617
744
  state.files.entries = j.entries || [];
618
745
  state.files.roots = j.roots || [];
619
746
  state.files.error = null;
747
+ // Deep-link the open directory: push so Back walks the tree; a load that
748
+ // came FROM the hash (popstate/boot) replaces instead - the entry already
749
+ // exists, only the URL needs to stay accurate (writeHash no-ops when the
750
+ // hash already matches, so popstate never loops).
751
+ if (state.tab === 'files') writeHash({ push: !fromHash });
620
752
  } catch (e) {
621
753
  // W9: translate HTTP status to plain, non-leaky copy.
622
754
  state.files.error = e.status === 403
@@ -659,8 +791,163 @@ function closePreview() {
659
791
  if (!state.files) return;
660
792
  state.files.preview = null;
661
793
  state.files.previewState = null;
794
+ // Clear file= from the URL (replace - the pushed open-entry stays poppable).
795
+ if (state.tab === 'files') writeHash();
796
+ render();
797
+ }
798
+
799
+ // Restore (or clear) the file= preview named by the hash once the directory
800
+ // listing for dir= has resolved - popstate and boot both funnel through here.
801
+ function restoreFileFromHash(filePath) {
802
+ const f = state.files || {};
803
+ if (!filePath) {
804
+ if (f.preview) { f.preview = null; f.previewState = null; render(); }
805
+ return;
806
+ }
807
+ if (f.preview && f.preview.path === filePath) return;
808
+ const entry = (f.entries || []).find(e => e.path === filePath);
809
+ if (entry && entry.type !== 'dir') openPreview(entry, { fromHash: true });
810
+ }
811
+
812
+ // --- file mutations (rename / delete / new folder / upload) ---
813
+ // One dialog at a time lives in state.files.dialog: {kind, file, error, busy}.
814
+ // Errors from the confined endpoints map to plain copy by HTTP status.
815
+ function fileMutationCopy(e) {
816
+ if (e.status === 403) return 'Permission denied, or the target is outside the allowed roots.';
817
+ if (e.status === 404) return 'That file no longer exists.';
818
+ if (e.status === 409) return e.message || 'A file with that name already exists.';
819
+ if (e.status === 413) return 'Too large (50MB upload cap).';
820
+ return e.message || 'The operation failed.';
821
+ }
822
+ function openFileDialog(kind, file) {
823
+ state.files.dialog = { kind, file: file || null, error: null, busy: false };
824
+ render();
825
+ }
826
+ function closeFileDialog() {
827
+ // A mid-flight close would orphan the mutation's result and swallow its
828
+ // error - hold the dialog open until the operation settles.
829
+ if (state.files.dialog?.busy) { announce('still working - please wait'); return; }
830
+ state.files.dialog = null; render();
831
+ }
832
+ async function runFileMutation(fn, doneMsg) {
833
+ const d = state.files.dialog;
834
+ if (!d || d.busy) return;
835
+ d.busy = true; d.error = null; render();
836
+ try {
837
+ await fn();
838
+ state.files.dialog = null;
839
+ announce(doneMsg);
840
+ await loadDir(state.files.path, { fromHash: true }); // refresh in place, no history entry
841
+ } catch (e) {
842
+ // If the dialog detached anyway (e.g. state replaced), the error would be
843
+ // written onto a dead object and lost - announce it instead.
844
+ if (state.files.dialog !== d) { announce(fileMutationCopy(e)); render(); return; }
845
+ d.busy = false; d.error = fileMutationCopy(e); render();
846
+ }
847
+ }
848
+ // Upload a FileList into the current directory; per-file rows feed the kit
849
+ // UploadProgress (done/error per file - fetch has no chunk progress).
850
+ async function uploadFiles(fileList) {
851
+ const dir = state.files.path;
852
+ if (!dir || !fileList || !fileList.length) return;
853
+ const items = Array.from(fileList).map((f) => ({ name: f.name, pct: 0, done: false, error: null, status: null, _file: f, _dir: dir }));
854
+ // Concurrent drops APPEND to the shared queue (a second drop mid-upload must
855
+ // not replace the first batch's progress/errors); one running loop drains it.
856
+ state.files.uploads = (state.files.uploads || []).concat(items);
857
+ render();
858
+ if (state.files.uploading) return;
859
+ state.files.uploading = true;
860
+ try {
861
+ let it;
862
+ while ((it = (state.files.uploads || []).find(i => !i.done && !i.error && !i._started))) {
863
+ it._started = true;
864
+ try {
865
+ await B.uploadFile(state.backend, it._dir, it._file);
866
+ it.pct = 100; it.done = true;
867
+ } catch (e) {
868
+ it.status = e.status || null;
869
+ it.error = fileMutationCopy(e);
870
+ }
871
+ render();
872
+ }
873
+ } finally {
874
+ state.files.uploading = false;
875
+ }
876
+ announce('upload finished');
877
+ await loadDir(state.files.path, { fromHash: true });
878
+ // Keep error rows visible; clear the list entirely when everything landed.
879
+ if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
662
880
  render();
663
881
  }
882
+ // 'replace' on a 409 row: re-PUT the same file with overwrite=1.
883
+ async function retryUploadOverwrite(it) {
884
+ if (!it || it._retrying || !it._file) return;
885
+ it._retrying = true; it.error = null; it.status = null; render();
886
+ try {
887
+ await B.uploadFile(state.backend, it._dir, it._file, true);
888
+ it.pct = 100; it.done = true;
889
+ } catch (e) {
890
+ it.status = e.status || null;
891
+ it.error = fileMutationCopy(e);
892
+ }
893
+ it._retrying = false;
894
+ await loadDir(state.files.path, { fromHash: true });
895
+ if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
896
+ render();
897
+ }
898
+ function dismissUpload(it) {
899
+ const ups = state.files.uploads || [];
900
+ const i = ups.indexOf(it);
901
+ if (i >= 0) ups.splice(i, 1);
902
+ if (!ups.length) state.files.uploads = null;
903
+ render();
904
+ }
905
+ // The active file dialog (rename/delete/mkdir) as a kit modal, or null.
906
+ function fileDialog() {
907
+ const d = state.files && state.files.dialog;
908
+ if (!d) return null;
909
+ // error/busy live INSIDE the kit dialog (the modal overlay sits above page
910
+ // flow, so a sibling alert was invisible and outside the focus trap).
911
+ if (d.kind === 'rename') {
912
+ return PromptDialog({
913
+ title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
914
+ error: d.error || null, busy: !!d.busy,
915
+ confirmLabel: d.busy ? 'renaming...' : 'rename', cancelLabel: 'cancel',
916
+ onCancel: closeFileDialog,
917
+ onConfirm: (v) => {
918
+ // Every confirm press produces visible feedback - never a silent no-op.
919
+ if (!v || v === d.file.name) { d.error = 'enter a different name'; render(); return; }
920
+ runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v);
921
+ },
922
+ });
923
+ }
924
+ if (d.kind === 'delete') {
925
+ const isDir = d.file.type === 'dir';
926
+ return ConfirmDialog({
927
+ title: 'Delete ' + d.file.name,
928
+ message: isDir
929
+ ? 'Delete this folder and everything inside it? This cannot be undone.'
930
+ : 'Delete this file? This cannot be undone.',
931
+ error: d.error || null, busy: !!d.busy,
932
+ confirmLabel: d.busy ? 'deleting...' : 'delete', cancelLabel: 'cancel', destructive: true,
933
+ onCancel: closeFileDialog,
934
+ onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
935
+ });
936
+ }
937
+ if (d.kind === 'mkdir') {
938
+ return PromptDialog({
939
+ title: 'New folder', value: '', placeholder: 'folder name',
940
+ error: d.error || null, busy: !!d.busy,
941
+ confirmLabel: d.busy ? 'creating...' : 'create', cancelLabel: 'cancel',
942
+ onCancel: closeFileDialog,
943
+ onConfirm: (v) => {
944
+ if (!v) { d.error = 'enter a folder name'; render(); return; }
945
+ runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v);
946
+ },
947
+ });
948
+ }
949
+ return null;
950
+ }
664
951
 
665
952
  // Build the inner preview body (image / code / text / loading / error) shared by
666
953
  // the modal FileViewer (<900px) and the inline FilePreviewPane (split view).
@@ -697,8 +984,10 @@ function previewNeighbours() {
697
984
  return { prev: i > 0 ? list[i - 1] : null, next: i < list.length - 1 ? list[i + 1] : null };
698
985
  }
699
986
 
700
- function openPreview(file) {
987
+ function openPreview(file, { fromHash = false } = {}) {
701
988
  state.files.preview = file;
989
+ // Push file= so Back closes the preview; hash-driven opens replace instead.
990
+ if (state.tab === 'files') writeHash({ push: !fromHash });
702
991
  if (file.type !== 'image') loadPreviewContent(file);
703
992
  render();
704
993
  }
@@ -785,6 +1074,7 @@ function filesMain() {
785
1074
  // Click the active column to flip direction; a new column resets to asc.
786
1075
  if (state.files.sort === k) state.files.sortDir = state.files.sortDir === 'asc' ? 'desc' : 'asc';
787
1076
  else { state.files.sort = k; state.files.sortDir = 'asc'; }
1077
+ persistFilesPrefs();
788
1078
  render();
789
1079
  } },
790
1080
  filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: (v) => { state.files.filter = v; state.files.shown = null; render(); } },
@@ -793,15 +1083,18 @@ function filesMain() {
793
1083
  if (file.type === 'dir') loadDir(file.path);
794
1084
  else openPreview(file);
795
1085
  },
796
- // W7: download is the one mutation-free row action the read-only server
797
- // supports. rename/delete are a deliberate scope cut (no mutation endpoints
798
- // - documented in PUNCHLIST-COHESION.md), so only download is wired.
1086
+ // Full manager wiring: download streams the confined /api/download;
1087
+ // rename/delete open a kit dialog backed by the confined mutation
1088
+ // endpoints (the former read-only scope cut is reversed).
799
1089
  onAction: (act, file) => {
1090
+ if (file.permissions === 'EACCES') { announce('no access to ' + file.name); return; }
800
1091
  if (act === 'download' && file.type !== 'dir') {
801
1092
  const a = document.createElement('a');
802
1093
  a.href = B.downloadUrl(state.backend, file.path);
803
1094
  a.download = file.name; document.body.appendChild(a); a.click(); a.remove();
804
1095
  }
1096
+ if (act === 'rename') openFileDialog('rename', file);
1097
+ if (act === 'delete') openFileDialog('delete', file);
805
1098
  },
806
1099
  });
807
1100
  }
@@ -819,27 +1112,113 @@ function filesMain() {
819
1112
  // Kit FileToolbar (replaces the hand-built .ds-file-toolbar markup).
820
1113
  const toolbar = FileToolbar({
821
1114
  left: [crumb],
822
- right: [targetCwd
823
- ? Btn({ key: 'usecwd', onClick: () => { state.chatCwd = targetCwd; lsSet('agentgui.cwd', targetCwd); announce('working directory set to ' + targetCwd); navTo('chat'); }, children: 'use as chat cwd' })
824
- : null].filter(Boolean),
1115
+ right: [
1116
+ f.path ? Btn({ key: 'newdir', onClick: () => openFileDialog('mkdir'), children: 'new folder' }) : null,
1117
+ f.path ? Btn({ key: 'upload', onClick: () => {
1118
+ const inp = document.createElement('input');
1119
+ inp.type = 'file'; inp.multiple = true;
1120
+ inp.onchange = () => uploadFiles(inp.files);
1121
+ inp.click();
1122
+ }, children: 'upload' }) : null,
1123
+ targetCwd
1124
+ ? Btn({ key: 'usecwd', onClick: () => { state.chatCwd = targetCwd; lsSet('agentgui.cwd', targetCwd); announce('working directory set to ' + targetCwd); navTo('chat'); }, children: 'use as chat cwd' })
1125
+ : null,
1126
+ ].filter(Boolean),
825
1127
  });
1128
+ // Drag-and-drop upload wraps the grid (keyboard path = the toolbar upload
1129
+ // button); per-file progress rows render above the grid while in flight.
1130
+ const droppableBody = f.path && !f.error
1131
+ ? DropZone({
1132
+ dragover: !!f.dragover,
1133
+ label: 'drop files to upload to this folder',
1134
+ onDragOver: () => { if (!state.files.dragover) { state.files.dragover = true; render(); } },
1135
+ onDragLeave: () => { if (state.files.dragover) { state.files.dragover = false; render(); } },
1136
+ onDrop: (files) => { state.files.dragover = false; uploadFiles(files); },
1137
+ children: body,
1138
+ })
1139
+ : body;
826
1140
  return [
827
1141
  offlineBanner(),
828
- PageHeader({ compact: true, title: 'Files', lede: 'Browse the server filesystem within the allowed roots. Click a folder to open it; pick one as the chat working directory.' }),
1142
+ PageHeader({ compact: true, title: 'Files', lede: 'Browse and manage the server filesystem within the allowed roots. Click a folder to open it; pick one as the chat working directory.' }),
829
1143
  rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
830
1144
  h('div', { key: 'ftb' }, toolbar),
831
- h('div', { key: 'fbody' }, body),
1145
+ (f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
1146
+ // Recovery affordances per row: 'replace' on a name collision (409),
1147
+ // dismiss on any error row (errors otherwise persist until the next batch).
1148
+ items: f.uploads.map((it) => (it.error && it.status === 409 && !it._retrying)
1149
+ ? { ...it, actions: [{ label: 'replace', onClick: () => retryUploadOverwrite(it) }] }
1150
+ : it),
1151
+ onDismiss: (item, i) => {
1152
+ const src = (state.files.uploads || [])[i];
1153
+ if (src && src.error) dismissUpload(src);
1154
+ },
1155
+ })) : null,
1156
+ h('div', { key: 'fbody' }, droppableBody),
1157
+ fileDialog(),
832
1158
  // Inline pane handles wide-screen preview; the modal is only the <900px
833
1159
  // fallback (the pane has no room there).
834
1160
  (f.preview && isNarrow()) ? h('div', { key: 'fprev' }, filePreview()) : null,
835
1161
  ].filter(Boolean);
836
1162
  }
837
1163
 
1164
+ // --- live/files preference persistence (sort, errors-only). The live
1165
+ // selection Set is deliberately NOT persisted - stale sids would arm
1166
+ // stop-selected against sessions that no longer exist.
1167
+ const LIVE_PREFS_KEY = 'agentgui.live';
1168
+ const FILES_PREFS_KEY = 'agentgui.files';
1169
+ function persistLivePrefs() {
1170
+ lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly }));
1171
+ }
1172
+ function persistFilesPrefs() {
1173
+ lsSet(FILES_PREFS_KEY, JSON.stringify({ sort: state.files.sort || 'name', sortDir: state.files.sortDir || 'asc' }));
1174
+ }
1175
+ function hydratePrefs() {
1176
+ try {
1177
+ const lv = JSON.parse(lsGet(LIVE_PREFS_KEY) || 'null');
1178
+ if (lv) { if (lv.sort) state.live.sort = lv.sort; state.live.errorsOnly = !!lv.errorsOnly; }
1179
+ } catch {}
1180
+ try {
1181
+ const fp = JSON.parse(lsGet(FILES_PREFS_KEY) || 'null');
1182
+ if (fp) { if (fp.sort) state.files.sort = fp.sort; if (fp.sortDir) state.files.sortDir = fp.sortDir; }
1183
+ } catch {}
1184
+ }
1185
+
1186
+ // Two-step stop: the first click ARMS (confirming* flips the kit button to a
1187
+ // confirm state), a second click within 4s executes; the timer auto-resets.
1188
+ let _stopAllArmTimer = null;
1189
+ let _stopSelArmTimer = null;
1190
+ function armStopAll() {
1191
+ state.live.confirmingStopAll = true;
1192
+ clearTimeout(_stopAllArmTimer);
1193
+ _stopAllArmTimer = setTimeout(() => { state.live.confirmingStopAll = false; render(); }, 4000);
1194
+ render();
1195
+ }
1196
+ function armStopSelected() {
1197
+ state.live.confirmingStopSelected = true;
1198
+ clearTimeout(_stopSelArmTimer);
1199
+ _stopSelArmTimer = setTimeout(() => { state.live.confirmingStopSelected = false; render(); }, 4000);
1200
+ render();
1201
+ }
1202
+
838
1203
  // Stop every in-flight chat at once (the dashboard "stop all" bulk control).
1204
+ // Awaits every cancel, reports partial failure, and returns the sids that
1205
+ // actually stopped so callers only clear those from a selection.
839
1206
  async function stopAllActive(sessions) {
840
1207
  const sids = (Array.isArray(sessions) ? sessions : (state.active || [])).map(s => s.sid || s.sessionId).filter(Boolean);
841
- await Promise.all(sids.map(sid => B.cancelChat(state.backend, sid).catch(() => {})));
842
- refreshActive();
1208
+ if (!sids.length) return [];
1209
+ const st = state.live.stopping = state.live.stopping || new Set();
1210
+ for (const sid of sids) st.add(sid);
1211
+ render();
1212
+ const results = await Promise.allSettled(sids.map(sid => B.cancelChat(state.backend, sid)));
1213
+ const okSids = sids.filter((sid, i) => results[i].status === 'fulfilled');
1214
+ const failed = sids.length - okSids.length;
1215
+ state.live.bulkStopError = failed
1216
+ ? failed + ' session' + (failed === 1 ? '' : 's') + ' did not stop - try again'
1217
+ : null;
1218
+ announce('stopped ' + okSids.length + ' of ' + sids.length + ' sessions');
1219
+ await refreshActive();
1220
+ render();
1221
+ return okSids;
843
1222
  }
844
1223
 
845
1224
  // How long a session can go without activity (and without a running tool)
@@ -856,75 +1235,154 @@ function liveMain() {
856
1235
  const tally = state.live.tally || new Map();
857
1236
  const now = Date.now();
858
1237
  // Live-stream health: connected (recent event), connecting (opened, no event
859
- // yet), or lost (errored). Feeds the dashboard header so "connected, 0 running"
860
- // still tells the user the dashboard is listening.
861
- const streamState = state.live.error ? 'lost' : (state.live.connected ? 'connected' : 'connecting');
1238
+ // yet), or offline (errored - one connection vocabulary across the GUI).
1239
+ const streamState = state.live.error ? 'offline' : (state.live.connected ? 'connected' : 'connecting');
1240
+ const stoppingSet = state.live.stopping || new Set();
1241
+ // Clock-skew-corrected "now" in server-timestamp terms, so pure skew never
1242
+ // derives a session stale.
1243
+ const nowS = now - (state.live.clockSkew || 0);
862
1244
  let sessions = (Array.isArray(state.active) ? state.active : []).map((r) => {
863
- // Activity tally: prefer the history-index row; fall back to the per-sid live
864
- // tally so a brand-new chat not yet in the index still shows motion (W4).
865
- const sess = bySid.get(r.sessionId);
866
- const t = tally.get(r.sessionId);
867
- const events = sess && sess.events != null ? sess.events : (t ? t.events : null);
868
- const tools = sess && sess.tools != null ? sess.tools : (t ? t.tools : 0);
869
- const errors = sess && sess.errors != null ? sess.errors : (t ? t.errors : 0);
870
- const lastTs = (sess && sess.last) || (t && t.last) || 0;
1245
+ // The history index + SSE tally are keyed by claude's REAL session id, not
1246
+ // the ephemeral chat- id chat.active returns; join on the real one once
1247
+ // streaming_session has landed. The ephemeral id stays the card sid (it is
1248
+ // what chat.cancel takes).
1249
+ const realSid = r.claudeSessionId || r.sessionId;
1250
+ const sess = bySid.get(realSid);
1251
+ const t = tally.get(realSid);
1252
+ // Counters are MONOTONIC within a session: the index refresh lags the JSONL
1253
+ // flush, so take the max of index and live tally - never regress.
1254
+ const events = Math.max(sess?.events ?? -1, t?.events ?? -1);
1255
+ const tools = Math.max(sess?.tools || 0, t?.tools || 0);
1256
+ const errors = Math.max(sess?.errors || 0, t?.errors || 0);
1257
+ const lastTs = Math.max(sess?.last || 0, t?.last || 0);
1258
+ const lastErrorTs = Math.max(sess?.lastErrorTs || 0, t?.lastErrorTs || 0);
871
1259
  const counterBits = [];
872
- if (events != null) counterBits.push(events + ' ev');
1260
+ if (events >= 0) counterBits.push(events + ' ev');
873
1261
  if (tools) counterBits.push(tools + ' tools');
874
1262
  if (errors) counterBits.push(errors + ' err');
875
- // Current tool: while a turn is busy in the in-page chat for this sid, the
876
- // trailing assistant message carries a running tool part.
1263
+ // Current tool: the in-page chat knows its own running tool part; every
1264
+ // OTHER session gets it from the per-sid SSE tally (an unresolved tool_use
1265
+ // means busy-with-tool, not stalled).
877
1266
  let currentTool = '';
878
- if (state.chat.resumeSid === r.sessionId && state.chat.busy) {
1267
+ if (state.chat.resumeSid === realSid && state.chat.busy) {
879
1268
  const msgs = state.chat.messages || [];
880
1269
  const last = msgs[msgs.length - 1];
881
1270
  const running = last && Array.isArray(last.parts) && last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').slice(-1)[0];
882
1271
  if (running) currentTool = running.name || '';
883
1272
  }
884
- // W2: stale = no recent activity AND no running tool. Errors win over stale.
1273
+ if (!currentTool && t && t.toolRunning) currentTool = t.toolName || 'tool';
1274
+ // Status reflects CURRENT reality: error only when an error is recent (a
1275
+ // recovered tool error hours ago is history, kept in the counter chip);
1276
+ // stale = no recent activity AND no running tool.
885
1277
  let status = 'running';
886
- if (errors) status = 'error';
887
- else if (!currentTool && lastTs && (now - lastTs) > STALE_AFTER_MS) status = 'stale';
1278
+ if (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) status = 'error';
1279
+ else if (!currentTool && lastTs && (nowS - lastTs) > STALE_AFTER_MS) status = 'stale';
1280
+ const startedTs = r.startedAt || 0;
1281
+ const elapsedMs = startedTs ? Math.max(0, now - startedTs) : 0;
1282
+ // One title source shared with the rails: projectLabel(title|project)|sid.
1283
+ const title = sess
1284
+ ? (projectLabel(sess.title) || projectLabel(sess.project) || realSid)
1285
+ : (r.claudeSessionId ? realSid : '');
888
1286
  return {
889
1287
  sid: r.sessionId,
1288
+ realSid,
1289
+ title: title || undefined,
890
1290
  agent: agentById(r.agentId)?.name || r.agentId || 'agent',
891
1291
  model: r.model || '',
892
1292
  cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
893
- elapsed: r.startedAt ? Math.round((now - r.startedAt) / 1000) + 's' : '',
1293
+ elapsed: elapsedMs ? fmtDuration(elapsedMs) : '',
1294
+ elapsedMs,
1295
+ startedTs,
894
1296
  counter: counterBits.length ? counterBits.join(' · ') : null,
895
- lastActivity: lastTs ? fmtRelTime(lastTs) : '',
1297
+ lastActivity: lastTs ? fmtRelTime(lastTs + (state.live.clockSkew || 0)) : '',
1298
+ lastTs,
1299
+ errors,
896
1300
  currentTool,
897
1301
  status,
1302
+ stopping: stoppingSet.has(r.sessionId),
898
1303
  };
899
1304
  });
1305
+ // External sessions (a claude CLI in a terminal, etc.): live SSE motion that
1306
+ // belongs to no agentgui-spawned chat. The page promises EVERY in-flight
1307
+ // session, so render them as read-only cards (we own no process to stop).
1308
+ const ownedReal = new Set(sessions.map(s => s.realSid));
1309
+ for (const [sid, t] of tally) {
1310
+ if (ownedReal.has(sid)) continue;
1311
+ if (!t.last || (nowS - t.last) >= STALE_AFTER_MS) continue;
1312
+ const sess = bySid.get(sid);
1313
+ const events = Math.max(sess?.events ?? -1, t.events ?? -1);
1314
+ const tools = Math.max(sess?.tools || 0, t.tools || 0);
1315
+ const errors = Math.max(sess?.errors || 0, t.errors || 0);
1316
+ const lastErrorTs = Math.max(sess?.lastErrorTs || 0, t.lastErrorTs || 0);
1317
+ const counterBits = [];
1318
+ if (events >= 0) counterBits.push(events + ' ev');
1319
+ if (tools) counterBits.push(tools + ' tools');
1320
+ if (errors) counterBits.push(errors + ' err');
1321
+ sessions.push({
1322
+ sid,
1323
+ realSid: sid,
1324
+ external: true,
1325
+ readOnly: true,
1326
+ title: sess ? (projectLabel(sess.title) || projectLabel(sess.project) || sid) : sid,
1327
+ agent: 'external session',
1328
+ model: '',
1329
+ cwd: sess && sess.cwd ? sess.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
1330
+ elapsed: '',
1331
+ elapsedMs: 0,
1332
+ startedTs: 0,
1333
+ counter: counterBits.length ? counterBits.join(' · ') : null,
1334
+ lastActivity: fmtRelTime(t.last + (state.live.clockSkew || 0)),
1335
+ lastTs: t.last,
1336
+ errors,
1337
+ currentTool: t.toolRunning ? (t.toolName || 'tool') : '',
1338
+ status: (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) ? 'error' : 'running',
1339
+ stopping: false,
1340
+ });
1341
+ }
900
1342
  // W12: in-dir filter + errors-only toggle.
901
1343
  const lv = state.live;
902
1344
  if (lv.errorsOnly) sessions = sessions.filter(s => s.status === 'error');
903
1345
  if (lv.filter) {
904
1346
  const q = lv.filter.toLowerCase();
905
- sessions = sessions.filter(s => (s.agent + ' ' + s.model + ' ' + s.cwd).toLowerCase().includes(q));
1347
+ sessions = sessions.filter(s => ((s.title || '') + ' ' + s.agent + ' ' + s.model + ' ' + s.cwd).toLowerCase().includes(q));
906
1348
  }
907
- // W3/W12: sort. Default floats error then stale to the front (triage surface).
1349
+ // Sort: real numeric comparisons (recency/elapsed/error count), owned before
1350
+ // external, and a deterministic sid tiebreaker so equal-rank cards never
1351
+ // reshuffle with the server's return order.
908
1352
  const sortKey = lv.sort || 'status';
909
1353
  sessions.sort((a, b) => {
910
- if (sortKey === 'elapsed') return (parseInt(b.elapsed) || 0) - (parseInt(a.elapsed) || 0);
911
- if (sortKey === 'activity') return (a.lastActivity ? 0 : 1) - (b.lastActivity ? 0 : 1);
912
- if (sortKey === 'errors') return (a.status === 'error' ? 0 : 1) - (b.status === 'error' ? 0 : 1);
913
- return (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
1354
+ let d = (a.external ? 1 : 0) - (b.external ? 1 : 0);
1355
+ if (d) return d;
1356
+ if (sortKey === 'elapsed') d = (b.elapsedMs || 0) - (a.elapsedMs || 0);
1357
+ else if (sortKey === 'activity') d = (b.lastTs || 0) - (a.lastTs || 0);
1358
+ else if (sortKey === 'errors') d = (b.errors || 0) - (a.errors || 0);
1359
+ else d = (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
1360
+ return d || String(a.sid).localeCompare(String(b.sid));
914
1361
  });
915
1362
  const selected = lv.selected instanceof Set ? lv.selected : new Set();
1363
+ // The in-page chat's card accent matches on the real sid, but the kit
1364
+ // compares activeSid against card.sid (the ephemeral id for owned cards).
1365
+ const activeCard = state.chat.resumeSid
1366
+ ? sessions.find(s => s.realSid === state.chat.resumeSid)
1367
+ : null;
916
1368
  return [
917
1369
  offlineBanner(),
1370
+ lv.bulkStopError
1371
+ ? Alert({ key: 'bulkstoperr', kind: 'warn', title: 'Some sessions did not stop',
1372
+ children: [
1373
+ h('span', { key: 'bsetxt' }, lv.bulkStopError + ' '),
1374
+ Btn({ key: 'bsedismiss', onClick: () => { state.live.bulkStopError = null; render(); }, children: 'dismiss' })] })
1375
+ : null,
918
1376
  PageHeader({ compact: true, title: 'Live sessions', lede: 'Every in-flight agent session, managed at once. Stop, open, or jump to events per session.' }),
919
1377
  SessionDashboard({
920
1378
  sessions,
921
1379
  offline,
922
1380
  streamState,
923
- activeSid: state.chat.resumeSid, // W13
924
- sort: { value: sortKey, onChange: (v) => { state.live.sort = v; render(); } },
1381
+ activeSid: activeCard ? activeCard.sid : state.chat.resumeSid, // W13
1382
+ sort: { value: sortKey, onChange: (v) => { state.live.sort = v; persistLivePrefs(); render(); } },
925
1383
  filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
926
1384
  errorsOnly: !!lv.errorsOnly,
927
- onErrorsOnly: (on) => { state.live.errorsOnly = on; render(); },
1385
+ onErrorsOnly: (on) => { state.live.errorsOnly = on; persistLivePrefs(); render(); },
928
1386
  selectable: true,
929
1387
  selected,
930
1388
  onToggleSelect: (s) => {
@@ -932,12 +1390,32 @@ function liveMain() {
932
1390
  if (set.has(s.sid)) set.delete(s.sid); else set.add(s.sid);
933
1391
  state.live.selected = set; render();
934
1392
  },
935
- onStopSelected: (sids) => { stopAllActive(sids.map(sid => ({ sid }))); state.live.selected = new Set(); },
936
- emptyText: 'No live sessions - start a chat or run a local agent.',
937
- onStop: (s) => stopActiveChat(s.sid),
938
- onStopAll: (all) => stopAllActive(all),
939
- onOpen: (s) => { resumeInChat({ sid: s.sid }); },
940
- onView: (s) => { navTo('history'); loadSession(s.sid); },
1393
+ // Two-step bulk stops: arm first, execute on the confirmed click.
1394
+ confirmingStopAll: !!lv.confirmingStopAll,
1395
+ confirmingStopSelected: !!lv.confirmingStopSelected,
1396
+ onArmStopAll: armStopAll,
1397
+ onArmStopSelected: armStopSelected,
1398
+ onStopSelected: async (sids) => {
1399
+ state.live.confirmingStopSelected = false;
1400
+ clearTimeout(_stopSelArmTimer);
1401
+ // Await the cancels and only clear the sids that actually stopped, so
1402
+ // a partially-failed bulk stop stays selected and visibly armed-again.
1403
+ const ok = await stopAllActive(sids.map(sid => ({ sid })));
1404
+ const sel = (state.live.selected instanceof Set) ? state.live.selected : new Set();
1405
+ for (const sid of ok) sel.delete(sid);
1406
+ state.live.selected = sel;
1407
+ render();
1408
+ },
1409
+ emptyText: 'No live sessions — start a chat or run a local agent.',
1410
+ onStop: (s) => { if (!s.external) stopActiveChat(s.sid); },
1411
+ onStopAll: async (all) => {
1412
+ state.live.confirmingStopAll = false;
1413
+ clearTimeout(_stopAllArmTimer);
1414
+ await stopAllActive((all || []).filter(s => !s.external));
1415
+ render();
1416
+ },
1417
+ onOpen: (s) => { resumeInChat({ sid: s.realSid || s.sid }); },
1418
+ onView: (s) => { navTo('history'); loadSession(s.realSid || s.sid); },
941
1419
  }),
942
1420
  ].filter(Boolean);
943
1421
  }
@@ -1016,15 +1494,29 @@ function applyToolResult(parts, block) {
1016
1494
  }
1017
1495
  }
1018
1496
 
1019
- function errText(e) {
1497
+ // Map raw transport/server errors to plain-language copy. The raw string is
1498
+ // preserved via errTextRaw so renderers can carry it on a title attribute.
1499
+ const ERR_COPY = [
1500
+ [/^ws closed$/, 'Lost connection to the server.'],
1501
+ [/connection lost during stream/, 'Lost connection while the agent was responding - use retry.'],
1502
+ [/no sessionId from server/, 'The server could not start the agent - check it is installed.'],
1503
+ [/^sessions: \d+/, 'History is still indexing - try again in a moment.'],
1504
+ ];
1505
+ function errTextRaw(e) {
1020
1506
  if (e == null) return 'unknown error';
1021
1507
  if (typeof e === 'string') return e;
1022
1508
  if (e.message) return e.message;
1023
1509
  try { return JSON.stringify(e); } catch { return String(e); }
1024
1510
  }
1511
+ function errText(e) {
1512
+ const raw = errTextRaw(e);
1513
+ for (const [re, copy] of ERR_COPY) { if (re.test(raw)) return copy; }
1514
+ return raw;
1515
+ }
1025
1516
 
1026
1517
  function chatMain() {
1027
1518
  const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
1519
+ const userTurnCount = state.chat.messages.filter(m => m.role === 'user').length;
1028
1520
 
1029
1521
  // Banners agentgui owns (resume, agent-switched, unavailable, confirm-clear,
1030
1522
  // stream error) are pre-built here and handed to the kit, which renders them
@@ -1045,7 +1537,7 @@ function chatMain() {
1045
1537
  // than as a separate stacked Alert, so resume context reads as one block.
1046
1538
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
1047
1539
  h('span', { key: 'rbtxt', class: 'lede' },
1048
- 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'
1540
+ 'next message continues session ' + state.chat.resumeSid.slice(0, 8) + '…'
1049
1541
  + (state.chat.resumeNote ? ' - ' + state.chat.resumeNote : '')),
1050
1542
  Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
1051
1543
  }
@@ -1057,19 +1549,51 @@ function chatMain() {
1057
1549
  banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
1058
1550
  children: [
1059
1551
  h('span', { key: 'cntxt' }, 'This cannot be undone. '),
1060
- Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
1061
- Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
1552
+ Btn({ key: 'cnyes', danger: true, onClick: confirmNewChat, children: 'yes, clear chat' }),
1553
+ Btn({ key: 'cnno', onClick: () => { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
1062
1554
  }
1063
1555
  if (state.cwdError) {
1064
1556
  banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
1065
1557
  }
1558
+ if (state.chat.externalUpdate) {
1559
+ banners.push(Alert({ key: 'xupd', kind: 'info', title: 'This conversation was updated in another tab',
1560
+ children: [
1561
+ h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '),
1562
+ Btn({ key: 'xureload', disabled: state.chat.busy, onClick: () => {
1563
+ if (state.chat.busy) return;
1564
+ state.chat.externalUpdate = false;
1565
+ state.chat.messages = []; state.chat.resumeSid = null; state.chat.totalCost = 0;
1566
+ restoreChat(); render();
1567
+ }, children: 'reload it' }),
1568
+ Btn({ key: 'xudismiss', onClick: () => { state.chat.externalUpdate = false; render(); }, children: 'dismiss' })] }));
1569
+ }
1570
+ if (state.chat.persistError) {
1571
+ banners.push(Alert({ key: 'perr', kind: 'warn', title: 'Chat too large to save locally',
1572
+ children: [
1573
+ h('span', { key: 'petxt' }, 'New turns stay in this tab but will not survive a reload - export the transcript from settings. '),
1574
+ Btn({ key: 'pedismiss', onClick: () => { state.chat.persistError = false; render(); }, children: 'dismiss' })] }));
1575
+ }
1576
+ if (state.agentsError) {
1577
+ banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
1578
+ children: [
1579
+ h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load. '),
1580
+ Btn({ key: 'agretry', onClick: () => loadAgents(), children: 'retry' })] }));
1581
+ }
1582
+ if (state.chat.confirmingEdit) {
1583
+ banners.push(Alert({ key: 'confedit', kind: 'warn', title: 'Edit this message?',
1584
+ children: [
1585
+ h('span', { key: 'cetext' }, 'Editing will remove the later turns - continue? '),
1586
+ Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: 'continue' }),
1587
+ Btn({ key: 'ceno', onClick: cancelEditAndResend, children: 'cancel' })] }));
1588
+ }
1066
1589
  const lastMsg = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1] : null;
1067
1590
  const lastErr = lastMsg ? lastMsg.error : null;
1068
1591
  if (lastErr && !state.chat.busy) {
1069
1592
  banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error',
1070
1593
  children: [
1071
- h('span', { key: 'cetxt' }, lastErr + ' '),
1072
- Btn({ key: 'cedismiss', onClick: () => { if (lastMsg) { delete lastMsg.error; persistChat(); render(); } }, children: 'dismiss' })] }));
1594
+ h('span', { key: 'cetxt', title: lastMsg.errorRaw || lastErr }, lastErr + ' '),
1595
+ Btn({ key: 'ceretry', onClick: () => { delete lastMsg.error; delete lastMsg.errorRaw; retryLastTurn(); }, children: 'retry' }),
1596
+ Btn({ key: 'cedismiss', onClick: () => { if (lastMsg) { delete lastMsg.error; delete lastMsg.errorRaw; persistChat(); render(); } }, children: 'dismiss' })] }));
1073
1597
  }
1074
1598
 
1075
1599
  const placeholder = !state.selectedAgent
@@ -1091,9 +1615,12 @@ function chatMain() {
1091
1615
  messages: state.chat.messages,
1092
1616
  busy: state.chat.busy,
1093
1617
  draft: state.chat.draft,
1618
+ // Idle never reads 'resuming…' (nothing is in flight - the continuation
1619
+ // fact lives in the composer context line and banner); a remotely-stopped
1620
+ // turn reads 'stopped', not a normal finish.
1094
1621
  status: state.chat.busy
1095
- ? (state.health.ws === 'reconnecting' ? 'reconnecting…' : 'streaming…')
1096
- : (state.modelsLoading ? 'loading models…' : ((state.chat.resumeSid && state.selectedAgent === 'claude-code') ? 'resuming…' : 'ready')),
1622
+ ? (state.health.ws === 'reconnecting' ? 'connecting…' : 'streaming…')
1623
+ : (state.modelsLoading ? 'loading models…' : ((lastMsg && lastMsg.role === 'assistant' && lastMsg.stopped) ? 'stopped' : 'ready')),
1097
1624
  agentName,
1098
1625
  placeholder,
1099
1626
  canSend: canSend(),
@@ -1103,25 +1630,68 @@ function chatMain() {
1103
1630
  'What are the recent changes on this branch?',
1104
1631
  'Find and explain the main entry point',
1105
1632
  ],
1106
- onSuggestionClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
1633
+ // Chips send their own text and never touch a typed draft (clicking a
1634
+ // chip with a half-typed message in the composer must not destroy it).
1635
+ onSuggestionClick: (t) => { if (!canSend()) return; sendChat(t); },
1107
1636
  // W14: a stable per-agent product mark (a line-SVG, not a per-agent letter)
1108
1637
  // and the active target shown inline above the composer.
1109
1638
  avatar: state.selectedAgent ? h('span', { class: 'agentchat-avatar-mark', 'aria-hidden': 'true' }, Icon('forum', { size: 16 })) : undefined,
1110
1639
  composerContext: state.selectedAgent ? {
1111
- bits: [agentName, state.selectedModel || null, state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default'].filter(Boolean),
1112
- onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); },
1640
+ // Split click targets: only the cwd bit is interactive (it opens the
1641
+ // inline cwd editor); agent/model stay inert text - the picker above
1642
+ // already owns them. The resume fact is stated plainly at the point of
1643
+ // typing so send's behavior is never a surprise.
1644
+ bits: [
1645
+ agentName,
1646
+ state.selectedModel || null,
1647
+ {
1648
+ label: state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default',
1649
+ title: 'change working directory',
1650
+ onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
1651
+ },
1652
+ userTurnCount > 0 ? userTurnCount + ' turns' : null,
1653
+ (state.chat.resumeSid && state.selectedAgent === 'claude-code')
1654
+ ? 'continues session ' + state.chat.resumeSid.slice(0, 8)
1655
+ : null,
1656
+ ].filter(Boolean),
1657
+ } : undefined,
1658
+ // W15: contextual follow-up chips derived from the settled last turn
1659
+ // (tool error / code fence / file path), seeded statically otherwise.
1660
+ followups: chatFollowups(),
1661
+ onFollowupClick: (t) => { if (!canSend()) return; sendChat(t); },
1662
+ // Transcript export: copy all / markdown / json.
1663
+ exportActions: state.chat.messages.length ? [
1664
+ { label: 'copy all', title: 'Copy the whole transcript as markdown', onClick: () => copyText(transcriptToMarkdown(state.chat.messages), 'transcript copied') },
1665
+ { label: 'export md', title: 'Download the transcript as markdown', onClick: () => downloadBlob(transcriptToMarkdown(state.chat.messages), 'agentgui-chat-' + dateStamp() + '.md', 'text/markdown') },
1666
+ { label: 'export json', title: 'Download the transcript as JSON', onClick: () => downloadBlob(JSON.stringify(state.chat.messages, null, 2), 'agentgui-chat-' + dateStamp() + '.json', 'application/json') },
1667
+ ] : [],
1668
+ // When the server reports agents but NONE is installed, show install
1669
+ // commands inline instead of a dead picker.
1670
+ installHint: (state.agents.length && state.agents.every(a => a.available === false)) ? {
1671
+ text: 'No agent CLIs found on the server.',
1672
+ commands: state.agents.filter(a => a.npxInstallable).map(a => ({ agent: a.name || a.id, command: 'npx ' + (a.npxPackage || a.id) })),
1673
+ onRecheck: () => loadAgents(),
1113
1674
  } : undefined,
1114
- // W15: contextual follow-up chips after a settled assistant turn.
1115
- followups: state.chat.messages.length ? ['Explain that in more detail', 'Show me the diff', 'Run the tests'] : [],
1116
- onFollowupClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
1117
1675
  // Per-message actions: copy any message; retry the last assistant turn;
1118
- // edit-and-resend a user message (drops everything after it, refills draft).
1676
+ // edit-and-resend a user message (two-step: arm a confirm banner first,
1677
+ // since the truncation destroys the later turns).
1119
1678
  onCopyMessage: (m) => copyMessageText(m),
1120
1679
  onRetryMessage: () => retryLastTurn(),
1121
- onEditMessage: (m) => editAndResend(m),
1680
+ confirmEdit: true,
1681
+ onArmEdit: (m) => armEditAndResend(m),
1682
+ onEditMessage: (m) => armEditAndResend(m),
1122
1683
  cwd: state.chatCwd,
1123
1684
  cwdEditing: !!state.cwdEditing,
1124
1685
  cwdDraft: state.cwdDraft,
1686
+ cwdError: state.cwdError || null,
1687
+ cwdChecking: !!state.cwdChecking,
1688
+ // Pasting an image has no upload path on the chat surface yet - say so
1689
+ // politely instead of silently dropping it.
1690
+ onPasteFiles: () => announce('images cannot be attached here yet'),
1691
+ // Long threads render a capped window with a 'show earlier' row (the
1692
+ // chat-side equivalent of history's eventsLimit), reset per conversation.
1693
+ shownMessages: state.chat.shownMessages || undefined,
1694
+ onShowEarlier: (n) => { state.chat.shownMessages = n; render(); },
1125
1695
  onSelectAgent: (v) => selectAgent(v),
1126
1696
  onSelectModel: (v) => selectModel(v),
1127
1697
  onNewChat: newChat,
@@ -1133,11 +1703,12 @@ function chatMain() {
1133
1703
  const was = !!(state.chat.draft && state.chat.draft.trim());
1134
1704
  const now = !!(v && v.trim());
1135
1705
  state.chat.draft = v;
1706
+ debouncedPersistDraft(); // a typed-but-unsent draft survives reload
1136
1707
  if (was !== now) render();
1137
1708
  },
1138
1709
  onSend: (v) => { state.chat.draft = v; sendChat(); },
1139
1710
  onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
1140
- onCwdSave: () => {
1711
+ onCwdSave: async () => {
1141
1712
  const path = (state.cwdDraft ?? '').trim();
1142
1713
  // A relative cwd would resolve against the server process dir, not what
1143
1714
  // the user means - require an absolute path (POSIX /..., UNC \\..., or
@@ -1147,14 +1718,28 @@ function chatMain() {
1147
1718
  render();
1148
1719
  return;
1149
1720
  }
1721
+ // Validate against the server: the path must resolve and be a directory.
1722
+ if (path) {
1723
+ try {
1724
+ const st = await B.statPath(state.backend, path);
1725
+ if (!st || st.ok === false) { state.cwdError = 'directory not found on the server: ' + path; render(); return; }
1726
+ if (!st.dir) { state.cwdError = 'that path is not a directory'; render(); return; }
1727
+ } catch (e) {
1728
+ state.cwdError = e.status === 403 ? 'outside the allowed roots'
1729
+ : (e.status === 404 ? 'directory not found on the server: ' + path
1730
+ : (e.status ? 'directory not found on the server: ' + path : 'could not validate the path - server unreachable'));
1731
+ render();
1732
+ return;
1733
+ }
1734
+ }
1150
1735
  state.cwdError = null;
1151
1736
  state.chatCwd = path;
1152
1737
  if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
1153
1738
  state.cwdEditing = false; state.cwdDraft = undefined; render();
1154
1739
  },
1155
- onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; render(); },
1740
+ onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false; render(); },
1156
1741
  onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
1157
- onCwdDraft: (v) => { state.cwdDraft = v; },
1742
+ onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
1158
1743
  }),
1159
1744
  ].filter(Boolean);
1160
1745
  }
@@ -1168,14 +1753,28 @@ function offlineBanner() {
1168
1753
  // (The working-directory bar now lives in the AgentChat kit; agentgui wires its
1169
1754
  // cwd state + handlers as kit callbacks in chatMain.)
1170
1755
 
1756
+ // Destructive new-chat is two-step with DISTINCT arm/confirm controls: pressing
1757
+ // 'n' (or the rail action) only ARMS the banner; while armed, repeat presses
1758
+ // are no-ops and the arm auto-resets after 4s. Confirmation happens exclusively
1759
+ // via the banner's explicit 'clear' button - a double-tap can never wipe the
1760
+ // transcript.
1761
+ let _newChatArmTimer = null;
1171
1762
  function newChat() {
1172
- if (state.chat.messages.length && !state.confirmingNewChat) {
1173
- state.confirmingNewChat = true; render();
1763
+ if (state.chat.messages.length) {
1764
+ if (state.confirmingNewChat) { render(); return; } // armed: repeat press is a no-op
1765
+ state.confirmingNewChat = true;
1766
+ clearTimeout(_newChatArmTimer);
1767
+ _newChatArmTimer = setTimeout(() => { state.confirmingNewChat = false; render(); }, 4000);
1768
+ render();
1174
1769
  return;
1175
1770
  }
1771
+ confirmNewChat();
1772
+ }
1773
+ function confirmNewChat() {
1774
+ clearTimeout(_newChatArmTimer);
1176
1775
  state.confirmingNewChat = false;
1177
1776
  state.chat.abort?.abort();
1178
- state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null };
1777
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0, shownMessages: null };
1179
1778
  lsRemove(CHAT_KEY);
1180
1779
  render();
1181
1780
  }
@@ -1186,10 +1785,13 @@ function cancelChat() {
1186
1785
  state.chat.abort?.abort();
1187
1786
  // Drop a trailing empty assistant shell (aborted before any content) from the
1188
1787
  // live array so the message count + suggestions-empty check stay correct; the
1189
- // paired user message is intentionally kept.
1788
+ // paired user message is intentionally kept. A turn stopped mid-content is
1789
+ // labeled 'stopped' so truncated output never reads as a finished answer.
1190
1790
  const msgs = state.chat.messages;
1191
1791
  if (msgs.length && isEmptyTurn(msgs[msgs.length - 1])) msgs.pop();
1792
+ else if (msgs.length && msgs[msgs.length - 1].role === 'assistant') msgs[msgs.length - 1].stopped = true;
1192
1793
  if (state.chat.busy) { state.chat.busy = false; }
1794
+ refreshActive();
1193
1795
  render();
1194
1796
  }
1195
1797
 
@@ -1200,24 +1802,80 @@ const CHAT_KEY = 'agentgui.chat';
1200
1802
  function isEmptyTurn(m) {
1201
1803
  return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
1202
1804
  }
1805
+ // Cap the persisted footprint: the last 100 messages, with any tool args/result
1806
+ // payload over 4KB truncated in the SAVED copy only (in-memory state keeps the
1807
+ // full payload). A quota failure is surfaced once instead of silently dropping
1808
+ // persistence forever.
1809
+ const PERSIST_MSG_CAP = 100;
1810
+ const PERSIST_PART_CAP = 4096;
1811
+ let _persistFailedOnce = false;
1812
+ let _lastPersistTs = 0;
1813
+ function trimPartForStorage(p) {
1814
+ if (!p || typeof p !== 'object') return p;
1815
+ let out = p;
1816
+ for (const k of ['result', 'args']) {
1817
+ const v = out[k];
1818
+ if (v == null) continue;
1819
+ let s;
1820
+ if (typeof v === 'string') s = v;
1821
+ else { try { s = JSON.stringify(v); } catch { s = String(v); } }
1822
+ if (s.length > PERSIST_PART_CAP) {
1823
+ if (out === p) out = { ...p };
1824
+ out[k] = s.slice(0, PERSIST_PART_CAP);
1825
+ out.truncatedForStorage = true;
1826
+ }
1827
+ }
1828
+ return out;
1829
+ }
1203
1830
  function persistChat() {
1204
1831
  try {
1205
1832
  const msgs = state.chat.messages
1206
1833
  .filter(m => !isEmptyTurn(m))
1207
- .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
1208
- if (!msgs.length) { lsRemove(CHAT_KEY); return; }
1209
- lsSet(CHAT_KEY, JSON.stringify({ messages: msgs, resumeSid: state.chat.resumeSid, agent: state.selectedAgent, model: state.selectedModel }));
1210
- } catch {}
1834
+ .slice(-PERSIST_MSG_CAP)
1835
+ .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, costUsd: m.costUsd, stopped: m.stopped || undefined, parts: Array.isArray(m.parts) ? m.parts.map(trimPartForStorage) : m.parts }));
1836
+ const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
1837
+ // Persist when there is a transcript OR a non-empty draft (a typed-but-not-
1838
+ // sent message must survive a reload too).
1839
+ if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
1840
+ _lastPersistTs = Date.now();
1841
+ localStorage.setItem(CHAT_KEY, JSON.stringify({ ts: _lastPersistTs, messages: msgs, draft, resumeSid: state.chat.resumeSid, totalCost: state.chat.totalCost || 0, agent: state.selectedAgent, model: state.selectedModel }));
1842
+ } catch {
1843
+ if (!_persistFailedOnce) {
1844
+ _persistFailedOnce = true;
1845
+ state.chat.persistError = true;
1846
+ announce('chat too large to save locally - export it from settings');
1847
+ scheduleRender();
1848
+ }
1849
+ }
1211
1850
  }
1851
+ const debouncedPersistDraft = debounce(persistChat, 500);
1852
+ // Another GUI tab rewrote the shared chat key: never silently diverge - surface
1853
+ // a banner offering to reload the newer copy (last-writer-wins with a ts guard).
1854
+ window.addEventListener('storage', (e) => {
1855
+ if (e.key !== CHAT_KEY || !e.newValue) return;
1856
+ try {
1857
+ const remote = JSON.parse(e.newValue);
1858
+ if (remote && remote.ts && remote.ts > _lastPersistTs) {
1859
+ state.chat.externalUpdate = true;
1860
+ scheduleRender();
1861
+ }
1862
+ } catch {}
1863
+ });
1212
1864
  function restoreChat() {
1213
1865
  try {
1214
1866
  const raw = lsGet(CHAT_KEY);
1215
1867
  if (!raw) return;
1216
1868
  const saved = JSON.parse(raw);
1869
+ if (typeof saved.draft === 'string' && saved.draft.trim()) state.chat.draft = saved.draft;
1870
+ if (typeof saved.totalCost === 'number') state.chat.totalCost = saved.totalCost;
1217
1871
  if (Array.isArray(saved.messages) && saved.messages.length) {
1218
1872
  state.chat.messages = saved.messages
1219
1873
  .filter(m => !isEmptyTurn(m))
1220
1874
  .map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
1875
+ // Prefer the per-message cost sum when present (it self-corrects after
1876
+ // edit/retry truncation); the scalar is only the legacy fallback.
1877
+ const derived = computeTotalCost();
1878
+ if (derived) state.chat.totalCost = derived;
1221
1879
  state.chat.resumeSid = saved.resumeSid || null;
1222
1880
  // Restore the agent/model the transcript belongs to, so a restored chat
1223
1881
  // isn't silently shown under whatever agent the picker defaulted to.
@@ -1239,18 +1897,76 @@ function messageToText(m) {
1239
1897
  return '';
1240
1898
  }).filter(Boolean).join('\n');
1241
1899
  }
1242
- function copyMessageText(m) {
1243
- const text = messageToText(m);
1900
+ // Shared clipboard helper with an insecure-origin fallback; announces via the
1901
+ // aria-live region so AT users hear the result.
1902
+ function copyText(text, msg) {
1244
1903
  if (!text) return;
1245
- if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce('message copied')).catch(() => {});
1904
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce(msg)).catch(() => {});
1246
1905
  else {
1247
- try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce('message copied'); } catch {}
1906
+ try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce(msg); } catch {}
1907
+ }
1908
+ }
1909
+ function copyMessageText(m) {
1910
+ copyText(messageToText(m), 'message copied');
1911
+ }
1912
+
1913
+ // --- transcript export ---
1914
+ function dateStamp() { return new Date().toISOString().slice(0, 10); }
1915
+ function downloadBlob(content, filename, type) {
1916
+ const blob = new Blob([content], { type });
1917
+ const url = URL.createObjectURL(blob);
1918
+ const a = document.createElement('a');
1919
+ a.href = url; a.download = filename;
1920
+ document.body.appendChild(a); a.click(); a.remove();
1921
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
1922
+ }
1923
+ function transcriptToMarkdown(messages) {
1924
+ return (messages || []).map((m) => {
1925
+ const head = '## ' + (m.role === 'user' ? 'User' : 'Assistant') + (m.time ? ' (' + m.time + ')' : '');
1926
+ const parts = Array.isArray(m.parts) ? m.parts : [];
1927
+ const body = parts.length
1928
+ ? parts.map((p) => {
1929
+ if (typeof p === 'string') return p;
1930
+ if (p.kind === 'md' || p.kind === 'text') return p.text || '';
1931
+ if (p.kind === 'tool' || p.kind === 'tool_result') {
1932
+ const bits = ['## tool: ' + (p.name || 'tool')];
1933
+ if (p.args && Object.keys(p.args).length) bits.push('```json\n' + JSON.stringify(p.args, null, 2) + '\n```');
1934
+ if (p.result != null) bits.push('```\n' + (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2)) + '\n```');
1935
+ return bits.join('\n');
1936
+ }
1937
+ return '';
1938
+ }).filter(Boolean).join('\n\n')
1939
+ : (m.content || '');
1940
+ return head + '\n\n' + body;
1941
+ }).join('\n\n');
1942
+ }
1943
+
1944
+ // Contextual follow-up chips derived from the settled last assistant turn;
1945
+ // falls back to the static seeds when nothing contextual applies.
1946
+ const FOLLOWUP_SEEDS = ['Explain that in more detail', 'Show me the diff', 'Run the tests'];
1947
+ function chatFollowups() {
1948
+ const msgs = state.chat.messages || [];
1949
+ if (!msgs.length || state.chat.busy) return [];
1950
+ const last = [...msgs].reverse().find(m => m.role === 'assistant');
1951
+ if (!last) return [];
1952
+ const out = [];
1953
+ const parts = Array.isArray(last.parts) ? last.parts : [];
1954
+ if (parts.some(p => p && p.kind === 'tool' && p.status === 'error')) out.push('Fix that error');
1955
+ const text = messageToText(last);
1956
+ if (/```/.test(text) || parts.some(p => p && p.kind === 'code')) out.push('Explain this code');
1957
+ const fm = text.match(/([A-Za-z]:\\\S+|\/[\w./-]+\.\w+)/);
1958
+ if (fm) {
1959
+ const base = fm[1].split(/[/\\]/).filter(Boolean).pop();
1960
+ if (base) out.push('Open ' + base.replace(/[^\w.\-]/g, '') + ' in files');
1248
1961
  }
1962
+ return out.length ? out.slice(0, 3) : FOLLOWUP_SEEDS;
1249
1963
  }
1250
1964
  // Retry the last assistant turn: drop it and re-send the preceding user message.
1251
1965
  function retryLastTurn() {
1252
1966
  if (!canSend() && !state.chat.busy) { /* allow when idle */ }
1253
1967
  if (state.chat.busy) return;
1968
+ // The armed edit confirm refers to indices this retry is about to shift.
1969
+ state.chat.confirmingEdit = null;
1254
1970
  const msgs = state.chat.messages;
1255
1971
  // Find the trailing assistant turn and the user message before it.
1256
1972
  let ai = msgs.length - 1;
@@ -1262,33 +1978,65 @@ function retryLastTurn() {
1262
1978
  const userText = msgs[ui].content || '';
1263
1979
  // Drop the user+assistant pair (and anything after) and resend the user text.
1264
1980
  state.chat.messages = msgs.slice(0, ui);
1981
+ state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
1265
1982
  state.chat.draft = userText;
1266
1983
  persistChat();
1267
1984
  if (canSend()) sendChat();
1268
1985
  else render();
1269
1986
  }
1270
- // Edit-and-resend a user message: truncate the transcript at it, refill the
1271
- // composer with its text, and let the user revise before sending.
1272
- function editAndResend(m) {
1987
+ // Edit-and-resend a user message: two-step. The edit click ARMS a confirmation
1988
+ // (truncating destroys the later turns), the banner's continue executes it.
1989
+ function armEditAndResend(m) {
1273
1990
  if (state.chat.busy) return;
1274
1991
  const idx = state.chat.messages.indexOf(m);
1275
1992
  if (idx < 0) return;
1276
- state.chat.messages = state.chat.messages.slice(0, idx);
1277
- state.chat.draft = m.content || messageToText(m);
1993
+ state.chat.confirmingEdit = { idx, text: m.content || messageToText(m) };
1994
+ render();
1995
+ }
1996
+ function confirmEditAndResend() {
1997
+ const ce = state.chat.confirmingEdit;
1998
+ if (!ce || state.chat.busy) return;
1999
+ state.chat.confirmingEdit = null;
2000
+ state.chat.messages = state.chat.messages.slice(0, ce.idx);
2001
+ state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
2002
+ // Never --resume a session whose tail diverged from what the server saw.
2003
+ state.chat.resumeSid = null;
2004
+ state.chat.resumeNote = null;
2005
+ state.chat.draft = ce.text;
1278
2006
  persistChat();
1279
2007
  render();
1280
2008
  requestAnimationFrame(() => focusComposer());
1281
2009
  }
2010
+ function cancelEditAndResend() {
2011
+ state.chat.confirmingEdit = null;
2012
+ render();
2013
+ }
2014
+
2015
+ // Compute the conversation's spend from the per-message costUsd markers, so
2016
+ // edit/retry truncation self-corrects the total (no phantom spend from turns
2017
+ // that no longer exist in the transcript).
2018
+ function computeTotalCost() {
2019
+ return (state.chat.messages || []).reduce((s, m) => s + (typeof m.costUsd === 'number' ? m.costUsd : 0), 0);
2020
+ }
1282
2021
 
1283
- async function sendChat() {
1284
- const text = (state.chat.draft || '').trim();
2022
+ // sendChat(text) sends an explicit text (suggestion/followup chips) WITHOUT
2023
+ // touching the typed draft; with no argument it sends + clears the draft.
2024
+ async function sendChat(textArg) {
2025
+ const text = ((textArg != null ? textArg : state.chat.draft) || '').trim();
1285
2026
  if (!text || !canSend()) return;
2027
+ // The conversation is moving on: any armed edit-and-resend confirm refers to
2028
+ // an index that is about to be stale - disarm it.
2029
+ state.chat.confirmingEdit = null;
1286
2030
  const t = timeNow();
1287
2031
  const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
1288
2032
  const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t, parts: [] };
1289
2033
  state.chat.messages = [...state.chat.messages, userMsg, curMsg];
1290
- state.chat.draft = '';
2034
+ if (textArg == null) state.chat.draft = '';
1291
2035
  state.chat.busy = true;
2036
+ // Open the live stream + refresh the active list right away so the rail and
2037
+ // dashboard reflect this turn without waiting for the 3s poll.
2038
+ openLiveStream();
2039
+ refreshActive();
1292
2040
  const ctrl = new AbortController();
1293
2041
  state.chat.abort = ctrl;
1294
2042
  persistChat();
@@ -1306,6 +2054,10 @@ async function sendChat() {
1306
2054
  // another agent (it makes agy spuriously run --continue).
1307
2055
  resumeSid: (state.selectedAgent === 'claude-code' && state.chat.resumeSid) || undefined,
1308
2056
  })) {
2057
+ // After a stop, the iterator drains buffered events for a turn the user
2058
+ // aborted - applying them would set resumeSid / accrue cost / write text
2059
+ // into a popped shell. A stopped turn applies no further state.
2060
+ if (ctrl.signal.aborted) break;
1309
2061
  if (ev.type === 'session') {
1310
2062
  // Remember the server's session id so the NEXT turn resumes this
1311
2063
  // conversation instead of cold-spawning. Only claude-code supports
@@ -1333,21 +2085,55 @@ async function sendChat() {
1333
2085
  turns: b.num_turns != null ? b.num_turns : null,
1334
2086
  durationMs: b.duration_ms != null ? b.duration_ms : null,
1335
2087
  };
2088
+ // Per-message cost markers; the session total is DERIVED from the
2089
+ // visible messages so edit/retry truncation self-corrects the spend.
2090
+ const cost = typeof b.total_cost_usd === 'number' ? b.total_cost_usd : (typeof u.cost === 'number' ? u.cost : 0);
2091
+ if (cost) { cur.costUsd = (cur.costUsd || 0) + cost; state.chat.totalCost = computeTotalCost(); }
2092
+ scheduleStreamRender();
2093
+ }
2094
+ else if (ev.type === 'cancelled') {
2095
+ // Remote stop (another tab / dashboard): label the turn stopped so the
2096
+ // truncated output never reads as a finished answer.
2097
+ cur.stopped = true;
2098
+ announce('generation stopped');
1336
2099
  scheduleStreamRender();
1337
2100
  }
1338
- else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
2101
+ else if (ev.type === 'error') { cur.error = errText(ev.error); cur.errorRaw = errTextRaw(ev.error); render(); }
1339
2102
  }
1340
2103
  } catch (e) {
1341
- if (e.name !== 'AbortError') cur.error = errText(e.message);
2104
+ if (e.name !== 'AbortError') { cur.error = errText(e); cur.errorRaw = errTextRaw(e); }
1342
2105
  } finally {
1343
2106
  state.chat.busy = false;
1344
2107
  state.chat.abort = null;
1345
2108
  persistChat();
2109
+ refreshActive(); // settle the running panel/dashboard now, not at the next poll
1346
2110
  render();
1347
2111
  scrollChatToBottom();
1348
2112
  }
1349
2113
  }
1350
2114
 
2115
+ // Validate the cwd draft while editing (debounced) so an invalid path reads as
2116
+ // invalid before the save click, via the existing confined /api/stat endpoint.
2117
+ const debouncedCwdProbe = debounce(async () => {
2118
+ const path = (state.cwdDraft ?? '').trim();
2119
+ if (!state.cwdEditing) return;
2120
+ if (!path || !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) { state.cwdChecking = false; render(); return; }
2121
+ state.cwdChecking = true; render();
2122
+ const probed = path;
2123
+ try {
2124
+ const st = await B.statPath(state.backend, probed);
2125
+ if ((state.cwdDraft ?? '').trim() !== probed) return; // draft moved on
2126
+ state.cwdError = (!st || st.ok === false) ? 'folder not found on the server'
2127
+ : (!st.dir ? 'that path is not a directory' : null);
2128
+ } catch (e) {
2129
+ if ((state.cwdDraft ?? '').trim() !== probed) return;
2130
+ state.cwdError = e.status === 403 ? 'outside the allowed roots'
2131
+ : (e.status === 404 ? 'folder not found on the server' : null);
2132
+ }
2133
+ state.cwdChecking = false;
2134
+ render();
2135
+ }, 400);
2136
+
1351
2137
  // --- history ---
1352
2138
  function reconnectAlert() {
1353
2139
  if (!state.live.error) return null;
@@ -1359,6 +2145,48 @@ function reconnectAlert() {
1359
2145
  });
1360
2146
  }
1361
2147
 
2148
+ // Humanize a millisecond span (1m 30s scale words, no glyphs).
2149
+ function humanizeMs(ms) {
2150
+ if (ms == null || !isFinite(ms) || ms < 0) return '';
2151
+ const s = Math.round(ms / 1000);
2152
+ if (s < 60) return s + 's';
2153
+ if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
2154
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
2155
+ }
2156
+
2157
+ // First-to-last event timestamp span for the selected session, humanized.
2158
+ function sessionDuration() {
2159
+ const ts = (state.events || []).map(e => e.ts).filter(Boolean);
2160
+ if (ts.length < 2) return '';
2161
+ return humanizeMs(Math.max(...ts) - Math.min(...ts));
2162
+ }
2163
+
2164
+ // Event-type filter predicate (all | text | tool | errors).
2165
+ function eventMatchesFilter(e, f) {
2166
+ if (f === 'tool') return e.type === 'tool_use' || e.type === 'tool_result';
2167
+ if (f === 'errors') return !!e.isError;
2168
+ if (f === 'text') return !e.isError && e.type !== 'tool_use' && e.type !== 'tool_result';
2169
+ return true;
2170
+ }
2171
+
2172
+ // Scroll to + flash the first error event, widening the render window (and
2173
+ // clearing the type filter) so the row is actually rendered.
2174
+ function jumpToFirstError() {
2175
+ const idx = state.events.findIndex(e => e.isError);
2176
+ if (idx < 0) return;
2177
+ state.eventFilter = 'all';
2178
+ const fromEnd = state.events.length - idx;
2179
+ if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
2180
+ render();
2181
+ const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
2182
+ const rowPos = idx - sliceStart;
2183
+ requestAnimationFrame(() => {
2184
+ const rows = document.querySelectorAll('.ds-event-list .row');
2185
+ const row = rows[rowPos];
2186
+ if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
2187
+ });
2188
+ }
2189
+
1362
2190
  function historyMain() {
1363
2191
  if (!state.selectedSid) {
1364
2192
  const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
@@ -1390,26 +2218,70 @@ function historyMain() {
1390
2218
  lede,
1391
2219
  });
1392
2220
 
1393
- const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' },
2221
+ const hasErrors = state.events.some(e => e.isError);
2222
+ const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' }, [
1394
2223
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
1395
2224
  Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
1396
- );
2225
+ Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
2226
+ onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
2227
+ children: 'export' }),
2228
+ hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
2229
+ ].filter(Boolean));
1397
2230
 
1398
2231
  if (state.events.length === 0) {
1399
2232
  // Distinguish "still loading" from "genuinely empty" so a 0-event session
1400
- // doesn't spin forever.
2233
+ // doesn't spin forever. After 5s of an unresolved first fetch, swap to the
2234
+ // indexing copy (ccsniff's first JSONL walk can take a minute).
1401
2235
  const body = state.eventsLoaded
1402
2236
  ? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
1403
2237
  h('span', { key: 'noevtxt' }, 'no events in this session - '),
1404
2238
  Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
1405
- : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
2239
+ : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }),
2240
+ state.eventsSlow ? 'Indexing your Claude history — the first load can take a minute…' : 'loading events…');
1406
2241
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
1407
2242
  }
1408
2243
 
1409
2244
  if (!state.expandedEvents) state.expandedEvents = new Set();
1410
- const total = state.events.length;
2245
+ // Event-type filter applies BEFORE the render-window slice so "errors" shows
2246
+ // every error in the session, not only errors among the most-recent 300.
2247
+ const ef = state.eventFilter || 'all';
2248
+ const filteredEvents = ef === 'all' ? state.events : state.events.filter(e => eventMatchesFilter(e, ef));
2249
+ const filterPills = FilterPills({
2250
+ options: [
2251
+ { id: 'all', label: 'all' },
2252
+ { id: 'text', label: 'text' },
2253
+ { id: 'tool', label: 'tools' },
2254
+ { id: 'errors', label: 'errors' },
2255
+ ],
2256
+ selected: ef,
2257
+ onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
2258
+ label: 'Filter events by type',
2259
+ });
2260
+ const meta = SessionMeta({
2261
+ items: [
2262
+ sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
2263
+ sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
2264
+ { label: 'sid', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'sid copied') },
2265
+ // Spelled counter vocabulary in the detail strip (events/turns/tools/
2266
+ // errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
2267
+ { label: 'events', value: String(state.events.length) },
2268
+ { label: 'turns', value: String(sess?.userTurns ?? state.events.filter(e => e.role === 'user').length) },
2269
+ { label: 'tools', value: String(state.events.filter(e => e.type === 'tool_use').length) },
2270
+ { label: 'errors', value: String(state.events.filter(e => e.isError).length) },
2271
+ ].filter(Boolean),
2272
+ });
2273
+ if (filteredEvents.length === 0) {
2274
+ return [reconnectAlert(), head, actions, Panel({ title: 'events', children: [
2275
+ h('div', { key: 'evmeta' }, meta),
2276
+ h('div', { key: 'evfp' }, filterPills),
2277
+ h('div', { key: 'nofilt', class: 'lede empty-state', role: 'status' },
2278
+ h('span', { key: 'noftxt' }, '0 events match this filter - '),
2279
+ Btn({ key: 'clearf', onClick: () => { state.eventFilter = 'all'; render(); }, children: 'clear filter' })),
2280
+ ] })].filter(Boolean);
2281
+ }
2282
+ const total = filteredEvents.length;
1411
2283
  const limit = state.eventsLimit;
1412
- const shown = state.events.slice(-limit);
2284
+ const shown = filteredEvents.slice(-limit);
1413
2285
  const hiddenCount = total - shown.length;
1414
2286
  // Keys of the currently-shown rows, so expand-all toggles only what's rendered.
1415
2287
  const shownKeys = shown.map((e, i) => {
@@ -1432,8 +2304,8 @@ function historyMain() {
1432
2304
  head,
1433
2305
  actions,
1434
2306
  Panel({
1435
- title: total + ' events' + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
1436
- children: [eventControls, EventList({
2307
+ title: total + ' events' + (ef !== 'all' ? ' (' + ef + ' filter)' : '') + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
2308
+ children: [h('div', { key: 'evmeta' }, meta), h('div', { key: 'evfp' }, filterPills), eventControls, EventList({
1437
2309
  items: shown.map((e, i) => {
1438
2310
  // Stable key: prefer the server-assigned event index, else the
1439
2311
  // event timestamp + position, never a bare array index (which
@@ -1456,17 +2328,33 @@ function historyMain() {
1456
2328
  // Rail tone matches the session/agents rail semantics so an event's
1457
2329
  // kind is visible at a glance, consistent across the GUI:
1458
2330
  // flame = error, purple = tool_use, green = normal turn.
1459
- const rail = e.isError ? 'flame' : 'green';
2331
+ const rail = e.isError ? 'flame' : (e.type === 'tool_use' ? 'purple' : 'green');
2332
+ // When the session was opened from a search hit, window the collapsed
2333
+ // title AROUND the first query match (a match at char 5000 would
2334
+ // otherwise be invisible behind the 0-220 slice).
2335
+ let collapsedTitle = text.slice(0, 220);
2336
+ const q = state.sessionSearchQ;
2337
+ if (q && !expanded) {
2338
+ const qi = text.toLowerCase().indexOf(q.toLowerCase());
2339
+ if (qi > 60) collapsedTitle = '…' + text.slice(qi - 60, qi - 60 + 220);
2340
+ }
1460
2341
  return {
1461
2342
  key,
1462
2343
  code: String(absIdx + 1).padStart(4, '0'),
1463
2344
  rail,
1464
2345
  expanded, // disclosure state -> kit Row sets aria-expanded
1465
- title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
2346
+ highlight: q || undefined,
2347
+ actions: expanded ? [{
2348
+ label: 'copy', title: 'copy event',
2349
+ onClick: () => copyText(full || raw || ('(' + type + ')'), 'event copied'),
2350
+ }] : undefined,
2351
+ title: expanded ? (full || '(' + type + ')') : (collapsedTitle || '(' + type + ')'),
1466
2352
  // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
1467
2353
  // Every row is click-to-expand, so always show the affordance word
1468
2354
  // (not only when text overflows 220 chars).
1469
- sub: (e.ts ? new Date(e.ts).toLocaleString() : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
2355
+ // Relative time matches every other surface; the absolute stamp
2356
+ // appears when the row is expanded (forensic precision preserved).
2357
+ sub: (e.ts ? (expanded ? new Date(e.ts).toLocaleString() : fmtRelTime(e.ts)) : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
1470
2358
  onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
1471
2359
  };
1472
2360
  }),
@@ -1478,7 +2366,14 @@ function historyMain() {
1478
2366
  let copyToast = null;
1479
2367
  // Hold the toast long enough to read (2.5s); the copy button label is inside a
1480
2368
  // role=status region (history-actions) so AT announces the change.
1481
- function setCopyToast(msg) { copyToast = msg; render(); setTimeout(() => { copyToast = null; render(); }, 2500); }
2369
+ let _copyToastTimer = null;
2370
+ function setCopyToast(msg) {
2371
+ copyToast = msg; render();
2372
+ // One timer per invocation: a rapid second copy must not have its feedback
2373
+ // truncated by the first copy's expiring timeout.
2374
+ clearTimeout(_copyToastTimer);
2375
+ _copyToastTimer = setTimeout(() => { copyToast = null; render(); }, 2500);
2376
+ }
1482
2377
  function copySid() {
1483
2378
  const sid = state.selectedSid;
1484
2379
  if (!sid) return;
@@ -1495,11 +2390,17 @@ function copySid() {
1495
2390
  }
1496
2391
  }
1497
2392
 
1498
- function resumeInChat(sess) {
2393
+ function resumeInChat(sess, { fromHash = false } = {}) {
1499
2394
  state.tab = 'chat';
1500
2395
  closeLiveStream();
1501
2396
  state.chat.resumeSid = sess?.sid || state.selectedSid;
2397
+ // Carry the sid on the chat tab too (tab=chat&sid=...) so reload/Back
2398
+ // restores the resumed conversation rather than a blank chat.
2399
+ state.selectedSid = state.chat.resumeSid || state.selectedSid;
2400
+ if (!fromHash) writeHash({ push: true });
1502
2401
  state.chat.messages = [];
2402
+ state.chat.totalCost = 0;
2403
+ state.chat.shownMessages = null;
1503
2404
  state.chat.draft = '';
1504
2405
  // Only claude-code supports --resume by sid; warn if we have to switch the
1505
2406
  // user's selected agent rather than silently discarding it.
@@ -1548,19 +2449,28 @@ function uniqueProjects() {
1548
2449
  function runningPanel() {
1549
2450
  const running = Array.isArray(state.active) ? state.active : [];
1550
2451
  if (!running.length) return null;
2452
+ const stopping = state.live.stopping || new Set();
1551
2453
  return Panel({
1552
2454
  key: 'runningPanel',
1553
2455
  title: 'running · ' + running.length,
1554
- children: running.map((r) => {
1555
- const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
1556
- const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
1557
- // All three children must be keyed VElements (mixing a keyed span with an
1558
- // unkeyed one crashes webjsx applyDiff "reading 'key'").
1559
- return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
1560
- h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
1561
- h('span', { key: 'rl-' + r.sessionId, class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
1562
- Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
1563
- }),
2456
+ children: [
2457
+ // A discoverable path from "this running chat" to the management surface.
2458
+ h('div', { key: 'runall', class: 'resume-banner', role: 'group' },
2459
+ h('span', { key: 'runalllbl', class: 'lede' }, 'manage all running sessions'),
2460
+ Btn({ key: 'runalllive', onClick: () => navTo('live'), children: 'view all in live' })),
2461
+ ...running.map((r) => {
2462
+ const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
2463
+ const elapsedMs = r.startedAt ? Math.max(0, Date.now() - r.startedAt) : 0;
2464
+ const isStopping = stopping.has(r.sessionId);
2465
+ // All children must be keyed VElements (mixing a keyed span with an
2466
+ // unkeyed one crashes webjsx applyDiff "reading 'key'").
2467
+ return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
2468
+ h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
2469
+ 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] : '')),
2470
+ Btn({ key: 'open' + r.sessionId, onClick: () => navTo('live'), children: 'open in live' }),
2471
+ Btn({ key: 'stop' + r.sessionId, disabled: isStopping, onClick: () => stopActiveChat(r.sessionId), children: isStopping ? 'stopping…' : 'stop' }));
2472
+ }),
2473
+ ],
1564
2474
  });
1565
2475
  }
1566
2476
 
@@ -1578,7 +2488,7 @@ function historySide() {
1578
2488
  key: 'sr-' + (r.sid || '?') + '-' + i,
1579
2489
  rank: String(i + 1).padStart(3, '0'),
1580
2490
  title: r.snippet || '(no snippet)',
1581
- sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
2491
+ sub: (projectLabel(r.project) || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
1582
2492
  // Rail carries the same semantics as session rows: error > subagent > normal.
1583
2493
  rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
1584
2494
  // Carry the matched event's index so loadSession scrolls to + flashes
@@ -1632,13 +2542,13 @@ function historySide() {
1632
2542
  ? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
1633
2543
  : null,
1634
2544
  state.searchQ
1635
- ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: 'clear search' })
2545
+ ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; writeHash(); render(); }, children: 'clear search' })
1636
2546
  : null,
1637
2547
  !searching && projects.length > 1
1638
2548
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
1639
- pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
2549
+ pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; writeHash(); render(); }),
1640
2550
  ...projects.slice(0, 8).map(([name, count]) =>
1641
- pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
2551
+ pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; writeHash(); render(); })))
1642
2552
  : null,
1643
2553
  !searching && subagentCount
1644
2554
  ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
@@ -1682,11 +2592,13 @@ function normalizeBackend(s) {
1682
2592
  async function saveBackend() {
1683
2593
  if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
1684
2594
  // Switching backend orphans the local chat transcript (it belongs to the old
1685
- // server's sessions). Confirm once if there's a transcript to lose.
1686
- if (state.chat.messages.length && !state.confirmingBackend) {
1687
- state.confirmingBackend = true; render(); return;
2595
+ // server's sessions). Confirm once if there's a transcript to lose - and the
2596
+ // confirmation binds to the EXACT value confirmed: editing the URL after
2597
+ // arming re-arms instead of executing under a stale confirmation.
2598
+ if (state.chat.messages.length && state.confirmingBackend !== state.backendDraft) {
2599
+ state.confirmingBackend = state.backendDraft; render(); return;
1688
2600
  }
1689
- state.confirmingBackend = false;
2601
+ state.confirmingBackend = undefined;
1690
2602
  const canonical = normalizeBackend(state.backendDraft);
1691
2603
  state.backendDraft = canonical;
1692
2604
  B.setBackend(canonical);
@@ -1730,15 +2642,19 @@ function settingsMain() {
1730
2642
  PageHeader({
1731
2643
  compact: true,
1732
2644
  title: 'Settings',
1733
- lede: 'Point agentgui at any backend. Blank = same-origin (ccsniff in-process). ?backend=... or the field below persists via localStorage.',
2645
+ // The page lede describes the PAGE; the backend explanation lives in the
2646
+ // backend panel where it applies.
2647
+ lede: 'Connection, agents, appearance, keyboard, and local data.',
1734
2648
  }),
1735
2649
  h('div', { key: 'settings-grid', class: 'settings-grid' }, [
1736
2650
  Panel({
2651
+ id: 'backend',
1737
2652
  title: 'backend',
1738
2653
  children: h('form', {
1739
2654
  key: 'backendForm',
1740
2655
  onSubmit: (e) => { e.preventDefault(); saveBackend(); },
1741
2656
  }, [
2657
+ h('p', { key: 'blede', class: 'lede', style: 'margin:0 0 .5em' }, 'Point agentgui at any backend. Blank = same-origin (ccsniff in-process). ?backend=... or the field below persists via localStorage.'),
1742
2658
  TextField({
1743
2659
  key: 'backendField',
1744
2660
  label: 'backend url',
@@ -1747,13 +2663,19 @@ function settingsMain() {
1747
2663
  'aria-describedby': !isValid ? 'backend-url-error' : undefined,
1748
2664
  'aria-invalid': !isValid ? 'true' : 'false',
1749
2665
  title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
1750
- onInput: (v) => { state.backendDraft = v; render(); },
2666
+ onInput: (v) => {
2667
+ state.backendDraft = v;
2668
+ // The armed confirmation refers to a different value now - disarm.
2669
+ if (state.confirmingBackend !== undefined && state.confirmingBackend !== v) state.confirmingBackend = undefined;
2670
+ render();
2671
+ },
1751
2672
  }),
1752
2673
  !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
1753
2674
  state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
1754
2675
  state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
1755
2676
  state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
1756
- state.confirmingBackend ? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript - press save again to confirm') : null,
2677
+ (state.confirmingBackend !== undefined && state.confirmingBackend === state.backendDraft && isValid && state.backendDraft !== state.backend)
2678
+ ? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript - press save again to confirm') : null,
1757
2679
  healthSummary(),
1758
2680
  Btn({
1759
2681
  key: 'savebtn',
@@ -1766,19 +2688,56 @@ function settingsMain() {
1766
2688
  }),
1767
2689
  ]),
1768
2690
  }),
2691
+ // Panel order: connection group (backend + server) first, then agents,
2692
+ // then personal preferences (appearance / keyboard / data).
2693
+ serverPanel(),
2694
+ agentsPanel(),
1769
2695
  Panel({
2696
+ id: 'appearance',
1770
2697
  title: 'appearance',
1771
2698
  children: [
1772
2699
  h('div', { key: 'apl', class: 'lede', style: 'margin-bottom:.5em' }, 'theme - follows the OS in auto, or pick light/dark.'),
1773
2700
  ThemeToggle({ key: 'tt' }),
1774
2701
  ],
1775
2702
  }),
1776
- agentsPanel(),
2703
+ keyboardPanel(),
1777
2704
  preferencesPanel(),
1778
2705
  ]),
1779
2706
  ];
1780
2707
  }
1781
2708
 
2709
+ // Server info from /health: version, uptime, ws clients, allowed roots (with
2710
+ // per-root copy), projects dir. Renders unknowns plainly rather than hiding.
2711
+ function serverPanel() {
2712
+ const hh = state.health || {};
2713
+ const roots = Array.isArray(hh.allowRoots) ? hh.allowRoots : [];
2714
+ const upMs = hh.uptimeMs != null ? hh.uptimeMs : (hh.uptime != null ? hh.uptime * 1000 : null);
2715
+ const wsClients = hh.wsClients != null ? hh.wsClients : (hh.clients != null ? hh.clients : null);
2716
+ return Panel({
2717
+ id: 'server',
2718
+ title: 'server',
2719
+ children: [
2720
+ h('div', { key: 'sv', class: 'lede' }, 'version: ' + (hh.version ? 'v' + hh.version : 'unknown')),
2721
+ h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ? humanizeMs(upMs) : 'unknown')),
2722
+ h('div', { key: 'swc', class: 'lede' }, 'ws clients: ' + (wsClients != null ? wsClients : 'unknown')),
2723
+ h('div', { key: 'spd', class: 'lede' }, 'projects dir: ' + (hh.projectsDir || 'unknown')),
2724
+ roots.length
2725
+ ? SessionMeta({ key: 'sroots', items: roots.map((r, i) => ({ label: 'root ' + (i + 1), value: r, title: r, onCopy: () => copyText(r, 'root copied') })) })
2726
+ : h('div', { key: 'snoroots', class: 'lede' }, 'allowed roots: unknown'),
2727
+ ],
2728
+ });
2729
+ }
2730
+
2731
+ // The keyboard panel consumes the same SHORTCUTS definition as the ?-overlay.
2732
+ function keyboardPanel() {
2733
+ return Panel({
2734
+ id: 'keyboard',
2735
+ title: 'keyboard',
2736
+ children: SHORTCUTS.map((s, i) =>
2737
+ h('div', { key: 'kb' + i, class: 'lede' }, s.keys + ' - ' + s.desc)),
2738
+ });
2739
+ }
2740
+
1782
2741
  function clearLocalData() {
1783
2742
  if (!state.confirmingClearData) { state.confirmingClearData = true; render(); return; }
1784
2743
  state.confirmingClearData = false;
@@ -1788,19 +2747,38 @@ function clearLocalData() {
1788
2747
  // re-persist them, so the "clear" wouldn't survive. A reload re-inits from
1789
2748
  // defaults with the keys gone.
1790
2749
  state.chat.abort?.abort(); // stop any in-flight stream before we drop the page
1791
- for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend']) lsRemove(k);
1792
- state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
2750
+ for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend', 'agentgui.live', 'agentgui.files']) lsRemove(k);
2751
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 };
1793
2752
  location.reload();
1794
2753
  }
1795
2754
 
1796
2755
  function preferencesPanel() {
1797
2756
  const hh = state.health || {};
2757
+ const savedChat = lsGet(CHAT_KEY);
2758
+ // Footprint of agentgui's own localStorage keys.
2759
+ let lsKeys = 0, lsBytes = 0;
2760
+ try {
2761
+ for (let i = 0; i < localStorage.length; i++) {
2762
+ const k = localStorage.key(i);
2763
+ if (k && k.startsWith('agentgui.')) { lsKeys++; lsBytes += (localStorage.getItem(k) || '').length; }
2764
+ }
2765
+ } catch {}
1798
2766
  return Panel({
1799
- title: 'preferences',
2767
+ id: 'data',
2768
+ title: 'data',
1800
2769
  children: [
1801
- h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
1802
- h('div', { key: 'sc', class: 'lede', style: 'margin:.5em 0' },
1803
- 'keyboard: g then c/h/s - switch tabs · n - new chat · / - focus search/composer · ? - toggle hint · Esc - blur field'),
2770
+ // The server version lives in the server panel; carry only a build stamp
2771
+ // here, and only when it differs from the server version.
2772
+ (window.__SERVER_VERSION && window.__SERVER_VERSION !== hh.version)
2773
+ ? h('div', { key: 'ver', class: 'lede' }, 'build ' + window.__SERVER_VERSION)
2774
+ : null,
2775
+ h('div', { key: 'lsize', class: 'lede', style: 'margin:.5em 0' },
2776
+ 'local data: ' + fmtBytes(lsBytes) + ' across ' + lsKeys + ' key' + (lsKeys === 1 ? '' : 's')),
2777
+ h('div', { key: 'expchatrow', style: 'margin:.5em 0' },
2778
+ Btn({ key: 'expchat', disabled: !savedChat,
2779
+ title: savedChat ? 'Download the saved chat transcript as JSON' : 'no saved chat',
2780
+ onClick: savedChat ? () => downloadBlob(savedChat, 'agentgui-chat-' + dateStamp() + '.json', 'application/json') : undefined,
2781
+ children: savedChat ? 'export chat' : 'no saved chat' })),
1804
2782
  h('div', { key: 'cwdnote', class: 'lede', style: 'margin:.5em 0' },
1805
2783
  'working directory: set per-chat in the chat composer’s cwd bar' + (state.chatCwd ? ' (current: ' + state.chatCwd + ')' : ' (currently: server default)')),
1806
2784
  state.confirmingClearData
@@ -1810,7 +2788,7 @@ function preferencesPanel() {
1810
2788
  Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
1811
2789
  Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
1812
2790
  : Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
1813
- ],
2791
+ ].filter(Boolean),
1814
2792
  });
1815
2793
  }
1816
2794
 
@@ -1823,6 +2801,7 @@ function agentsPanel() {
1823
2801
  const installed = state.agents.filter(a => a.available !== false);
1824
2802
  const hasAcp = (Array.isArray(state.health.acp) ? state.health.acp : []).length > 0;
1825
2803
  return Panel({
2804
+ id: 'agents',
1826
2805
  title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
1827
2806
  children: [
1828
2807
  // ACP agents (opencode/kilo/codex) are managed automatically: started
@@ -1863,11 +2842,33 @@ function agentsPanel() {
1863
2842
 
1864
2843
  // --- data ---
1865
2844
  async function refreshHistory() {
2845
+ // Warmup copy: the FIRST sessions fetch can sit behind ccsniff's 30-90s
2846
+ // JSONL walk; after 5s swap the loading copy to indexing language.
2847
+ const firstLoad = !state._historyLoadedOnce;
2848
+ const slowTimer = firstLoad
2849
+ ? setTimeout(() => { if (!state._historyLoadedOnce) { state.historySlow = true; render(); } }, 5000)
2850
+ : null;
1866
2851
  try {
1867
2852
  state.sessions = await B.listSessions(state.backend);
2853
+ state._historyLoadedOnce = true;
2854
+ state.historySlow = false;
1868
2855
  // Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
1869
2856
  // linear scan per event during a burst load.
1870
2857
  state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
2858
+ // Bound the live tally: drop entries with no activity in 24h and cap the
2859
+ // Map at ~200 most-recent sids (a long-lived tab otherwise accumulates
2860
+ // every sid ever seen, and dead entries could resurrect wrong externals).
2861
+ if (state.live.tally) {
2862
+ const cutoff = Date.now() - 24 * 3600 * 1000;
2863
+ for (const [sid, t] of [...state.live.tally]) {
2864
+ if (!t.last || t.last < cutoff) state.live.tally.delete(sid);
2865
+ }
2866
+ if (state.live.tally.size > 200) {
2867
+ state.live.tally = new Map([...state.live.tally.entries()]
2868
+ .sort((a, b) => (b[1].last || 0) - (a[1].last || 0))
2869
+ .slice(0, 200));
2870
+ }
2871
+ }
1871
2872
  // If the selected session vanished from the list (deleted/aged out server-side),
1872
2873
  // drop the selection so the main pane doesn't sit on stale events that can no
1873
2874
  // longer be reloaded; fall back to the no-selection empty state.
@@ -1875,34 +2876,42 @@ async function refreshHistory() {
1875
2876
  state.selectedSid = null;
1876
2877
  state.events = [];
1877
2878
  state.eventsLoaded = false;
1878
- writeHash(null);
2879
+ writeHash();
1879
2880
  }
1880
2881
  state.historyError = null;
1881
2882
  } catch (e) {
1882
2883
  // Only a genuine fetch/list failure is a history error. A render exception
1883
2884
  // must not masquerade as one (it would poison the sessions panel with a
1884
2885
  // render-stack string and never clear), so render() lives outside this try.
1885
- state.historyError = e.message;
2886
+ state.historyError = errText(e);
1886
2887
  console.warn('history fetch failed:', e.message);
1887
2888
  }
2889
+ if (slowTimer) clearTimeout(slowTimer);
1888
2890
  render();
1889
2891
  }
1890
2892
  const debouncedRefreshHistory = debounce(refreshHistory, 500);
1891
2893
 
1892
2894
  async function runSearch() {
1893
2895
  const q = state.searchQ.trim();
1894
- if (!q) { state.searchHits = null; state.searchBusy = false; render(); return; }
1895
- if (q.length < 2) { state.searchHits = null; state.searchBusy = false; render(); return; }
2896
+ if (!q) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
2897
+ if (q.length < 2) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
1896
2898
  // The project-filter pills are hidden while searching; clear the filter so it
1897
2899
  // doesn't silently re-apply (and surprise the user) when they later clear the
1898
2900
  // search and the now-visible session list is unexpectedly narrowed.
1899
2901
  state.projectFilter = '';
2902
+ // The debounced search keeps the URL's q=/project= in sync via replaceState
2903
+ // so a reload (or share) restores the search, without flooding history.
2904
+ writeHash();
1900
2905
  state.searchBusy = true;
1901
2906
  render();
1902
2907
  try {
1903
2908
  state.searchHits = await B.searchHistory(state.backend, q, 60);
2909
+ // Announce the settled count for AT - the sessions-column count is only
2910
+ // rendered visually (the history actions row is the only aria-live region).
2911
+ const n = (state.searchHits.results || []).length;
2912
+ announce((n || 'no') + ' matches for ' + q);
1904
2913
  } catch (e) {
1905
- state.searchHits = { query: q, results: [], error: e.message };
2914
+ state.searchHits = { query: q, results: [], error: errText(e) };
1906
2915
  } finally {
1907
2916
  state.searchBusy = false;
1908
2917
  render();
@@ -1910,18 +2919,26 @@ async function runSearch() {
1910
2919
  }
1911
2920
  const debouncedSearch = debounce(runSearch, 300);
1912
2921
 
1913
- async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}) {
2922
+ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromHash = false } = {}) {
1914
2923
  // Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
1915
2924
  if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
1916
2925
  state.selectedSid = sid;
1917
2926
  state.events = [];
1918
2927
  state.eventsLoaded = false;
2928
+ state.eventsSlow = false;
1919
2929
  state.eventsLimit = 300; // reset the render window per session
2930
+ state.eventFilter = 'all'; // don't carry the type filter across sessions
1920
2931
  state.expandedEvents = new Set(); // don't carry expansion to the new session
2932
+ // Remember the query this session was opened FROM (search hit) so the event
2933
+ // rows can highlight + window around the match; a plain selection clears it.
2934
+ state.sessionSearchQ = (focusEventI != null || focusEventTs != null) && state.searchQ.trim().length >= 2
2935
+ ? state.searchQ.trim() : null;
1921
2936
  // The live "live · N" crumb counter reads as the selected session's activity,
1922
2937
  // so reset it per selection rather than letting it accrue across all sessions.
1923
2938
  state.live.eventCount = 0;
1924
- writeHash(sid, { push: true });
2939
+ writeHash({ push: !fromHash });
2940
+ // Warmup copy: a first events fetch can sit behind ccsniff's JSONL walk.
2941
+ const slowTimer = setTimeout(() => { if (!state.eventsLoaded && state.selectedSid === sid) { state.eventsSlow = true; render(); } }, 5000);
1925
2942
  // Close the mobile sidebar drawer on selection. The DS only auto-closes when
1926
2943
  // the clicked element is an <a>; agentgui's session rows are onClick divs, so
1927
2944
  // we close it explicitly here.
@@ -1934,6 +2951,12 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}
1934
2951
  });
1935
2952
  try {
1936
2953
  state.events = await B.getSessionEvents(state.backend, sid);
2954
+ // ccsniff's events route has no ?limit= (checked: router.js returns the
2955
+ // whole session) - cap in-memory state at the most-recent 5000 so a
2956
+ // monster session can't pin the tab; the render window stays 300+load-older.
2957
+ if (state.events.length > 5000) state.events = state.events.slice(-5000);
2958
+ clearTimeout(slowTimer);
2959
+ state.eventsSlow = false;
1937
2960
  state.eventsLoaded = true;
1938
2961
  // If we arrived from a search hit, make sure the matched event is within the
1939
2962
  // render window, then scroll to + flash it so the match isn't lost.
@@ -1961,21 +2984,20 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}
1961
2984
  ts: Date.now(),
1962
2985
  role: 'error',
1963
2986
  type: 'fetch',
1964
- text: 'Failed to load session: ' + e.message + ' - retry via sidebar',
2987
+ text: 'Failed to load session: ' + errText(e) + ' - retry via sidebar',
1965
2988
  }];
2989
+ clearTimeout(slowTimer);
2990
+ state.eventsSlow = false;
1966
2991
  state.eventsLoaded = true;
1967
2992
  render();
1968
2993
  }
1969
2994
  }
1970
2995
 
1971
- async function init() {
1972
- try {
1973
- const r = await B.probeBackend(state.backend);
1974
- state.health = r.ok ? { status: 'ok', ...r.info } : { status: 'down', ...r };
1975
- } catch (e) {
1976
- state.health = { status: 'error', error: e.message };
1977
- }
1978
- render();
2996
+ // Fetch agents + pick the active one. Reusable: boot, backend save, and the
2997
+ // reconnect path all re-run it. Returns true on success; failure lands in
2998
+ // state.agentsError so the chat tab can surface it with a retry control.
2999
+ async function loadAgents() {
3000
+ state.agentsError = null;
1979
3001
  try {
1980
3002
  state.agents = await B.listAgents(state.backend);
1981
3003
  // Agent selection priority: the agent a restored transcript belongs to (so
@@ -1986,15 +3008,85 @@ async function init() {
1986
3008
  if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
1987
3009
  if (target) await selectAgent(target.id);
1988
3010
  render();
1989
- } catch (e) { console.warn('agents fetch failed:', e.message); }
3011
+ return true;
3012
+ } catch (e) {
3013
+ state.agentsError = errText(e);
3014
+ console.warn('agents fetch failed:', e.message);
3015
+ render();
3016
+ return false;
3017
+ }
3018
+ }
3019
+
3020
+ // Boot-time automatic retries with backoff when the first agents fetch fails
3021
+ // (the server may still be warming up).
3022
+ async function retryLoadAgents() {
3023
+ for (const ms of [2000, 5000, 10000]) {
3024
+ await new Promise(r => setTimeout(r, ms));
3025
+ if (!state.agentsError) return; // a manual retry already succeeded
3026
+ if (await loadAgents()) return;
3027
+ }
3028
+ }
3029
+
3030
+ // While the backend is unreachable, poll /health every ~10s; on recovery
3031
+ // re-fetch agents + models and announce, so a mid-session outage self-heals.
3032
+ let _healthPollTimer = null;
3033
+ function stopHealthPolling() { if (_healthPollTimer) { clearInterval(_healthPollTimer); _healthPollTimer = null; } }
3034
+ function startHealthPolling() {
3035
+ if (_healthPollTimer) return;
3036
+ _healthPollTimer = setInterval(() => {
3037
+ if (state.health.status === 'ok') { stopHealthPolling(); return; }
3038
+ recheckHealth();
3039
+ }, 10000);
3040
+ }
3041
+ async function recheckHealth() {
3042
+ const wasOk = state.health.status === 'ok';
3043
+ const r = await B.probeBackend(state.backend);
3044
+ if (r.ok) {
3045
+ state.health = { status: 'ok', ...r.info };
3046
+ stopHealthPolling();
3047
+ if (!wasOk) { announce('reconnected'); loadAgents(); }
3048
+ } else {
3049
+ state.health = { status: 'down', ...r };
3050
+ startHealthPolling();
3051
+ }
3052
+ render();
3053
+ }
1990
3054
 
1991
- const { sid: initialSid, tab: initialTab } = readHash();
1992
- if (initialSid) {
1993
- navTo('history');
3055
+ async function init() {
3056
+ try {
3057
+ const r = await B.probeBackend(state.backend);
3058
+ state.health = r.ok ? { status: 'ok', ...r.info } : { status: 'down', ...r };
3059
+ } catch (e) {
3060
+ state.health = { status: 'error', error: e.message };
3061
+ }
3062
+ render();
3063
+ if (!(await loadAgents())) retryLoadAgents();
3064
+
3065
+ const hp = readHash();
3066
+ const bootTab = hp.tab || (hp.sid ? 'history' : 'chat');
3067
+ if (hp.sid && bootTab === 'chat') {
3068
+ // tab=chat&sid deep link: resume the conversation in chat.
3069
+ resumeInChat({ sid: hp.sid }, { fromHash: true });
3070
+ refreshHistory();
3071
+ } else if (hp.sid) {
3072
+ if (hp.q) state.searchQ = hp.q;
3073
+ if (hp.project) state.projectFilter = hp.project;
3074
+ navTo('history', { push: false });
1994
3075
  await refreshHistory();
1995
- await loadSession(initialSid);
1996
- } else if (initialTab && initialTab !== state.tab) {
1997
- navTo(initialTab);
3076
+ await loadSession(hp.sid, { fromHash: true });
3077
+ if (state.searchQ.trim().length >= 2) runSearch();
3078
+ } else if (bootTab !== state.tab) {
3079
+ // Files deep-link: restore the directory the URL names (reload keeps
3080
+ // context instead of resetting to the default root). Kick the load BEFORE
3081
+ // navTo - loadDir sets loading=true synchronously, so navTo's default
3082
+ // loadDir('') guard skips and the two never race. Once the listing
3083
+ // resolves, restore the file= preview on top of it.
3084
+ if (bootTab === 'files' && hp.dir) loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
3085
+ if (bootTab === 'history' && hp.q) state.searchQ = hp.q;
3086
+ if (bootTab === 'history' && hp.project) state.projectFilter = hp.project;
3087
+ navTo(bootTab, { push: false });
3088
+ if (bootTab === 'history' && state.searchQ.trim().length >= 2) runSearch();
3089
+ if (bootTab === 'settings' && hp.section) focusSettingsSection(hp.section);
1998
3090
  }
1999
3091
 
2000
3092
  registerWsStatusOnce();
@@ -2015,12 +3107,17 @@ function registerWsStatusOnce() {
2015
3107
  B.onWsStatus?.((s) => {
2016
3108
  if (s === 'closed' || s === 'error') {
2017
3109
  if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
3110
+ // The WS dropping often means the whole server went away - re-probe
3111
+ // /health so the offline banner appears mid-session (and the 10s
3112
+ // recovery poll starts) instead of silently retrying forever.
3113
+ recheckHealth();
2018
3114
  } else if (s === 'open') {
2019
3115
  if (state.health.ws) { delete state.health.ws; render(); }
2020
3116
  }
2021
3117
  });
2022
3118
  }
2023
3119
 
3120
+ hydratePrefs();
2024
3121
  restoreChat();
2025
3122
  render = mount(document.getElementById('app'), view);
2026
3123
  requestAnimationFrame(syncAriaCurrent);
@@ -2029,12 +3126,60 @@ requestAnimationFrame(syncAriaCurrent);
2029
3126
  // (they read window.innerWidth only at render time).
2030
3127
  window.addEventListener('resize', debounce(() => scheduleRender(), 150));
2031
3128
 
2032
- // Browser Back/forward: re-sync tab + selected session from the hash.
3129
+ // Scroll to + focus a settings panel by its id (section= deep link), reusing
3130
+ // the data-prog-focus pattern so the programmatic focus ring is suppressed.
3131
+ function focusSettingsSection(id) {
3132
+ state.settingsSection = id;
3133
+ requestAnimationFrame(() => {
3134
+ const el = document.getElementById(id);
3135
+ if (!el) return;
3136
+ el.scrollIntoView({ block: 'start' });
3137
+ if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1');
3138
+ el.setAttribute('data-prog-focus', '');
3139
+ try { el.focus({ preventScroll: true }); } catch {}
3140
+ const clear = () => { el.removeAttribute('data-prog-focus'); el.removeEventListener('blur', clear); };
3141
+ el.addEventListener('blur', clear);
3142
+ });
3143
+ }
3144
+
3145
+ // Browser Back/forward: diff the FULL hash param set against state and re-sync
3146
+ // each piece. Everything here runs with writeHash:false / fromHash:true so the
3147
+ // handler never clobbers or re-pushes the entry it just popped.
2033
3148
  window.addEventListener('popstate', () => {
2034
- const { sid, tab } = readHash();
2035
- if (sid && sid !== state.selectedSid) { state.tab = 'history'; loadSession(sid); }
2036
- else if (!sid && tab && tab !== state.tab) navTo(tab);
2037
- else if (!sid && !tab && state.tab !== 'chat') navTo('chat');
3149
+ const hp = readHash();
3150
+ const tab = hp.tab || 'chat';
3151
+ if (tab !== state.tab) navTo(tab, { writeHash: false });
3152
+ // Session selection: honor tab= when present (tab=chat&sid resumes in chat,
3153
+ // anything else - or a bare sid - opens it in history).
3154
+ if (hp.sid && hp.sid !== state.selectedSid) {
3155
+ if (tab === 'chat') resumeInChat({ sid: hp.sid }, { fromHash: true });
3156
+ else loadSession(hp.sid, { fromHash: true });
3157
+ } else if (!hp.sid && state.selectedSid && tab === 'history') {
3158
+ state.selectedSid = null;
3159
+ state.events = [];
3160
+ state.eventsLoaded = false;
3161
+ }
3162
+ if (tab === 'files') {
3163
+ const cur = state.files && state.files.path;
3164
+ if (hp.dir && hp.dir !== cur) {
3165
+ // Restore the file= preview only once the directory listing resolves
3166
+ // (the entry object lives in state.files.entries).
3167
+ loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
3168
+ } else {
3169
+ restoreFileFromHash(hp.file);
3170
+ }
3171
+ }
3172
+ if (tab === 'history') {
3173
+ const q = hp.q || '';
3174
+ if (q !== state.searchQ) {
3175
+ state.searchQ = q;
3176
+ if (q.trim().length >= 2) runSearch();
3177
+ else { state.searchHits = null; state.searchBusy = false; }
3178
+ }
3179
+ state.projectFilter = hp.project || '';
3180
+ }
3181
+ if (tab === 'settings' && hp.section) focusSettingsSection(hp.section);
3182
+ render();
2038
3183
  });
2039
3184
 
2040
3185
  window.__agentgui = { state, render };
@@ -2050,14 +3195,49 @@ function focusSearch() {
2050
3195
  const el = document.querySelector('#app input[type="search"]');
2051
3196
  el?.focus();
2052
3197
  }
3198
+ // Focus the active surface's filter input (Files grid filter / Live dashboard
3199
+ // filter) - both are search-type inputs inside the main region.
3200
+ function focusFilter() {
3201
+ const el = document.querySelector('#agentgui-main input[type="search"]')
3202
+ || document.querySelector('#agentgui-main .ds-file-filter-input')
3203
+ || document.querySelector('#agentgui-main input[type="text"]');
3204
+ el?.focus();
3205
+ return !!el;
3206
+ }
2053
3207
  window.addEventListener('keydown', (e) => {
2054
3208
  const t = e.target;
2055
3209
  const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
3210
+ // One explicit chord BEFORE the modifier early-return: Mod+Shift+L focuses
3211
+ // the composer from anywhere, even while typing in another field.
3212
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'L' || e.key === 'l')) {
3213
+ e.preventDefault();
3214
+ navTo('chat');
3215
+ requestAnimationFrame(() => focusComposer());
3216
+ announce('composer focused');
3217
+ return;
3218
+ }
2056
3219
  if (e.metaKey || e.ctrlKey || e.altKey) return;
2057
3220
  if (typing) {
2058
3221
  if (e.key === 'Escape') t.blur();
2059
3222
  return;
2060
3223
  }
3224
+ if (e.key === 'Escape') {
3225
+ // Priority ladder for transient state (modals/drawers are kit-handled):
3226
+ // shortcuts overlay > armed confirms > stop a streaming generation.
3227
+ if (state.showShortcuts) { state.showShortcuts = false; render(); announce('shortcuts closed'); return; }
3228
+ // File-mutation dialog: close on Escape wherever focus sits (the kit's
3229
+ // backdrop listener covers in-dialog focus; this covers everything else).
3230
+ if (state.files.dialog) { if (!state.files.dialog.busy) closeFileDialog(); return; }
3231
+ if (state.chat.confirmingEdit) { state.chat.confirmingEdit = null; render(); announce('edit cancelled'); return; }
3232
+ if (state.confirmingNewChat) { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); announce('new chat cancelled'); return; }
3233
+ if (state.live.confirmingStopAll || state.live.confirmingStopSelected) {
3234
+ state.live.confirmingStopAll = false; state.live.confirmingStopSelected = false;
3235
+ clearTimeout(_stopAllArmTimer); clearTimeout(_stopSelArmTimer);
3236
+ render(); announce('stop cancelled'); return;
3237
+ }
3238
+ if (state.chat.busy && state.tab === 'chat') { cancelChat(); announce('generation stopped'); return; }
3239
+ return;
3240
+ }
2061
3241
  if (gPending) {
2062
3242
  gPending = false;
2063
3243
  if (e.key === 'c') { navTo('chat'); return; }
@@ -2070,13 +3250,24 @@ window.addEventListener('keydown', (e) => {
2070
3250
  if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
2071
3251
  if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
2072
3252
  if (e.key === '/') {
2073
- // / focuses search on history, composer on chat; on settings there is no
2074
- // such field, so ignore it cleanly rather than focusing nothing.
3253
+ // / targets the active surface's find affordance: search on history,
3254
+ // composer on chat, the filter inputs on files/live. Settings has no
3255
+ // field - the only documented no-op.
2075
3256
  if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
2076
3257
  else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
3258
+ else if (state.tab === 'files' || state.tab === 'live') { e.preventDefault(); if (focusFilter()) announce('filter focused'); }
2077
3259
  return;
2078
3260
  }
2079
3261
  if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
2080
3262
  });
2081
3263
 
3264
+ // A file dropped anywhere outside a DropZone must never navigate the browser
3265
+ // away (destroying the live session view). DropZones handle their own events.
3266
+ window.addEventListener('dragover', (e) => {
3267
+ if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
3268
+ });
3269
+ window.addEventListener('drop', (e) => {
3270
+ if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
3271
+ });
3272
+
2082
3273
  init();