agentgui 1.0.963 → 1.0.965

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, BulkBar } = 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(() => {
@@ -536,10 +540,10 @@ function view() {
536
540
  } else if (state.tab === 'chat') {
537
541
  crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
538
542
  } else if (state.tab === 'files') {
539
- // Location reads as hierarchy (parents / dir), not a mid-path ellipsis.
540
- const segs = state.files?.segments || [];
541
- crumbLeaf = segs.length ? truncate(segs[segs.length - 1], 18, 32) : 'files';
542
- 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';
543
547
  } else if (state.tab === 'live') {
544
548
  crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
545
549
  } else if (state.tab === 'settings') {
@@ -551,8 +555,10 @@ function view() {
551
555
  const agentLabel = state.selectedAgent
552
556
  ? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
553
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.
554
560
  const status = Status({
555
- left: [state.backend || 'same-origin', ok ? 'connected' : 'offline'],
561
+ left: [state.backend || null, ok ? 'connected' : 'offline'].filter(Boolean),
556
562
  right: [agentLabel, 'press ? for shortcuts'],
557
563
  });
558
564
 
@@ -654,6 +660,16 @@ function sessionGroups(sessionsView) {
654
660
  return groups;
655
661
  }
656
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
+
657
673
  // The sessions column (conversation list) shared by chat + history. Selecting a
658
674
  // row on the chat tab resumes it in chat; on the history tab it loads its events.
659
675
  function sessionsColumn() {
@@ -694,21 +710,31 @@ function sessionsColumn() {
694
710
  const sessionsView = visibleSessions();
695
711
  const sliced = sessionsView.slice(0, state.sessionsLimit);
696
712
  const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.claudeSessionId || a.sessionId));
697
- const items = sliced.map((s) => ({
698
- sid: s.sid,
699
- title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
700
- project: projectLabel(s.project) || '',
701
- time: fmtRelTime(s.last),
702
- agent: undefined,
703
- running: runningSids.has(s.sid),
704
- unread: false,
705
- rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
706
- }));
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
+ });
707
733
  return ConversationList({
708
734
  sessions: items,
709
735
  // Per-tab caption: selecting a row does different things on chat vs history,
710
736
  // so disambiguate the visually-identical rows (W17).
711
- 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',
712
738
  // Today/Yesterday/This-week + a pinned Running section, the Claude-Desktop
713
739
  // Chats shape. Groups reference sids the kit maps back to the items above.
714
740
  groups: sessionGroups(sliced),
@@ -900,6 +926,43 @@ async function runBulkDelete() {
900
926
  await loadDir(state.files.path, { fromHash: true });
901
927
  render();
902
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
+ }
903
966
  function closeFileDialog() {
904
967
  // A mid-flight close would orphan the mutation's result and swallow its
905
968
  // error - hold the dialog open until the operation settles.
@@ -1025,6 +1088,21 @@ function fileDialog() {
1025
1088
  onConfirm: runBulkDelete,
1026
1089
  });
1027
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
+ }
1028
1106
  if (d.kind === 'mkdir') {
1029
1107
  return PromptDialog({
1030
1108
  title: 'New folder', value: '', placeholder: 'folder name',
@@ -1249,30 +1327,38 @@ function filesMain() {
1249
1327
  : body;
1250
1328
  return [
1251
1329
  offlineBanner(),
1252
- 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.' }),
1253
- rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
1254
- h('div', { key: 'ftb' }, toolbar),
1255
- // Bulk action strip - appears while a multi-select is active; delete runs
1256
- // through the same armed ConfirmDialog vocabulary as the per-row delete.
1257
- filesMarked().size ? h('div', { key: 'fbulk' }, BulkBar({
1258
- count: filesMarked().size,
1259
- noun: 'entry',
1260
- busy: !!(f.dialog && f.dialog.kind === 'bulk-delete' && f.dialog.busy),
1261
- actions: [{ label: 'delete selected', danger: true, onClick: () => openFileDialog('bulk-delete') }],
1262
- onClear: () => clearFileSelection(),
1263
- })) : null,
1264
- (f.uploads && f.uploads.length) ? h('div', { key: 'fup' }, UploadProgress({
1265
- // Recovery affordances per row: 'replace' on a name collision (409),
1266
- // dismiss on any error row (errors otherwise persist until the next batch).
1267
- items: f.uploads.map((it) => (it.error && it.status === 409 && !it._retrying)
1268
- ? { ...it, actions: [{ label: 'replace', onClick: () => retryUploadOverwrite(it) }] }
1269
- : it),
1270
- onDismiss: (item, i) => {
1271
- const src = (state.files.uploads || [])[i];
1272
- if (src && src.error) dismissUpload(src);
1273
- },
1274
- })) : null,
1275
- 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)),
1276
1362
  fileDialog(),
1277
1363
  // Inline pane handles wide-screen preview; the modal is only the <900px
1278
1364
  // fallback (the pane has no room there).
@@ -1334,7 +1420,9 @@ async function stopAllActive(sessions) {
1334
1420
  state.live.bulkStopError = failed
1335
1421
  ? failed + ' session' + (failed === 1 ? '' : 's') + ' did not stop - try again'
1336
1422
  : null;
1337
- 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'));
1338
1426
  await refreshActive();
1339
1427
  render();
1340
1428
  return okSids;
@@ -1498,7 +1586,7 @@ function liveMain() {
1498
1586
  h('span', { key: 'bsetxt' }, lv.bulkStopError + ' '),
1499
1587
  Btn({ key: 'bsedismiss', onClick: () => { state.live.bulkStopError = null; render(); }, children: 'dismiss' })] })
1500
1588
  : null,
1501
- 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.' }),
1502
1590
  SessionDashboard({
1503
1591
  sessions,
1504
1592
  offline,
@@ -1538,7 +1626,8 @@ function liveMain() {
1538
1626
  state.live.selected = sel;
1539
1627
  render();
1540
1628
  },
1541
- 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'); } },
1542
1631
  onStop: (s) => { if (!s.external) stopActiveChat(s.sid); },
1543
1632
  onStopAll: async (all) => {
1544
1633
  state.live.confirmingStopAll = false;
@@ -1681,8 +1770,8 @@ function chatMain() {
1681
1770
  banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
1682
1771
  children: [
1683
1772
  h('span', { key: 'cntxt' }, 'This cannot be undone. '),
1684
- Btn({ key: 'cnyes', danger: true, onClick: confirmNewChat, children: 'yes, clear chat' }),
1685
- 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' })] }));
1686
1775
  }
1687
1776
  if (state.cwdError) {
1688
1777
  banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
@@ -1715,8 +1804,8 @@ function chatMain() {
1715
1804
  banners.push(Alert({ key: 'confedit', kind: 'warn', title: 'Edit this message?',
1716
1805
  children: [
1717
1806
  h('span', { key: 'cetext' }, 'Editing will remove the later turns - continue? '),
1718
- Btn({ key: 'ceyes', danger: true, onClick: confirmEditAndResend, children: 'continue' }),
1719
- 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' })] }));
1720
1809
  }
1721
1810
  const lastMsg = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1] : null;
1722
1811
  const lastErr = lastMsg ? lastMsg.error : null;
@@ -1781,9 +1870,9 @@ function chatMain() {
1781
1870
  title: 'change working directory',
1782
1871
  onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
1783
1872
  },
1784
- userTurnCount > 0 ? userTurnCount + ' turns' : null,
1873
+ userTurnCount > 0 ? plural(userTurnCount, 'turn') : null,
1785
1874
  (state.chat.resumeSid && state.selectedAgent === 'claude-code')
1786
- ? 'continues session ' + state.chat.resumeSid.slice(0, 8)
1875
+ ? 'continues session ' + state.chat.resumeSid.slice(0, 8) + '…'
1787
1876
  : null,
1788
1877
  ].filter(Boolean),
1789
1878
  } : undefined,
@@ -2326,26 +2415,28 @@ function historyMain() {
2326
2415
  reconnectAlert(),
2327
2416
  PageHeader({
2328
2417
  compact: true,
2418
+ dense: true,
2329
2419
  title: 'History',
2330
- 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.',
2331
2421
  }),
2332
2422
  h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
2333
2423
  h('p', { key: 'gt', class: 'history-empty-title' },
2334
- count ? 'Select a session to view its events' : 'No sessions yet'),
2424
+ count ? 'Select a conversation to view its events' : 'No conversations yet'),
2335
2425
  h('p', { key: 'gs', class: 'lede history-empty-sub' },
2336
2426
  count
2337
- ? count + ' session' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
2338
- : '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.')),
2339
2429
  ].filter(Boolean);
2340
2430
  }
2341
2431
 
2342
2432
  const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
2343
2433
  const lede = sess
2344
- ? (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)
2345
2435
  : state.selectedSid;
2346
2436
 
2347
2437
  const head = PageHeader({
2348
2438
  compact: true,
2439
+ dense: true,
2349
2440
  title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
2350
2441
  lede,
2351
2442
  });
@@ -2353,7 +2444,7 @@ function historyMain() {
2353
2444
  const hasErrors = state.events.some(e => e.isError);
2354
2445
  const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' }, [
2355
2446
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
2356
- Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
2447
+ Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy session id' }),
2357
2448
  Btn({ key: 'exportsess', disabled: !state.eventsLoaded, title: 'Download this session\'s events as JSON',
2358
2449
  onClick: () => downloadBlob(JSON.stringify(state.events, null, 2), (projectLabel(sess?.project) || 'session') + '-' + state.selectedSid + '.json', 'application/json'),
2359
2450
  children: 'export' }),
@@ -2366,10 +2457,12 @@ function historyMain() {
2366
2457
  // indexing copy (ccsniff's first JSONL walk can take a minute).
2367
2458
  const body = state.eventsLoaded
2368
2459
  ? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
2369
- h('span', { key: 'noevtxt' }, 'no events in this session - '),
2460
+ h('span', { key: 'noevtxt' }, 'no events in this session'),
2370
2461
  Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
2371
- : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }),
2372
- 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…' }));
2373
2466
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
2374
2467
  }
2375
2468
 
@@ -2393,7 +2486,7 @@ function historyMain() {
2393
2486
  items: [
2394
2487
  sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
2395
2488
  sessionDuration() ? { label: 'duration', value: sessionDuration() } : null,
2396
- { 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') },
2397
2490
  // Spelled counter vocabulary in the detail strip (events/turns/tools/
2398
2491
  // errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
2399
2492
  { label: 'events', value: String(state.events.length) },
@@ -2407,7 +2500,7 @@ function historyMain() {
2407
2500
  h('div', { key: 'evmeta' }, meta),
2408
2501
  h('div', { key: 'evfp' }, filterPills),
2409
2502
  h('div', { key: 'nofilt', class: 'lede empty-state', role: 'status' },
2410
- h('span', { key: 'noftxt' }, '0 events match this filter - '),
2503
+ h('span', { key: 'noftxt' }, 'no events match this filter'),
2411
2504
  Btn({ key: 'clearf', onClick: () => { state.eventFilter = 'all'; render(); }, children: 'clear filter' })),
2412
2505
  ] })].filter(Boolean);
2413
2506
  }
@@ -2436,7 +2529,7 @@ function historyMain() {
2436
2529
  head,
2437
2530
  actions,
2438
2531
  Panel({
2439
- 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)' : ''),
2440
2533
  children: [h('div', { key: 'evmeta' }, meta), h('div', { key: 'evfp' }, filterPills), eventControls, EventList({
2441
2534
  items: shown.map((e, i) => {
2442
2535
  // Stable key: prefer the server-assigned event index, else the
@@ -2636,7 +2729,7 @@ function historySide() {
2636
2729
  title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
2637
2730
  // Always show the error count so 0 reads as "no errors", not "untracked".
2638
2731
  sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools · ' + (s.errors || 0) + ' err',
2639
- rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
2732
+ rail: sessionErrorDense(s) ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
2640
2733
  active: s.sid === state.selectedSid,
2641
2734
  onClick: () => loadSession(s.sid),
2642
2735
  })
@@ -2683,9 +2776,12 @@ function historySide() {
2683
2776
  pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; writeHash(); render(); })))
2684
2777
  : null,
2685
2778
  !searching && subagentCount
2686
- ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
2687
- h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
2688
- '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
+ }))
2689
2785
  : null,
2690
2786
  state.historyError
2691
2787
  ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
@@ -2773,6 +2869,7 @@ function settingsMain() {
2773
2869
  return [
2774
2870
  PageHeader({
2775
2871
  compact: true,
2872
+ dense: true,
2776
2873
  title: 'Settings',
2777
2874
  // The page lede describes the PAGE; the backend explanation lives in the
2778
2875
  // backend panel where it applies.
@@ -2786,7 +2883,9 @@ function settingsMain() {
2786
2883
  key: 'backendForm',
2787
2884
  onSubmit: (e) => { e.preventDefault(); saveBackend(); },
2788
2885
  }, [
2789
- 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.'),
2790
2889
  TextField({
2791
2890
  key: 'backendField',
2792
2891
  label: 'backend url',
@@ -2851,8 +2950,8 @@ function serverPanel() {
2851
2950
  children: [
2852
2951
  h('div', { key: 'sv', class: 'lede' }, 'version: ' + (hh.version ? 'v' + hh.version : 'unknown')),
2853
2952
  h('div', { key: 'sup', class: 'lede' }, 'uptime: ' + (upMs != null ? humanizeMs(upMs) : 'unknown')),
2854
- h('div', { key: 'swc', class: 'lede' }, 'ws clients: ' + (wsClients != null ? wsClients : 'unknown')),
2855
- 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')),
2856
2955
  roots.length
2857
2956
  ? SessionMeta({ key: 'sroots', items: roots.map((r, i) => ({ label: 'root ' + (i + 1), value: r, title: r, onCopy: () => copyText(r, 'root copied') })) })
2858
2957
  : h('div', { key: 'snoroots', class: 'lede' }, 'allowed roots: unknown'),
@@ -2917,8 +3016,8 @@ function preferencesPanel() {
2917
3016
  ? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
2918
3017
  children: [
2919
3018
  h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, and backend from this browser. This cannot be undone. '),
2920
- Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
2921
- 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' })] })
2922
3021
  : Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
2923
3022
  ].filter(Boolean),
2924
3023
  });
@@ -2946,11 +3045,10 @@ function agentsPanel() {
2946
3045
  const acp = acpStatusFor(a.id);
2947
3046
  const avail = a.available !== false;
2948
3047
  const usable = avail || a.npxInstallable; // selectable from this row
2949
- const bits = [a.protocol || 'agent'];
3048
+ const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
2950
3049
  if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
2951
3050
  if (acp) bits.push(acp.healthy ? 'running healthy' : (acp.running ? 'running' : 'stopped'));
2952
- if (acp && acp.port) bits.push('port ' + acp.port);
2953
- if (acp && acp.restartCount) bits.push(acp.restartCount + ' restarts');
3051
+ if (acp && acp.restartCount > 1) bits.push('restarted ' + acp.restartCount + ' times');
2954
3052
  return Row({
2955
3053
  key: 'ag' + a.id,
2956
3054
  rank: String(i + 1).padStart(3, '0'),
@@ -2967,7 +3065,16 @@ function agentsPanel() {
2967
3065
  onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
2968
3066
  });
2969
3067
  })
2970
- : [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'))]),
2971
3078
  ].filter(Boolean),
2972
3079
  });
2973
3080
  }
@@ -3130,8 +3237,10 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
3130
3237
  // state.agentsError so the chat tab can surface it with a retry control.
3131
3238
  async function loadAgents() {
3132
3239
  state.agentsError = null;
3240
+ state.agentsLoading = true;
3133
3241
  try {
3134
3242
  state.agents = await B.listAgents(state.backend);
3243
+ state.agentsLoading = false;
3135
3244
  // Agent selection priority: the agent a restored transcript belongs to (so
3136
3245
  // the chat isn't shown under the wrong agent), else the saved picker agent,
3137
3246
  // else first available, else first.
@@ -3142,6 +3251,7 @@ async function loadAgents() {
3142
3251
  render();
3143
3252
  return true;
3144
3253
  } catch (e) {
3254
+ state.agentsLoading = false;
3145
3255
  state.agentsError = errText(e);
3146
3256
  console.warn('agents fetch failed:', e.message);
3147
3257
  render();
@@ -120,6 +120,7 @@ export async function statPath(base, p) {
120
120
  export function renameEntry(base, filePath, newName) { return mutateJSON(base, '/api/rename', { path: filePath, newName }); }
121
121
  export function deleteEntry(base, filePath, recursive) { return mutateJSON(base, '/api/delete', { path: filePath, recursive: !!recursive }); }
122
122
  export function makeDir(base, dirPath, name) { return mutateJSON(base, '/api/mkdir', { dir: dirPath, name }); }
123
+ export function moveEntry(base, filePath, destDir, overwrite) { return mutateJSON(base, '/api/move', { path: filePath, destDir, overwrite: !!overwrite }); }
123
124
 
124
125
  // Upload a File/Blob as raw bytes (PUT /api/upload-file). onProgress is not
125
126
  // available via fetch streaming everywhere, so progress is per-file (done or
@@ -199,20 +200,26 @@ function scheduleReconnect() {
199
200
  }
200
201
 
201
202
  function wsUrl(base) {
202
- let proto, host;
203
+ let proto, host, prefix = '';
203
204
  if (base) {
204
205
  try {
205
206
  const u = new URL(base);
206
207
  proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
207
208
  host = u.host;
209
+ // A custom backend URL may carry a routing path prefix (e.g. https://host/gm).
210
+ prefix = u.pathname.replace(/\/+$/, '');
208
211
  } catch {}
209
212
  }
210
213
  if (!host) {
211
214
  proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
212
215
  host = location.host;
216
+ // Same-origin: honour the server-injected router prefix so the socket is
217
+ // routed correctly behind a path-routing proxy (e.g. nginx /gm/ -> 9897).
218
+ const bu = (typeof window !== 'undefined' && window.__BASE_URL) || '';
219
+ prefix = String(bu).replace(/\/+$/, '');
213
220
  }
214
221
  const tok = authToken();
215
- return proto + '//' + host + SYNC_PATH + (tok ? '?token=' + encodeURIComponent(tok) : '');
222
+ return proto + '//' + host + prefix + SYNC_PATH + (tok ? '?token=' + encodeURIComponent(tok) : '');
216
223
  }
217
224
 
218
225
  function ensureWs(base) {
@@ -230,7 +237,9 @@ function ensureWs(base) {
230
237
  }
231
238
  resolve(_ws);
232
239
  });
233
- _ws.addEventListener('error', (e) => { emitStatus('error'); reject(e); });
240
+ // The error Event has no .message; reject with a real Error so callers log
241
+ // a usable reason instead of "undefined" (e.g. "agents fetch failed: ...").
242
+ _ws.addEventListener('error', () => { emitStatus('error'); reject(new Error('WebSocket connection failed')); });
234
243
  _ws.addEventListener('close', () => {
235
244
  emitStatus('closed');
236
245
  for (const [, p] of _pending) p.reject(new Error('ws closed'));