agentgui 1.0.957 → 1.0.959

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
6
- const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane } = C;
6
+ const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta } = C;
7
7
 
8
8
  const state = {
9
9
  backend: B.getBackend(),
@@ -15,7 +15,13 @@ const state = {
15
15
  agentModels: [],
16
16
  selectedModel: lsGet('agentgui.model') || '',
17
17
  chatCwd: lsGet('agentgui.cwd') || '',
18
- chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
18
+ chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 },
19
+ agentsError: null,
20
+ settingsSection: null,
21
+ eventFilter: 'all', // history event-type filter: all | text | tool | errors
22
+ sessionSearchQ: null, // the query the selected session was opened from (search-hit highlight)
23
+ historySlow: false, // first history fetch unresolved after 5s -> indexing copy
24
+ eventsSlow: false, // first events fetch unresolved after 5s -> indexing copy
19
25
  sessions: [],
20
26
  selectedSid: null,
21
27
  events: [],
@@ -32,27 +38,47 @@ const state = {
32
38
  files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
33
39
  };
34
40
 
41
+ // Full routable param set. Every view-defining piece of state round-trips
42
+ // through the hash so reload and Back/forward restore the exact view.
43
+ const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section'];
35
44
  function readHash() {
36
45
  const hash = location.hash || '';
37
- const sidM = hash.match(/sid=([^&]+)/);
38
- const tabM = hash.match(/tab=([^&]+)/);
39
- return {
40
- sid: sidM ? decodeURIComponent(sidM[1]) : null,
41
- tab: tabM ? decodeURIComponent(tabM[1]) : null,
42
- };
46
+ const out = {};
47
+ for (const k of HASH_KEYS) {
48
+ const m = hash.match(new RegExp('(?:^#|&)' + k + '=([^&]*)'));
49
+ out[k] = m ? decodeURIComponent(m[1]) : null;
50
+ }
51
+ return out;
43
52
  }
44
- function buildHash(tab, sid) {
53
+ function buildHash() {
45
54
  const parts = [];
46
- if (tab && tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
47
- if (sid) parts.push('sid=' + encodeURIComponent(sid));
55
+ const tab = state.tab || 'chat';
56
+ // tab is omitted for the default chat tab, EXCEPT when a session id rides
57
+ // along - a bare #sid= historically meant history, so chat+sid must name its
58
+ // tab explicitly or the deep-link restores the wrong surface.
59
+ if (tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
60
+ else if (state.selectedSid) parts.push('tab=chat');
61
+ // Keep sid whenever set (regardless of tab) so Back restores the selection.
62
+ if (state.selectedSid) parts.push('sid=' + encodeURIComponent(state.selectedSid));
63
+ if (tab === 'files' && state.files) {
64
+ if (state.files.path) parts.push('dir=' + encodeURIComponent(state.files.path));
65
+ if (state.files.preview && state.files.preview.path) parts.push('file=' + encodeURIComponent(state.files.preview.path));
66
+ }
67
+ if (tab === 'history') {
68
+ const q = (state.searchQ || '').trim();
69
+ if (q.length >= 2) parts.push('q=' + encodeURIComponent(q));
70
+ if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
71
+ }
72
+ if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
48
73
  return parts.length ? '#' + parts.join('&') : '';
49
74
  }
50
- function writeHash(sid, { push = false } = {}) {
51
- const h = buildHash(state.tab, sid);
75
+ function writeHash({ push = false } = {}) {
76
+ const h = buildHash();
52
77
  const url = location.pathname + location.search + h;
53
- if (location.hash === h) return;
54
- // pushState for session selection so Back returns to the session list;
55
- // replaceState for tab-only changes.
78
+ if (location.hash === h || (!location.hash && !h)) return;
79
+ // pushState for user navigation steps (tab visits, session selection,
80
+ // directory walks, preview opens) so Back retraces them; replaceState for
81
+ // passive state sync (search text, filter resets).
56
82
  (push ? history.pushState : history.replaceState).call(history, null, '', url);
57
83
  }
58
84
  function fmtRelTime(ts) {
@@ -206,7 +232,7 @@ function sortedAgents() {
206
232
  .map(({ a }) => a);
207
233
  }
208
234
 
209
- function navTo(tab) {
235
+ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
210
236
  const prev = state.tab;
211
237
  // Leaving chat clears any pending new-chat confirmation so it doesn't linger
212
238
  // as a stale banner when the user returns.
@@ -230,7 +256,9 @@ function navTo(tab) {
230
256
  // filesMain (which runs during render) - a render-time fetch is fragile under
231
257
  // a double-render and re-enters render() while building the tree.
232
258
  if (tab === 'files' && !state.files.path && !state.files.loading && !state.files.error) loadDir('');
233
- writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
259
+ // popstate calls navTo with writeHash:false so it never replaceState-clobbers
260
+ // the entry it popped; user-initiated navigation pushes so Back walks tabs.
261
+ if (doWriteHash) writeHash({ push });
234
262
  announce('Now on ' + tab + ' tab');
235
263
  render();
236
264
  // Move focus into the new region for keyboard/AT users.
@@ -390,6 +418,17 @@ function closeLiveStream() {
390
418
  state.live.connected = false;
391
419
  }
392
420
 
421
+ // ONE shortcuts definition consumed by BOTH the ?-overlay and the settings
422
+ // keyboard panel, so the two surfaces cannot drift from the keydown handler
423
+ // below (which implements exactly these: g+c/h/f/l/s, n, /, ?, Esc).
424
+ const SHORTCUTS = [
425
+ { keys: 'g then c / h / f / l / s', desc: 'switch tabs (chat / history / files / live / settings)' },
426
+ { keys: 'n', desc: 'new chat (on the chat tab)' },
427
+ { keys: '/', desc: 'focus search or composer' },
428
+ { keys: '?', desc: 'show shortcuts' },
429
+ { keys: 'Esc', desc: 'blur the focused field' },
430
+ ];
431
+
393
432
  function view() {
394
433
  const ok = state.health.status === 'ok';
395
434
  const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
@@ -444,7 +483,7 @@ function view() {
444
483
  : 'min-height:0';
445
484
  const shortcutsHint = state.showShortcuts
446
485
  ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
447
- children: 'g then c/h/f/l/s - chat/history/files/live/settings · n - new chat · / - focus search/composer · ? - toggle this · Esc - blur field' })
486
+ children: SHORTCUTS.map(s => s.keys + ' - ' + s.desc).join(' · ') })
448
487
  : null;
449
488
  const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
450
489
 
@@ -466,6 +505,10 @@ function view() {
466
505
  cwd: state.chatCwd || '',
467
506
  toolCount: runningToolCount(),
468
507
  usage: state.chat.usage || null,
508
+ session: {
509
+ turns: state.chat.messages.filter(m => m.role === 'user').length,
510
+ cost: state.chat.totalCost || null,
511
+ },
469
512
  onSetCwd: () => { navTo('files'); },
470
513
  });
471
514
  } else if (state.tab === 'files' && state.files && state.files.preview && !isNarrow()) {
@@ -593,6 +636,7 @@ function sessionsColumn() {
593
636
  onNew: () => { navTo('chat'); newChat(); },
594
637
  onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
595
638
  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,
596
640
  error: state.historyError,
597
641
  emptyText: 'No conversations yet',
598
642
  });
@@ -607,7 +651,7 @@ function mainContent() {
607
651
  }
608
652
 
609
653
  // --- files (folder browser) ---
610
- async function loadDir(dirPath) {
654
+ async function loadDir(dirPath, { fromHash = false } = {}) {
611
655
  state.files = state.files || {};
612
656
  state.files.loading = true; state.files.error = null; render();
613
657
  try {
@@ -617,6 +661,11 @@ async function loadDir(dirPath) {
617
661
  state.files.entries = j.entries || [];
618
662
  state.files.roots = j.roots || [];
619
663
  state.files.error = null;
664
+ // Deep-link the open directory: push so Back walks the tree; a load that
665
+ // came FROM the hash (popstate/boot) replaces instead - the entry already
666
+ // exists, only the URL needs to stay accurate (writeHash no-ops when the
667
+ // hash already matches, so popstate never loops).
668
+ if (state.tab === 'files') writeHash({ push: !fromHash });
620
669
  } catch (e) {
621
670
  // W9: translate HTTP status to plain, non-leaky copy.
622
671
  state.files.error = e.status === 403
@@ -659,9 +708,110 @@ function closePreview() {
659
708
  if (!state.files) return;
660
709
  state.files.preview = null;
661
710
  state.files.previewState = null;
711
+ // Clear file= from the URL (replace - the pushed open-entry stays poppable).
712
+ if (state.tab === 'files') writeHash();
662
713
  render();
663
714
  }
664
715
 
716
+ // Restore (or clear) the file= preview named by the hash once the directory
717
+ // listing for dir= has resolved - popstate and boot both funnel through here.
718
+ function restoreFileFromHash(filePath) {
719
+ const f = state.files || {};
720
+ if (!filePath) {
721
+ if (f.preview) { f.preview = null; f.previewState = null; render(); }
722
+ return;
723
+ }
724
+ if (f.preview && f.preview.path === filePath) return;
725
+ const entry = (f.entries || []).find(e => e.path === filePath);
726
+ if (entry && entry.type !== 'dir') openPreview(entry, { fromHash: true });
727
+ }
728
+
729
+ // --- file mutations (rename / delete / new folder / upload) ---
730
+ // One dialog at a time lives in state.files.dialog: {kind, file, error, busy}.
731
+ // Errors from the confined endpoints map to plain copy by HTTP status.
732
+ function fileMutationCopy(e) {
733
+ if (e.status === 403) return 'Permission denied, or the target is outside the allowed roots.';
734
+ if (e.status === 404) return 'That file no longer exists.';
735
+ if (e.status === 409) return e.message || 'A file with that name already exists.';
736
+ if (e.status === 413) return 'Too large (50MB upload cap).';
737
+ return e.message || 'The operation failed.';
738
+ }
739
+ function openFileDialog(kind, file) {
740
+ state.files.dialog = { kind, file: file || null, error: null, busy: false };
741
+ render();
742
+ }
743
+ function closeFileDialog() { state.files.dialog = null; render(); }
744
+ async function runFileMutation(fn, doneMsg) {
745
+ const d = state.files.dialog;
746
+ if (!d || d.busy) return;
747
+ d.busy = true; d.error = null; render();
748
+ try {
749
+ await fn();
750
+ state.files.dialog = null;
751
+ announce(doneMsg);
752
+ await loadDir(state.files.path, { fromHash: true }); // refresh in place, no history entry
753
+ } catch (e) {
754
+ d.busy = false; d.error = fileMutationCopy(e); render();
755
+ }
756
+ }
757
+ // Upload a FileList into the current directory; per-file rows feed the kit
758
+ // UploadProgress (done/error per file - fetch has no chunk progress).
759
+ async function uploadFiles(fileList) {
760
+ const dir = state.files.path;
761
+ 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);
770
+ }
771
+ render();
772
+ }
773
+ announce('upload finished');
774
+ await loadDir(dir, { fromHash: true });
775
+ // Keep error rows visible; clear the list entirely when everything landed.
776
+ if (!items.some(i => i.error)) state.files.uploads = null;
777
+ render();
778
+ }
779
+ // The active file dialog (rename/delete/mkdir) as a kit modal, or null.
780
+ function fileDialog() {
781
+ const d = state.files && state.files.dialog;
782
+ if (!d) return null;
783
+ const err = d.error ? h('p', { key: 'fderr', class: 'lede', role: 'alert' }, d.error) : null;
784
+ if (d.kind === 'rename') {
785
+ return h('div', { key: 'fdlg' }, err, PromptDialog({
786
+ title: 'Rename ' + d.file.name, value: d.file.name, placeholder: 'new name',
787
+ confirmLabel: d.busy ? 'renaming...' : 'rename', cancelLabel: 'cancel',
788
+ onCancel: closeFileDialog,
789
+ onConfirm: (v) => { if (v && v !== d.file.name) runFileMutation(() => B.renameEntry(state.backend, d.file.path, v), 'renamed to ' + v); },
790
+ }));
791
+ }
792
+ if (d.kind === 'delete') {
793
+ const isDir = d.file.type === 'dir';
794
+ return h('div', { key: 'fdlg' }, err, ConfirmDialog({
795
+ title: 'Delete ' + d.file.name,
796
+ message: isDir
797
+ ? 'Delete this folder and everything inside it? This cannot be undone.'
798
+ : 'Delete this file? This cannot be undone.',
799
+ confirmLabel: d.busy ? 'deleting...' : 'delete', cancelLabel: 'cancel', destructive: true,
800
+ onCancel: closeFileDialog,
801
+ onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
802
+ }));
803
+ }
804
+ if (d.kind === 'mkdir') {
805
+ return h('div', { key: 'fdlg' }, err, PromptDialog({
806
+ title: 'New folder', value: '', placeholder: 'folder name',
807
+ confirmLabel: d.busy ? 'creating...' : 'create', cancelLabel: 'cancel',
808
+ onCancel: closeFileDialog,
809
+ onConfirm: (v) => { if (v) runFileMutation(() => B.makeDir(state.backend, state.files.path, v), 'created ' + v); },
810
+ }));
811
+ }
812
+ return null;
813
+ }
814
+
665
815
  // Build the inner preview body (image / code / text / loading / error) shared by
666
816
  // the modal FileViewer (<900px) and the inline FilePreviewPane (split view).
667
817
  function filePreviewBody(file) {
@@ -697,8 +847,10 @@ function previewNeighbours() {
697
847
  return { prev: i > 0 ? list[i - 1] : null, next: i < list.length - 1 ? list[i + 1] : null };
698
848
  }
699
849
 
700
- function openPreview(file) {
850
+ function openPreview(file, { fromHash = false } = {}) {
701
851
  state.files.preview = file;
852
+ // Push file= so Back closes the preview; hash-driven opens replace instead.
853
+ if (state.tab === 'files') writeHash({ push: !fromHash });
702
854
  if (file.type !== 'image') loadPreviewContent(file);
703
855
  render();
704
856
  }
@@ -785,6 +937,7 @@ function filesMain() {
785
937
  // Click the active column to flip direction; a new column resets to asc.
786
938
  if (state.files.sort === k) state.files.sortDir = state.files.sortDir === 'asc' ? 'desc' : 'asc';
787
939
  else { state.files.sort = k; state.files.sortDir = 'asc'; }
940
+ persistFilesPrefs();
788
941
  render();
789
942
  } },
790
943
  filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: (v) => { state.files.filter = v; state.files.shown = null; render(); } },
@@ -793,15 +946,18 @@ function filesMain() {
793
946
  if (file.type === 'dir') loadDir(file.path);
794
947
  else openPreview(file);
795
948
  },
796
- // W7: download is the one mutation-free row action the read-only server
797
- // supports. rename/delete are a deliberate scope cut (no mutation endpoints
798
- // - documented in PUNCHLIST-COHESION.md), so only download is wired.
949
+ // Full manager wiring: download streams the confined /api/download;
950
+ // rename/delete open a kit dialog backed by the confined mutation
951
+ // endpoints (the former read-only scope cut is reversed).
799
952
  onAction: (act, file) => {
953
+ if (file.permissions === 'EACCES') { announce('no access to ' + file.name); return; }
800
954
  if (act === 'download' && file.type !== 'dir') {
801
955
  const a = document.createElement('a');
802
956
  a.href = B.downloadUrl(state.backend, file.path);
803
957
  a.download = file.name; document.body.appendChild(a); a.click(); a.remove();
804
958
  }
959
+ if (act === 'rename') openFileDialog('rename', file);
960
+ if (act === 'delete') openFileDialog('delete', file);
805
961
  },
806
962
  });
807
963
  }
@@ -819,22 +975,84 @@ function filesMain() {
819
975
  // Kit FileToolbar (replaces the hand-built .ds-file-toolbar markup).
820
976
  const toolbar = FileToolbar({
821
977
  left: [crumb],
822
- right: [targetCwd
823
- ? Btn({ key: 'usecwd', onClick: () => { state.chatCwd = targetCwd; lsSet('agentgui.cwd', targetCwd); announce('working directory set to ' + targetCwd); navTo('chat'); }, children: 'use as chat cwd' })
824
- : null].filter(Boolean),
978
+ right: [
979
+ f.path ? Btn({ key: 'newdir', onClick: () => openFileDialog('mkdir'), children: 'new folder' }) : null,
980
+ f.path ? Btn({ key: 'upload', onClick: () => {
981
+ const inp = document.createElement('input');
982
+ inp.type = 'file'; inp.multiple = true;
983
+ inp.onchange = () => uploadFiles(inp.files);
984
+ inp.click();
985
+ }, children: 'upload' }) : null,
986
+ targetCwd
987
+ ? Btn({ key: 'usecwd', onClick: () => { state.chatCwd = targetCwd; lsSet('agentgui.cwd', targetCwd); announce('working directory set to ' + targetCwd); navTo('chat'); }, children: 'use as chat cwd' })
988
+ : null,
989
+ ].filter(Boolean),
825
990
  });
991
+ // Drag-and-drop upload wraps the grid (keyboard path = the toolbar upload
992
+ // button); per-file progress rows render above the grid while in flight.
993
+ const droppableBody = f.path && !f.error
994
+ ? DropZone({
995
+ dragover: !!f.dragover,
996
+ label: 'drop files to upload to this folder',
997
+ onDragOver: () => { if (!state.files.dragover) { state.files.dragover = true; render(); } },
998
+ onDragLeave: () => { if (state.files.dragover) { state.files.dragover = false; render(); } },
999
+ onDrop: (files) => { state.files.dragover = false; uploadFiles(files); },
1000
+ children: body,
1001
+ })
1002
+ : body;
826
1003
  return [
827
1004
  offlineBanner(),
828
- PageHeader({ compact: true, title: 'Files', lede: 'Browse the server filesystem within the allowed roots. Click a folder to open it; pick one as the chat working directory.' }),
1005
+ PageHeader({ compact: true, title: 'Files', lede: 'Browse and manage the server filesystem within the allowed roots. Click a folder to open it; pick one as the chat working directory.' }),
829
1006
  rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
830
1007
  h('div', { key: 'ftb' }, toolbar),
831
- h('div', { key: 'fbody' }, body),
1008
+ (f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({ items: f.uploads })) : null,
1009
+ h('div', { key: 'fbody' }, droppableBody),
1010
+ fileDialog(),
832
1011
  // Inline pane handles wide-screen preview; the modal is only the <900px
833
1012
  // fallback (the pane has no room there).
834
1013
  (f.preview && isNarrow()) ? h('div', { key: 'fprev' }, filePreview()) : null,
835
1014
  ].filter(Boolean);
836
1015
  }
837
1016
 
1017
+ // --- live/files preference persistence (sort, errors-only). The live
1018
+ // selection Set is deliberately NOT persisted - stale sids would arm
1019
+ // stop-selected against sessions that no longer exist.
1020
+ const LIVE_PREFS_KEY = 'agentgui.live';
1021
+ const FILES_PREFS_KEY = 'agentgui.files';
1022
+ function persistLivePrefs() {
1023
+ lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly }));
1024
+ }
1025
+ function persistFilesPrefs() {
1026
+ lsSet(FILES_PREFS_KEY, JSON.stringify({ sort: state.files.sort || 'name', sortDir: state.files.sortDir || 'asc' }));
1027
+ }
1028
+ function hydratePrefs() {
1029
+ try {
1030
+ const lv = JSON.parse(lsGet(LIVE_PREFS_KEY) || 'null');
1031
+ if (lv) { if (lv.sort) state.live.sort = lv.sort; state.live.errorsOnly = !!lv.errorsOnly; }
1032
+ } catch {}
1033
+ try {
1034
+ const fp = JSON.parse(lsGet(FILES_PREFS_KEY) || 'null');
1035
+ if (fp) { if (fp.sort) state.files.sort = fp.sort; if (fp.sortDir) state.files.sortDir = fp.sortDir; }
1036
+ } catch {}
1037
+ }
1038
+
1039
+ // Two-step stop: the first click ARMS (confirming* flips the kit button to a
1040
+ // confirm state), a second click within 4s executes; the timer auto-resets.
1041
+ let _stopAllArmTimer = null;
1042
+ let _stopSelArmTimer = null;
1043
+ function armStopAll() {
1044
+ state.live.confirmingStopAll = true;
1045
+ clearTimeout(_stopAllArmTimer);
1046
+ _stopAllArmTimer = setTimeout(() => { state.live.confirmingStopAll = false; render(); }, 4000);
1047
+ render();
1048
+ }
1049
+ function armStopSelected() {
1050
+ state.live.confirmingStopSelected = true;
1051
+ clearTimeout(_stopSelArmTimer);
1052
+ _stopSelArmTimer = setTimeout(() => { state.live.confirmingStopSelected = false; render(); }, 4000);
1053
+ render();
1054
+ }
1055
+
838
1056
  // Stop every in-flight chat at once (the dashboard "stop all" bulk control).
839
1057
  async function stopAllActive(sessions) {
840
1058
  const sids = (Array.isArray(sessions) ? sessions : (state.active || [])).map(s => s.sid || s.sessionId).filter(Boolean);
@@ -921,10 +1139,10 @@ function liveMain() {
921
1139
  offline,
922
1140
  streamState,
923
1141
  activeSid: state.chat.resumeSid, // W13
924
- sort: { value: sortKey, onChange: (v) => { state.live.sort = v; render(); } },
1142
+ sort: { value: sortKey, onChange: (v) => { state.live.sort = v; persistLivePrefs(); render(); } },
925
1143
  filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
926
1144
  errorsOnly: !!lv.errorsOnly,
927
- onErrorsOnly: (on) => { state.live.errorsOnly = on; render(); },
1145
+ onErrorsOnly: (on) => { state.live.errorsOnly = on; persistLivePrefs(); render(); },
928
1146
  selectable: true,
929
1147
  selected,
930
1148
  onToggleSelect: (s) => {
@@ -932,10 +1150,24 @@ function liveMain() {
932
1150
  if (set.has(s.sid)) set.delete(s.sid); else set.add(s.sid);
933
1151
  state.live.selected = set; render();
934
1152
  },
935
- onStopSelected: (sids) => { stopAllActive(sids.map(sid => ({ sid }))); state.live.selected = new Set(); },
1153
+ // Two-step bulk stops: arm first, execute on the confirmed click.
1154
+ confirmingStopAll: !!lv.confirmingStopAll,
1155
+ confirmingStopSelected: !!lv.confirmingStopSelected,
1156
+ onArmStopAll: armStopAll,
1157
+ onArmStopSelected: armStopSelected,
1158
+ onStopSelected: (sids) => {
1159
+ state.live.confirmingStopSelected = false;
1160
+ clearTimeout(_stopSelArmTimer);
1161
+ stopAllActive(sids.map(sid => ({ sid })));
1162
+ state.live.selected = new Set();
1163
+ },
936
1164
  emptyText: 'No live sessions - start a chat or run a local agent.',
937
1165
  onStop: (s) => stopActiveChat(s.sid),
938
- onStopAll: (all) => stopAllActive(all),
1166
+ onStopAll: (all) => {
1167
+ state.live.confirmingStopAll = false;
1168
+ clearTimeout(_stopAllArmTimer);
1169
+ stopAllActive(all);
1170
+ },
939
1171
  onOpen: (s) => { resumeInChat({ sid: s.sid }); },
940
1172
  onView: (s) => { navTo('history'); loadSession(s.sid); },
941
1173
  }),
@@ -1016,15 +1248,29 @@ function applyToolResult(parts, block) {
1016
1248
  }
1017
1249
  }
1018
1250
 
1019
- function errText(e) {
1251
+ // Map raw transport/server errors to plain-language copy. The raw string is
1252
+ // preserved via errTextRaw so renderers can carry it on a title attribute.
1253
+ const ERR_COPY = [
1254
+ [/^ws closed$/, 'Lost connection to the server.'],
1255
+ [/connection lost during stream/, 'Lost connection while the agent was responding - use retry.'],
1256
+ [/no sessionId from server/, 'The server could not start the agent - check it is installed.'],
1257
+ [/^sessions: \d+/, 'History is still indexing - try again in a moment.'],
1258
+ ];
1259
+ function errTextRaw(e) {
1020
1260
  if (e == null) return 'unknown error';
1021
1261
  if (typeof e === 'string') return e;
1022
1262
  if (e.message) return e.message;
1023
1263
  try { return JSON.stringify(e); } catch { return String(e); }
1024
1264
  }
1265
+ function errText(e) {
1266
+ const raw = errTextRaw(e);
1267
+ for (const [re, copy] of ERR_COPY) { if (re.test(raw)) return copy; }
1268
+ return raw;
1269
+ }
1025
1270
 
1026
1271
  function chatMain() {
1027
1272
  const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
1273
+ const userTurnCount = state.chat.messages.filter(m => m.role === 'user').length;
1028
1274
 
1029
1275
  // Banners agentgui owns (resume, agent-switched, unavailable, confirm-clear,
1030
1276
  // stream error) are pre-built here and handed to the kit, which renders them
@@ -1063,13 +1309,27 @@ function chatMain() {
1063
1309
  if (state.cwdError) {
1064
1310
  banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
1065
1311
  }
1312
+ if (state.agentsError) {
1313
+ banners.push(Alert({ key: 'agerr', kind: 'error', title: 'Could not load agents from the server',
1314
+ children: [
1315
+ h('span', { key: 'agtxt', title: state.agentsError }, 'The agent list failed to load. '),
1316
+ Btn({ key: 'agretry', onClick: () => loadAgents(), children: 'retry' })] }));
1317
+ }
1318
+ if (state.chat.confirmingEdit) {
1319
+ banners.push(Alert({ key: 'confedit', kind: 'warn', title: 'Edit this message?',
1320
+ children: [
1321
+ h('span', { key: 'cetext' }, 'Editing will remove the later turns - continue? '),
1322
+ Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: 'continue' }),
1323
+ Btn({ key: 'ceno', onClick: cancelEditAndResend, children: 'cancel' })] }));
1324
+ }
1066
1325
  const lastMsg = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1] : null;
1067
1326
  const lastErr = lastMsg ? lastMsg.error : null;
1068
1327
  if (lastErr && !state.chat.busy) {
1069
1328
  banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error',
1070
1329
  children: [
1071
- h('span', { key: 'cetxt' }, lastErr + ' '),
1072
- Btn({ key: 'cedismiss', onClick: () => { if (lastMsg) { delete lastMsg.error; persistChat(); render(); } }, children: 'dismiss' })] }));
1330
+ h('span', { key: 'cetxt', title: lastMsg.errorRaw || lastErr }, lastErr + ' '),
1331
+ Btn({ key: 'ceretry', onClick: () => { delete lastMsg.error; delete lastMsg.errorRaw; retryLastTurn(); }, children: 'retry' }),
1332
+ Btn({ key: 'cedismiss', onClick: () => { if (lastMsg) { delete lastMsg.error; delete lastMsg.errorRaw; persistChat(); render(); } }, children: 'dismiss' })] }));
1073
1333
  }
1074
1334
 
1075
1335
  const placeholder = !state.selectedAgent
@@ -1108,17 +1368,34 @@ function chatMain() {
1108
1368
  // and the active target shown inline above the composer.
1109
1369
  avatar: state.selectedAgent ? h('span', { class: 'agentchat-avatar-mark', 'aria-hidden': 'true' }, Icon('forum', { size: 16 })) : undefined,
1110
1370
  composerContext: state.selectedAgent ? {
1111
- bits: [agentName, state.selectedModel || null, state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default'].filter(Boolean),
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),
1112
1372
  onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); },
1113
1373
  } : undefined,
1114
- // W15: contextual follow-up chips after a settled assistant turn.
1115
- followups: state.chat.messages.length ? ['Explain that in more detail', 'Show me the diff', 'Run the tests'] : [],
1374
+ // W15: contextual follow-up chips derived from the settled last turn
1375
+ // (tool error / code fence / file path), seeded statically otherwise.
1376
+ followups: chatFollowups(),
1116
1377
  onFollowupClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
1378
+ // Transcript export: copy all / markdown / json.
1379
+ exportActions: state.chat.messages.length ? [
1380
+ { label: 'copy all', title: 'Copy the whole transcript as markdown', onClick: () => copyText(transcriptToMarkdown(state.chat.messages), 'transcript copied') },
1381
+ { label: 'export md', title: 'Download the transcript as markdown', onClick: () => downloadBlob(transcriptToMarkdown(state.chat.messages), 'agentgui-chat-' + dateStamp() + '.md', 'text/markdown') },
1382
+ { label: 'export json', title: 'Download the transcript as JSON', onClick: () => downloadBlob(JSON.stringify(state.chat.messages, null, 2), 'agentgui-chat-' + dateStamp() + '.json', 'application/json') },
1383
+ ] : [],
1384
+ // When the server reports agents but NONE is installed, show install
1385
+ // commands inline instead of a dead picker.
1386
+ installHint: (state.agents.length && state.agents.every(a => a.available === false)) ? {
1387
+ text: 'No agent CLIs found on the server.',
1388
+ commands: state.agents.filter(a => a.npxInstallable).map(a => ({ agent: a.name || a.id, command: 'npx ' + (a.npxPackage || a.id) })),
1389
+ onRecheck: () => loadAgents(),
1390
+ } : undefined,
1117
1391
  // Per-message actions: copy any message; retry the last assistant turn;
1118
- // edit-and-resend a user message (drops everything after it, refills draft).
1392
+ // edit-and-resend a user message (two-step: arm a confirm banner first,
1393
+ // since the truncation destroys the later turns).
1119
1394
  onCopyMessage: (m) => copyMessageText(m),
1120
1395
  onRetryMessage: () => retryLastTurn(),
1121
- onEditMessage: (m) => editAndResend(m),
1396
+ confirmEdit: true,
1397
+ onArmEdit: (m) => armEditAndResend(m),
1398
+ onEditMessage: (m) => armEditAndResend(m),
1122
1399
  cwd: state.chatCwd,
1123
1400
  cwdEditing: !!state.cwdEditing,
1124
1401
  cwdDraft: state.cwdDraft,
@@ -1133,11 +1410,12 @@ function chatMain() {
1133
1410
  const was = !!(state.chat.draft && state.chat.draft.trim());
1134
1411
  const now = !!(v && v.trim());
1135
1412
  state.chat.draft = v;
1413
+ debouncedPersistDraft(); // a typed-but-unsent draft survives reload
1136
1414
  if (was !== now) render();
1137
1415
  },
1138
1416
  onSend: (v) => { state.chat.draft = v; sendChat(); },
1139
1417
  onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
1140
- onCwdSave: () => {
1418
+ onCwdSave: async () => {
1141
1419
  const path = (state.cwdDraft ?? '').trim();
1142
1420
  // A relative cwd would resolve against the server process dir, not what
1143
1421
  // the user means - require an absolute path (POSIX /..., UNC \\..., or
@@ -1147,6 +1425,20 @@ function chatMain() {
1147
1425
  render();
1148
1426
  return;
1149
1427
  }
1428
+ // Validate against the server: the path must resolve and be a directory.
1429
+ if (path) {
1430
+ try {
1431
+ const st = await B.statPath(state.backend, path);
1432
+ if (!st || st.ok === false) { state.cwdError = 'directory not found on the server: ' + path; render(); return; }
1433
+ if (!st.dir) { state.cwdError = 'that path is not a directory'; render(); return; }
1434
+ } catch (e) {
1435
+ state.cwdError = e.status === 403 ? 'outside the allowed roots'
1436
+ : (e.status === 404 ? 'directory not found on the server: ' + path
1437
+ : (e.status ? 'directory not found on the server: ' + path : 'could not validate the path - server unreachable'));
1438
+ render();
1439
+ return;
1440
+ }
1441
+ }
1150
1442
  state.cwdError = null;
1151
1443
  state.chatCwd = path;
1152
1444
  if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
@@ -1175,7 +1467,7 @@ function newChat() {
1175
1467
  }
1176
1468
  state.confirmingNewChat = false;
1177
1469
  state.chat.abort?.abort();
1178
- state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null };
1470
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null, confirmingEdit: null, totalCost: 0 };
1179
1471
  lsRemove(CHAT_KEY);
1180
1472
  render();
1181
1473
  }
@@ -1205,15 +1497,21 @@ function persistChat() {
1205
1497
  const msgs = state.chat.messages
1206
1498
  .filter(m => !isEmptyTurn(m))
1207
1499
  .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
1208
- if (!msgs.length) { lsRemove(CHAT_KEY); return; }
1209
- lsSet(CHAT_KEY, JSON.stringify({ messages: msgs, resumeSid: state.chat.resumeSid, agent: state.selectedAgent, model: state.selectedModel }));
1500
+ const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
1501
+ // Persist when there is a transcript OR a non-empty draft (a typed-but-not-
1502
+ // sent message must survive a reload too).
1503
+ 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 }));
1210
1505
  } catch {}
1211
1506
  }
1507
+ const debouncedPersistDraft = debounce(persistChat, 500);
1212
1508
  function restoreChat() {
1213
1509
  try {
1214
1510
  const raw = lsGet(CHAT_KEY);
1215
1511
  if (!raw) return;
1216
1512
  const saved = JSON.parse(raw);
1513
+ if (typeof saved.draft === 'string' && saved.draft.trim()) state.chat.draft = saved.draft;
1514
+ if (typeof saved.totalCost === 'number') state.chat.totalCost = saved.totalCost;
1217
1515
  if (Array.isArray(saved.messages) && saved.messages.length) {
1218
1516
  state.chat.messages = saved.messages
1219
1517
  .filter(m => !isEmptyTurn(m))
@@ -1239,14 +1537,70 @@ function messageToText(m) {
1239
1537
  return '';
1240
1538
  }).filter(Boolean).join('\n');
1241
1539
  }
1242
- function copyMessageText(m) {
1243
- const text = messageToText(m);
1540
+ // Shared clipboard helper with an insecure-origin fallback; announces via the
1541
+ // aria-live region so AT users hear the result.
1542
+ function copyText(text, msg) {
1244
1543
  if (!text) return;
1245
- if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce('message copied')).catch(() => {});
1544
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce(msg)).catch(() => {});
1246
1545
  else {
1247
- try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce('message copied'); } catch {}
1546
+ try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce(msg); } catch {}
1248
1547
  }
1249
1548
  }
1549
+ function copyMessageText(m) {
1550
+ copyText(messageToText(m), 'message copied');
1551
+ }
1552
+
1553
+ // --- transcript export ---
1554
+ function dateStamp() { return new Date().toISOString().slice(0, 10); }
1555
+ function downloadBlob(content, filename, type) {
1556
+ const blob = new Blob([content], { type });
1557
+ const url = URL.createObjectURL(blob);
1558
+ const a = document.createElement('a');
1559
+ a.href = url; a.download = filename;
1560
+ document.body.appendChild(a); a.click(); a.remove();
1561
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
1562
+ }
1563
+ function transcriptToMarkdown(messages) {
1564
+ return (messages || []).map((m) => {
1565
+ const head = '## ' + (m.role === 'user' ? 'User' : 'Assistant') + (m.time ? ' (' + m.time + ')' : '');
1566
+ const parts = Array.isArray(m.parts) ? m.parts : [];
1567
+ const body = parts.length
1568
+ ? parts.map((p) => {
1569
+ if (typeof p === 'string') return p;
1570
+ if (p.kind === 'md' || p.kind === 'text') return p.text || '';
1571
+ if (p.kind === 'tool' || p.kind === 'tool_result') {
1572
+ const bits = ['## tool: ' + (p.name || 'tool')];
1573
+ if (p.args && Object.keys(p.args).length) bits.push('```json\n' + JSON.stringify(p.args, null, 2) + '\n```');
1574
+ if (p.result != null) bits.push('```\n' + (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2)) + '\n```');
1575
+ return bits.join('\n');
1576
+ }
1577
+ return '';
1578
+ }).filter(Boolean).join('\n\n')
1579
+ : (m.content || '');
1580
+ return head + '\n\n' + body;
1581
+ }).join('\n\n');
1582
+ }
1583
+
1584
+ // Contextual follow-up chips derived from the settled last assistant turn;
1585
+ // falls back to the static seeds when nothing contextual applies.
1586
+ const FOLLOWUP_SEEDS = ['Explain that in more detail', 'Show me the diff', 'Run the tests'];
1587
+ function chatFollowups() {
1588
+ const msgs = state.chat.messages || [];
1589
+ if (!msgs.length || state.chat.busy) return [];
1590
+ const last = [...msgs].reverse().find(m => m.role === 'assistant');
1591
+ if (!last) return [];
1592
+ const out = [];
1593
+ const parts = Array.isArray(last.parts) ? last.parts : [];
1594
+ if (parts.some(p => p && p.kind === 'tool' && p.status === 'error')) out.push('Fix that error');
1595
+ const text = messageToText(last);
1596
+ if (/```/.test(text) || parts.some(p => p && p.kind === 'code')) out.push('Explain this code');
1597
+ const fm = text.match(/([A-Za-z]:\\\S+|\/[\w./-]+\.\w+)/);
1598
+ if (fm) {
1599
+ const base = fm[1].split(/[/\\]/).filter(Boolean).pop();
1600
+ if (base) out.push('Open ' + base.replace(/[^\w.\-]/g, '') + ' in files');
1601
+ }
1602
+ return out.length ? out.slice(0, 3) : FOLLOWUP_SEEDS;
1603
+ }
1250
1604
  // Retry the last assistant turn: drop it and re-send the preceding user message.
1251
1605
  function retryLastTurn() {
1252
1606
  if (!canSend() && !state.chat.busy) { /* allow when idle */ }
@@ -1267,18 +1621,32 @@ function retryLastTurn() {
1267
1621
  if (canSend()) sendChat();
1268
1622
  else render();
1269
1623
  }
1270
- // Edit-and-resend a user message: truncate the transcript at it, refill the
1271
- // composer with its text, and let the user revise before sending.
1272
- function editAndResend(m) {
1624
+ // Edit-and-resend a user message: two-step. The edit click ARMS a confirmation
1625
+ // (truncating destroys the later turns), the banner's continue executes it.
1626
+ function armEditAndResend(m) {
1273
1627
  if (state.chat.busy) return;
1274
1628
  const idx = state.chat.messages.indexOf(m);
1275
1629
  if (idx < 0) return;
1276
- state.chat.messages = state.chat.messages.slice(0, idx);
1277
- state.chat.draft = m.content || messageToText(m);
1630
+ state.chat.confirmingEdit = { idx, text: m.content || messageToText(m) };
1631
+ render();
1632
+ }
1633
+ function confirmEditAndResend() {
1634
+ const ce = state.chat.confirmingEdit;
1635
+ if (!ce || state.chat.busy) return;
1636
+ state.chat.confirmingEdit = null;
1637
+ state.chat.messages = state.chat.messages.slice(0, ce.idx);
1638
+ // Never --resume a session whose tail diverged from what the server saw.
1639
+ state.chat.resumeSid = null;
1640
+ state.chat.resumeNote = null;
1641
+ state.chat.draft = ce.text;
1278
1642
  persistChat();
1279
1643
  render();
1280
1644
  requestAnimationFrame(() => focusComposer());
1281
1645
  }
1646
+ function cancelEditAndResend() {
1647
+ state.chat.confirmingEdit = null;
1648
+ render();
1649
+ }
1282
1650
 
1283
1651
  async function sendChat() {
1284
1652
  const text = (state.chat.draft || '').trim();
@@ -1333,12 +1701,16 @@ async function sendChat() {
1333
1701
  turns: b.num_turns != null ? b.num_turns : null,
1334
1702
  durationMs: b.duration_ms != null ? b.duration_ms : null,
1335
1703
  };
1704
+ // Accumulate cost ACROSS turns so the ContextPane session line shows
1705
+ // the whole conversation's spend, not just the last turn's.
1706
+ 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;
1336
1708
  scheduleStreamRender();
1337
1709
  }
1338
- else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
1710
+ else if (ev.type === 'error') { cur.error = errText(ev.error); cur.errorRaw = errTextRaw(ev.error); render(); }
1339
1711
  }
1340
1712
  } catch (e) {
1341
- if (e.name !== 'AbortError') cur.error = errText(e.message);
1713
+ if (e.name !== 'AbortError') { cur.error = errText(e); cur.errorRaw = errTextRaw(e); }
1342
1714
  } finally {
1343
1715
  state.chat.busy = false;
1344
1716
  state.chat.abort = null;
@@ -1359,6 +1731,48 @@ function reconnectAlert() {
1359
1731
  });
1360
1732
  }
1361
1733
 
1734
+ // Humanize a millisecond span (1m 30s scale words, no glyphs).
1735
+ function humanizeMs(ms) {
1736
+ if (ms == null || !isFinite(ms) || ms < 0) return '';
1737
+ const s = Math.round(ms / 1000);
1738
+ if (s < 60) return s + 's';
1739
+ if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
1740
+ return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
1741
+ }
1742
+
1743
+ // First-to-last event timestamp span for the selected session, humanized.
1744
+ function sessionDuration() {
1745
+ const ts = (state.events || []).map(e => e.ts).filter(Boolean);
1746
+ if (ts.length < 2) return '';
1747
+ return humanizeMs(Math.max(...ts) - Math.min(...ts));
1748
+ }
1749
+
1750
+ // Event-type filter predicate (all | text | tool | errors).
1751
+ function eventMatchesFilter(e, f) {
1752
+ if (f === 'tool') return e.type === 'tool_use' || e.type === 'tool_result';
1753
+ if (f === 'errors') return !!e.isError;
1754
+ if (f === 'text') return !e.isError && e.type !== 'tool_use' && e.type !== 'tool_result';
1755
+ return true;
1756
+ }
1757
+
1758
+ // Scroll to + flash the first error event, widening the render window (and
1759
+ // clearing the type filter) so the row is actually rendered.
1760
+ function jumpToFirstError() {
1761
+ const idx = state.events.findIndex(e => e.isError);
1762
+ if (idx < 0) return;
1763
+ state.eventFilter = 'all';
1764
+ const fromEnd = state.events.length - idx;
1765
+ if (fromEnd > state.eventsLimit) state.eventsLimit = Math.ceil(fromEnd / 300) * 300;
1766
+ render();
1767
+ const sliceStart = Math.max(0, state.events.length - state.eventsLimit);
1768
+ const rowPos = idx - sliceStart;
1769
+ requestAnimationFrame(() => {
1770
+ const rows = document.querySelectorAll('.ds-event-list .row');
1771
+ const row = rows[rowPos];
1772
+ if (row) { row.scrollIntoView({ block: 'center' }); row.classList.add('event-flash'); setTimeout(() => row.classList.remove('event-flash'), 2000); }
1773
+ });
1774
+ }
1775
+
1362
1776
  function historyMain() {
1363
1777
  if (!state.selectedSid) {
1364
1778
  const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
@@ -1390,26 +1804,65 @@ function historyMain() {
1390
1804
  lede,
1391
1805
  });
1392
1806
 
1393
- const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' },
1807
+ const hasErrors = state.events.some(e => e.isError);
1808
+ const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' }, [
1394
1809
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
1395
1810
  Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
1396
- );
1811
+ Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
1812
+ onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
1813
+ children: 'export' }),
1814
+ hasErrors ? Btn({ key: 'jumperr', onClick: jumpToFirstError, children: 'jump to first error' }) : null,
1815
+ ].filter(Boolean));
1397
1816
 
1398
1817
  if (state.events.length === 0) {
1399
1818
  // Distinguish "still loading" from "genuinely empty" so a 0-event session
1400
- // doesn't spin forever.
1819
+ // doesn't spin forever. After 5s of an unresolved first fetch, swap to the
1820
+ // indexing copy (ccsniff's first JSONL walk can take a minute).
1401
1821
  const body = state.eventsLoaded
1402
1822
  ? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
1403
1823
  h('span', { key: 'noevtxt' }, 'no events in this session - '),
1404
1824
  Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
1405
- : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
1825
+ : 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…');
1406
1827
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
1407
1828
  }
1408
1829
 
1409
1830
  if (!state.expandedEvents) state.expandedEvents = new Set();
1410
- const total = state.events.length;
1831
+ // Event-type filter applies BEFORE the render-window slice so "errors" shows
1832
+ // every error in the session, not only errors among the most-recent 300.
1833
+ const ef = state.eventFilter || 'all';
1834
+ const filteredEvents = ef === 'all' ? state.events : state.events.filter(e => eventMatchesFilter(e, ef));
1835
+ const filterPills = FilterPills({
1836
+ options: [
1837
+ { id: 'all', label: 'all' },
1838
+ { id: 'text', label: 'text' },
1839
+ { id: 'tool', label: 'tools' },
1840
+ { id: 'errors', label: 'errors' },
1841
+ ],
1842
+ selected: ef,
1843
+ onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
1844
+ label: 'Filter events by type',
1845
+ });
1846
+ const meta = SessionMeta({
1847
+ items: [
1848
+ sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
1849
+ sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
1850
+ { label: 'sid', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'sid copied') },
1851
+ { label: 'events', value: String(state.events.length) },
1852
+ ].filter(Boolean),
1853
+ });
1854
+ if (filteredEvents.length === 0) {
1855
+ return [reconnectAlert(), head, actions, Panel({ title: 'events', children: [
1856
+ h('div', { key: 'evmeta' }, meta),
1857
+ h('div', { key: 'evfp' }, filterPills),
1858
+ h('div', { key: 'nofilt', class: 'lede empty-state', role: 'status' },
1859
+ h('span', { key: 'noftxt' }, '0 events match this filter - '),
1860
+ Btn({ key: 'clearf', onClick: () => { state.eventFilter = 'all'; render(); }, children: 'clear filter' })),
1861
+ ] })].filter(Boolean);
1862
+ }
1863
+ const total = filteredEvents.length;
1411
1864
  const limit = state.eventsLimit;
1412
- const shown = state.events.slice(-limit);
1865
+ const shown = filteredEvents.slice(-limit);
1413
1866
  const hiddenCount = total - shown.length;
1414
1867
  // Keys of the currently-shown rows, so expand-all toggles only what's rendered.
1415
1868
  const shownKeys = shown.map((e, i) => {
@@ -1432,8 +1885,8 @@ function historyMain() {
1432
1885
  head,
1433
1886
  actions,
1434
1887
  Panel({
1435
- title: total + ' events' + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
1436
- children: [eventControls, EventList({
1888
+ title: total + ' events' + (ef !== 'all' ? ' (' + ef + ' filter)' : '') + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
1889
+ children: [h('div', { key: 'evmeta' }, meta), h('div', { key: 'evfp' }, filterPills), eventControls, EventList({
1437
1890
  items: shown.map((e, i) => {
1438
1891
  // Stable key: prefer the server-assigned event index, else the
1439
1892
  // event timestamp + position, never a bare array index (which
@@ -1456,13 +1909,27 @@ function historyMain() {
1456
1909
  // Rail tone matches the session/agents rail semantics so an event's
1457
1910
  // kind is visible at a glance, consistent across the GUI:
1458
1911
  // flame = error, purple = tool_use, green = normal turn.
1459
- const rail = e.isError ? 'flame' : 'green';
1912
+ const rail = e.isError ? 'flame' : (e.type === 'tool_use' ? 'purple' : 'green');
1913
+ // When the session was opened from a search hit, window the collapsed
1914
+ // title AROUND the first query match (a match at char 5000 would
1915
+ // otherwise be invisible behind the 0-220 slice).
1916
+ let collapsedTitle = text.slice(0, 220);
1917
+ const q = state.sessionSearchQ;
1918
+ if (q && !expanded) {
1919
+ const qi = text.toLowerCase().indexOf(q.toLowerCase());
1920
+ if (qi > 60) collapsedTitle = '…' + text.slice(qi - 60, qi - 60 + 220);
1921
+ }
1460
1922
  return {
1461
1923
  key,
1462
1924
  code: String(absIdx + 1).padStart(4, '0'),
1463
1925
  rail,
1464
1926
  expanded, // disclosure state -> kit Row sets aria-expanded
1465
- title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
1927
+ highlight: q || undefined,
1928
+ actions: expanded ? [{
1929
+ label: 'copy', title: 'copy event',
1930
+ onClick: () => copyText(full || raw || ('(' + type + ')'), 'event copied'),
1931
+ }] : undefined,
1932
+ title: expanded ? (full || '(' + type + ')') : (collapsedTitle || '(' + type + ')'),
1466
1933
  // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
1467
1934
  // Every row is click-to-expand, so always show the affordance word
1468
1935
  // (not only when text overflows 220 chars).
@@ -1495,10 +1962,14 @@ function copySid() {
1495
1962
  }
1496
1963
  }
1497
1964
 
1498
- function resumeInChat(sess) {
1965
+ function resumeInChat(sess, { fromHash = false } = {}) {
1499
1966
  state.tab = 'chat';
1500
1967
  closeLiveStream();
1501
1968
  state.chat.resumeSid = sess?.sid || state.selectedSid;
1969
+ // Carry the sid on the chat tab too (tab=chat&sid=...) so reload/Back
1970
+ // restores the resumed conversation rather than a blank chat.
1971
+ state.selectedSid = state.chat.resumeSid || state.selectedSid;
1972
+ if (!fromHash) writeHash({ push: true });
1502
1973
  state.chat.messages = [];
1503
1974
  state.chat.draft = '';
1504
1975
  // Only claude-code supports --resume by sid; warn if we have to switch the
@@ -1578,7 +2049,7 @@ function historySide() {
1578
2049
  key: 'sr-' + (r.sid || '?') + '-' + i,
1579
2050
  rank: String(i + 1).padStart(3, '0'),
1580
2051
  title: r.snippet || '(no snippet)',
1581
- sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
2052
+ sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
1582
2053
  // Rail carries the same semantics as session rows: error > subagent > normal.
1583
2054
  rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
1584
2055
  // Carry the matched event's index so loadSession scrolls to + flashes
@@ -1632,13 +2103,13 @@ function historySide() {
1632
2103
  ? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
1633
2104
  : null,
1634
2105
  state.searchQ
1635
- ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: 'clear search' })
2106
+ ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; writeHash(); render(); }, children: 'clear search' })
1636
2107
  : null,
1637
2108
  !searching && projects.length > 1
1638
2109
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
1639
- pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
2110
+ pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; writeHash(); render(); }),
1640
2111
  ...projects.slice(0, 8).map(([name, count]) =>
1641
- pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
2112
+ pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; writeHash(); render(); })))
1642
2113
  : null,
1643
2114
  !searching && subagentCount
1644
2115
  ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
@@ -1734,6 +2205,7 @@ function settingsMain() {
1734
2205
  }),
1735
2206
  h('div', { key: 'settings-grid', class: 'settings-grid' }, [
1736
2207
  Panel({
2208
+ id: 'backend',
1737
2209
  title: 'backend',
1738
2210
  children: h('form', {
1739
2211
  key: 'backendForm',
@@ -1767,18 +2239,53 @@ function settingsMain() {
1767
2239
  ]),
1768
2240
  }),
1769
2241
  Panel({
2242
+ id: 'appearance',
1770
2243
  title: 'appearance',
1771
2244
  children: [
1772
2245
  h('div', { key: 'apl', class: 'lede', style: 'margin-bottom:.5em' }, 'theme - follows the OS in auto, or pick light/dark.'),
1773
2246
  ThemeToggle({ key: 'tt' }),
1774
2247
  ],
1775
2248
  }),
2249
+ serverPanel(),
1776
2250
  agentsPanel(),
2251
+ keyboardPanel(),
1777
2252
  preferencesPanel(),
1778
2253
  ]),
1779
2254
  ];
1780
2255
  }
1781
2256
 
2257
+ // Server info from /health: version, uptime, ws clients, allowed roots (with
2258
+ // per-root copy), projects dir. Renders unknowns plainly rather than hiding.
2259
+ function serverPanel() {
2260
+ const hh = state.health || {};
2261
+ const roots = Array.isArray(hh.allowRoots) ? hh.allowRoots : [];
2262
+ const upMs = hh.uptimeMs != null ? hh.uptimeMs : (hh.uptime != null ? hh.uptime * 1000 : null);
2263
+ const wsClients = hh.wsClients != null ? hh.wsClients : (hh.clients != null ? hh.clients : null);
2264
+ return Panel({
2265
+ id: 'server',
2266
+ title: 'server',
2267
+ children: [
2268
+ h('div', { key: 'sv', class: 'lede' }, 'version: ' + (hh.version ? 'v' + hh.version : 'unknown')),
2269
+ h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ? humanizeMs(upMs) : 'unknown')),
2270
+ h('div', { key: 'swc', class: 'lede' }, 'ws clients: ' + (wsClients != null ? wsClients : 'unknown')),
2271
+ h('div', { key: 'spd', class: 'lede' }, 'projects dir: ' + (hh.projectsDir || 'unknown')),
2272
+ roots.length
2273
+ ? SessionMeta({ key: 'sroots', items: roots.map((r, i) => ({ label: 'root ' + (i + 1), value: r, title: r, onCopy: () => copyText(r, 'root copied') })) })
2274
+ : h('div', { key: 'snoroots', class: 'lede' }, 'allowed roots: unknown'),
2275
+ ],
2276
+ });
2277
+ }
2278
+
2279
+ // The keyboard panel consumes the same SHORTCUTS definition as the ?-overlay.
2280
+ function keyboardPanel() {
2281
+ return Panel({
2282
+ id: 'keyboard',
2283
+ title: 'keyboard',
2284
+ children: SHORTCUTS.map((s, i) =>
2285
+ h('div', { key: 'kb' + i, class: 'lede' }, s.keys + ' - ' + s.desc)),
2286
+ });
2287
+ }
2288
+
1782
2289
  function clearLocalData() {
1783
2290
  if (!state.confirmingClearData) { state.confirmingClearData = true; render(); return; }
1784
2291
  state.confirmingClearData = false;
@@ -1788,19 +2295,34 @@ function clearLocalData() {
1788
2295
  // re-persist them, so the "clear" wouldn't survive. A reload re-inits from
1789
2296
  // defaults with the keys gone.
1790
2297
  state.chat.abort?.abort(); // stop any in-flight stream before we drop the page
1791
- for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend']) lsRemove(k);
1792
- state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
2298
+ for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend', 'agentgui.live', 'agentgui.files']) lsRemove(k);
2299
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 };
1793
2300
  location.reload();
1794
2301
  }
1795
2302
 
1796
2303
  function preferencesPanel() {
1797
2304
  const hh = state.health || {};
2305
+ const savedChat = lsGet(CHAT_KEY);
2306
+ // Footprint of agentgui's own localStorage keys.
2307
+ let lsKeys = 0, lsBytes = 0;
2308
+ try {
2309
+ for (let i = 0; i < localStorage.length; i++) {
2310
+ const k = localStorage.key(i);
2311
+ if (k && k.startsWith('agentgui.')) { lsKeys++; lsBytes += (localStorage.getItem(k) || '').length; }
2312
+ }
2313
+ } catch {}
1798
2314
  return Panel({
1799
- title: 'preferences',
2315
+ id: 'data',
2316
+ title: 'data',
1800
2317
  children: [
1801
2318
  h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
1802
- h('div', { key: 'sc', class: 'lede', style: 'margin:.5em 0' },
1803
- 'keyboard: g then c/h/s - switch tabs · n - new chat · / - focus search/composer · ? - toggle hint · Esc - blur field'),
2319
+ 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')),
2321
+ h('div', { key: 'expchatrow', style: 'margin:.5em 0' },
2322
+ Btn({ key: 'expchat', disabled: !savedChat,
2323
+ title: savedChat ? 'Download the saved chat transcript as JSON' : 'no saved chat',
2324
+ onClick: savedChat ? () => downloadBlob(savedChat, 'agentgui-chat-' + dateStamp() + '.json', 'application/json') : undefined,
2325
+ children: savedChat ? 'export chat' : 'no saved chat' })),
1804
2326
  h('div', { key: 'cwdnote', class: 'lede', style: 'margin:.5em 0' },
1805
2327
  'working directory: set per-chat in the chat composer’s cwd bar' + (state.chatCwd ? ' (current: ' + state.chatCwd + ')' : ' (currently: server default)')),
1806
2328
  state.confirmingClearData
@@ -1823,6 +2345,7 @@ function agentsPanel() {
1823
2345
  const installed = state.agents.filter(a => a.available !== false);
1824
2346
  const hasAcp = (Array.isArray(state.health.acp) ? state.health.acp : []).length > 0;
1825
2347
  return Panel({
2348
+ id: 'agents',
1826
2349
  title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
1827
2350
  children: [
1828
2351
  // ACP agents (opencode/kilo/codex) are managed automatically: started
@@ -1863,8 +2386,16 @@ function agentsPanel() {
1863
2386
 
1864
2387
  // --- data ---
1865
2388
  async function refreshHistory() {
2389
+ // Warmup copy: the FIRST sessions fetch can sit behind ccsniff's 30-90s
2390
+ // JSONL walk; after 5s swap the loading copy to indexing language.
2391
+ const firstLoad = !state._historyLoadedOnce;
2392
+ const slowTimer = firstLoad
2393
+ ? setTimeout(() => { if (!state._historyLoadedOnce) { state.historySlow = true; render(); } }, 5000)
2394
+ : null;
1866
2395
  try {
1867
2396
  state.sessions = await B.listSessions(state.backend);
2397
+ state._historyLoadedOnce = true;
2398
+ state.historySlow = false;
1868
2399
  // Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
1869
2400
  // linear scan per event during a burst load.
1870
2401
  state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
@@ -1875,34 +2406,38 @@ async function refreshHistory() {
1875
2406
  state.selectedSid = null;
1876
2407
  state.events = [];
1877
2408
  state.eventsLoaded = false;
1878
- writeHash(null);
2409
+ writeHash();
1879
2410
  }
1880
2411
  state.historyError = null;
1881
2412
  } catch (e) {
1882
2413
  // Only a genuine fetch/list failure is a history error. A render exception
1883
2414
  // must not masquerade as one (it would poison the sessions panel with a
1884
2415
  // render-stack string and never clear), so render() lives outside this try.
1885
- state.historyError = e.message;
2416
+ state.historyError = errText(e);
1886
2417
  console.warn('history fetch failed:', e.message);
1887
2418
  }
2419
+ if (slowTimer) clearTimeout(slowTimer);
1888
2420
  render();
1889
2421
  }
1890
2422
  const debouncedRefreshHistory = debounce(refreshHistory, 500);
1891
2423
 
1892
2424
  async function runSearch() {
1893
2425
  const q = state.searchQ.trim();
1894
- if (!q) { state.searchHits = null; state.searchBusy = false; render(); return; }
1895
- if (q.length < 2) { state.searchHits = null; state.searchBusy = false; render(); return; }
2426
+ if (!q) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
2427
+ if (q.length < 2) { state.searchHits = null; state.searchBusy = false; writeHash(); render(); return; }
1896
2428
  // The project-filter pills are hidden while searching; clear the filter so it
1897
2429
  // doesn't silently re-apply (and surprise the user) when they later clear the
1898
2430
  // search and the now-visible session list is unexpectedly narrowed.
1899
2431
  state.projectFilter = '';
2432
+ // The debounced search keeps the URL's q=/project= in sync via replaceState
2433
+ // so a reload (or share) restores the search, without flooding history.
2434
+ writeHash();
1900
2435
  state.searchBusy = true;
1901
2436
  render();
1902
2437
  try {
1903
2438
  state.searchHits = await B.searchHistory(state.backend, q, 60);
1904
2439
  } catch (e) {
1905
- state.searchHits = { query: q, results: [], error: e.message };
2440
+ state.searchHits = { query: q, results: [], error: errText(e) };
1906
2441
  } finally {
1907
2442
  state.searchBusy = false;
1908
2443
  render();
@@ -1910,18 +2445,26 @@ async function runSearch() {
1910
2445
  }
1911
2446
  const debouncedSearch = debounce(runSearch, 300);
1912
2447
 
1913
- async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}) {
2448
+ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromHash = false } = {}) {
1914
2449
  // Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
1915
2450
  if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
1916
2451
  state.selectedSid = sid;
1917
2452
  state.events = [];
1918
2453
  state.eventsLoaded = false;
2454
+ state.eventsSlow = false;
1919
2455
  state.eventsLimit = 300; // reset the render window per session
2456
+ state.eventFilter = 'all'; // don't carry the type filter across sessions
1920
2457
  state.expandedEvents = new Set(); // don't carry expansion to the new session
2458
+ // Remember the query this session was opened FROM (search hit) so the event
2459
+ // rows can highlight + window around the match; a plain selection clears it.
2460
+ state.sessionSearchQ = (focusEventI != null || focusEventTs != null) && state.searchQ.trim().length >= 2
2461
+ ? state.searchQ.trim() : null;
1921
2462
  // The live "live · N" crumb counter reads as the selected session's activity,
1922
2463
  // so reset it per selection rather than letting it accrue across all sessions.
1923
2464
  state.live.eventCount = 0;
1924
- writeHash(sid, { push: true });
2465
+ writeHash({ push: !fromHash });
2466
+ // Warmup copy: a first events fetch can sit behind ccsniff's JSONL walk.
2467
+ const slowTimer = setTimeout(() => { if (!state.eventsLoaded && state.selectedSid === sid) { state.eventsSlow = true; render(); } }, 5000);
1925
2468
  // Close the mobile sidebar drawer on selection. The DS only auto-closes when
1926
2469
  // the clicked element is an <a>; agentgui's session rows are onClick divs, so
1927
2470
  // we close it explicitly here.
@@ -1934,6 +2477,8 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}
1934
2477
  });
1935
2478
  try {
1936
2479
  state.events = await B.getSessionEvents(state.backend, sid);
2480
+ clearTimeout(slowTimer);
2481
+ state.eventsSlow = false;
1937
2482
  state.eventsLoaded = true;
1938
2483
  // If we arrived from a search hit, make sure the matched event is within the
1939
2484
  // render window, then scroll to + flash it so the match isn't lost.
@@ -1961,21 +2506,20 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null } = {}
1961
2506
  ts: Date.now(),
1962
2507
  role: 'error',
1963
2508
  type: 'fetch',
1964
- text: 'Failed to load session: ' + e.message + ' - retry via sidebar',
2509
+ text: 'Failed to load session: ' + errText(e) + ' - retry via sidebar',
1965
2510
  }];
2511
+ clearTimeout(slowTimer);
2512
+ state.eventsSlow = false;
1966
2513
  state.eventsLoaded = true;
1967
2514
  render();
1968
2515
  }
1969
2516
  }
1970
2517
 
1971
- async function init() {
1972
- try {
1973
- const r = await B.probeBackend(state.backend);
1974
- state.health = r.ok ? { status: 'ok', ...r.info } : { status: 'down', ...r };
1975
- } catch (e) {
1976
- state.health = { status: 'error', error: e.message };
1977
- }
1978
- render();
2518
+ // Fetch agents + pick the active one. Reusable: boot, backend save, and the
2519
+ // reconnect path all re-run it. Returns true on success; failure lands in
2520
+ // state.agentsError so the chat tab can surface it with a retry control.
2521
+ async function loadAgents() {
2522
+ state.agentsError = null;
1979
2523
  try {
1980
2524
  state.agents = await B.listAgents(state.backend);
1981
2525
  // Agent selection priority: the agent a restored transcript belongs to (so
@@ -1986,15 +2530,85 @@ async function init() {
1986
2530
  if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
1987
2531
  if (target) await selectAgent(target.id);
1988
2532
  render();
1989
- } catch (e) { console.warn('agents fetch failed:', e.message); }
2533
+ return true;
2534
+ } catch (e) {
2535
+ state.agentsError = errText(e);
2536
+ console.warn('agents fetch failed:', e.message);
2537
+ render();
2538
+ return false;
2539
+ }
2540
+ }
1990
2541
 
1991
- const { sid: initialSid, tab: initialTab } = readHash();
1992
- if (initialSid) {
1993
- navTo('history');
2542
+ // Boot-time automatic retries with backoff when the first agents fetch fails
2543
+ // (the server may still be warming up).
2544
+ async function retryLoadAgents() {
2545
+ for (const ms of [2000, 5000, 10000]) {
2546
+ await new Promise(r => setTimeout(r, ms));
2547
+ if (!state.agentsError) return; // a manual retry already succeeded
2548
+ if (await loadAgents()) return;
2549
+ }
2550
+ }
2551
+
2552
+ // While the backend is unreachable, poll /health every ~10s; on recovery
2553
+ // re-fetch agents + models and announce, so a mid-session outage self-heals.
2554
+ let _healthPollTimer = null;
2555
+ function stopHealthPolling() { if (_healthPollTimer) { clearInterval(_healthPollTimer); _healthPollTimer = null; } }
2556
+ function startHealthPolling() {
2557
+ if (_healthPollTimer) return;
2558
+ _healthPollTimer = setInterval(() => {
2559
+ if (state.health.status === 'ok') { stopHealthPolling(); return; }
2560
+ recheckHealth();
2561
+ }, 10000);
2562
+ }
2563
+ async function recheckHealth() {
2564
+ const wasOk = state.health.status === 'ok';
2565
+ const r = await B.probeBackend(state.backend);
2566
+ if (r.ok) {
2567
+ state.health = { status: 'ok', ...r.info };
2568
+ stopHealthPolling();
2569
+ if (!wasOk) { announce('reconnected'); loadAgents(); }
2570
+ } else {
2571
+ state.health = { status: 'down', ...r };
2572
+ startHealthPolling();
2573
+ }
2574
+ render();
2575
+ }
2576
+
2577
+ async function init() {
2578
+ try {
2579
+ const r = await B.probeBackend(state.backend);
2580
+ state.health = r.ok ? { status: 'ok', ...r.info } : { status: 'down', ...r };
2581
+ } catch (e) {
2582
+ state.health = { status: 'error', error: e.message };
2583
+ }
2584
+ render();
2585
+ if (!(await loadAgents())) retryLoadAgents();
2586
+
2587
+ const hp = readHash();
2588
+ const bootTab = hp.tab || (hp.sid ? 'history' : 'chat');
2589
+ if (hp.sid && bootTab === 'chat') {
2590
+ // tab=chat&sid deep link: resume the conversation in chat.
2591
+ resumeInChat({ sid: hp.sid }, { fromHash: true });
2592
+ refreshHistory();
2593
+ } else if (hp.sid) {
2594
+ if (hp.q) state.searchQ = hp.q;
2595
+ if (hp.project) state.projectFilter = hp.project;
2596
+ navTo('history', { push: false });
1994
2597
  await refreshHistory();
1995
- await loadSession(initialSid);
1996
- } else if (initialTab && initialTab !== state.tab) {
1997
- navTo(initialTab);
2598
+ await loadSession(hp.sid, { fromHash: true });
2599
+ if (state.searchQ.trim().length >= 2) runSearch();
2600
+ } else if (bootTab !== state.tab) {
2601
+ // Files deep-link: restore the directory the URL names (reload keeps
2602
+ // context instead of resetting to the default root). Kick the load BEFORE
2603
+ // navTo - loadDir sets loading=true synchronously, so navTo's default
2604
+ // loadDir('') guard skips and the two never race. Once the listing
2605
+ // resolves, restore the file= preview on top of it.
2606
+ if (bootTab === 'files' && hp.dir) loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
2607
+ if (bootTab === 'history' && hp.q) state.searchQ = hp.q;
2608
+ if (bootTab === 'history' && hp.project) state.projectFilter = hp.project;
2609
+ navTo(bootTab, { push: false });
2610
+ if (bootTab === 'history' && state.searchQ.trim().length >= 2) runSearch();
2611
+ if (bootTab === 'settings' && hp.section) focusSettingsSection(hp.section);
1998
2612
  }
1999
2613
 
2000
2614
  registerWsStatusOnce();
@@ -2015,12 +2629,17 @@ function registerWsStatusOnce() {
2015
2629
  B.onWsStatus?.((s) => {
2016
2630
  if (s === 'closed' || s === 'error') {
2017
2631
  if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
2632
+ // The WS dropping often means the whole server went away - re-probe
2633
+ // /health so the offline banner appears mid-session (and the 10s
2634
+ // recovery poll starts) instead of silently retrying forever.
2635
+ recheckHealth();
2018
2636
  } else if (s === 'open') {
2019
2637
  if (state.health.ws) { delete state.health.ws; render(); }
2020
2638
  }
2021
2639
  });
2022
2640
  }
2023
2641
 
2642
+ hydratePrefs();
2024
2643
  restoreChat();
2025
2644
  render = mount(document.getElementById('app'), view);
2026
2645
  requestAnimationFrame(syncAriaCurrent);
@@ -2029,12 +2648,60 @@ requestAnimationFrame(syncAriaCurrent);
2029
2648
  // (they read window.innerWidth only at render time).
2030
2649
  window.addEventListener('resize', debounce(() => scheduleRender(), 150));
2031
2650
 
2032
- // Browser Back/forward: re-sync tab + selected session from the hash.
2651
+ // Scroll to + focus a settings panel by its id (section= deep link), reusing
2652
+ // the data-prog-focus pattern so the programmatic focus ring is suppressed.
2653
+ function focusSettingsSection(id) {
2654
+ state.settingsSection = id;
2655
+ requestAnimationFrame(() => {
2656
+ const el = document.getElementById(id);
2657
+ if (!el) return;
2658
+ el.scrollIntoView({ block: 'start' });
2659
+ if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1');
2660
+ el.setAttribute('data-prog-focus', '');
2661
+ try { el.focus({ preventScroll: true }); } catch {}
2662
+ const clear = () => { el.removeAttribute('data-prog-focus'); el.removeEventListener('blur', clear); };
2663
+ el.addEventListener('blur', clear);
2664
+ });
2665
+ }
2666
+
2667
+ // Browser Back/forward: diff the FULL hash param set against state and re-sync
2668
+ // each piece. Everything here runs with writeHash:false / fromHash:true so the
2669
+ // handler never clobbers or re-pushes the entry it just popped.
2033
2670
  window.addEventListener('popstate', () => {
2034
- const { sid, tab } = readHash();
2035
- if (sid && sid !== state.selectedSid) { state.tab = 'history'; loadSession(sid); }
2036
- else if (!sid && tab && tab !== state.tab) navTo(tab);
2037
- else if (!sid && !tab && state.tab !== 'chat') navTo('chat');
2671
+ const hp = readHash();
2672
+ const tab = hp.tab || 'chat';
2673
+ if (tab !== state.tab) navTo(tab, { writeHash: false });
2674
+ // Session selection: honor tab= when present (tab=chat&sid resumes in chat,
2675
+ // anything else - or a bare sid - opens it in history).
2676
+ if (hp.sid && hp.sid !== state.selectedSid) {
2677
+ if (tab === 'chat') resumeInChat({ sid: hp.sid }, { fromHash: true });
2678
+ else loadSession(hp.sid, { fromHash: true });
2679
+ } else if (!hp.sid && state.selectedSid && tab === 'history') {
2680
+ state.selectedSid = null;
2681
+ state.events = [];
2682
+ state.eventsLoaded = false;
2683
+ }
2684
+ if (tab === 'files') {
2685
+ const cur = state.files && state.files.path;
2686
+ if (hp.dir && hp.dir !== cur) {
2687
+ // Restore the file= preview only once the directory listing resolves
2688
+ // (the entry object lives in state.files.entries).
2689
+ loadDir(hp.dir, { fromHash: true }).then(() => restoreFileFromHash(hp.file));
2690
+ } else {
2691
+ restoreFileFromHash(hp.file);
2692
+ }
2693
+ }
2694
+ if (tab === 'history') {
2695
+ const q = hp.q || '';
2696
+ if (q !== state.searchQ) {
2697
+ state.searchQ = q;
2698
+ if (q.trim().length >= 2) runSearch();
2699
+ else { state.searchHits = null; state.searchBusy = false; }
2700
+ }
2701
+ state.projectFilter = hp.project || '';
2702
+ }
2703
+ if (tab === 'settings' && hp.section) focusSettingsSection(hp.section);
2704
+ render();
2038
2705
  });
2039
2706
 
2040
2707
  window.__agentgui = { state, render };