agentgui 1.0.959 → 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.
@@ -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,7 +583,10 @@ 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();
@@ -556,7 +633,9 @@ const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier
556
633
  // chats are pinned to a "Running" section at the top (a live workspace surfaces
557
634
  // in-flight work first), the rest bucket by recency. Returns { items, groups }.
558
635
  function sessionGroups(sessionsView) {
559
- 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));
560
639
  const buckets = new Map();
561
640
  for (const s of sessionsView) {
562
641
  const label = runningSids.has(s.sid) ? 'Running' : dateGroupLabel(s.last);
@@ -608,7 +687,7 @@ function sessionsColumn() {
608
687
  }
609
688
  const sessionsView = visibleSessions();
610
689
  const sliced = sessionsView.slice(0, state.sessionsLimit);
611
- 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));
612
691
  const items = sliced.map((s) => ({
613
692
  sid: s.sid,
614
693
  title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
@@ -636,7 +715,7 @@ function sessionsColumn() {
636
715
  onNew: () => { navTo('chat'); newChat(); },
637
716
  onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
638
717
  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,
718
+ loadingText: state.historySlow ? 'Indexing your Claude history the first load can take a minute…' : undefined,
640
719
  error: state.historyError,
641
720
  emptyText: 'No conversations yet',
642
721
  });
@@ -656,6 +735,10 @@ async function loadDir(dirPath, { fromHash = false } = {}) {
656
735
  state.files.loading = true; state.files.error = null; render();
657
736
  try {
658
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; }
659
742
  state.files.path = j.path;
660
743
  state.files.segments = j.segments || [];
661
744
  state.files.entries = j.entries || [];
@@ -740,7 +823,12 @@ function openFileDialog(kind, file) {
740
823
  state.files.dialog = { kind, file: file || null, error: null, busy: false };
741
824
  render();
742
825
  }
743
- function closeFileDialog() { state.files.dialog = null; render(); }
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
+ }
744
832
  async function runFileMutation(fn, doneMsg) {
745
833
  const d = state.files.dialog;
746
834
  if (!d || d.busy) return;
@@ -751,6 +839,9 @@ async function runFileMutation(fn, doneMsg) {
751
839
  announce(doneMsg);
752
840
  await loadDir(state.files.path, { fromHash: true }); // refresh in place, no history entry
753
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; }
754
845
  d.busy = false; d.error = fileMutationCopy(e); render();
755
846
  }
756
847
  }
@@ -759,55 +850,101 @@ async function runFileMutation(fn, doneMsg) {
759
850
  async function uploadFiles(fileList) {
760
851
  const dir = state.files.path;
761
852
  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);
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();
770
872
  }
771
- render();
873
+ } finally {
874
+ state.files.uploading = false;
772
875
  }
773
876
  announce('upload finished');
774
- await loadDir(dir, { fromHash: true });
877
+ await loadDir(state.files.path, { fromHash: true });
775
878
  // Keep error rows visible; clear the list entirely when everything landed.
776
- if (!items.some(i => i.error)) state.files.uploads = null;
879
+ if (!(state.files.uploads || []).some(i => i.error)) state.files.uploads = null;
880
+ render();
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;
777
903
  render();
778
904
  }
779
905
  // The active file dialog (rename/delete/mkdir) as a kit modal, or null.
780
906
  function fileDialog() {
781
907
  const d = state.files && state.files.dialog;
782
908
  if (!d) return null;
783
- const err = d.error ? h('p', { key: 'fderr', class: 'lede', role: 'alert' }, d.error) : 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).
784
911
  if (d.kind === 'rename') {
785
- return h('div', { key: 'fdlg' }, err, PromptDialog({
912
+ return PromptDialog({
786
913
  title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
914
+ error: d.error || null, busy: !!d.busy,
787
915
  confirmLabel: d.busy ? 'renaming...' : 'rename', cancelLabel: 'cancel',
788
916
  onCancel: closeFileDialog,
789
- onConfirm: (v) => { if (v && v !== d.file.name) runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v); },
790
- }));
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
+ });
791
923
  }
792
924
  if (d.kind === 'delete') {
793
925
  const isDir = d.file.type === 'dir';
794
- return h('div', { key: 'fdlg' }, err, ConfirmDialog({
926
+ return ConfirmDialog({
795
927
  title: 'Delete ' + d.file.name,
796
928
  message: isDir
797
929
  ? 'Delete this folder and everything inside it? This cannot be undone.'
798
930
  : 'Delete this file? This cannot be undone.',
931
+ error: d.error || null, busy: !!d.busy,
799
932
  confirmLabel: d.busy ? 'deleting...' : 'delete', cancelLabel: 'cancel', destructive: true,
800
933
  onCancel: closeFileDialog,
801
934
  onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
802
- }));
935
+ });
803
936
  }
804
937
  if (d.kind === 'mkdir') {
805
- return h('div', { key: 'fdlg' }, err, PromptDialog({
938
+ return PromptDialog({
806
939
  title: 'New folder', value: '', placeholder: 'folder name',
940
+ error: d.error || null, busy: !!d.busy,
807
941
  confirmLabel: d.busy ? 'creating...' : 'create', cancelLabel: 'cancel',
808
942
  onCancel: closeFileDialog,
809
- onConfirm: (v) => { if (v) runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v); },
810
- }));
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
+ });
811
948
  }
812
949
  return null;
813
950
  }
@@ -1005,7 +1142,17 @@ function filesMain() {
1005
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.' }),
1006
1143
  rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
1007
1144
  h('div', { key: 'ftb' }, toolbar),
1008
- (f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({ items: f.uploads })) : null,
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,
1009
1156
  h('div', { key: 'fbody' }, droppableBody),
1010
1157
  fileDialog(),
1011
1158
  // Inline pane handles wide-screen preview; the modal is only the <900px
@@ -1054,10 +1201,24 @@ function armStopSelected() {
1054
1201
  }
1055
1202
 
1056
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.
1057
1206
  async function stopAllActive(sessions) {
1058
1207
  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();
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;
1061
1222
  }
1062
1223
 
1063
1224
  // How long a session can go without activity (and without a running tool)
@@ -1074,71 +1235,150 @@ function liveMain() {
1074
1235
  const tally = state.live.tally || new Map();
1075
1236
  const now = Date.now();
1076
1237
  // 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');
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);
1080
1244
  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;
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);
1089
1259
  const counterBits = [];
1090
- if (events != null) counterBits.push(events + ' ev');
1260
+ if (events >= 0) counterBits.push(events + ' ev');
1091
1261
  if (tools) counterBits.push(tools + ' tools');
1092
1262
  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.
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).
1095
1266
  let currentTool = '';
1096
- if (state.chat.resumeSid === r.sessionId && state.chat.busy) {
1267
+ if (state.chat.resumeSid === realSid && state.chat.busy) {
1097
1268
  const msgs = state.chat.messages || [];
1098
1269
  const last = msgs[msgs.length - 1];
1099
1270
  const running = last && Array.isArray(last.parts) && last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').slice(-1)[0];
1100
1271
  if (running) currentTool = running.name || '';
1101
1272
  }
1102
- // 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.
1103
1277
  let status = 'running';
1104
- if (errors) status = 'error';
1105
- 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 : '');
1106
1286
  return {
1107
1287
  sid: r.sessionId,
1288
+ realSid,
1289
+ title: title || undefined,
1108
1290
  agent: agentById(r.agentId)?.name || r.agentId || 'agent',
1109
1291
  model: r.model || '',
1110
1292
  cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
1111
- elapsed: r.startedAt ? Math.round((now - r.startedAt) / 1000) + 's' : '',
1293
+ elapsed: elapsedMs ? fmtDuration(elapsedMs) : '',
1294
+ elapsedMs,
1295
+ startedTs,
1112
1296
  counter: counterBits.length ? counterBits.join(' · ') : null,
1113
- lastActivity: lastTs ? fmtRelTime(lastTs) : '',
1297
+ lastActivity: lastTs ? fmtRelTime(lastTs + (state.live.clockSkew || 0)) : '',
1298
+ lastTs,
1299
+ errors,
1114
1300
  currentTool,
1115
1301
  status,
1302
+ stopping: stoppingSet.has(r.sessionId),
1116
1303
  };
1117
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
+ }
1118
1342
  // W12: in-dir filter + errors-only toggle.
1119
1343
  const lv = state.live;
1120
1344
  if (lv.errorsOnly) sessions = sessions.filter(s => s.status === 'error');
1121
1345
  if (lv.filter) {
1122
1346
  const q = lv.filter.toLowerCase();
1123
- 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));
1124
1348
  }
1125
- // 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.
1126
1352
  const sortKey = lv.sort || 'status';
1127
1353
  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);
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));
1132
1361
  });
1133
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;
1134
1368
  return [
1135
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,
1136
1376
  PageHeader({ compact: true, title: 'Live sessions', lede: 'Every in-flight agent session, managed at once. Stop, open, or jump to events per session.' }),
1137
1377
  SessionDashboard({
1138
1378
  sessions,
1139
1379
  offline,
1140
1380
  streamState,
1141
- activeSid: state.chat.resumeSid, // W13
1381
+ activeSid: activeCard ? activeCard.sid : state.chat.resumeSid, // W13
1142
1382
  sort: { value: sortKey, onChange: (v) => { state.live.sort = v; persistLivePrefs(); render(); } },
1143
1383
  filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
1144
1384
  errorsOnly: !!lv.errorsOnly,
@@ -1155,21 +1395,27 @@ function liveMain() {
1155
1395
  confirmingStopSelected: !!lv.confirmingStopSelected,
1156
1396
  onArmStopAll: armStopAll,
1157
1397
  onArmStopSelected: armStopSelected,
1158
- onStopSelected: (sids) => {
1398
+ onStopSelected: async (sids) => {
1159
1399
  state.live.confirmingStopSelected = false;
1160
1400
  clearTimeout(_stopSelArmTimer);
1161
- stopAllActive(sids.map(sid => ({ sid })));
1162
- state.live.selected = new Set();
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();
1163
1408
  },
1164
- emptyText: 'No live sessions - start a chat or run a local agent.',
1165
- onStop: (s) => stopActiveChat(s.sid),
1166
- onStopAll: (all) => {
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) => {
1167
1412
  state.live.confirmingStopAll = false;
1168
1413
  clearTimeout(_stopAllArmTimer);
1169
- stopAllActive(all);
1414
+ await stopAllActive((all || []).filter(s => !s.external));
1415
+ render();
1170
1416
  },
1171
- onOpen: (s) => { resumeInChat({ sid: s.sid }); },
1172
- onView: (s) => { navTo('history'); loadSession(s.sid); },
1417
+ onOpen: (s) => { resumeInChat({ sid: s.realSid || s.sid }); },
1418
+ onView: (s) => { navTo('history'); loadSession(s.realSid || s.sid); },
1173
1419
  }),
1174
1420
  ].filter(Boolean);
1175
1421
  }
@@ -1291,7 +1537,7 @@ function chatMain() {
1291
1537
  // than as a separate stacked Alert, so resume context reads as one block.
1292
1538
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
1293
1539
  h('span', { key: 'rbtxt', class: 'lede' },
1294
- 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'
1540
+ 'next message continues session ' + state.chat.resumeSid.slice(0, 8) + '…'
1295
1541
  + (state.chat.resumeNote ? ' - ' + state.chat.resumeNote : '')),
1296
1542
  Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
1297
1543
  }
@@ -1303,12 +1549,30 @@ function chatMain() {
1303
1549
  banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
1304
1550
  children: [
1305
1551
  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' })] }));
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' })] }));
1308
1554
  }
1309
1555
  if (state.cwdError) {
1310
1556
  banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
1311
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
+ }
1312
1576
  if (state.agentsError) {
1313
1577
  banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
1314
1578
  children: [
@@ -1351,9 +1615,12 @@ function chatMain() {
1351
1615
  messages: state.chat.messages,
1352
1616
  busy: state.chat.busy,
1353
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.
1354
1621
  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')),
1622
+ ? (state.health.ws === 'reconnecting' ? 'connecting…' : 'streaming…')
1623
+ : (state.modelsLoading ? 'loading models…' : ((lastMsg && lastMsg.role === 'assistant' && lastMsg.stopped) ? 'stopped' : 'ready')),
1357
1624
  agentName,
1358
1625
  placeholder,
1359
1626
  canSend: canSend(),
@@ -1363,18 +1630,35 @@ function chatMain() {
1363
1630
  'What are the recent changes on this branch?',
1364
1631
  'Find and explain the main entry point',
1365
1632
  ],
1366
- 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); },
1367
1636
  // W14: a stable per-agent product mark (a line-SVG, not a per-agent letter)
1368
1637
  // and the active target shown inline above the composer.
1369
1638
  avatar: state.selectedAgent ? h('span', { class: 'agentchat-avatar-mark', 'aria-hidden': 'true' }, Icon('forum', { size: 16 })) : undefined,
1370
1639
  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(); },
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),
1373
1657
  } : undefined,
1374
1658
  // W15: contextual follow-up chips derived from the settled last turn
1375
1659
  // (tool error / code fence / file path), seeded statically otherwise.
1376
1660
  followups: chatFollowups(),
1377
- onFollowupClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
1661
+ onFollowupClick: (t) => { if (!canSend()) return; sendChat(t); },
1378
1662
  // Transcript export: copy all / markdown / json.
1379
1663
  exportActions: state.chat.messages.length ? [
1380
1664
  { label: 'copy all', title: 'Copy the whole transcript as markdown', onClick: () => copyText(transcriptToMarkdown(state.chat.messages), 'transcript copied') },
@@ -1399,6 +1683,15 @@ function chatMain() {
1399
1683
  cwd: state.chatCwd,
1400
1684
  cwdEditing: !!state.cwdEditing,
1401
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(); },
1402
1695
  onSelectAgent: (v) => selectAgent(v),
1403
1696
  onSelectModel: (v) => selectModel(v),
1404
1697
  onNewChat: newChat,
@@ -1444,9 +1737,9 @@ function chatMain() {
1444
1737
  if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
1445
1738
  state.cwdEditing = false; state.cwdDraft = undefined; render();
1446
1739
  },
1447
- 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(); },
1448
1741
  onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
1449
- onCwdDraft: (v) => { state.cwdDraft = v; },
1742
+ onCwdDraft: (v) => { state.cwdDraft = v; state.cwdError = null; debouncedCwdProbe(); },
1450
1743
  }),
1451
1744
  ].filter(Boolean);
1452
1745
  }
@@ -1460,14 +1753,28 @@ function offlineBanner() {
1460
1753
  // (The working-directory bar now lives in the AgentChat kit; agentgui wires its
1461
1754
  // cwd state + handlers as kit callbacks in chatMain.)
1462
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;
1463
1762
  function newChat() {
1464
- if (state.chat.messages.length && !state.confirmingNewChat) {
1465
- 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();
1466
1769
  return;
1467
1770
  }
1771
+ confirmNewChat();
1772
+ }
1773
+ function confirmNewChat() {
1774
+ clearTimeout(_newChatArmTimer);
1468
1775
  state.confirmingNewChat = false;
1469
1776
  state.chat.abort?.abort();
1470
- state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0 };
1777
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0, shownMessages: null };
1471
1778
  lsRemove(CHAT_KEY);
1472
1779
  render();
1473
1780
  }
@@ -1478,10 +1785,13 @@ function cancelChat() {
1478
1785
  state.chat.abort?.abort();
1479
1786
  // Drop a trailing empty assistant shell (aborted before any content) from the
1480
1787
  // live array so the message count + suggestions-empty check stay correct; the
1481
- // 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.
1482
1790
  const msgs = state.chat.messages;
1483
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;
1484
1793
  if (state.chat.busy) { state.chat.busy = false; }
1794
+ refreshActive();
1485
1795
  render();
1486
1796
  }
1487
1797
 
@@ -1492,19 +1802,65 @@ const CHAT_KEY = 'agentgui.chat';
1492
1802
  function isEmptyTurn(m) {
1493
1803
  return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
1494
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
+ }
1495
1830
  function persistChat() {
1496
1831
  try {
1497
1832
  const msgs = state.chat.messages
1498
1833
  .filter(m => !isEmptyTurn(m))
1499
- .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
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 }));
1500
1836
  const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
1501
1837
  // Persist when there is a transcript OR a non-empty draft (a typed-but-not-
1502
1838
  // sent message must survive a reload too).
1503
1839
  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 {}
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
+ }
1506
1850
  }
1507
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
+ });
1508
1864
  function restoreChat() {
1509
1865
  try {
1510
1866
  const raw = lsGet(CHAT_KEY);
@@ -1516,6 +1872,10 @@ function restoreChat() {
1516
1872
  state.chat.messages = saved.messages
1517
1873
  .filter(m => !isEmptyTurn(m))
1518
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;
1519
1879
  state.chat.resumeSid = saved.resumeSid || null;
1520
1880
  // Restore the agent/model the transcript belongs to, so a restored chat
1521
1881
  // isn't silently shown under whatever agent the picker defaulted to.
@@ -1605,6 +1965,8 @@ function chatFollowups() {
1605
1965
  function retryLastTurn() {
1606
1966
  if (!canSend() && !state.chat.busy) { /* allow when idle */ }
1607
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;
1608
1970
  const msgs = state.chat.messages;
1609
1971
  // Find the trailing assistant turn and the user message before it.
1610
1972
  let ai = msgs.length - 1;
@@ -1616,6 +1978,7 @@ function retryLastTurn() {
1616
1978
  const userText = msgs[ui].content || '';
1617
1979
  // Drop the user+assistant pair (and anything after) and resend the user text.
1618
1980
  state.chat.messages = msgs.slice(0, ui);
1981
+ state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
1619
1982
  state.chat.draft = userText;
1620
1983
  persistChat();
1621
1984
  if (canSend()) sendChat();
@@ -1635,6 +1998,7 @@ function confirmEditAndResend() {
1635
1998
  if (!ce || state.chat.busy) return;
1636
1999
  state.chat.confirmingEdit = null;
1637
2000
  state.chat.messages = state.chat.messages.slice(0, ce.idx);
2001
+ state.chat.totalCost = computeTotalCost(); // discarded turns leave the spend
1638
2002
  // Never --resume a session whose tail diverged from what the server saw.
1639
2003
  state.chat.resumeSid = null;
1640
2004
  state.chat.resumeNote = null;
@@ -1648,15 +2012,31 @@ function cancelEditAndResend() {
1648
2012
  render();
1649
2013
  }
1650
2014
 
1651
- async function sendChat() {
1652
- const text = (state.chat.draft || '').trim();
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
+ }
2021
+
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();
1653
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;
1654
2030
  const t = timeNow();
1655
2031
  const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
1656
2032
  const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t, parts: [] };
1657
2033
  state.chat.messages = [...state.chat.messages, userMsg, curMsg];
1658
- state.chat.draft = '';
2034
+ if (textArg == null) state.chat.draft = '';
1659
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();
1660
2040
  const ctrl = new AbortController();
1661
2041
  state.chat.abort = ctrl;
1662
2042
  persistChat();
@@ -1674,6 +2054,10 @@ async function sendChat() {
1674
2054
  // another agent (it makes agy spuriously run --continue).
1675
2055
  resumeSid: (state.selectedAgent === 'claude-code' && state.chat.resumeSid) || undefined,
1676
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;
1677
2061
  if (ev.type === 'session') {
1678
2062
  // Remember the server's session id so the NEXT turn resumes this
1679
2063
  // conversation instead of cold-spawning. Only claude-code supports
@@ -1701,10 +2085,17 @@ async function sendChat() {
1701
2085
  turns: b.num_turns != null ? b.num_turns : null,
1702
2086
  durationMs: b.duration_ms != null ? b.duration_ms : null,
1703
2087
  };
1704
- // Accumulate cost ACROSS turns so the ContextPane session line shows
1705
- // the whole conversation's spend, not just the last turn's.
2088
+ // Per-message cost markers; the session total is DERIVED from the
2089
+ // visible messages so edit/retry truncation self-corrects the spend.
1706
2090
  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;
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');
1708
2099
  scheduleStreamRender();
1709
2100
  }
1710
2101
  else if (ev.type === 'error') { cur.error = errText(ev.error); cur.errorRaw = errTextRaw(ev.error); render(); }
@@ -1715,11 +2106,34 @@ async function sendChat() {
1715
2106
  state.chat.busy = false;
1716
2107
  state.chat.abort = null;
1717
2108
  persistChat();
2109
+ refreshActive(); // settle the running panel/dashboard now, not at the next poll
1718
2110
  render();
1719
2111
  scrollChatToBottom();
1720
2112
  }
1721
2113
  }
1722
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
+
1723
2137
  // --- history ---
1724
2138
  function reconnectAlert() {
1725
2139
  if (!state.live.error) return null;
@@ -1823,7 +2237,7 @@ function historyMain() {
1823
2237
  h('span', { key: 'noevtxt' }, 'no events in this session - '),
1824
2238
  Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
1825
2239
  : 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…');
2240
+ state.eventsSlow ? 'Indexing your Claude history the first load can take a minute…' : 'loading events…');
1827
2241
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
1828
2242
  }
1829
2243
 
@@ -1848,7 +2262,12 @@ function historyMain() {
1848
2262
  sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
1849
2263
  sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
1850
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.
1851
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) },
1852
2271
  ].filter(Boolean),
1853
2272
  });
1854
2273
  if (filteredEvents.length === 0) {
@@ -1933,7 +2352,9 @@ function historyMain() {
1933
2352
  // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
1934
2353
  // Every row is click-to-expand, so always show the affordance word
1935
2354
  // (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'),
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'),
1937
2358
  onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
1938
2359
  };
1939
2360
  }),
@@ -1945,7 +2366,14 @@ function historyMain() {
1945
2366
  let copyToast = null;
1946
2367
  // Hold the toast long enough to read (2.5s); the copy button label is inside a
1947
2368
  // role=status region (history-actions) so AT announces the change.
1948
- 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
+ }
1949
2377
  function copySid() {
1950
2378
  const sid = state.selectedSid;
1951
2379
  if (!sid) return;
@@ -1971,6 +2399,8 @@ function resumeInChat(sess, { fromHash = false } = {}) {
1971
2399
  state.selectedSid = state.chat.resumeSid || state.selectedSid;
1972
2400
  if (!fromHash) writeHash({ push: true });
1973
2401
  state.chat.messages = [];
2402
+ state.chat.totalCost = 0;
2403
+ state.chat.shownMessages = null;
1974
2404
  state.chat.draft = '';
1975
2405
  // Only claude-code supports --resume by sid; warn if we have to switch the
1976
2406
  // user's selected agent rather than silently discarding it.
@@ -2019,19 +2449,28 @@ function uniqueProjects() {
2019
2449
  function runningPanel() {
2020
2450
  const running = Array.isArray(state.active) ? state.active : [];
2021
2451
  if (!running.length) return null;
2452
+ const stopping = state.live.stopping || new Set();
2022
2453
  return Panel({
2023
2454
  key: 'runningPanel',
2024
2455
  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
- }),
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
+ ],
2035
2474
  });
2036
2475
  }
2037
2476
 
@@ -2049,7 +2488,7 @@ function historySide() {
2049
2488
  key: 'sr-' + (r.sid || '?') + '-' + i,
2050
2489
  rank: String(i + 1).padStart(3, '0'),
2051
2490
  title: r.snippet || '(no snippet)',
2052
- sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
2491
+ sub: (projectLabel(r.project) || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
2053
2492
  // Rail carries the same semantics as session rows: error > subagent > normal.
2054
2493
  rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
2055
2494
  // Carry the matched event's index so loadSession scrolls to + flashes
@@ -2153,11 +2592,13 @@ function normalizeBackend(s) {
2153
2592
  async function saveBackend() {
2154
2593
  if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
2155
2594
  // 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;
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;
2159
2600
  }
2160
- state.confirmingBackend = false;
2601
+ state.confirmingBackend = undefined;
2161
2602
  const canonical = normalizeBackend(state.backendDraft);
2162
2603
  state.backendDraft = canonical;
2163
2604
  B.setBackend(canonical);
@@ -2201,7 +2642,9 @@ function settingsMain() {
2201
2642
  PageHeader({
2202
2643
  compact: true,
2203
2644
  title: 'Settings',
2204
- 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.',
2205
2648
  }),
2206
2649
  h('div', { key: 'settings-grid', class: 'settings-grid' }, [
2207
2650
  Panel({
@@ -2211,6 +2654,7 @@ function settingsMain() {
2211
2654
  key: 'backendForm',
2212
2655
  onSubmit: (e) => { e.preventDefault(); saveBackend(); },
2213
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.'),
2214
2658
  TextField({
2215
2659
  key: 'backendField',
2216
2660
  label: 'backend url',
@@ -2219,13 +2663,19 @@ function settingsMain() {
2219
2663
  'aria-describedby': !isValid ? 'backend-url-error' : undefined,
2220
2664
  'aria-invalid': !isValid ? 'true' : 'false',
2221
2665
  title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
2222
- 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
+ },
2223
2672
  }),
2224
2673
  !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
2225
2674
  state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
2226
2675
  state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
2227
2676
  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,
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,
2229
2679
  healthSummary(),
2230
2680
  Btn({
2231
2681
  key: 'savebtn',
@@ -2238,6 +2688,10 @@ function settingsMain() {
2238
2688
  }),
2239
2689
  ]),
2240
2690
  }),
2691
+ // Panel order: connection group (backend + server) first, then agents,
2692
+ // then personal preferences (appearance / keyboard / data).
2693
+ serverPanel(),
2694
+ agentsPanel(),
2241
2695
  Panel({
2242
2696
  id: 'appearance',
2243
2697
  title: 'appearance',
@@ -2246,8 +2700,6 @@ function settingsMain() {
2246
2700
  ThemeToggle({ key: 'tt' }),
2247
2701
  ],
2248
2702
  }),
2249
- serverPanel(),
2250
- agentsPanel(),
2251
2703
  keyboardPanel(),
2252
2704
  preferencesPanel(),
2253
2705
  ]),
@@ -2315,9 +2767,13 @@ function preferencesPanel() {
2315
2767
  id: 'data',
2316
2768
  title: 'data',
2317
2769
  children: [
2318
- h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
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,
2319
2775
  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')),
2776
+ 'local data: ' + fmtBytes(lsBytes) + ' across ' + lsKeys + ' key' + (lsKeys === 1 ? '' : 's')),
2321
2777
  h('div', { key: 'expchatrow', style: 'margin:.5em 0' },
2322
2778
  Btn({ key: 'expchat', disabled: !savedChat,
2323
2779
  title: savedChat ? 'Download the saved chat transcript as JSON' : 'no saved chat',
@@ -2332,7 +2788,7 @@ function preferencesPanel() {
2332
2788
  Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
2333
2789
  Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
2334
2790
  : Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
2335
- ],
2791
+ ].filter(Boolean),
2336
2792
  });
2337
2793
  }
2338
2794
 
@@ -2399,6 +2855,20 @@ async function refreshHistory() {
2399
2855
  // Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
2400
2856
  // linear scan per event during a burst load.
2401
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
+ }
2402
2872
  // If the selected session vanished from the list (deleted/aged out server-side),
2403
2873
  // drop the selection so the main pane doesn't sit on stale events that can no
2404
2874
  // longer be reloaded; fall back to the no-selection empty state.
@@ -2436,6 +2906,10 @@ async function runSearch() {
2436
2906
  render();
2437
2907
  try {
2438
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);
2439
2913
  } catch (e) {
2440
2914
  state.searchHits = { query: q, results: [], error: errText(e) };
2441
2915
  } finally {
@@ -2477,6 +2951,10 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
2477
2951
  });
2478
2952
  try {
2479
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);
2480
2958
  clearTimeout(slowTimer);
2481
2959
  state.eventsSlow = false;
2482
2960
  state.eventsLoaded = true;
@@ -2717,14 +3195,49 @@ function focusSearch() {
2717
3195
  const el = document.querySelector('#app input[type="search"]');
2718
3196
  el?.focus();
2719
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
+ }
2720
3207
  window.addEventListener('keydown', (e) => {
2721
3208
  const t = e.target;
2722
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
+ }
2723
3219
  if (e.metaKey || e.ctrlKey || e.altKey) return;
2724
3220
  if (typing) {
2725
3221
  if (e.key === 'Escape') t.blur();
2726
3222
  return;
2727
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
+ }
2728
3241
  if (gPending) {
2729
3242
  gPending = false;
2730
3243
  if (e.key === 'c') { navTo('chat'); return; }
@@ -2737,13 +3250,24 @@ window.addEventListener('keydown', (e) => {
2737
3250
  if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
2738
3251
  if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
2739
3252
  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.
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.
2742
3256
  if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
2743
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'); }
2744
3259
  return;
2745
3260
  }
2746
3261
  if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
2747
3262
  });
2748
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
+
2749
3273
  init();