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.
- package/.claude/workflows/gui-ux-craft.js +101 -0
- package/AGENTS.md +40 -38
- package/PUNCHLIST-PARITY.md +6 -0
- package/PUNCHLIST-UX.md +222 -0
- package/lib/http-handler.js +62 -1
- package/package.json +1 -1
- package/scripts/validate-mutations.mjs +39 -13
- package/site/app/index.html +14 -4
- package/site/app/js/app.js +296 -72
- package/site/app/js/backend.js +1 -0
- package/site/app/vendor/anentrypoint-design/247420.css +490 -123
- package/site/app/vendor/anentrypoint-design/247420.js +14 -14
package/site/app/js/app.js
CHANGED
|
@@ -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('
|
|
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
|
-
//
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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 ||
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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(
|
|
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: '
|
|
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 —
|
|
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: '
|
|
1574
|
-
Btn({ key: '
|
|
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: '
|
|
1608
|
-
Btn({ key: '
|
|
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
|
|
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
|
|
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
|
|
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 + '
|
|
2227
|
-
: 'Start a chat or run a local coding agent - its
|
|
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) + '
|
|
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
|
|
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
|
-
|
|
2261
|
-
|
|
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: '
|
|
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' }, '
|
|
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
|
|
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
|
|
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('
|
|
2576
|
-
|
|
2577
|
-
|
|
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'
|
|
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' }, '
|
|
2744
|
-
h('div', { key: 'spd', class: 'lede' }, 'projects
|
|
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: '
|
|
2810
|
-
Btn({ key: '
|
|
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.
|
|
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
|
-
|
|
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
|
}
|