agentgui 1.0.959 → 1.0.961

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.
@@ -5,6 +5,12 @@ installStyles().catch(() => {});
5
5
 
6
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
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');
13
+
8
14
  const state = {
9
15
  backend: B.getBackend(),
10
16
  backendDraft: B.getBackend(),
@@ -31,7 +37,7 @@ const state = {
31
37
  showSubagents: false,
32
38
  sessionsLimit: 60,
33
39
  projectFilter: '',
34
- 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 },
35
41
  active: [],
36
42
  activeTimer: null,
37
43
  eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
@@ -84,6 +90,9 @@ function writeHash({ push = false } = {}) {
84
90
  function fmtRelTime(ts) {
85
91
  if (!ts) return '';
86
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';
87
96
  if (s < 60) return s + 's ago';
88
97
  if (s < 3600) return Math.round(s/60) + 'm ago';
89
98
  if (s < 86400) return Math.round(s/3600) + 'h ago';
@@ -104,6 +113,18 @@ function scheduleRender() {
104
113
  let streamRenderScheduled = false;
105
114
  function scheduleStreamRender() {
106
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 {}
107
128
  streamRenderScheduled = true;
108
129
  requestAnimationFrame(() => {
109
130
  streamRenderScheduled = false;
@@ -241,7 +262,11 @@ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
241
262
  // Live history SSE feeds both the History tab (event log) and the Live
242
263
  // dashboard (per-session activity tally + stream-health signal); open it on
243
264
  // either, close it when leaving both. Active-chat polling runs globally.
244
- 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)));
245
270
  if (wantsStream(tab)) {
246
271
  if (tab === 'history') refreshHistory();
247
272
  openLiveStream();
@@ -296,13 +321,22 @@ function syncAriaCurrent() {
296
321
  // re-renders every row on each render). Only re-render when the set actually
297
322
  // changed (or while on the live tab, where elapsed advances every second).
298
323
  function activeSig(list) {
299
- 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('|');
300
327
  }
301
328
  async function refreshActive() {
302
329
  let next;
303
330
  try { next = await B.listActiveChats(state.backend); } catch { return; }
304
331
  const changed = activeSig(next) !== activeSig(state.active);
305
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
+ }
306
340
  // The live tab needs the steady elapsed tick regardless; elsewhere only
307
341
  // re-render when the active set genuinely changed.
308
342
  if (changed || state.tab === 'live') render();
@@ -333,8 +367,16 @@ function startLiveTick() {
333
367
  }, 1000);
334
368
  }
335
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();
336
374
  try { await B.cancelChat(state.backend, sid); } catch {}
337
- 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();
338
380
  }
339
381
 
340
382
  function openLiveStream() {
@@ -344,16 +386,27 @@ function openLiveStream() {
344
386
  try {
345
387
  state.live.es = B.streamHistory(state.backend, (kind, data) => {
346
388
  state.live.lastEventTs = Date.now();
347
- state.live.eventCount++;
389
+ // Only real event frames count - hello/error frames inflated the number.
390
+ if (kind === 'event') state.live.eventCount++;
348
391
  if (kind === 'hello') {
349
392
  if (!state.live.connected) state.live.connected = true;
350
- 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
+ }
351
401
  } else if (kind === 'event' && data) {
352
402
  // ccsniff's stream frame is { sid, payload: <flattened event> } - the
353
403
  // real event (type/isError/i/ts) lives under .payload, not on the
354
404
  // wrapper. Reading the wrapper gave every counter `undefined`, so live
355
405
  // tool/error tallies never moved. Route by data.sid, read ev for the rest.
356
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;
357
410
  if (state.selectedSid && data.sid === state.selectedSid) {
358
411
  // Dedupe against the snapshot/prior pushes by event index - a
359
412
  // reconnect or overlap would otherwise double-append the same event.
@@ -369,18 +422,32 @@ function openLiveStream() {
369
422
  // exists yet.
370
423
  if (data.sid) {
371
424
  state.live.tally = state.live.tally || new Map();
372
- const t = state.live.tally.get(data.sid) || { events: 0, tools: 0, errors: 0, last: 0 };
373
- t.events++; t.last = ev.ts || Date.now();
374
- if (ev.type === 'tool_use') t.tools++;
375
- 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
+ }
376
439
  state.live.tally.set(data.sid, t);
377
440
  }
378
441
  const sess = state.sessionsBySid ? state.sessionsBySid.get(data.sid) : null;
379
442
  if (sess) {
380
- sess.events = (sess.events || 0) + 1;
381
- sess.last = ev.ts || Date.now();
382
- if (ev.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
383
- 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
+ }
384
451
  } else {
385
452
  // Unknown session: a burst of events for a new session would trigger
386
453
  // a full session-list refetch per event - debounce it into one. The
@@ -424,9 +491,11 @@ function closeLiveStream() {
424
491
  const SHORTCUTS = [
425
492
  { keys: 'g then c / h / f / l / s', desc: 'switch tabs (chat / history / files / live / settings)' },
426
493
  { keys: 'n', desc: 'new chat (on the chat tab)' },
427
- { keys: '/', desc: 'focus search or composer' },
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)' },
428
497
  { keys: '?', desc: 'show shortcuts' },
429
- { keys: 'Esc', desc: 'blur the focused field' },
498
+ { keys: 'Esc', desc: 'close overlays, cancel confirms, stop generation, or blur the field' },
430
499
  ];
431
500
 
432
501
  function view() {
@@ -436,7 +505,7 @@ function view() {
436
505
  ? (state.live.error
437
506
  ? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
438
507
  : (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
439
- : (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
508
+ : (ok ? (state.health.ws === 'reconnecting' ? 'connecting' : 'connected') : 'offline');
440
509
  const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
441
510
  // The status dot is drawn entirely by CSS (.status-dot::before) - a small
442
511
  // colored disc, real product design, not a text glyph. State drives its colour
@@ -454,6 +523,7 @@ function view() {
454
523
  // only the dot: on history/chat it names the selected session/agent, on files
455
524
  // the current dir, on live the live count, on settings "configuration".
456
525
  let crumbLeaf = '';
526
+ let crumbTrail = [];
457
527
  if (state.tab === 'history') {
458
528
  const sel = state.selectedSid && (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
459
529
  crumbLeaf = state.selectedSid
@@ -462,13 +532,17 @@ function view() {
462
532
  } else if (state.tab === 'chat') {
463
533
  crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
464
534
  } else if (state.tab === 'files') {
465
- 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));
466
539
  } else if (state.tab === 'live') {
467
540
  crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
468
541
  } else if (state.tab === 'settings') {
469
- crumbLeaf = 'configuration';
542
+ // Same word as the rail item - location chrome must not fork vocabulary.
543
+ crumbLeaf = 'settings';
470
544
  }
471
- const crumb = Crumb({ trail: [], leaf: crumbLeaf, right: [dot] });
545
+ const crumb = Crumb({ trail: crumbTrail, leaf: crumbLeaf, right: [dot] });
472
546
 
473
547
  const agentLabel = state.selectedAgent
474
548
  ? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
@@ -509,12 +583,17 @@ function view() {
509
583
  turns: state.chat.messages.filter(m => m.role === 'user').length,
510
584
  cost: state.chat.totalCost || null,
511
585
  },
512
- onSetCwd: () => { navTo('files'); },
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(); },
513
590
  });
514
591
  } else if (state.tab === 'files' && state.files && state.files.preview && !isNarrow()) {
515
592
  pane = filePreviewPane();
516
593
  }
517
- return WorkspaceShell({ rail, sessions, main, pane, crumb, status, narrow: isNarrow(), stableFrame: true });
594
+ // The chat tab manages its own --measure gutter, so it opts out of the content
595
+ // column padding; every other surface (files/live/history/settings) keeps it.
596
+ return WorkspaceShell({ rail, sessions, main, pane, crumb, status, narrow: isNarrow(), stableFrame: true, mainFlush: state.tab === 'chat' });
518
597
  }
519
598
 
520
599
  // The left workspace rail: brand, New chat action, and the primary view nav.
@@ -556,7 +635,9 @@ const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier
556
635
  // chats are pinned to a "Running" section at the top (a live workspace surfaces
557
636
  // in-flight work first), the rest bucket by recency. Returns { items, groups }.
558
637
  function sessionGroups(sessionsView) {
559
- const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
638
+ // Join on the REAL claude/ccsniff sid when known (chat.active rows carry the
639
+ // ephemeral chat- id; claudeSessionId lands once streaming_session arrives).
640
+ const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
560
641
  const buckets = new Map();
561
642
  for (const s of sessionsView) {
562
643
  const label = runningSids.has(s.sid) ? 'Running' : dateGroupLabel(s.last);
@@ -608,7 +689,7 @@ function sessionsColumn() {
608
689
  }
609
690
  const sessionsView = visibleSessions();
610
691
  const sliced = sessionsView.slice(0, state.sessionsLimit);
611
- const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
692
+ const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
612
693
  const items = sliced.map((s) => ({
613
694
  sid: s.sid,
614
695
  title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
@@ -636,7 +717,7 @@ function sessionsColumn() {
636
717
  onNew: () => { navTo('chat'); newChat(); },
637
718
  onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
638
719
  loading: state.tab === 'history' && !state.sessions.length && !state.historyError,
639
- loadingText: state.historySlow ? 'Indexing your Claude history - the first load can take a minute…' : undefined,
720
+ loadingText: state.historySlow ? 'Indexing your Claude history the first load can take a minute…' : undefined,
640
721
  error: state.historyError,
641
722
  emptyText: 'No conversations yet',
642
723
  });
@@ -656,6 +737,10 @@ async function loadDir(dirPath, { fromHash = false } = {}) {
656
737
  state.files.loading = true; state.files.error = null; render();
657
738
  try {
658
739
  const j = await B.listDir(state.backend, dirPath || '');
740
+ // The filter text and show-more cap are per-directory state: keep them
741
+ // across an in-place refresh (same path after a mutation), reset them when
742
+ // the resolved directory actually changed.
743
+ if (j.path !== state.files.path) { state.files.filter = ''; state.files.shown = null; }
659
744
  state.files.path = j.path;
660
745
  state.files.segments = j.segments || [];
661
746
  state.files.entries = j.entries || [];
@@ -740,7 +825,12 @@ function openFileDialog(kind, file) {
740
825
  state.files.dialog = { kind, file: file || null, error: null, busy: false };
741
826
  render();
742
827
  }
743
- function closeFileDialog() { state.files.dialog = null; render(); }
828
+ function closeFileDialog() {
829
+ // A mid-flight close would orphan the mutation's result and swallow its
830
+ // error - hold the dialog open until the operation settles.
831
+ if (state.files.dialog?.busy) { announce('still working - please wait'); return; }
832
+ state.files.dialog = null; render();
833
+ }
744
834
  async function runFileMutation(fn, doneMsg) {
745
835
  const d = state.files.dialog;
746
836
  if (!d || d.busy) return;
@@ -751,6 +841,9 @@ async function runFileMutation(fn, doneMsg) {
751
841
  announce(doneMsg);
752
842
  await loadDir(state.files.path, { fromHash: true }); // refresh in place, no history entry
753
843
  } catch (e) {
844
+ // If the dialog detached anyway (e.g. state replaced), the error would be
845
+ // written onto a dead object and lost - announce it instead.
846
+ if (state.files.dialog !== d) { announce(fileMutationCopy(e)); render(); return; }
754
847
  d.busy = false; d.error = fileMutationCopy(e); render();
755
848
  }
756
849
  }
@@ -759,55 +852,101 @@ async function runFileMutation(fn, doneMsg) {
759
852
  async function uploadFiles(fileList) {
760
853
  const dir = state.files.path;
761
854
  if (!dir || !fileList || !fileList.length) return;
762
- const items = Array.from(fileList).map((f) => ({ name: f.name, pct: 0, done: false, error: null, _file: f }));
763
- state.files.uploads = items; render();
764
- for (const it of items) {
765
- try {
766
- await B.uploadFile(state.backend, dir, it._file);
767
- it.pct = 100; it.done = true;
768
- } catch (e) {
769
- it.error = fileMutationCopy(e);
855
+ const items = Array.from(fileList).map((f) => ({ name: f.name, pct: 0, done: false, error: null, status: null, _file: f, _dir: dir }));
856
+ // Concurrent drops APPEND to the shared queue (a second drop mid-upload must
857
+ // not replace the first batch's progress/errors); one running loop drains it.
858
+ state.files.uploads = (state.files.uploads || []).concat(items);
859
+ render();
860
+ if (state.files.uploading) return;
861
+ state.files.uploading = true;
862
+ try {
863
+ let it;
864
+ while ((it = (state.files.uploads || []).find(i => !i.done && !i.error && !i._started))) {
865
+ it._started = true;
866
+ try {
867
+ await B.uploadFile(state.backend, it._dir, it._file);
868
+ it.pct = 100; it.done = true;
869
+ } catch (e) {
870
+ it.status = e.status || null;
871
+ it.error = fileMutationCopy(e);
872
+ }
873
+ render();
770
874
  }
771
- render();
875
+ } finally {
876
+ state.files.uploading = false;
772
877
  }
773
878
  announce('upload finished');
774
- await loadDir(dir, { fromHash: true });
879
+ await loadDir(state.files.path, { fromHash: true });
775
880
  // Keep error rows visible; clear the list entirely when everything landed.
776
- if (!items.some(i => i.error)) state.files.uploads = null;
881
+ if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
882
+ render();
883
+ }
884
+ // 'replace' on a 409 row: re-PUT the same file with overwrite=1.
885
+ async function retryUploadOverwrite(it) {
886
+ if (!it || it._retrying || !it._file) return;
887
+ it._retrying = true; it.error = null; it.status = null; render();
888
+ try {
889
+ await B.uploadFile(state.backend, it._dir, it._file, true);
890
+ it.pct = 100; it.done = true;
891
+ } catch (e) {
892
+ it.status = e.status || null;
893
+ it.error = fileMutationCopy(e);
894
+ }
895
+ it._retrying = false;
896
+ await loadDir(state.files.path, { fromHash: true });
897
+ if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
898
+ render();
899
+ }
900
+ function dismissUpload(it) {
901
+ const ups = state.files.uploads || [];
902
+ const i = ups.indexOf(it);
903
+ if (i >= 0) ups.splice(i, 1);
904
+ if (!ups.length) state.files.uploads = null;
777
905
  render();
778
906
  }
779
907
  // The active file dialog (rename/delete/mkdir) as a kit modal, or null.
780
908
  function fileDialog() {
781
909
  const d = state.files && state.files.dialog;
782
910
  if (!d) return null;
783
- const err = d.error ? h('p', { key: 'fderr', class: 'lede', role: 'alert' }, d.error) : null;
911
+ // error/busy live INSIDE the kit dialog (the modal overlay sits above page
912
+ // flow, so a sibling alert was invisible and outside the focus trap).
784
913
  if (d.kind === 'rename') {
785
- return h('div', { key: 'fdlg' }, err, PromptDialog({
914
+ return PromptDialog({
786
915
  title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
916
+ error: d.error || null, busy: !!d.busy,
787
917
  confirmLabel: d.busy ? 'renaming...' : 'rename', cancelLabel: 'cancel',
788
918
  onCancel: closeFileDialog,
789
- onConfirm: (v) => { if (v && v !== d.file.name) runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v); },
790
- }));
919
+ onConfirm: (v) => {
920
+ // Every confirm press produces visible feedback - never a silent no-op.
921
+ if (!v || v === d.file.name) { d.error = 'enter a different name'; render(); return; }
922
+ runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v);
923
+ },
924
+ });
791
925
  }
792
926
  if (d.kind === 'delete') {
793
927
  const isDir = d.file.type === 'dir';
794
- return h('div', { key: 'fdlg' }, err, ConfirmDialog({
928
+ return ConfirmDialog({
795
929
  title: 'Delete ' + d.file.name,
796
930
  message: isDir
797
931
  ? 'Delete this folder and everything inside it? This cannot be undone.'
798
932
  : 'Delete this file? This cannot be undone.',
933
+ error: d.error || null, busy: !!d.busy,
799
934
  confirmLabel: d.busy ? 'deleting...' : 'delete', cancelLabel: 'cancel', destructive: true,
800
935
  onCancel: closeFileDialog,
801
936
  onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
802
- }));
937
+ });
803
938
  }
804
939
  if (d.kind === 'mkdir') {
805
- return h('div', { key: 'fdlg' }, err, PromptDialog({
940
+ return PromptDialog({
806
941
  title: 'New folder', value: '', placeholder: 'folder name',
942
+ error: d.error || null, busy: !!d.busy,
807
943
  confirmLabel: d.busy ? 'creating...' : 'create', cancelLabel: 'cancel',
808
944
  onCancel: closeFileDialog,
809
- onConfirm: (v) => { if (v) runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v); },
810
- }));
945
+ onConfirm: (v) => {
946
+ if (!v) { d.error = 'enter a folder name'; render(); return; }
947
+ runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v);
948
+ },
949
+ });
811
950
  }
812
951
  return null;
813
952
  }
@@ -1005,7 +1144,17 @@ function filesMain() {
1005
1144
  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.' }),
1006
1145
  rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
1007
1146
  h('div', { key: 'ftb' }, toolbar),
1008
- (f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({ items: f.uploads })) : null,
1147
+ (f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
1148
+ // Recovery affordances per row: 'replace' on a name collision (409),
1149
+ // dismiss on any error row (errors otherwise persist until the next batch).
1150
+ items: f.uploads.map((it) => (it.error && it.status === 409 && !it._retrying)
1151
+ ? { ...it, actions: [{ label: 'replace', onClick: () => retryUploadOverwrite(it) }] }
1152
+ : it),
1153
+ onDismiss: (item, i) => {
1154
+ const src = (state.files.uploads || [])[i];
1155
+ if (src && src.error) dismissUpload(src);
1156
+ },
1157
+ })) : null,
1009
1158
  h('div', { key: 'fbody' }, droppableBody),
1010
1159
  fileDialog(),
1011
1160
  // Inline pane handles wide-screen preview; the modal is only the <900px
@@ -1054,10 +1203,24 @@ function armStopSelected() {
1054
1203
  }
1055
1204
 
1056
1205
  // Stop every in-flight chat at once (the dashboard "stop all" bulk control).
1206
+ // Awaits every cancel, reports partial failure, and returns the sids that
1207
+ // actually stopped so callers only clear those from a selection.
1057
1208
  async function stopAllActive(sessions) {
1058
1209
  const sids = (Array.isArray(sessions) ? sessions : (state.active || [])).map(s => s.sid || s.sessionId).filter(Boolean);
1059
- await Promise.all(sids.map(sid => B.cancelChat(state.backend, sid).catch(() => {})));
1060
- refreshActive();
1210
+ if (!sids.length) return [];
1211
+ const st = state.live.stopping = state.live.stopping || new Set();
1212
+ for (const sid of sids) st.add(sid);
1213
+ render();
1214
+ const results = await Promise.allSettled(sids.map(sid => B.cancelChat(state.backend, sid)));
1215
+ const okSids = sids.filter((sid, i) => results[i].status === 'fulfilled');
1216
+ const failed = sids.length - okSids.length;
1217
+ state.live.bulkStopError = failed
1218
+ ? failed + ' session' + (failed === 1 ? '' : 's') + ' did not stop - try again'
1219
+ : null;
1220
+ announce('stopped ' + okSids.length + ' of ' + sids.length + ' sessions');
1221
+ await refreshActive();
1222
+ render();
1223
+ return okSids;
1061
1224
  }
1062
1225
 
1063
1226
  // How long a session can go without activity (and without a running tool)
@@ -1074,71 +1237,156 @@ function liveMain() {
1074
1237
  const tally = state.live.tally || new Map();
1075
1238
  const now = Date.now();
1076
1239
  // Live-stream health: connected (recent event), connecting (opened, no event
1077
- // yet), or lost (errored). Feeds the dashboard header so "connected, 0 running"
1078
- // still tells the user the dashboard is listening.
1079
- const streamState = state.live.error ? 'lost' : (state.live.connected ? 'connected' : 'connecting');
1240
+ // yet), or offline (errored - one connection vocabulary across the GUI).
1241
+ const streamState = state.live.error ? 'offline' : (state.live.connected ? 'connected' : 'connecting');
1242
+ const stoppingSet = state.live.stopping || new Set();
1243
+ // Clock-skew-corrected "now" in server-timestamp terms, so pure skew never
1244
+ // derives a session stale.
1245
+ const nowS = now - (state.live.clockSkew || 0);
1080
1246
  let sessions = (Array.isArray(state.active) ? state.active : []).map((r) => {
1081
- // Activity tally: prefer the history-index row; fall back to the per-sid live
1082
- // tally so a brand-new chat not yet in the index still shows motion (W4).
1083
- const sess = bySid.get(r.sessionId);
1084
- const t = tally.get(r.sessionId);
1085
- const events = sess && sess.events != null ? sess.events : (t ? t.events : null);
1086
- const tools = sess && sess.tools != null ? sess.tools : (t ? t.tools : 0);
1087
- const errors = sess && sess.errors != null ? sess.errors : (t ? t.errors : 0);
1088
- const lastTs = (sess && sess.last) || (t && t.last) || 0;
1247
+ // The history index + SSE tally are keyed by claude's REAL session id, not
1248
+ // the ephemeral chat- id chat.active returns; join on the real one once
1249
+ // streaming_session has landed. The ephemeral id stays the card sid (it is
1250
+ // what chat.cancel takes).
1251
+ const realSid = r.claudeSessionId || r.sessionId;
1252
+ const sess = bySid.get(realSid);
1253
+ const t = tally.get(realSid);
1254
+ // Counters are MONOTONIC within a session: the index refresh lags the JSONL
1255
+ // flush, so take the max of index and live tally - never regress.
1256
+ const events = Math.max(sess?.events ?? -1, t?.events ?? -1);
1257
+ const tools = Math.max(sess?.tools || 0, t?.tools || 0);
1258
+ const errors = Math.max(sess?.errors || 0, t?.errors || 0);
1259
+ const lastTs = Math.max(sess?.last || 0, t?.last || 0);
1260
+ const lastErrorTs = Math.max(sess?.lastErrorTs || 0, t?.lastErrorTs || 0);
1089
1261
  const counterBits = [];
1090
- if (events != null) counterBits.push(events + ' ev');
1262
+ if (events >= 0) counterBits.push(events + ' ev');
1091
1263
  if (tools) counterBits.push(tools + ' tools');
1092
1264
  if (errors) counterBits.push(errors + ' err');
1093
- // Current tool: while a turn is busy in the in-page chat for this sid, the
1094
- // trailing assistant message carries a running tool part.
1265
+ // Current tool: the in-page chat knows its own running tool part; every
1266
+ // OTHER session gets it from the per-sid SSE tally (an unresolved tool_use
1267
+ // means busy-with-tool, not stalled).
1095
1268
  let currentTool = '';
1096
- if (state.chat.resumeSid === r.sessionId && state.chat.busy) {
1269
+ if (state.chat.resumeSid === realSid && state.chat.busy) {
1097
1270
  const msgs = state.chat.messages || [];
1098
1271
  const last = msgs[msgs.length - 1];
1099
1272
  const running = last && Array.isArray(last.parts) && last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').slice(-1)[0];
1100
1273
  if (running) currentTool = running.name || '';
1101
1274
  }
1102
- // W2: stale = no recent activity AND no running tool. Errors win over stale.
1275
+ if (!currentTool && t && t.toolRunning) currentTool = t.toolName || 'tool';
1276
+ // Status reflects CURRENT reality: error only when an error is recent (a
1277
+ // recovered tool error hours ago is history, kept in the counter chip);
1278
+ // stale = no recent activity AND no running tool.
1103
1279
  let status = 'running';
1104
- if (errors) status = 'error';
1105
- else if (!currentTool && lastTs && (now - lastTs) > STALE_AFTER_MS) status = 'stale';
1280
+ if (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) status = 'error';
1281
+ else if (!currentTool && lastTs && (nowS - lastTs) > STALE_AFTER_MS) status = 'stale';
1282
+ const startedTs = r.startedAt || 0;
1283
+ const elapsedMs = startedTs ? Math.max(0, now - startedTs) : 0;
1284
+ // One title source shared with the rails: projectLabel(title|project)|sid.
1285
+ const title = sess
1286
+ ? (projectLabel(sess.title) || projectLabel(sess.project) || realSid)
1287
+ : (r.claudeSessionId ? realSid : '');
1106
1288
  return {
1107
1289
  sid: r.sessionId,
1290
+ realSid,
1291
+ title: title || undefined,
1108
1292
  agent: agentById(r.agentId)?.name || r.agentId || 'agent',
1109
1293
  model: r.model || '',
1110
1294
  cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
1111
- elapsed: r.startedAt ? Math.round((now - r.startedAt) / 1000) + 's' : '',
1295
+ elapsed: elapsedMs ? fmtDuration(elapsedMs) : '',
1296
+ elapsedMs,
1297
+ startedTs,
1112
1298
  counter: counterBits.length ? counterBits.join(' · ') : null,
1113
- lastActivity: lastTs ? fmtRelTime(lastTs) : '',
1299
+ lastActivity: lastTs ? fmtRelTime(lastTs + (state.live.clockSkew || 0)) : '',
1300
+ lastTs,
1301
+ errors,
1114
1302
  currentTool,
1115
1303
  status,
1304
+ stopping: stoppingSet.has(r.sessionId),
1305
+ // Arrival cue for a freshly-started session (a brief enter animation).
1306
+ isNew: startedTs ? (now - startedTs < 3000) : false,
1307
+ // Surface the in-page chat's own running cost on its card (the only
1308
+ // session we hold a reliable per-session cost for; others omit it).
1309
+ cost: (state.chat.resumeSid && (r.claudeSessionId === state.chat.resumeSid || r.sessionId === state.chat.resumeSid))
1310
+ ? (state.chat.totalCost || null) : null,
1116
1311
  };
1117
1312
  });
1313
+ // External sessions (a claude CLI in a terminal, etc.): live SSE motion that
1314
+ // belongs to no agentgui-spawned chat. The page promises EVERY in-flight
1315
+ // session, so render them as read-only cards (we own no process to stop).
1316
+ const ownedReal = new Set(sessions.map(s => s.realSid));
1317
+ for (const [sid, t] of tally) {
1318
+ if (ownedReal.has(sid)) continue;
1319
+ if (!t.last || (nowS - t.last) >= STALE_AFTER_MS) continue;
1320
+ const sess = bySid.get(sid);
1321
+ const events = Math.max(sess?.events ?? -1, t.events ?? -1);
1322
+ const tools = Math.max(sess?.tools || 0, t.tools || 0);
1323
+ const errors = Math.max(sess?.errors || 0, t.errors || 0);
1324
+ const lastErrorTs = Math.max(sess?.lastErrorTs || 0, t.lastErrorTs || 0);
1325
+ const counterBits = [];
1326
+ if (events >= 0) counterBits.push(events + ' ev');
1327
+ if (tools) counterBits.push(tools + ' tools');
1328
+ if (errors) counterBits.push(errors + ' err');
1329
+ sessions.push({
1330
+ sid,
1331
+ realSid: sid,
1332
+ external: true,
1333
+ readOnly: true,
1334
+ title: sess ? (projectLabel(sess.title) || projectLabel(sess.project) || sid) : sid,
1335
+ agent: 'external session',
1336
+ model: '',
1337
+ cwd: sess && sess.cwd ? sess.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
1338
+ elapsed: '',
1339
+ elapsedMs: 0,
1340
+ startedTs: 0,
1341
+ counter: counterBits.length ? counterBits.join(' · ') : null,
1342
+ lastActivity: fmtRelTime(t.last + (state.live.clockSkew || 0)),
1343
+ lastTs: t.last,
1344
+ errors,
1345
+ currentTool: t.toolRunning ? (t.toolName || 'tool') : '',
1346
+ status: (lastErrorTs && (nowS - lastErrorTs) <= STALE_AFTER_MS) ? 'error' : 'running',
1347
+ stopping: false,
1348
+ });
1349
+ }
1118
1350
  // W12: in-dir filter + errors-only toggle.
1119
1351
  const lv = state.live;
1120
1352
  if (lv.errorsOnly) sessions = sessions.filter(s => s.status === 'error');
1121
1353
  if (lv.filter) {
1122
1354
  const q = lv.filter.toLowerCase();
1123
- sessions = sessions.filter(s => (s.agent + ' ' + s.model + ' ' + s.cwd).toLowerCase().includes(q));
1355
+ sessions = sessions.filter(s => ((s.title || '') + ' ' + s.agent + ' ' + s.model + ' ' + s.cwd).toLowerCase().includes(q));
1124
1356
  }
1125
- // W3/W12: sort. Default floats error then stale to the front (triage surface).
1357
+ // Sort: real numeric comparisons (recency/elapsed/error count), owned before
1358
+ // external, and a deterministic sid tiebreaker so equal-rank cards never
1359
+ // reshuffle with the server's return order.
1126
1360
  const sortKey = lv.sort || 'status';
1127
1361
  sessions.sort((a, b) => {
1128
- if (sortKey === 'elapsed') return (parseInt(b.elapsed) || 0) - (parseInt(a.elapsed) || 0);
1129
- if (sortKey === 'activity') return (a.lastActivity ? 0 : 1) - (b.lastActivity ? 0 : 1);
1130
- if (sortKey === 'errors') return (a.status === 'error' ? 0 : 1) - (b.status === 'error' ? 0 : 1);
1131
- return (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
1362
+ let d = (a.external ? 1 : 0) - (b.external ? 1 : 0);
1363
+ if (d) return d;
1364
+ if (sortKey === 'elapsed') d = (b.elapsedMs || 0) - (a.elapsedMs || 0);
1365
+ else if (sortKey === 'activity') d = (b.lastTs || 0) - (a.lastTs || 0);
1366
+ else if (sortKey === 'errors') d = (b.errors || 0) - (a.errors || 0);
1367
+ else d = (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
1368
+ return d || String(a.sid).localeCompare(String(b.sid));
1132
1369
  });
1133
1370
  const selected = lv.selected instanceof Set ? lv.selected : new Set();
1371
+ // The in-page chat's card accent matches on the real sid, but the kit
1372
+ // compares activeSid against card.sid (the ephemeral id for owned cards).
1373
+ const activeCard = state.chat.resumeSid
1374
+ ? sessions.find(s => s.realSid === state.chat.resumeSid)
1375
+ : null;
1134
1376
  return [
1135
1377
  offlineBanner(),
1378
+ lv.bulkStopError
1379
+ ? Alert({ key: 'bulkstoperr', kind: 'warn', title: 'Some sessions did not stop',
1380
+ children: [
1381
+ h('span', { key: 'bsetxt' }, lv.bulkStopError + ' '),
1382
+ Btn({ key: 'bsedismiss', onClick: () => { state.live.bulkStopError = null; render(); }, children: 'dismiss' })] })
1383
+ : null,
1136
1384
  PageHeader({ compact: true, title: 'Live sessions', lede: 'Every in-flight agent session, managed at once. Stop, open, or jump to events per session.' }),
1137
1385
  SessionDashboard({
1138
1386
  sessions,
1139
1387
  offline,
1140
1388
  streamState,
1141
- activeSid: state.chat.resumeSid, // W13
1389
+ activeSid: activeCard ? activeCard.sid : state.chat.resumeSid, // W13
1142
1390
  sort: { value: sortKey, onChange: (v) => { state.live.sort = v; persistLivePrefs(); render(); } },
1143
1391
  filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
1144
1392
  errorsOnly: !!lv.errorsOnly,
@@ -1150,26 +1398,39 @@ function liveMain() {
1150
1398
  if (set.has(s.sid)) set.delete(s.sid); else set.add(s.sid);
1151
1399
  state.live.selected = set; render();
1152
1400
  },
1401
+ // Tri-state select-all over the currently-visible selectable sids.
1402
+ onSelectAll: (sids) => {
1403
+ const set = (state.live.selected instanceof Set) ? state.live.selected : new Set();
1404
+ for (const sid of sids) set.add(sid);
1405
+ state.live.selected = set; render();
1406
+ },
1407
+ onClearSelection: () => { state.live.selected = new Set(); render(); },
1153
1408
  // Two-step bulk stops: arm first, execute on the confirmed click.
1154
1409
  confirmingStopAll: !!lv.confirmingStopAll,
1155
1410
  confirmingStopSelected: !!lv.confirmingStopSelected,
1156
1411
  onArmStopAll: armStopAll,
1157
1412
  onArmStopSelected: armStopSelected,
1158
- onStopSelected: (sids) => {
1413
+ onStopSelected: async (sids) => {
1159
1414
  state.live.confirmingStopSelected = false;
1160
1415
  clearTimeout(_stopSelArmTimer);
1161
- stopAllActive(sids.map(sid => ({ sid })));
1162
- state.live.selected = new Set();
1416
+ // Await the cancels and only clear the sids that actually stopped, so
1417
+ // a partially-failed bulk stop stays selected and visibly armed-again.
1418
+ const ok = await stopAllActive(sids.map(sid => ({ sid })));
1419
+ const sel = (state.live.selected instanceof Set) ? state.live.selected : new Set();
1420
+ for (const sid of ok) sel.delete(sid);
1421
+ state.live.selected = sel;
1422
+ render();
1163
1423
  },
1164
- emptyText: 'No live sessions - start a chat or run a local agent.',
1165
- onStop: (s) => stopActiveChat(s.sid),
1166
- onStopAll: (all) => {
1424
+ emptyText: 'No live sessions start a chat or run a local agent.',
1425
+ onStop: (s) => { if (!s.external) stopActiveChat(s.sid); },
1426
+ onStopAll: async (all) => {
1167
1427
  state.live.confirmingStopAll = false;
1168
1428
  clearTimeout(_stopAllArmTimer);
1169
- stopAllActive(all);
1429
+ await stopAllActive((all || []).filter(s => !s.external));
1430
+ render();
1170
1431
  },
1171
- onOpen: (s) => { resumeInChat({ sid: s.sid }); },
1172
- onView: (s) => { navTo('history'); loadSession(s.sid); },
1432
+ onOpen: (s) => { resumeInChat({ sid: s.realSid || s.sid }); },
1433
+ onView: (s) => { navTo('history'); loadSession(s.realSid || s.sid); },
1173
1434
  }),
1174
1435
  ].filter(Boolean);
1175
1436
  }
@@ -1291,7 +1552,7 @@ function chatMain() {
1291
1552
  // than as a separate stacked Alert, so resume context reads as one block.
1292
1553
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
1293
1554
  h('span', { key: 'rbtxt', class: 'lede' },
1294
- 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'
1555
+ 'next message continues session ' + state.chat.resumeSid.slice(0, 8) + '…'
1295
1556
  + (state.chat.resumeNote ? ' - ' + state.chat.resumeNote : '')),
1296
1557
  Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
1297
1558
  }
@@ -1303,12 +1564,30 @@ function chatMain() {
1303
1564
  banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
1304
1565
  children: [
1305
1566
  h('span', { key: 'cntxt' }, 'This cannot be undone. '),
1306
- Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
1307
- Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
1567
+ Btn({ key: 'cnyes', danger: true, onClick: confirmNewChat, children: 'yes, clear chat' }),
1568
+ Btn({ key: 'cnno', onClick: () => { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
1308
1569
  }
1309
1570
  if (state.cwdError) {
1310
1571
  banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
1311
1572
  }
1573
+ if (state.chat.externalUpdate) {
1574
+ banners.push(Alert({ key: 'xupd', kind: 'info', title: 'This conversation was updated in another tab',
1575
+ children: [
1576
+ h('span', { key: 'xutxt' }, 'Reload it to see the latest turns, or dismiss to keep this tab\'s view. '),
1577
+ Btn({ key: 'xureload', disabled: state.chat.busy, onClick: () => {
1578
+ if (state.chat.busy) return;
1579
+ state.chat.externalUpdate = false;
1580
+ state.chat.messages = []; state.chat.resumeSid = null; state.chat.totalCost = 0;
1581
+ restoreChat(); render();
1582
+ }, children: 'reload it' }),
1583
+ Btn({ key: 'xudismiss', onClick: () => { state.chat.externalUpdate = false; render(); }, children: 'dismiss' })] }));
1584
+ }
1585
+ if (state.chat.persistError) {
1586
+ banners.push(Alert({ key: 'perr', kind: 'warn', title: 'Chat too large to save locally',
1587
+ children: [
1588
+ h('span', { key: 'petxt' }, 'New turns stay in this tab but will not survive a reload - export the transcript from settings. '),
1589
+ Btn({ key: 'pedismiss', onClick: () => { state.chat.persistError = false; render(); }, children: 'dismiss' })] }));
1590
+ }
1312
1591
  if (state.agentsError) {
1313
1592
  banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
1314
1593
  children: [
@@ -1351,9 +1630,12 @@ function chatMain() {
1351
1630
  messages: state.chat.messages,
1352
1631
  busy: state.chat.busy,
1353
1632
  draft: state.chat.draft,
1633
+ // Idle never reads 'resuming…' (nothing is in flight - the continuation
1634
+ // fact lives in the composer context line and banner); a remotely-stopped
1635
+ // turn reads 'stopped', not a normal finish.
1354
1636
  status: state.chat.busy
1355
- ? (state.health.ws === 'reconnecting' ? 'reconnecting…' : 'streaming…')
1356
- : (state.modelsLoading ? 'loading models…' : ((state.chat.resumeSid && state.selectedAgent === 'claude-code') ? 'resuming…' : 'ready')),
1637
+ ? (state.health.ws === 'reconnecting' ? 'connecting…' : 'streaming…')
1638
+ : (state.modelsLoading ? 'loading models…' : ((lastMsg && lastMsg.role === 'assistant' && lastMsg.stopped) ? 'stopped' : 'ready')),
1357
1639
  agentName,
1358
1640
  placeholder,
1359
1641
  canSend: canSend(),
@@ -1363,18 +1645,35 @@ function chatMain() {
1363
1645
  'What are the recent changes on this branch?',
1364
1646
  'Find and explain the main entry point',
1365
1647
  ],
1366
- onSuggestionClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
1648
+ // Chips send their own text and never touch a typed draft (clicking a
1649
+ // chip with a half-typed message in the composer must not destroy it).
1650
+ onSuggestionClick: (t) => { if (!canSend()) return; sendChat(t); },
1367
1651
  // W14: a stable per-agent product mark (a line-SVG, not a per-agent letter)
1368
1652
  // and the active target shown inline above the composer.
1369
1653
  avatar: state.selectedAgent ? h('span', { class: 'agentchat-avatar-mark', 'aria-hidden': 'true' }, Icon('forum', { size: 16 })) : undefined,
1370
1654
  composerContext: state.selectedAgent ? {
1371
- bits: [agentName, state.selectedModel || null, state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default', userTurnCount > 0 ? userTurnCount + ' turns' : null].filter(Boolean),
1372
- onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); },
1655
+ // Split click targets: only the cwd bit is interactive (it opens the
1656
+ // inline cwd editor); agent/model stay inert text - the picker above
1657
+ // already owns them. The resume fact is stated plainly at the point of
1658
+ // typing so send's behavior is never a surprise.
1659
+ bits: [
1660
+ agentName,
1661
+ state.selectedModel || null,
1662
+ {
1663
+ label: state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default',
1664
+ title: 'change working directory',
1665
+ onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
1666
+ },
1667
+ userTurnCount > 0 ? userTurnCount + ' turns' : null,
1668
+ (state.chat.resumeSid && state.selectedAgent === 'claude-code')
1669
+ ? 'continues session ' + state.chat.resumeSid.slice(0, 8)
1670
+ : null,
1671
+ ].filter(Boolean),
1373
1672
  } : undefined,
1374
1673
  // W15: contextual follow-up chips derived from the settled last turn
1375
1674
  // (tool error / code fence / file path), seeded statically otherwise.
1376
1675
  followups: chatFollowups(),
1377
- onFollowupClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
1676
+ onFollowupClick: (t) => { if (!canSend()) return; sendChat(t); },
1378
1677
  // Transcript export: copy all / markdown / json.
1379
1678
  exportActions: state.chat.messages.length ? [
1380
1679
  { label: 'copy all', title: 'Copy the whole transcript as markdown', onClick: () => copyText(transcriptToMarkdown(state.chat.messages), 'transcript copied') },
@@ -1399,6 +1698,15 @@ function chatMain() {
1399
1698
  cwd: state.chatCwd,
1400
1699
  cwdEditing: !!state.cwdEditing,
1401
1700
  cwdDraft: state.cwdDraft,
1701
+ cwdError: state.cwdError || null,
1702
+ cwdChecking: !!state.cwdChecking,
1703
+ // Pasting an image has no upload path on the chat surface yet - say so
1704
+ // politely instead of silently dropping it.
1705
+ onPasteFiles: () => announce('images cannot be attached here yet'),
1706
+ // Long threads render a capped window with a 'show earlier' row (the
1707
+ // chat-side equivalent of history's eventsLimit), reset per conversation.
1708
+ shownMessages: state.chat.shownMessages || undefined,
1709
+ onShowEarlier: (n) => { state.chat.shownMessages = n; render(); },
1402
1710
  onSelectAgent: (v) => selectAgent(v),
1403
1711
  onSelectModel: (v) => selectModel(v),
1404
1712
  onNewChat: newChat,
@@ -1444,9 +1752,9 @@ function chatMain() {
1444
1752
  if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
1445
1753
  state.cwdEditing = false; state.cwdDraft = undefined; render();
1446
1754
  },
1447
- onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; render(); },
1755
+ onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; state.cwdChecking = false; render(); },
1448
1756
  onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
1449
- onCwdDraft: (v) => { state.cwdDraft = v; },
1757
+ onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
1450
1758
  }),
1451
1759
  ].filter(Boolean);
1452
1760
  }
@@ -1460,14 +1768,28 @@ function offlineBanner() {
1460
1768
  // (The working-directory bar now lives in the AgentChat kit; agentgui wires its
1461
1769
  // cwd state + handlers as kit callbacks in chatMain.)
1462
1770
 
1771
+ // Destructive new-chat is two-step with DISTINCT arm/confirm controls: pressing
1772
+ // 'n' (or the rail action) only ARMS the banner; while armed, repeat presses
1773
+ // are no-ops and the arm auto-resets after 4s. Confirmation happens exclusively
1774
+ // via the banner's explicit 'clear' button - a double-tap can never wipe the
1775
+ // transcript.
1776
+ let _newChatArmTimer = null;
1463
1777
  function newChat() {
1464
- if (state.chat.messages.length && !state.confirmingNewChat) {
1465
- state.confirmingNewChat = true; render();
1778
+ if (state.chat.messages.length) {
1779
+ if (state.confirmingNewChat) { render(); return; } // armed: repeat press is a no-op
1780
+ state.confirmingNewChat = true;
1781
+ clearTimeout(_newChatArmTimer);
1782
+ _newChatArmTimer = setTimeout(() => { state.confirmingNewChat = false; render(); }, 4000);
1783
+ render();
1466
1784
  return;
1467
1785
  }
1786
+ confirmNewChat();
1787
+ }
1788
+ function confirmNewChat() {
1789
+ clearTimeout(_newChatArmTimer);
1468
1790
  state.confirmingNewChat = false;
1469
1791
  state.chat.abort?.abort();
1470
- state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0 };
1792
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0, shownMessages: null };
1471
1793
  lsRemove(CHAT_KEY);
1472
1794
  render();
1473
1795
  }
@@ -1478,10 +1800,13 @@ function cancelChat() {
1478
1800
  state.chat.abort?.abort();
1479
1801
  // Drop a trailing empty assistant shell (aborted before any content) from the
1480
1802
  // live array so the message count + suggestions-empty check stay correct; the
1481
- // paired user message is intentionally kept.
1803
+ // paired user message is intentionally kept. A turn stopped mid-content is
1804
+ // labeled 'stopped' so truncated output never reads as a finished answer.
1482
1805
  const msgs = state.chat.messages;
1483
1806
  if (msgs.length && isEmptyTurn(msgs[msgs.length - 1])) msgs.pop();
1807
+ else if (msgs.length && msgs[msgs.length - 1].role === 'assistant') msgs[msgs.length - 1].stopped = true;
1484
1808
  if (state.chat.busy) { state.chat.busy = false; }
1809
+ refreshActive();
1485
1810
  render();
1486
1811
  }
1487
1812
 
@@ -1492,19 +1817,65 @@ const CHAT_KEY = 'agentgui.chat';
1492
1817
  function isEmptyTurn(m) {
1493
1818
  return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
1494
1819
  }
1820
+ // Cap the persisted footprint: the last 100 messages, with any tool args/result
1821
+ // payload over 4KB truncated in the SAVED copy only (in-memory state keeps the
1822
+ // full payload). A quota failure is surfaced once instead of silently dropping
1823
+ // persistence forever.
1824
+ const PERSIST_MSG_CAP = 100;
1825
+ const PERSIST_PART_CAP = 4096;
1826
+ let _persistFailedOnce = false;
1827
+ let _lastPersistTs = 0;
1828
+ function trimPartForStorage(p) {
1829
+ if (!p || typeof p !== 'object') return p;
1830
+ let out = p;
1831
+ for (const k of ['result', 'args']) {
1832
+ const v = out[k];
1833
+ if (v == null) continue;
1834
+ let s;
1835
+ if (typeof v === 'string') s = v;
1836
+ else { try { s = JSON.stringify(v); } catch { s = String(v); } }
1837
+ if (s.length > PERSIST_PART_CAP) {
1838
+ if (out === p) out = { ...p };
1839
+ out[k] = s.slice(0, PERSIST_PART_CAP);
1840
+ out.truncatedForStorage = true;
1841
+ }
1842
+ }
1843
+ return out;
1844
+ }
1495
1845
  function persistChat() {
1496
1846
  try {
1497
1847
  const msgs = state.chat.messages
1498
1848
  .filter(m => !isEmptyTurn(m))
1499
- .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
1849
+ .slice(-PERSIST_MSG_CAP)
1850
+ .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 }));
1500
1851
  const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
1501
1852
  // Persist when there is a transcript OR a non-empty draft (a typed-but-not-
1502
1853
  // sent message must survive a reload too).
1503
1854
  if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
1504
- lsSet(CHAT_KEY, JSON.stringify({ messages: msgs, draft, resumeSid: state.chat.resumeSid, totalCost: state.chat.totalCost || 0, agent: state.selectedAgent, model: state.selectedModel }));
1505
- } catch {}
1855
+ _lastPersistTs = Date.now();
1856
+ 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 }));
1857
+ } catch {
1858
+ if (!_persistFailedOnce) {
1859
+ _persistFailedOnce = true;
1860
+ state.chat.persistError = true;
1861
+ announce('chat too large to save locally - export it from settings');
1862
+ scheduleRender();
1863
+ }
1864
+ }
1506
1865
  }
1507
1866
  const debouncedPersistDraft = debounce(persistChat, 500);
1867
+ // Another GUI tab rewrote the shared chat key: never silently diverge - surface
1868
+ // a banner offering to reload the newer copy (last-writer-wins with a ts guard).
1869
+ window.addEventListener('storage', (e) => {
1870
+ if (e.key !== CHAT_KEY || !e.newValue) return;
1871
+ try {
1872
+ const remote = JSON.parse(e.newValue);
1873
+ if (remote && remote.ts && remote.ts > _lastPersistTs) {
1874
+ state.chat.externalUpdate = true;
1875
+ scheduleRender();
1876
+ }
1877
+ } catch {}
1878
+ });
1508
1879
  function restoreChat() {
1509
1880
  try {
1510
1881
  const raw = lsGet(CHAT_KEY);
@@ -1516,6 +1887,10 @@ function restoreChat() {
1516
1887
  state.chat.messages = saved.messages
1517
1888
  .filter(m => !isEmptyTurn(m))
1518
1889
  .map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
1890
+ // Prefer the per-message cost sum when present (it self-corrects after
1891
+ // edit/retry truncation); the scalar is only the legacy fallback.
1892
+ const derived = computeTotalCost();
1893
+ if (derived) state.chat.totalCost = derived;
1519
1894
  state.chat.resumeSid = saved.resumeSid || null;
1520
1895
  // Restore the agent/model the transcript belongs to, so a restored chat
1521
1896
  // isn't silently shown under whatever agent the picker defaulted to.
@@ -1605,6 +1980,8 @@ function chatFollowups() {
1605
1980
  function retryLastTurn() {
1606
1981
  if (!canSend() && !state.chat.busy) { /* allow when idle */ }
1607
1982
  if (state.chat.busy) return;
1983
+ // The armed edit confirm refers to indices this retry is about to shift.
1984
+ state.chat.confirmingEdit = null;
1608
1985
  const msgs = state.chat.messages;
1609
1986
  // Find the trailing assistant turn and the user message before it.
1610
1987
  let ai = msgs.length - 1;
@@ -1616,6 +1993,7 @@ function retryLastTurn() {
1616
1993
  const userText = msgs[ui].content || '';
1617
1994
  // Drop the user+assistant pair (and anything after) and resend the user text.
1618
1995
  state.chat.messages = msgs.slice(0, ui);
1996
+ state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
1619
1997
  state.chat.draft = userText;
1620
1998
  persistChat();
1621
1999
  if (canSend()) sendChat();
@@ -1635,6 +2013,7 @@ function confirmEditAndResend() {
1635
2013
  if (!ce || state.chat.busy) return;
1636
2014
  state.chat.confirmingEdit = null;
1637
2015
  state.chat.messages = state.chat.messages.slice(0, ce.idx);
2016
+ state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
1638
2017
  // Never --resume a session whose tail diverged from what the server saw.
1639
2018
  state.chat.resumeSid = null;
1640
2019
  state.chat.resumeNote = null;
@@ -1648,15 +2027,31 @@ function cancelEditAndResend() {
1648
2027
  render();
1649
2028
  }
1650
2029
 
1651
- async function sendChat() {
1652
- const text = (state.chat.draft || '').trim();
2030
+ // Compute the conversation's spend from the per-message costUsd markers, so
2031
+ // edit/retry truncation self-corrects the total (no phantom spend from turns
2032
+ // that no longer exist in the transcript).
2033
+ function computeTotalCost() {
2034
+ return (state.chat.messages || []).reduce((s, m) => s + (typeof m.costUsd === 'number' ? m.costUsd : 0), 0);
2035
+ }
2036
+
2037
+ // sendChat(text) sends an explicit text (suggestion/followup chips) WITHOUT
2038
+ // touching the typed draft; with no argument it sends + clears the draft.
2039
+ async function sendChat(textArg) {
2040
+ const text = ((textArg != null ? textArg : state.chat.draft) || '').trim();
1653
2041
  if (!text || !canSend()) return;
2042
+ // The conversation is moving on: any armed edit-and-resend confirm refers to
2043
+ // an index that is about to be stale - disarm it.
2044
+ state.chat.confirmingEdit = null;
1654
2045
  const t = timeNow();
1655
2046
  const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
1656
2047
  const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t, parts: [] };
1657
2048
  state.chat.messages = [...state.chat.messages, userMsg, curMsg];
1658
- state.chat.draft = '';
2049
+ if (textArg == null) state.chat.draft = '';
1659
2050
  state.chat.busy = true;
2051
+ // Open the live stream + refresh the active list right away so the rail and
2052
+ // dashboard reflect this turn without waiting for the 3s poll.
2053
+ openLiveStream();
2054
+ refreshActive();
1660
2055
  const ctrl = new AbortController();
1661
2056
  state.chat.abort = ctrl;
1662
2057
  persistChat();
@@ -1674,6 +2069,10 @@ async function sendChat() {
1674
2069
  // another agent (it makes agy spuriously run --continue).
1675
2070
  resumeSid: (state.selectedAgent === 'claude-code' && state.chat.resumeSid) || undefined,
1676
2071
  })) {
2072
+ // After a stop, the iterator drains buffered events for a turn the user
2073
+ // aborted - applying them would set resumeSid / accrue cost / write text
2074
+ // into a popped shell. A stopped turn applies no further state.
2075
+ if (ctrl.signal.aborted) break;
1677
2076
  if (ev.type === 'session') {
1678
2077
  // Remember the server's session id so the NEXT turn resumes this
1679
2078
  // conversation instead of cold-spawning. Only claude-code supports
@@ -1701,10 +2100,17 @@ async function sendChat() {
1701
2100
  turns: b.num_turns != null ? b.num_turns : null,
1702
2101
  durationMs: b.duration_ms != null ? b.duration_ms : null,
1703
2102
  };
1704
- // Accumulate cost ACROSS turns so the ContextPane session line shows
1705
- // the whole conversation's spend, not just the last turn's.
2103
+ // Per-message cost markers; the session total is DERIVED from the
2104
+ // visible messages so edit/retry truncation self-corrects the spend.
1706
2105
  const cost = typeof b.total_cost_usd === 'number' ? b.total_cost_usd : (typeof u.cost === 'number' ? u.cost : 0);
1707
- if (cost) state.chat.totalCost = (state.chat.totalCost || 0) + cost;
2106
+ if (cost) { cur.costUsd = (cur.costUsd || 0) + cost; state.chat.totalCost = computeTotalCost(); }
2107
+ scheduleStreamRender();
2108
+ }
2109
+ else if (ev.type === 'cancelled') {
2110
+ // Remote stop (another tab / dashboard): label the turn stopped so the
2111
+ // truncated output never reads as a finished answer.
2112
+ cur.stopped = true;
2113
+ announce('generation stopped');
1708
2114
  scheduleStreamRender();
1709
2115
  }
1710
2116
  else if (ev.type === 'error') { cur.error = errText(ev.error); cur.errorRaw = errTextRaw(ev.error); render(); }
@@ -1715,11 +2121,34 @@ async function sendChat() {
1715
2121
  state.chat.busy = false;
1716
2122
  state.chat.abort = null;
1717
2123
  persistChat();
2124
+ refreshActive(); // settle the running panel/dashboard now, not at the next poll
1718
2125
  render();
1719
2126
  scrollChatToBottom();
1720
2127
  }
1721
2128
  }
1722
2129
 
2130
+ // Validate the cwd draft while editing (debounced) so an invalid path reads as
2131
+ // invalid before the save click, via the existing confined /api/stat endpoint.
2132
+ const debouncedCwdProbe = debounce(async () => {
2133
+ const path = (state.cwdDraft ?? '').trim();
2134
+ if (!state.cwdEditing) return;
2135
+ if (!path || !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) { state.cwdChecking = false; render(); return; }
2136
+ state.cwdChecking = true; render();
2137
+ const probed = path;
2138
+ try {
2139
+ const st = await B.statPath(state.backend, probed);
2140
+ if ((state.cwdDraft ?? '').trim() !== probed) return; // draft moved on
2141
+ state.cwdError = (!st || st.ok === false) ? 'folder not found on the server'
2142
+ : (!st.dir ? 'that path is not a directory' : null);
2143
+ } catch (e) {
2144
+ if ((state.cwdDraft ?? '').trim() !== probed) return;
2145
+ state.cwdError = e.status === 403 ? 'outside the allowed roots'
2146
+ : (e.status === 404 ? 'folder not found on the server' : null);
2147
+ }
2148
+ state.cwdChecking = false;
2149
+ render();
2150
+ }, 400);
2151
+
1723
2152
  // --- history ---
1724
2153
  function reconnectAlert() {
1725
2154
  if (!state.live.error) return null;
@@ -1823,7 +2252,7 @@ function historyMain() {
1823
2252
  h('span', { key: 'noevtxt' }, 'no events in this session - '),
1824
2253
  Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
1825
2254
  : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }),
1826
- state.eventsSlow ? 'Indexing your Claude history - the first load can take a minute…' : 'loading events…');
2255
+ state.eventsSlow ? 'Indexing your Claude history the first load can take a minute…' : 'loading events…');
1827
2256
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
1828
2257
  }
1829
2258
 
@@ -1848,7 +2277,12 @@ function historyMain() {
1848
2277
  sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
1849
2278
  sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
1850
2279
  { label: 'sid', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'sid copied') },
2280
+ // Spelled counter vocabulary in the detail strip (events/turns/tools/
2281
+ // errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
1851
2282
  { label: 'events', value: String(state.events.length) },
2283
+ { label: 'turns', value: String(sess?.userTurns ?? state.events.filter(e => e.role === 'user').length) },
2284
+ { label: 'tools', value: String(state.events.filter(e => e.type === 'tool_use').length) },
2285
+ { label: 'errors', value: String(state.events.filter(e => e.isError).length) },
1852
2286
  ].filter(Boolean),
1853
2287
  });
1854
2288
  if (filteredEvents.length === 0) {
@@ -1933,7 +2367,9 @@ function historyMain() {
1933
2367
  // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
1934
2368
  // Every row is click-to-expand, so always show the affordance word
1935
2369
  // (not only when text overflows 220 chars).
1936
- sub: (e.ts ? new Date(e.ts).toLocaleString() : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
2370
+ // Relative time matches every other surface; the absolute stamp
2371
+ // appears when the row is expanded (forensic precision preserved).
2372
+ sub: (e.ts ? (expanded ? new Date(e.ts).toLocaleString() : fmtRelTime(e.ts)) : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
1937
2373
  onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
1938
2374
  };
1939
2375
  }),
@@ -1945,7 +2381,14 @@ function historyMain() {
1945
2381
  let copyToast = null;
1946
2382
  // Hold the toast long enough to read (2.5s); the copy button label is inside a
1947
2383
  // role=status region (history-actions) so AT announces the change.
1948
- function setCopyToast(msg) { copyToast = msg; render(); setTimeout(() => { copyToast = null; render(); }, 2500); }
2384
+ let _copyToastTimer = null;
2385
+ function setCopyToast(msg) {
2386
+ copyToast = msg; render();
2387
+ // One timer per invocation: a rapid second copy must not have its feedback
2388
+ // truncated by the first copy's expiring timeout.
2389
+ clearTimeout(_copyToastTimer);
2390
+ _copyToastTimer = setTimeout(() => { copyToast = null; render(); }, 2500);
2391
+ }
1949
2392
  function copySid() {
1950
2393
  const sid = state.selectedSid;
1951
2394
  if (!sid) return;
@@ -1971,6 +2414,8 @@ function resumeInChat(sess, { fromHash = false } = {}) {
1971
2414
  state.selectedSid = state.chat.resumeSid || state.selectedSid;
1972
2415
  if (!fromHash) writeHash({ push: true });
1973
2416
  state.chat.messages = [];
2417
+ state.chat.totalCost = 0;
2418
+ state.chat.shownMessages = null;
1974
2419
  state.chat.draft = '';
1975
2420
  // Only claude-code supports --resume by sid; warn if we have to switch the
1976
2421
  // user's selected agent rather than silently discarding it.
@@ -2019,19 +2464,28 @@ function uniqueProjects() {
2019
2464
  function runningPanel() {
2020
2465
  const running = Array.isArray(state.active) ? state.active : [];
2021
2466
  if (!running.length) return null;
2467
+ const stopping = state.live.stopping || new Set();
2022
2468
  return Panel({
2023
2469
  key: 'runningPanel',
2024
2470
  title: 'running · ' + running.length,
2025
- children: running.map((r) => {
2026
- const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
2027
- const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
2028
- // All three children must be keyed VElements (mixing a keyed span with an
2029
- // unkeyed one crashes webjsx applyDiff "reading 'key'").
2030
- return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
2031
- h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
2032
- 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] : '')),
2033
- Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
2034
- }),
2471
+ children: [
2472
+ // A discoverable path from "this running chat" to the management surface.
2473
+ h('div', { key: 'runall', class: 'resume-banner', role: 'group' },
2474
+ h('span', { key: 'runalllbl', class: 'lede' }, 'manage all running sessions'),
2475
+ Btn({ key: 'runalllive', onClick: () => navTo('live'), children: 'view all in live' })),
2476
+ ...running.map((r) => {
2477
+ const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
2478
+ const elapsedMs = r.startedAt ? Math.max(0, Date.now() - r.startedAt) : 0;
2479
+ const isStopping = stopping.has(r.sessionId);
2480
+ // All children must be keyed VElements (mixing a keyed span with an
2481
+ // unkeyed one crashes webjsx applyDiff "reading 'key'").
2482
+ return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
2483
+ h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
2484
+ 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] : '')),
2485
+ Btn({ key: 'open' + r.sessionId, onClick: () => navTo('live'), children: 'open in live' }),
2486
+ Btn({ key: 'stop' + r.sessionId, disabled: isStopping, onClick: () => stopActiveChat(r.sessionId), children: isStopping ? 'stopping…' : 'stop' }));
2487
+ }),
2488
+ ],
2035
2489
  });
2036
2490
  }
2037
2491
 
@@ -2049,7 +2503,7 @@ function historySide() {
2049
2503
  key: 'sr-' + (r.sid || '?') + '-' + i,
2050
2504
  rank: String(i + 1).padStart(3, '0'),
2051
2505
  title: r.snippet || '(no snippet)',
2052
- sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
2506
+ sub: (projectLabel(r.project) || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
2053
2507
  // Rail carries the same semantics as session rows: error > subagent > normal.
2054
2508
  rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
2055
2509
  // Carry the matched event's index so loadSession scrolls to + flashes
@@ -2153,11 +2607,13 @@ function normalizeBackend(s) {
2153
2607
  async function saveBackend() {
2154
2608
  if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
2155
2609
  // Switching backend orphans the local chat transcript (it belongs to the old
2156
- // server's sessions). Confirm once if there's a transcript to lose.
2157
- if (state.chat.messages.length && !state.confirmingBackend) {
2158
- state.confirmingBackend = true; render(); return;
2610
+ // server's sessions). Confirm once if there's a transcript to lose - and the
2611
+ // confirmation binds to the EXACT value confirmed: editing the URL after
2612
+ // arming re-arms instead of executing under a stale confirmation.
2613
+ if (state.chat.messages.length && state.confirmingBackend !== state.backendDraft) {
2614
+ state.confirmingBackend = state.backendDraft; render(); return;
2159
2615
  }
2160
- state.confirmingBackend = false;
2616
+ state.confirmingBackend = undefined;
2161
2617
  const canonical = normalizeBackend(state.backendDraft);
2162
2618
  state.backendDraft = canonical;
2163
2619
  B.setBackend(canonical);
@@ -2201,7 +2657,9 @@ function settingsMain() {
2201
2657
  PageHeader({
2202
2658
  compact: true,
2203
2659
  title: 'Settings',
2204
- lede: 'Point agentgui at any backend. Blank = same-origin (ccsniff in-process). ?backend=... or the field below persists via localStorage.',
2660
+ // The page lede describes the PAGE; the backend explanation lives in the
2661
+ // backend panel where it applies.
2662
+ lede: 'Connection, agents, appearance, keyboard, and local data.',
2205
2663
  }),
2206
2664
  h('div', { key: 'settings-grid', class: 'settings-grid' }, [
2207
2665
  Panel({
@@ -2211,6 +2669,7 @@ function settingsMain() {
2211
2669
  key: 'backendForm',
2212
2670
  onSubmit: (e) => { e.preventDefault(); saveBackend(); },
2213
2671
  }, [
2672
+ 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.'),
2214
2673
  TextField({
2215
2674
  key: 'backendField',
2216
2675
  label: 'backend url',
@@ -2219,13 +2678,19 @@ function settingsMain() {
2219
2678
  'aria-describedby': !isValid ? 'backend-url-error' : undefined,
2220
2679
  'aria-invalid': !isValid ? 'true' : 'false',
2221
2680
  title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
2222
- onInput: (v) => { state.backendDraft = v; render(); },
2681
+ onInput: (v) => {
2682
+ state.backendDraft = v;
2683
+ // The armed confirmation refers to a different value now - disarm.
2684
+ if (state.confirmingBackend !== undefined && state.confirmingBackend !== v) state.confirmingBackend = undefined;
2685
+ render();
2686
+ },
2223
2687
  }),
2224
2688
  !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
2225
2689
  state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
2226
2690
  state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
2227
2691
  state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
2228
- 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,
2692
+ (state.confirmingBackend !== undefined && state.confirmingBackend === state.backendDraft && isValid && state.backendDraft !== state.backend)
2693
+ ? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript - press save again to confirm') : null,
2229
2694
  healthSummary(),
2230
2695
  Btn({
2231
2696
  key: 'savebtn',
@@ -2238,6 +2703,10 @@ function settingsMain() {
2238
2703
  }),
2239
2704
  ]),
2240
2705
  }),
2706
+ // Panel order: connection group (backend + server) first, then agents,
2707
+ // then personal preferences (appearance / keyboard / data).
2708
+ serverPanel(),
2709
+ agentsPanel(),
2241
2710
  Panel({
2242
2711
  id: 'appearance',
2243
2712
  title: 'appearance',
@@ -2246,8 +2715,6 @@ function settingsMain() {
2246
2715
  ThemeToggle({ key: 'tt' }),
2247
2716
  ],
2248
2717
  }),
2249
- serverPanel(),
2250
- agentsPanel(),
2251
2718
  keyboardPanel(),
2252
2719
  preferencesPanel(),
2253
2720
  ]),
@@ -2315,9 +2782,13 @@ function preferencesPanel() {
2315
2782
  id: 'data',
2316
2783
  title: 'data',
2317
2784
  children: [
2318
- h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
2785
+ // The server version lives in the server panel; carry only a build stamp
2786
+ // here, and only when it differs from the server version.
2787
+ (window.__SERVER_VERSION && window.__SERVER_VERSION !== hh.version)
2788
+ ? h('div', { key: 'ver', class: 'lede' }, 'build ' + window.__SERVER_VERSION)
2789
+ : null,
2319
2790
  h('div', { key: 'lsize', class: 'lede', style: 'margin:.5em 0' },
2320
- 'local data: ' + (Math.round(lsBytes / 102.4) / 10) + ' KB across ' + lsKeys + ' key' + (lsKeys === 1 ? '' : 's')),
2791
+ 'local data: ' + fmtBytes(lsBytes) + ' across ' + lsKeys + ' key' + (lsKeys === 1 ? '' : 's')),
2321
2792
  h('div', { key: 'expchatrow', style: 'margin:.5em 0' },
2322
2793
  Btn({ key: 'expchat', disabled: !savedChat,
2323
2794
  title: savedChat ? 'Download the saved chat transcript as JSON' : 'no saved chat',
@@ -2332,7 +2803,7 @@ function preferencesPanel() {
2332
2803
  Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
2333
2804
  Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
2334
2805
  : Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
2335
- ],
2806
+ ].filter(Boolean),
2336
2807
  });
2337
2808
  }
2338
2809
 
@@ -2399,6 +2870,20 @@ async function refreshHistory() {
2399
2870
  // Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
2400
2871
  // linear scan per event during a burst load.
2401
2872
  state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
2873
+ // Bound the live tally: drop entries with no activity in 24h and cap the
2874
+ // Map at ~200 most-recent sids (a long-lived tab otherwise accumulates
2875
+ // every sid ever seen, and dead entries could resurrect wrong externals).
2876
+ if (state.live.tally) {
2877
+ const cutoff = Date.now() - 24 * 3600 * 1000;
2878
+ for (const [sid, t] of [...state.live.tally]) {
2879
+ if (!t.last || t.last < cutoff) state.live.tally.delete(sid);
2880
+ }
2881
+ if (state.live.tally.size > 200) {
2882
+ state.live.tally = new Map([...state.live.tally.entries()]
2883
+ .sort((a, b) => (b[1].last || 0) - (a[1].last || 0))
2884
+ .slice(0, 200));
2885
+ }
2886
+ }
2402
2887
  // If the selected session vanished from the list (deleted/aged out server-side),
2403
2888
  // drop the selection so the main pane doesn't sit on stale events that can no
2404
2889
  // longer be reloaded; fall back to the no-selection empty state.
@@ -2436,6 +2921,10 @@ async function runSearch() {
2436
2921
  render();
2437
2922
  try {
2438
2923
  state.searchHits = await B.searchHistory(state.backend, q, 60);
2924
+ // Announce the settled count for AT - the sessions-column count is only
2925
+ // rendered visually (the history actions row is the only aria-live region).
2926
+ const n = (state.searchHits.results || []).length;
2927
+ announce((n || 'no') + ' matches for ' + q);
2439
2928
  } catch (e) {
2440
2929
  state.searchHits = { query: q, results: [], error: errText(e) };
2441
2930
  } finally {
@@ -2477,6 +2966,10 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
2477
2966
  });
2478
2967
  try {
2479
2968
  state.events = await B.getSessionEvents(state.backend, sid);
2969
+ // ccsniff's events route has no ?limit= (checked: router.js returns the
2970
+ // whole session) - cap in-memory state at the most-recent 5000 so a
2971
+ // monster session can't pin the tab; the render window stays 300+load-older.
2972
+ if (state.events.length > 5000) state.events = state.events.slice(-5000);
2480
2973
  clearTimeout(slowTimer);
2481
2974
  state.eventsSlow = false;
2482
2975
  state.eventsLoaded = true;
@@ -2717,14 +3210,49 @@ function focusSearch() {
2717
3210
  const el = document.querySelector('#app input[type="search"]');
2718
3211
  el?.focus();
2719
3212
  }
3213
+ // Focus the active surface's filter input (Files grid filter / Live dashboard
3214
+ // filter) - both are search-type inputs inside the main region.
3215
+ function focusFilter() {
3216
+ const el = document.querySelector('#agentgui-main input[type="search"]')
3217
+ || document.querySelector('#agentgui-main .ds-file-filter-input')
3218
+ || document.querySelector('#agentgui-main input[type="text"]');
3219
+ el?.focus();
3220
+ return !!el;
3221
+ }
2720
3222
  window.addEventListener('keydown', (e) => {
2721
3223
  const t = e.target;
2722
3224
  const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
3225
+ // One explicit chord BEFORE the modifier early-return: Mod+Shift+L focuses
3226
+ // the composer from anywhere, even while typing in another field.
3227
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === 'L' || e.key === 'l')) {
3228
+ e.preventDefault();
3229
+ navTo('chat');
3230
+ requestAnimationFrame(() => focusComposer());
3231
+ announce('composer focused');
3232
+ return;
3233
+ }
2723
3234
  if (e.metaKey || e.ctrlKey || e.altKey) return;
2724
3235
  if (typing) {
2725
3236
  if (e.key === 'Escape') t.blur();
2726
3237
  return;
2727
3238
  }
3239
+ if (e.key === 'Escape') {
3240
+ // Priority ladder for transient state (modals/drawers are kit-handled):
3241
+ // shortcuts overlay > armed confirms > stop a streaming generation.
3242
+ if (state.showShortcuts) { state.showShortcuts = false; render(); announce('shortcuts closed'); return; }
3243
+ // File-mutation dialog: close on Escape wherever focus sits (the kit's
3244
+ // backdrop listener covers in-dialog focus; this covers everything else).
3245
+ if (state.files.dialog) { if (!state.files.dialog.busy) closeFileDialog(); return; }
3246
+ if (state.chat.confirmingEdit) { state.chat.confirmingEdit = null; render(); announce('edit cancelled'); return; }
3247
+ if (state.confirmingNewChat) { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); announce('new chat cancelled'); return; }
3248
+ if (state.live.confirmingStopAll || state.live.confirmingStopSelected) {
3249
+ state.live.confirmingStopAll = false; state.live.confirmingStopSelected = false;
3250
+ clearTimeout(_stopAllArmTimer); clearTimeout(_stopSelArmTimer);
3251
+ render(); announce('stop cancelled'); return;
3252
+ }
3253
+ if (state.chat.busy && state.tab === 'chat') { cancelChat(); announce('generation stopped'); return; }
3254
+ return;
3255
+ }
2728
3256
  if (gPending) {
2729
3257
  gPending = false;
2730
3258
  if (e.key === 'c') { navTo('chat'); return; }
@@ -2737,13 +3265,24 @@ window.addEventListener('keydown', (e) => {
2737
3265
  if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
2738
3266
  if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
2739
3267
  if (e.key === '/') {
2740
- // / focuses search on history, composer on chat; on settings there is no
2741
- // such field, so ignore it cleanly rather than focusing nothing.
3268
+ // / targets the active surface's find affordance: search on history,
3269
+ // composer on chat, the filter inputs on files/live. Settings has no
3270
+ // field - the only documented no-op.
2742
3271
  if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
2743
3272
  else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
3273
+ else if (state.tab === 'files' || state.tab === 'live') { e.preventDefault(); if (focusFilter()) announce('filter focused'); }
2744
3274
  return;
2745
3275
  }
2746
3276
  if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
2747
3277
  });
2748
3278
 
3279
+ // A file dropped anywhere outside a DropZone must never navigate the browser
3280
+ // away (destroying the live session view). DropZones handle their own events.
3281
+ window.addEventListener('dragover', (e) => {
3282
+ if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
3283
+ });
3284
+ window.addEventListener('drop', (e) => {
3285
+ if (!(e.target instanceof Element) || !e.target.closest('.ds-dropzone')) e.preventDefault();
3286
+ });
3287
+
2749
3288
  init();