agentgui 1.0.963 → 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 +25 -23
- package/PUNCHLIST-UX.md +222 -0
- package/lib/http-handler.js +42 -0
- package/package.json +1 -1
- package/scripts/validate-mutations.mjs +11 -0
- package/site/app/index.html +14 -4
- package/site/app/js/app.js +186 -76
- package/site/app/js/backend.js +1 -0
- package/site/app/vendor/anentrypoint-design/247420.css +366 -111
- 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, 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('
|
|
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
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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 ||
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
|
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
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
//
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
:
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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(
|
|
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: '
|
|
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 —
|
|
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: '
|
|
1685
|
-
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' })] }));
|
|
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: '
|
|
1719
|
-
Btn({ key: '
|
|
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
|
|
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
|
|
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
|
|
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 + '
|
|
2338
|
-
: '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.')),
|
|
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) + '
|
|
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
|
|
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
|
-
|
|
2372
|
-
|
|
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: '
|
|
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' }, '
|
|
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
|
|
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
|
|
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('
|
|
2687
|
-
|
|
2688
|
-
|
|
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'
|
|
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' }, '
|
|
2855
|
-
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')),
|
|
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: '
|
|
2921
|
-
Btn({ key: '
|
|
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.
|
|
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
|
-
|
|
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();
|
package/site/app/js/backend.js
CHANGED
|
@@ -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
|