agentgui 1.0.962 → 1.0.964

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, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta } = 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, BulkBar, Checkbox } = C;
7
7
 
8
8
  // One duration/bytes vocabulary across every surface: prefer the kit's shared
9
9
  // formatters (exported alongside the components), fall back to the local
@@ -23,6 +23,7 @@ const state = {
23
23
  chatCwd: lsGet('agentgui.cwd') || '',
24
24
  chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 },
25
25
  agentsError: null,
26
+ agentsLoading: false,
26
27
  settingsSection: null,
27
28
  eventFilter: 'all', // history event-type filter: all | text | tool | errors
28
29
  sessionSearchQ: null, // the query the selected session was opened from (search-hit highlight)
@@ -87,6 +88,7 @@ function writeHash({ push = false } = {}) {
87
88
  // passive state sync (search text, filter resets).
88
89
  (push ? history.pushState : history.replaceState).call(history, null, '', url);
89
90
  }
91
+ const plural = (n, w) => n + ' ' + w + (n === 1 ? '' : 's');
90
92
  function fmtRelTime(ts) {
91
93
  if (!ts) return '';
92
94
  const s = Math.round((Date.now() - ts) / 1000);
@@ -237,6 +239,8 @@ function agentAvailable(id) { const a = agentById(id); return !a || a.available
237
239
  // exists to drive are reachable without scanning a flat 17-item list. This
238
240
  // ordering is agentgui's orchestration priority, so it stays in the host and is
239
241
  // passed pre-sorted to the (app-agnostic) AgentChat kit.
242
+ // Protocol ids are plumbing vocabulary; rows speak product words.
243
+ const PROTOCOL_WORDS = { acp: 'managed server', cli: 'local CLI', direct: 'local CLI' };
240
244
  const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
241
245
  function sortedAgents() {
242
246
  const rank = (a) => {
@@ -284,7 +288,7 @@ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
284
288
  // popstate calls navTo with writeHash:false so it never replaceState-clobbers
285
289
  // the entry it popped; user-initiated navigation pushes so Back walks tabs.
286
290
  if (doWriteHash) writeHash({ push });
287
- announce('Now on ' + tab + ' tab');
291
+ announce('now on ' + tab + ' tab');
288
292
  render();
289
293
  // Move focus into the new region for keyboard/AT users.
290
294
  requestAnimationFrame(() => {
@@ -498,6 +502,7 @@ const SHORTCUTS = [
498
502
  { keys: 'Esc', desc: 'close overlays, cancel confirms, stop generation, or blur the field' },
499
503
  { keys: 'Up / Down / Home / End', desc: 'move the focused file row (files grid)' },
500
504
  { keys: 'Enter / Backspace', desc: 'open the focused file / go up a directory (files grid)' },
505
+ { keys: 'Ctrl/Cmd+A', desc: 'select all shown files (files grid); Shift+click selects a range' },
501
506
  { keys: 'Left / Right', desc: 'previous / next file (file preview)' },
502
507
  ];
503
508
 
@@ -535,10 +540,10 @@ function view() {
535
540
  } else if (state.tab === 'chat') {
536
541
  crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
537
542
  } else if (state.tab === 'files') {
538
- // Location reads as hierarchy (parents / dir), not a mid-path ellipsis.
539
- const segs = state.files?.segments || [];
540
- crumbLeaf = segs.length ? truncate(segs[segs.length - 1], 18, 32) : 'files';
541
- crumbTrail = segs.slice(Math.max(0, segs.length - 3), segs.length - 1).map(s => truncate(s, 12, 20));
543
+ // ONE breadcrumb owner: the in-page BreadcrumbPath is the interactive
544
+ // navigator, so the top crumb names only the tab (mirroring live/settings).
545
+ // Duplicating the full path in both bars read as a layout mistake.
546
+ crumbLeaf = 'files';
542
547
  } else if (state.tab === 'live') {
543
548
  crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
544
549
  } else if (state.tab === 'settings') {
@@ -550,8 +555,10 @@ function view() {
550
555
  const agentLabel = state.selectedAgent
551
556
  ? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
552
557
  : 'no agent';
558
+ // The default (same-origin) backend is implementation detail, not status -
559
+ // the footer names a backend only when the user pointed at a custom one.
553
560
  const status = Status({
554
- left: [state.backend || 'same-origin', ok ? 'connected' : 'offline'],
561
+ left: [state.backend || null, ok ? 'connected' : 'offline'].filter(Boolean),
555
562
  right: [agentLabel, 'press ? for shortcuts'],
556
563
  });
557
564
 
@@ -653,6 +660,16 @@ function sessionGroups(sessionsView) {
653
660
  return groups;
654
661
  }
655
662
 
663
+ // Tool errors are routine in agent transcripts (every failed command counts),
664
+ // so "has any error" is not a signal - it painted every real session flame.
665
+ // A session reads as errored only when failures DOMINATE it: several errors
666
+ // AND a meaningful share of all its events.
667
+ function sessionErrorDense(s) {
668
+ const errors = Number(s?.errors) || 0;
669
+ const events = Number(s?.events) || 0;
670
+ return errors >= 3 && errors / Math.max(events, 1) > 0.15;
671
+ }
672
+
656
673
  // The sessions column (conversation list) shared by chat + history. Selecting a
657
674
  // row on the chat tab resumes it in chat; on the history tab it loads its events.
658
675
  function sessionsColumn() {
@@ -693,21 +710,31 @@ function sessionsColumn() {
693
710
  const sessionsView = visibleSessions();
694
711
  const sliced = sessionsView.slice(0, state.sessionsLimit);
695
712
  const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
696
- const items = sliced.map((s) => ({
697
- sid: s.sid,
698
- title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
699
- project: projectLabel(s.project) || '',
700
- time: fmtRelTime(s.last),
701
- agent: undefined,
702
- running: runningSids.has(s.sid),
703
- unread: false,
704
- rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
705
- }));
713
+ const items = sliced.map((s) => {
714
+ const title = projectLabel(s.title) || projectLabel(s.project) || s.sid;
715
+ const project = projectLabel(s.project) || '';
716
+ return {
717
+ sid: s.sid,
718
+ title,
719
+ // title==project would print the same word twice in one row; the sub then
720
+ // carries only the time (and agent when present).
721
+ project: project === title ? '' : project,
722
+ time: fmtRelTime(s.last),
723
+ agent: undefined,
724
+ running: runningSids.has(s.sid),
725
+ unread: false,
726
+ // Tool errors are NORMAL in agent sessions (a failed Bash call counts) -
727
+ // flame-on-any-error painted every real session red. Flame only when
728
+ // failures dominate the session (error-dense), the recency-not-cumulative
729
+ // rule the live tab already follows.
730
+ rail: sessionErrorDense(s) ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
731
+ };
732
+ });
706
733
  return ConversationList({
707
734
  sessions: items,
708
735
  // Per-tab caption: selecting a row does different things on chat vs history,
709
736
  // so disambiguate the visually-identical rows (W17).
710
- caption: state.tab === 'chat' ? 'Resume a conversation in chat' : 'Browse a session\'s events',
737
+ caption: state.tab === 'chat' ? 'Resume a conversation in chat' : 'Browse a conversation\'s events',
711
738
  // Today/Yesterday/This-week + a pinned Running section, the Claude-Desktop
712
739
  // Chats shape. Groups reference sids the kit maps back to the items above.
713
740
  groups: sessionGroups(sliced),
@@ -742,8 +769,16 @@ async function loadDir(dirPath, { fromHash = false } = {}) {
742
769
  const j = await B.listDir(state.backend, dirPath || '');
743
770
  // The filter text and show-more cap are per-directory state: keep them
744
771
  // across an in-place refresh (same path after a mutation), reset them when
745
- // the resolved directory actually changed.
746
- if (j.path !== state.files.path) { state.files.filter = ''; state.files.shown = null; }
772
+ // the resolved directory actually changed. The multi-select set follows the
773
+ // same rule: cleared on a real directory change, pruned to the surviving
774
+ // entries on an in-place refresh (a deleted file must not stay marked).
775
+ if (j.path !== state.files.path) {
776
+ state.files.filter = ''; state.files.shown = null;
777
+ state.files.marked = new Set(); state.files._lastMarkIdx = null;
778
+ } else if (state.files.marked && state.files.marked.size) {
779
+ const alive = new Set((j.entries || []).map((e) => e.path || e.name));
780
+ state.files.marked = new Set([...state.files.marked].filter((p) => alive.has(p)));
781
+ }
747
782
  state.files.path = j.path;
748
783
  state.files.segments = j.segments || [];
749
784
  state.files.entries = j.entries || [];
@@ -828,6 +863,106 @@ function openFileDialog(kind, file) {
828
863
  state.files.dialog = { kind, file: file || null, error: null, busy: false };
829
864
  render();
830
865
  }
866
+ // --- multi-select (marked) helpers. Marked entries are keyed by path so the
867
+ // set survives sort/filter changes; loadDir owns clearing/pruning it.
868
+ function filesMarked() {
869
+ if (!(state.files.marked instanceof Set)) state.files.marked = new Set();
870
+ return state.files.marked;
871
+ }
872
+ function markFile(f, { range = false } = {}) {
873
+ const marked = filesMarked();
874
+ const key = f.path || f.name;
875
+ const list = state.files._sorted || [];
876
+ const idx = list.findIndex((e) => (e.path || e.name) === key);
877
+ if (range && state.files._lastMarkIdx != null && idx >= 0) {
878
+ // Shift-click: mark the whole span between the anchor and this row (always
879
+ // mark, never unmark - the common bulk gesture; EACCES rows are skipped).
880
+ const [a, b] = [Math.min(state.files._lastMarkIdx, idx), Math.max(state.files._lastMarkIdx, idx)];
881
+ for (const e of list.slice(a, b + 1)) {
882
+ if (e.permissions === 'EACCES') continue;
883
+ marked.add(e.path || e.name);
884
+ }
885
+ } else if (marked.has(key)) marked.delete(key);
886
+ else marked.add(key);
887
+ state.files._lastMarkIdx = idx >= 0 ? idx : null;
888
+ render();
889
+ }
890
+ function clearFileSelection({ quiet = false } = {}) {
891
+ const marked = filesMarked();
892
+ if (!marked.size) return false;
893
+ marked.clear();
894
+ state.files._lastMarkIdx = null;
895
+ if (!quiet) announce('selection cleared');
896
+ render();
897
+ return true;
898
+ }
899
+ // Bulk delete every marked entry via the same confined endpoint the per-row
900
+ // delete uses. Partial failure keeps the FAILED paths marked (so a retry
901
+ // targets exactly what's left) and surfaces a count in the dialog.
902
+ async function runBulkDelete() {
903
+ const d = state.files.dialog;
904
+ if (!d || d.kind !== 'bulk-delete' || d.busy) return;
905
+ const marked = filesMarked();
906
+ const targets = (state.files.entries || []).filter((e) => marked.has(e.path || e.name));
907
+ if (!targets.length) { state.files.dialog = null; render(); return; }
908
+ d.busy = true; d.error = null; render();
909
+ const results = await Promise.allSettled(
910
+ targets.map((e) => B.deleteEntry(state.backend, e.path, e.type === 'dir')));
911
+ const failed = targets.filter((_, i) => results[i].status === 'rejected');
912
+ const okCount = targets.length - failed.length;
913
+ if (!failed.length) {
914
+ state.files.dialog = null;
915
+ marked.clear(); state.files._lastMarkIdx = null;
916
+ announce('deleted ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries'));
917
+ } else {
918
+ // Keep only the failures marked; report the split inside the dialog.
919
+ state.files.marked = new Set(failed.map((e) => e.path || e.name));
920
+ const firstErr = results.find((r) => r.status === 'rejected');
921
+ d.busy = false;
922
+ d.error = 'deleted ' + okCount + ' of ' + targets.length + ' - '
923
+ + (failed.length === 1 ? '1 entry' : failed.length + ' entries') + ' failed ('
924
+ + fileMutationCopy(firstErr.reason || {}) + '). The failed entries stay selected.';
925
+ }
926
+ await loadDir(state.files.path, { fromHash: true });
927
+ render();
928
+ }
929
+ // Bulk move every marked entry into a destination directory (confined
930
+ // /api/move). Same partial-failure contract as bulk delete: failed paths
931
+ // stay marked, the split reports inside the dialog.
932
+ async function runBulkMove(destDir) {
933
+ const d = state.files.dialog;
934
+ if (!d || d.kind !== 'bulk-move' || d.busy) return;
935
+ const marked = filesMarked();
936
+ const targets = (state.files.entries || []).filter((e) => marked.has(e.path || e.name));
937
+ if (!targets.length) { state.files.dialog = null; render(); return; }
938
+ d.busy = true; d.error = null; render();
939
+ // Validate the destination once up front so 20 identical 403s read as one
940
+ // clear message instead of a partial-failure split.
941
+ try { await B.statPath(state.backend, destDir); }
942
+ catch (e) {
943
+ d.busy = false;
944
+ d.error = e.status === 404 ? 'that folder does not exist' : fileMutationCopy(e);
945
+ render(); return;
946
+ }
947
+ const results = await Promise.allSettled(
948
+ targets.map((e) => B.moveEntry(state.backend, e.path, destDir)));
949
+ const failed = targets.filter((_, i) => results[i].status === 'rejected');
950
+ const okCount = targets.length - failed.length;
951
+ if (!failed.length) {
952
+ state.files.dialog = null;
953
+ marked.clear(); state.files._lastMarkIdx = null;
954
+ announce('moved ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries'));
955
+ } else {
956
+ state.files.marked = new Set(failed.map((e) => e.path || e.name));
957
+ const firstErr = results.find((r) => r.status === 'rejected');
958
+ d.busy = false;
959
+ d.error = 'moved ' + okCount + ' of ' + targets.length + ' - '
960
+ + (failed.length === 1 ? '1 entry' : failed.length + ' entries') + ' failed ('
961
+ + fileMutationCopy(firstErr.reason || {}) + '). The failed entries stay selected.';
962
+ }
963
+ await loadDir(state.files.path, { fromHash: true });
964
+ render();
965
+ }
831
966
  function closeFileDialog() {
832
967
  // A mid-flight close would orphan the mutation's result and swallow its
833
968
  // error - hold the dialog open until the operation settles.
@@ -939,6 +1074,35 @@ function fileDialog() {
939
1074
  onConfirm: () => runFileMutation(() => B.deleteEntry(state.backend, d.file.path, isDir), 'deleted ' + d.file.name),
940
1075
  });
941
1076
  }
1077
+ if (d.kind === 'bulk-delete') {
1078
+ const n = filesMarked().size;
1079
+ const hasDir = (state.files.entries || []).some((e) => filesMarked().has(e.path || e.name) && e.type === 'dir');
1080
+ return ConfirmDialog({
1081
+ title: 'Delete ' + n + ' selected ' + (n === 1 ? 'entry' : 'entries'),
1082
+ message: (hasDir
1083
+ ? 'Folders are deleted with everything inside them. '
1084
+ : '') + 'This cannot be undone.',
1085
+ error: d.error || null, busy: !!d.busy,
1086
+ confirmLabel: d.busy ? 'deleting...' : 'delete ' + n, cancelLabel: 'cancel', destructive: true,
1087
+ onCancel: closeFileDialog,
1088
+ onConfirm: runBulkDelete,
1089
+ });
1090
+ }
1091
+ if (d.kind === 'bulk-move') {
1092
+ const n = filesMarked().size;
1093
+ return PromptDialog({
1094
+ title: 'Move ' + n + ' selected ' + (n === 1 ? 'entry' : 'entries'),
1095
+ value: state.files.path || '', placeholder: 'destination folder path',
1096
+ error: d.error || null, busy: !!d.busy,
1097
+ confirmLabel: d.busy ? 'moving...' : 'move ' + n, cancelLabel: 'cancel',
1098
+ onCancel: closeFileDialog,
1099
+ onConfirm: (v) => {
1100
+ if (!v) { d.error = 'enter a destination folder'; render(); return; }
1101
+ if (v === state.files.path) { d.error = 'already in that folder - enter a different destination'; render(); return; }
1102
+ runBulkMove(v);
1103
+ },
1104
+ });
1105
+ }
942
1106
  if (d.kind === 'mkdir') {
943
1107
  return PromptDialog({
944
1108
  title: 'New folder', value: '', placeholder: 'folder name',
@@ -1056,7 +1220,9 @@ function filesMain() {
1056
1220
  // and an epoch (modifiedTs) so the kit's modified-sort orders by real time.
1057
1221
  const mapped = (f.entries || []).map((e) => {
1058
1222
  const ts = e.modified ? Date.parse(e.modified) : 0;
1059
- return { name: e.name, type: e.type, size: e.size, modified: ts ? fmtRelTime(ts) : null, modifiedTs: ts, path: e.path };
1223
+ // permissions rides along so the kit's EACCES/read-only tag, disabled-open
1224
+ // and unmarkable-checkbox logic actually fire (it was silently dropped here).
1225
+ return { name: e.name, type: e.type, size: e.size, modified: ts ? fmtRelTime(ts) : null, modifiedTs: ts, path: e.path, permissions: e.permissions };
1060
1226
  });
1061
1227
  const filtered = f.filter
1062
1228
  ? mapped.filter(e => e.name.toLowerCase().includes(f.filter.toLowerCase()))
@@ -1075,6 +1241,20 @@ function filesMain() {
1075
1241
  shown: f.shown,
1076
1242
  onShowMore: (n) => { state.files.shown = n; render(); },
1077
1243
  emptyText: f.filter ? 'No files match "' + f.filter + '"' : 'Empty directory',
1244
+ // Multi-select: marked is path-keyed; shift-click ranges over the same
1245
+ // sorted order the grid shows; select-all covers the SHOWN window only.
1246
+ selectable: !!f.path,
1247
+ marked: filesMarked(),
1248
+ onMark: markFile,
1249
+ onSelectAll: (keys) => { state.files.marked = new Set(keys); state.files._lastMarkIdx = null; announce(keys.length + ' selected'); render(); },
1250
+ onClearSelection: () => clearFileSelection(),
1251
+ // Density: list / compact / thumbnails. Thumbnails stream through the
1252
+ // confined /api/download (same fsAllowRoots as the listing - /api/image
1253
+ // has its OWN narrower allowlist and 403s repo files; <img> ignores the
1254
+ // attachment disposition).
1255
+ density: f.density || 'list',
1256
+ onDensity: (d2) => { state.files.density = d2; persistFilesPrefs(); render(); },
1257
+ thumbUrl: (file) => B.downloadUrl(state.backend, file.path),
1078
1258
  sort: { key: f.sort || 'name', dir: f.sortDir || 'asc', onSort: (k) => {
1079
1259
  // Click the active column to flip direction; a new column resets to asc.
1080
1260
  if (state.files.sort === k) state.files.sortDir = state.files.sortDir === 'asc' ? 'desc' : 'asc';
@@ -1147,21 +1327,38 @@ function filesMain() {
1147
1327
  : body;
1148
1328
  return [
1149
1329
  offlineBanner(),
1150
- 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.' }),
1151
- rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
1152
- h('div', { key: 'ftb' }, toolbar),
1153
- (f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
1154
- // Recovery affordances per row: 'replace' on a name collision (409),
1155
- // dismiss on any error row (errors otherwise persist until the next batch).
1156
- items: f.uploads.map((it) => (it.error && it.status === 409 && !it._retrying)
1157
- ? { ...it, actions: [{ label: 'replace', onClick: () => retryUploadOverwrite(it) }] }
1158
- : it),
1159
- onDismiss: (item, i) => {
1160
- const src = (state.files.uploads || [])[i];
1161
- if (src && src.error) dismissUpload(src);
1162
- },
1163
- })) : null,
1164
- h('div', { key: 'fbody' }, droppableBody),
1330
+ PageHeader({ compact: true, dense: true, title: 'Files', lede: 'Browse and manage files in the allowed folders.' }),
1331
+ // One vertical beat (.ds-files-stack gap) for the whole command stack -
1332
+ // the bands used to butt edge-to-edge while the header gap was 24px.
1333
+ // The .filter(Boolean) is load-bearing (webjsx keyed-children rule).
1334
+ h('div', { key: 'fstack', class: 'ds-files-stack' }, ...[
1335
+ rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
1336
+ h('div', { key: 'ftb' }, toolbar),
1337
+ // Bulk action strip - appears while a multi-select is active; delete runs
1338
+ // through the same armed ConfirmDialog vocabulary as the per-row delete.
1339
+ filesMarked().size ? h('div', { key: 'fbulk' }, BulkBar({
1340
+ count: filesMarked().size,
1341
+ noun: 'entry',
1342
+ busy: !!(f.dialog && (f.dialog.kind === 'bulk-delete' || f.dialog.kind === 'bulk-move') && f.dialog.busy),
1343
+ actions: [
1344
+ { label: 'move selected', onClick: () => openFileDialog('bulk-move') },
1345
+ { label: 'delete selected', danger: true, onClick: () => openFileDialog('bulk-delete') },
1346
+ ],
1347
+ onClear: () => clearFileSelection(),
1348
+ })) : null,
1349
+ (f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
1350
+ // Recovery affordances per row: 'replace' on a name collision (409),
1351
+ // dismiss on any error row (errors otherwise persist until the next batch).
1352
+ items: f.uploads.map((it) => (it.error && it.status === 409 && !it._retrying)
1353
+ ? { ...it, actions: [{ label: 'replace', onClick: () => retryUploadOverwrite(it) }] }
1354
+ : it),
1355
+ onDismiss: (item, i) => {
1356
+ const src = (state.files.uploads || [])[i];
1357
+ if (src && src.error) dismissUpload(src);
1358
+ },
1359
+ })) : null,
1360
+ h('div', { key: 'fbody' }, droppableBody),
1361
+ ].filter(Boolean)),
1165
1362
  fileDialog(),
1166
1363
  // Inline pane handles wide-screen preview; the modal is only the <900px
1167
1364
  // fallback (the pane has no room there).
@@ -1178,7 +1375,7 @@ function persistLivePrefs() {
1178
1375
  lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly }));
1179
1376
  }
1180
1377
  function persistFilesPrefs() {
1181
- lsSet(FILES_PREFS_KEY, JSON.stringify({ sort: state.files.sort || 'name', sortDir: state.files.sortDir || 'asc' }));
1378
+ lsSet(FILES_PREFS_KEY, JSON.stringify({ sort: state.files.sort || 'name', sortDir: state.files.sortDir || 'asc', density: state.files.density || 'list' }));
1182
1379
  }
1183
1380
  function hydratePrefs() {
1184
1381
  try {
@@ -1187,7 +1384,7 @@ function hydratePrefs() {
1187
1384
  } catch {}
1188
1385
  try {
1189
1386
  const fp = JSON.parse(lsGet(FILES_PREFS_KEY) || 'null');
1190
- if (fp) { if (fp.sort) state.files.sort = fp.sort; if (fp.sortDir) state.files.sortDir = fp.sortDir; }
1387
+ if (fp) { if (fp.sort) state.files.sort = fp.sort; if (fp.sortDir) state.files.sortDir = fp.sortDir; if (fp.density) state.files.density = fp.density; }
1191
1388
  } catch {}
1192
1389
  }
1193
1390
 
@@ -1223,7 +1420,9 @@ async function stopAllActive(sessions) {
1223
1420
  state.live.bulkStopError = failed
1224
1421
  ? failed + ' session' + (failed === 1 ? '' : 's') + ' did not stop - try again'
1225
1422
  : null;
1226
- announce('stopped ' + okSids.length + ' of ' + sids.length + ' sessions');
1423
+ announce(failed
1424
+ ? 'stopped ' + okSids.length + ' of ' + sids.length + ' sessions'
1425
+ : 'stopped ' + plural(okSids.length, 'session'));
1227
1426
  await refreshActive();
1228
1427
  render();
1229
1428
  return okSids;
@@ -1387,7 +1586,7 @@ function liveMain() {
1387
1586
  h('span', { key: 'bsetxt' }, lv.bulkStopError + ' '),
1388
1587
  Btn({ key: 'bsedismiss', onClick: () => { state.live.bulkStopError = null; render(); }, children: 'dismiss' })] })
1389
1588
  : null,
1390
- PageHeader({ compact: true, title: 'Live sessions', lede: 'Every in-flight agent session, managed at once. Stop, open, or jump to events per session.' }),
1589
+ PageHeader({ compact: true, dense: true, title: 'Live sessions', lede: 'Watch, open, and stop running agent sessions.' }),
1391
1590
  SessionDashboard({
1392
1591
  sessions,
1393
1592
  offline,
@@ -1427,7 +1626,8 @@ function liveMain() {
1427
1626
  state.live.selected = sel;
1428
1627
  render();
1429
1628
  },
1430
- emptyText: 'No live sessions — start a chat or run a local agent.',
1629
+ emptyText: 'No live sessions — agents you start (or run locally) appear here.',
1630
+ emptyAction: { label: 'start a chat', onClick: () => { navTo('chat'); } },
1431
1631
  onStop: (s) => { if (!s.external) stopActiveChat(s.sid); },
1432
1632
  onStopAll: async (all) => {
1433
1633
  state.live.confirmingStopAll = false;
@@ -1570,8 +1770,8 @@ function chatMain() {
1570
1770
  banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
1571
1771
  children: [
1572
1772
  h('span', { key: 'cntxt' }, 'This cannot be undone. '),
1573
- Btn({ key: 'cnyes', danger: true, onClick: confirmNewChat, children: 'yes, clear chat' }),
1574
- Btn({ key: 'cnno', onClick: () => { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
1773
+ Btn({ key: 'cnno', onClick: () => { clearTimeout(_newChatArmTimer); state.confirmingNewChat = false; render(); }, children: 'cancel' }),
1774
+ Btn({ key: 'cnyes', danger: true, onClick: confirmNewChat, children: 'yes, clear chat' })] }));
1575
1775
  }
1576
1776
  if (state.cwdError) {
1577
1777
  banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
@@ -1604,8 +1804,8 @@ function chatMain() {
1604
1804
  banners.push(Alert({ key: 'confedit', kind: 'warn', title: 'Edit this message?',
1605
1805
  children: [
1606
1806
  h('span', { key: 'cetext' }, 'Editing will remove the later turns - continue? '),
1607
- Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: 'continue' }),
1608
- Btn({ key: 'ceno', onClick: cancelEditAndResend, children: 'cancel' })] }));
1807
+ Btn({ key: 'ceno', onClick: cancelEditAndResend, children: 'cancel' }),
1808
+ Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: 'continue' })] }));
1609
1809
  }
1610
1810
  const lastMsg = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1] : null;
1611
1811
  const lastErr = lastMsg ? lastMsg.error : null;
@@ -1670,9 +1870,9 @@ function chatMain() {
1670
1870
  title: 'change working directory',
1671
1871
  onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
1672
1872
  },
1673
- userTurnCount > 0 ? userTurnCount + ' turns' : null,
1873
+ userTurnCount > 0 ? plural(userTurnCount, 'turn') : null,
1674
1874
  (state.chat.resumeSid && state.selectedAgent === 'claude-code')
1675
- ? 'continues session ' + state.chat.resumeSid.slice(0, 8)
1875
+ ? 'continues session ' + state.chat.resumeSid.slice(0, 8) + '…'
1676
1876
  : null,
1677
1877
  ].filter(Boolean),
1678
1878
  } : undefined,
@@ -2215,26 +2415,28 @@ function historyMain() {
2215
2415
  reconnectAlert(),
2216
2416
  PageHeader({
2217
2417
  compact: true,
2418
+ dense: true,
2218
2419
  title: 'History',
2219
- lede: 'Pick a session from the sidebar - events stream live from ccsniff /v1/history.',
2420
+ lede: 'Pick a conversation to inspect its events as they happen.',
2220
2421
  }),
2221
2422
  h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
2222
2423
  h('p', { key: 'gt', class: 'history-empty-title' },
2223
- count ? 'Select a session to view its events' : 'No sessions yet'),
2424
+ count ? 'Select a conversation to view its events' : 'No conversations yet'),
2224
2425
  h('p', { key: 'gs', class: 'lede history-empty-sub' },
2225
2426
  count
2226
- ? count + ' session' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
2227
- : 'Start a chat or run a local coding agent - its session will appear here live.')),
2427
+ ? count + ' conversation' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
2428
+ : 'Start a chat or run a local coding agent - its conversation will appear here live.')),
2228
2429
  ].filter(Boolean);
2229
2430
  }
2230
2431
 
2231
2432
  const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
2232
2433
  const lede = sess
2233
- ? (projectLabel(sess.project) || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
2434
+ ? (projectLabel(sess.project) || sess.cwd || '?') + ' · ' + plural(sess.events || 0, 'event') + ' · ' + plural(sess.userTurns || 0, 'turn') + ' · ' + fmtRelTime(sess.last)
2234
2435
  : state.selectedSid;
2235
2436
 
2236
2437
  const head = PageHeader({
2237
2438
  compact: true,
2439
+ dense: true,
2238
2440
  title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
2239
2441
  lede,
2240
2442
  });
@@ -2242,7 +2444,7 @@ function historyMain() {
2242
2444
  const hasErrors = state.events.some(e => e.isError);
2243
2445
  const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' }, [
2244
2446
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
2245
- Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
2447
+ Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy session id' }),
2246
2448
  Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
2247
2449
  onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
2248
2450
  children: 'export' }),
@@ -2255,10 +2457,12 @@ function historyMain() {
2255
2457
  // indexing copy (ccsniff's first JSONL walk can take a minute).
2256
2458
  const body = state.eventsLoaded
2257
2459
  ? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
2258
- h('span', { key: 'noevtxt' }, 'no events in this session - '),
2460
+ h('span', { key: 'noevtxt' }, 'no events in this session'),
2259
2461
  Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
2260
- : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }),
2261
- state.eventsSlow ? 'Indexing your Claude history — the first load can take a minute…' : 'loading events…');
2462
+ // Shape-matched skeleton rows (kit EventList loading state) instead of a
2463
+ // lone spinner collapsing the slowest pane in the product.
2464
+ : h('div', { key: 'loading' }, EventList({ items: [], loading: true,
2465
+ loadingText: state.eventsSlow ? 'Indexing your Claude history — the first load can take a minute…' : 'loading events…' }));
2262
2466
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
2263
2467
  }
2264
2468
 
@@ -2282,7 +2486,7 @@ function historyMain() {
2282
2486
  items: [
2283
2487
  sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
2284
2488
  sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
2285
- { label: 'sid', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'sid copied') },
2489
+ { label: 'session id', value: state.selectedSid.slice(0, 8) + '…', title: state.selectedSid, onCopy: () => copyText(state.selectedSid, 'session id copied') },
2286
2490
  // Spelled counter vocabulary in the detail strip (events/turns/tools/
2287
2491
  // errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
2288
2492
  { label: 'events', value: String(state.events.length) },
@@ -2296,7 +2500,7 @@ function historyMain() {
2296
2500
  h('div', { key: 'evmeta' }, meta),
2297
2501
  h('div', { key: 'evfp' }, filterPills),
2298
2502
  h('div', { key: 'nofilt', class: 'lede empty-state', role: 'status' },
2299
- h('span', { key: 'noftxt' }, '0 events match this filter - '),
2503
+ h('span', { key: 'noftxt' }, 'no events match this filter'),
2300
2504
  Btn({ key: 'clearf', onClick: () => { state.eventFilter = 'all'; render(); }, children: 'clear filter' })),
2301
2505
  ] })].filter(Boolean);
2302
2506
  }
@@ -2325,7 +2529,7 @@ function historyMain() {
2325
2529
  head,
2326
2530
  actions,
2327
2531
  Panel({
2328
- title: total + ' events' + (ef !== 'all' ? ' (' + ef + ' filter)' : '') + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
2532
+ title: plural(total, 'event') + (ef !== 'all' ? ' (' + ef + ' filter)' : '') + (hiddenCount > 0 ? ' (showing last ' + shown.length + '; ' + hiddenCount + ' older)' : ''),
2329
2533
  children: [h('div', { key: 'evmeta' }, meta), h('div', { key: 'evfp' }, filterPills), eventControls, EventList({
2330
2534
  items: shown.map((e, i) => {
2331
2535
  // Stable key: prefer the server-assigned event index, else the
@@ -2525,7 +2729,7 @@ function historySide() {
2525
2729
  title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
2526
2730
  // Always show the error count so 0 reads as "no errors", not "untracked".
2527
2731
  sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools · ' + (s.errors || 0) + ' err',
2528
- rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
2732
+ rail: sessionErrorDense(s) ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
2529
2733
  active: s.sid === state.selectedSid,
2530
2734
  onClick: () => loadSession(s.sid),
2531
2735
  })
@@ -2572,9 +2776,12 @@ function historySide() {
2572
2776
  pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; writeHash(); render(); })))
2573
2777
  : null,
2574
2778
  !searching && subagentCount
2575
- ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
2576
- h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
2577
- 'show subagents (' + (state.showSubagents ? subagentCount + ' shown' : subagentCount + ' hidden') + ')')
2779
+ ? h('div', { key: 'subtog', class: 'lede subagent-toggle' },
2780
+ Checkbox({
2781
+ checked: state.showSubagents,
2782
+ label: 'show subagents (' + (state.showSubagents ? subagentCount + ' shown' : subagentCount + ' hidden') + ')',
2783
+ onChange: (v) => { state.showSubagents = v; render(); },
2784
+ }))
2578
2785
  : null,
2579
2786
  state.historyError
2580
2787
  ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
@@ -2662,6 +2869,7 @@ function settingsMain() {
2662
2869
  return [
2663
2870
  PageHeader({
2664
2871
  compact: true,
2872
+ dense: true,
2665
2873
  title: 'Settings',
2666
2874
  // The page lede describes the PAGE; the backend explanation lives in the
2667
2875
  // backend panel where it applies.
@@ -2675,7 +2883,9 @@ function settingsMain() {
2675
2883
  key: 'backendForm',
2676
2884
  onSubmit: (e) => { e.preventDefault(); saveBackend(); },
2677
2885
  }, [
2678
- 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.'),
2886
+ h('p', { key: 'blede', class: 'lede', style: 'margin:0 0 .5em',
2887
+ title: 'Also settable with a ?backend= URL parameter; the value persists in this browser.' },
2888
+ 'Connect to a different agentgui server. Leave blank to use this one.'),
2679
2889
  TextField({
2680
2890
  key: 'backendField',
2681
2891
  label: 'backend url',
@@ -2740,8 +2950,8 @@ function serverPanel() {
2740
2950
  children: [
2741
2951
  h('div', { key: 'sv', class: 'lede' }, 'version: ' + (hh.version ? 'v' + hh.version : 'unknown')),
2742
2952
  h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ? humanizeMs(upMs) : 'unknown')),
2743
- h('div', { key: 'swc', class: 'lede' }, 'ws clients: ' + (wsClients != null ? wsClients : 'unknown')),
2744
- h('div', { key: 'spd', class: 'lede' }, 'projects dir: ' + (hh.projectsDir || 'unknown')),
2953
+ h('div', { key: 'swc', class: 'lede' }, 'connected clients: ' + (wsClients != null ? wsClients : 'unknown')),
2954
+ h('div', { key: 'spd', class: 'lede' }, 'projects folder: ' + (hh.projectsDir || 'unknown')),
2745
2955
  roots.length
2746
2956
  ? SessionMeta({ key: 'sroots', items: roots.map((r, i) => ({ label: 'root ' + (i + 1), value: r, title: r, onCopy: () => copyText(r, 'root copied') })) })
2747
2957
  : h('div', { key: 'snoroots', class: 'lede' }, 'allowed roots: unknown'),
@@ -2806,8 +3016,8 @@ function preferencesPanel() {
2806
3016
  ? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
2807
3017
  children: [
2808
3018
  h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, and backend from this browser. This cannot be undone. '),
2809
- Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
2810
- Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
3019
+ Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' }),
3020
+ Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' })] })
2811
3021
  : Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
2812
3022
  ].filter(Boolean),
2813
3023
  });
@@ -2835,11 +3045,10 @@ function agentsPanel() {
2835
3045
  const acp = acpStatusFor(a.id);
2836
3046
  const avail = a.available !== false;
2837
3047
  const usable = avail || a.npxInstallable; // selectable from this row
2838
- const bits = [a.protocol || 'agent'];
3048
+ const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
2839
3049
  if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
2840
3050
  if (acp) bits.push(acp.healthy ? 'running healthy' : (acp.running ? 'running' : 'stopped'));
2841
- if (acp && acp.port) bits.push('port ' + acp.port);
2842
- if (acp && acp.restartCount) bits.push(acp.restartCount + ' restarts');
3051
+ if (acp && acp.restartCount > 1) bits.push('restarted ' + acp.restartCount + ' times');
2843
3052
  return Row({
2844
3053
  key: 'ag' + a.id,
2845
3054
  rank: String(i + 1).padStart(3, '0'),
@@ -2856,7 +3065,16 @@ function agentsPanel() {
2856
3065
  onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
2857
3066
  });
2858
3067
  })
2859
- : [h('p', { key: 'none', class: 'lede' }, 'no agents loaded')]),
3068
+ // The empty array means one of three things; never let an in-flight load
3069
+ // read as a broken registry.
3070
+ : [state.agentsLoading
3071
+ ? h('div', { key: 'agload', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' },
3072
+ Spinner({ key: 'spin', size: 'sm' }), 'loading agents…')
3073
+ : (state.agentsError
3074
+ ? h('div', { key: 'agfail', class: 'lede empty-state' },
3075
+ h('span', { key: 'agfailtxt' }, 'the agent list failed to load'),
3076
+ Btn({ key: 'agretry2', onClick: () => loadAgents(), children: 'retry' }))
3077
+ : h('p', { key: 'none', class: 'lede' }, 'no agents loaded'))]),
2860
3078
  ].filter(Boolean),
2861
3079
  });
2862
3080
  }
@@ -3019,8 +3237,10 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
3019
3237
  // state.agentsError so the chat tab can surface it with a retry control.
3020
3238
  async function loadAgents() {
3021
3239
  state.agentsError = null;
3240
+ state.agentsLoading = true;
3022
3241
  try {
3023
3242
  state.agents = await B.listAgents(state.backend);
3243
+ state.agentsLoading = false;
3024
3244
  // Agent selection priority: the agent a restored transcript belongs to (so
3025
3245
  // the chat isn't shown under the wrong agent), else the saved picker agent,
3026
3246
  // else first available, else first.
@@ -3031,6 +3251,7 @@ async function loadAgents() {
3031
3251
  render();
3032
3252
  return true;
3033
3253
  } catch (e) {
3254
+ state.agentsLoading = false;
3034
3255
  state.agentsError = errText(e);
3035
3256
  console.warn('agents fetch failed:', e.message);
3036
3257
  render();
@@ -3256,6 +3477,9 @@ window.addEventListener('keydown', (e) => {
3256
3477
  clearTimeout(_stopAllArmTimer); clearTimeout(_stopSelArmTimer);
3257
3478
  render(); announce('stop cancelled'); return;
3258
3479
  }
3480
+ // A live file multi-select is transient state too: Escape drops it before
3481
+ // falling through to stop-generation.
3482
+ if (state.tab === 'files' && filesMarked().size) { clearFileSelection(); return; }
3259
3483
  if (state.chat.busy && state.tab === 'chat') { cancelChat(); announce('generation stopped'); return; }
3260
3484
  return;
3261
3485
  }