agentgui 1.0.1114 → 1.0.1116
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/.gm/gm.db +0 -0
- package/.gm/mutables.yml +8 -0
- package/.gm/prd.yml +128 -5
- package/AGENTS.md +5 -1
- package/lib/ws-handlers-util.js +8 -64
- package/package.json +2 -4
- package/site/app/js/app.js +144 -146
- package/site/app/js/backend.js +7 -19
- package/site/app/vendor/anentrypoint-design/247420.css +2910 -805
- package/site/app/vendor/anentrypoint-design/247420.js +142 -69
- package/scripts/capture-screenshots.mjs +0 -206
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, TextField, Select, Btn, Icon, IconButton, 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, ShortcutList, FocusTrap, AgentListSkeleton, flashComposerNote, toast, withBusy, GitStatusPanel, GitDiffView, WorktreeSwitcher,
|
|
6
|
+
const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, TextField, Select, Btn, Icon, IconButton, 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, ShortcutList, FocusTrap, AgentListSkeleton, flashComposerNote, toast, withBusy, GitStatusPanel, GitDiffView, WorktreeSwitcher, Badge } = 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,7 +23,12 @@ 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
|
-
|
|
26
|
+
// true until the boot loadAgents() call resolves - starting false left a
|
|
27
|
+
// real window (mount -> boot's first render -> loadAgents() actually
|
|
28
|
+
// starting) where the picker rendered with an empty options list instead
|
|
29
|
+
// of the "loading agents…" placeholder, a transient state a toolbar button
|
|
30
|
+
// could source an undefined label/type from.
|
|
31
|
+
agentsLoading: true,
|
|
27
32
|
settingsSection: null,
|
|
28
33
|
eventFilter: 'all', // history event-type filter: all | text | tool | errors
|
|
29
34
|
sessionSearchQ: null, // the query the selected session was opened from (search-hit highlight)
|
|
@@ -44,13 +49,9 @@ const state = {
|
|
|
44
49
|
eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
|
|
45
50
|
files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
|
|
46
51
|
git: { loading: false, error: null, diff: '', commits: [], worktrees: [], files: [], file: '', worktreeBusy: false },
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
// Plugins tab: agentgui has no freddie-style plugin host - the closest real
|
|
51
|
-
// extensibility surface is the discovered agent-CLI registry (agents.list),
|
|
52
|
-
// adapted to PluginsConfig's {name,surfaces,requires,enabled,status} shape.
|
|
53
|
-
plugins: { selected: null },
|
|
52
|
+
// One-time welcome banner naming what each tab is for. Shown until
|
|
53
|
+
// dismissed once, ever - a returning user has already learned the tabs.
|
|
54
|
+
showOnboarding: lsGet('agentgui.onboarded') !== '1',
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
// Two-step arm controls auto-reset after this delay so an accidental first click
|
|
@@ -177,6 +178,12 @@ function lsGet(k) { try { return localStorage.getItem(k); } catch { return null;
|
|
|
177
178
|
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
|
|
178
179
|
function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
|
|
179
180
|
|
|
181
|
+
function dismissOnboarding() {
|
|
182
|
+
state.showOnboarding = false;
|
|
183
|
+
lsSet('agentgui.onboarded', '1');
|
|
184
|
+
render();
|
|
185
|
+
}
|
|
186
|
+
|
|
180
187
|
// A single visually-hidden aria-live region for transient announcements (tab
|
|
181
188
|
// changes, etc.) so screen-reader users hear context that's otherwise conveyed
|
|
182
189
|
// only by focus movement or color.
|
|
@@ -324,24 +331,6 @@ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
|
|
|
324
331
|
// filesMain (which runs during render) - a render-time fetch is fragile under
|
|
325
332
|
// a double-render and re-enters render() while building the tree.
|
|
326
333
|
if (tab === 'files' && !state.files.path && !state.files.loading && !state.files.error) loadDir('');
|
|
327
|
-
// Chat's @-mention autocomplete (mentionFiles) reuses state.files.entries -
|
|
328
|
-
// seed it from the root listing on first chat visit so mentions work
|
|
329
|
-
// without requiring a prior Files-tab visit. Uses the same confined
|
|
330
|
-
// B.listDir the Files tab itself calls; failure is silent (mentionFiles
|
|
331
|
-
// just stays empty, same as never having visited Files).
|
|
332
|
-
if (tab === 'chat' && !state.files.entries.length && !state.files.loading && !state.files.error) {
|
|
333
|
-
B.listDir(state.backend, '').then((j) => {
|
|
334
|
-
if (!state.files.entries.length) state.files.entries = j.entries || [];
|
|
335
|
-
if (!state.files.roots.length) state.files.roots = j.roots || [];
|
|
336
|
-
render();
|
|
337
|
-
}).catch(() => {});
|
|
338
|
-
}
|
|
339
|
-
// Models tab: composed agent/provider availability, fetched on first visit
|
|
340
|
-
// (and via the ModelsConfig 'refresh' action thereafter).
|
|
341
|
-
if (tab === 'models' && !state.models.data && !state.models.loading && !state.models.error) loadModelsAvailability();
|
|
342
|
-
// Plugins tab reuses state.agents (agents.list) - same lazy load as chat's
|
|
343
|
-
// agent picker, so visiting Plugins directly (deep-link/reload) still works.
|
|
344
|
-
if (tab === 'plugins' && !state.agents.length && !state.agentsLoading && !state.agentsError) loadAgents();
|
|
345
334
|
// popstate calls navTo with writeHash:false so it never replaceState-clobbers
|
|
346
335
|
// the entry it popped; user-initiated navigation pushes so Back walks tabs.
|
|
347
336
|
if (doWriteHash) writeHash({ push });
|
|
@@ -627,13 +616,19 @@ const SHORTCUTS = [
|
|
|
627
616
|
|
|
628
617
|
function view() {
|
|
629
618
|
const ok = state.health.status === 'ok';
|
|
630
|
-
|
|
631
|
-
|
|
619
|
+
// history/live both read the SSE stream, not the REST health poll - a tab
|
|
620
|
+
// that shows its own "connecting to live stream" widget must agree with the
|
|
621
|
+
// header badge, since they are the same underlying connection. Showing the
|
|
622
|
+
// REST-derived "connected" here while the tab's own widget says otherwise
|
|
623
|
+
// is exactly the mismatch this guards against.
|
|
624
|
+
const streamTab = state.tab === 'history' || state.tab === 'live';
|
|
625
|
+
const liveActive = streamTab && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
|
|
626
|
+
const dotLabel = streamTab
|
|
632
627
|
? (state.live.error
|
|
633
628
|
? 'stream: ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
|
|
634
629
|
: (liveActive ? 'stream: live · ' + state.live.eventCount : (state.live.connected ? 'stream: live' : 'stream: connecting…')))
|
|
635
630
|
: (ok ? (state.health.ws === 'reconnecting' ? 'connecting…' : 'connected') : 'offline');
|
|
636
|
-
const dotLive =
|
|
631
|
+
const dotLive = streamTab ? (liveActive || state.live.connected) : ok;
|
|
637
632
|
// The status dot is drawn entirely by CSS (.status-dot::before) - a small
|
|
638
633
|
// colored disc, real product design, not a text glyph. State drives its colour
|
|
639
634
|
// via the modifier class; the label carries only words so AT reads "live", and
|
|
@@ -659,7 +654,15 @@ function view() {
|
|
|
659
654
|
? truncate(projectLabel(sel?.title) || projectLabel(sel?.project) || state.selectedSid, 24, 48)
|
|
660
655
|
: 'all sessions';
|
|
661
656
|
} else if (state.tab === 'chat') {
|
|
662
|
-
|
|
657
|
+
// A resumed conversation loses the agent picker's own visual weight (it
|
|
658
|
+
// collapses to two small <select>s once turns exist) - the only other
|
|
659
|
+
// place the bound agent showed was this same tiny crumb text, easy to
|
|
660
|
+
// miss. Render it as a real badge on resumed threads so "which agent is
|
|
661
|
+
// this" reads as a persistent, visible indicator, not header trivia.
|
|
662
|
+
const chatAgentName = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : null;
|
|
663
|
+
crumbLeaf = chatAgentName
|
|
664
|
+
? (state.chat.resumeSid ? Badge({ children: 'agent: ' + chatAgentName, tone: 'neutral' }) : chatAgentName)
|
|
665
|
+
: 'no agent';
|
|
663
666
|
} else if (state.tab === 'files') {
|
|
664
667
|
// ONE breadcrumb owner: the in-page BreadcrumbPath is the interactive
|
|
665
668
|
// navigator, so the top crumb names only the tab (mirroring live/settings).
|
|
@@ -672,10 +675,6 @@ function view() {
|
|
|
672
675
|
} else if (state.tab === 'settings') {
|
|
673
676
|
// Same word as the rail item - location chrome must not fork vocabulary.
|
|
674
677
|
crumbLeaf = 'settings';
|
|
675
|
-
} else if (state.tab === 'models') {
|
|
676
|
-
crumbLeaf = 'models';
|
|
677
|
-
} else if (state.tab === 'plugins') {
|
|
678
|
-
crumbLeaf = 'plugins';
|
|
679
678
|
}
|
|
680
679
|
const crumb = Crumb({ trail: crumbTrail, leaf: crumbLeaf, right: [dot] });
|
|
681
680
|
|
|
@@ -684,8 +683,13 @@ function view() {
|
|
|
684
683
|
: 'no agent';
|
|
685
684
|
// The default (same-origin) backend is implementation detail, not status -
|
|
686
685
|
// the footer names a backend only when the user pointed at a custom one.
|
|
686
|
+
// On history/live, the persistent footer chip must agree with the crumb dot
|
|
687
|
+
// and the tab's own stream widget - reporting REST-health "connected" here
|
|
688
|
+
// while the Live tab's widget says "connecting to live stream" indefinitely
|
|
689
|
+
// is the exact contradiction users can't resolve into a real signal.
|
|
690
|
+
const footerConnLabel = streamTab ? (dotLive ? 'connected' : 'connecting…') : (ok ? 'connected' : 'offline');
|
|
687
691
|
const status = Status({
|
|
688
|
-
left: [state.backend || null,
|
|
692
|
+
left: [state.backend || null, footerConnLabel].filter(Boolean),
|
|
689
693
|
right: [agentLabel, 'press ? for shortcuts'],
|
|
690
694
|
});
|
|
691
695
|
|
|
@@ -698,7 +702,18 @@ function view() {
|
|
|
698
702
|
h('div', { key: 'sc-body', class: 'ds-alert-body' }, ShortcutList({ shortcuts: SHORTCUTS })),
|
|
699
703
|
] }))
|
|
700
704
|
: null;
|
|
701
|
-
|
|
705
|
+
// One-time welcome naming what each tab is for - a brand-new user's only
|
|
706
|
+
// other guidance is installHint (zero-agents case) or the calm chat empty
|
|
707
|
+
// state (agents-but-no-conversation case); neither explains History/Files/
|
|
708
|
+
// Live exist at all. Dismissed once, ever, via localStorage.
|
|
709
|
+
const onboardingBanner = state.showOnboarding
|
|
710
|
+
? Alert({
|
|
711
|
+
key: 'onboarding', kind: 'info', title: 'Welcome to AgentGUI',
|
|
712
|
+
onDismiss: dismissOnboarding,
|
|
713
|
+
children: 'Chat with an agent here. History holds every past conversation, Files browses and edits project directories, Live shows every running session at once, and Settings covers connection, appearance, and keyboard shortcuts.',
|
|
714
|
+
})
|
|
715
|
+
: null;
|
|
716
|
+
const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab }, [shortcutsHint, onboardingBanner, ...mainContent()].filter(Boolean));
|
|
702
717
|
|
|
703
718
|
// Claude-Desktop three-column shell: a persistent left rail (workspace nav), an
|
|
704
719
|
// optional sessions column (chat + history share the conversation list), the
|
|
@@ -758,8 +773,6 @@ function workspaceRail() {
|
|
|
758
773
|
{ key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
|
|
759
774
|
{ key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, rail: liveHasError ? 'flame' : undefined, onClick: () => navTo('live') },
|
|
760
775
|
{ key: 'git', label: 'Git', icon: 'branch', active: state.tab === 'git', onClick: () => navTo('git') },
|
|
761
|
-
{ key: 'models', label: 'Models', icon: 'circle-dot', active: state.tab === 'models', onClick: () => navTo('models') },
|
|
762
|
-
{ key: 'plugins', label: 'Plugins', icon: 'link', active: state.tab === 'plugins', onClick: () => navTo('plugins') },
|
|
763
776
|
{ key: 'settings', label: 'Settings', icon: 'settings', active: state.tab === 'settings', onClick: () => navTo('settings') },
|
|
764
777
|
];
|
|
765
778
|
return WorkspaceRail({
|
|
@@ -964,8 +977,6 @@ function mainContent() {
|
|
|
964
977
|
if (state.tab === 'files') return filesMain();
|
|
965
978
|
if (state.tab === 'live') return liveMain();
|
|
966
979
|
if (state.tab === 'git') return gitMain();
|
|
967
|
-
if (state.tab === 'models') return modelsMain();
|
|
968
|
-
if (state.tab === 'plugins') return pluginsMain();
|
|
969
980
|
return settingsMain();
|
|
970
981
|
}
|
|
971
982
|
|
|
@@ -993,8 +1004,9 @@ async function loadGitPanel() {
|
|
|
993
1004
|
async function loadGitDiff(file) {
|
|
994
1005
|
state.git.diffLoading = true; state.git.diffError = null; render();
|
|
995
1006
|
try {
|
|
996
|
-
const { diff } = await B.gitDiff(state.backend, { file: file || undefined });
|
|
1007
|
+
const { diff, binary } = await B.gitDiff(state.backend, { file: file || undefined });
|
|
997
1008
|
state.git.diff = diff || '(no changes)';
|
|
1009
|
+
state.git.diffBinary = !!binary;
|
|
998
1010
|
state.git.file = file || '';
|
|
999
1011
|
} catch (e) {
|
|
1000
1012
|
state.git.diffError = e.message || 'Could not load diff.';
|
|
@@ -1054,7 +1066,7 @@ function gitMain() {
|
|
|
1054
1066
|
GitStatusPanel({ files: g.files, active: g.file, onFileClick: (f) => loadGitDiff(f.path) }) }),
|
|
1055
1067
|
Panel({ id: 'git-diff', title: g.file ? ('diff: ' + g.file) : 'diff', children:
|
|
1056
1068
|
g.diffError ? h('p', { key: 'de', class: 't-meta field-error' }, g.diffError)
|
|
1057
|
-
: GitDiffView({ diff: g.diff || '', filename: g.file }) }),
|
|
1069
|
+
: GitDiffView({ diff: g.diff || '', filename: g.file, binary: !!g.diffBinary }) }),
|
|
1058
1070
|
Panel({ id: 'git-log', title: 'recent commits', children:
|
|
1059
1071
|
!g.commits.length ? h('p', { key: 'nc', class: 't-meta' }, g.loading ? 'loading…' : 'no commits')
|
|
1060
1072
|
: h('ul', { key: 'cl', class: 'git-commit-list' }, g.commits.map(c =>
|
|
@@ -1068,61 +1080,6 @@ function gitMain() {
|
|
|
1068
1080
|
];
|
|
1069
1081
|
}
|
|
1070
1082
|
|
|
1071
|
-
// Models tab: agentgui's real model surface composed by the models.availability
|
|
1072
|
-
// WS handler (agent-CLI registry availability + provider key presence), fed
|
|
1073
|
-
// into the design SDK's ModelsConfig component. Not freddie's probed per-mode
|
|
1074
|
-
// matrix - see lib/ws-handlers-util.js models.availability for the mapping.
|
|
1075
|
-
function modelsMain() {
|
|
1076
|
-
const m = state.models;
|
|
1077
|
-
return [
|
|
1078
|
-
PageHeader({ compact: true, dense: true, title: 'Models', lede: 'Discovered agent CLIs, their models, and provider key presence on this server.' }),
|
|
1079
|
-
ModelsConfig({
|
|
1080
|
-
data: m.data,
|
|
1081
|
-
loading: m.loading,
|
|
1082
|
-
error: m.error,
|
|
1083
|
-
selectedProviderId: m.selectedProviderId,
|
|
1084
|
-
onSelectProvider: (id) => { state.models.selectedProviderId = id; render(); },
|
|
1085
|
-
onRefresh: () => loadModelsAvailability(),
|
|
1086
|
-
}),
|
|
1087
|
-
];
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
// Plugins tab: agentgui has no freddie-style plugin host (~150 discoverable
|
|
1091
|
-
// plugin.js files) - its actual extensibility surface is the discovered
|
|
1092
|
-
// agent-CLI registry (lib/agent-discovery.js + lib/claude-runner-agents.js
|
|
1093
|
-
// AgentRegistry, the same data agents.list already exposes to the chat
|
|
1094
|
-
// picker). Adapted onto PluginsConfig's shape: name=agent id, surfaces=
|
|
1095
|
-
// protocol (cli|acp), requires=[] (agents have no dependency graph), enabled=
|
|
1096
|
-
// available (the CLI was actually found on this server), status=install hint
|
|
1097
|
-
// when missing. There is no enable/disable action (agentgui does not gate
|
|
1098
|
-
// which agent CLIs are usable) so onToggle re-checks availability instead of
|
|
1099
|
-
// pretending to flip a setting that doesn't exist.
|
|
1100
|
-
function pluginsMain() {
|
|
1101
|
-
const list = (state.agents || []).map((a) => ({
|
|
1102
|
-
name: a.id,
|
|
1103
|
-
version: undefined,
|
|
1104
|
-
surfaces: a.protocol || 'cli',
|
|
1105
|
-
requires: [],
|
|
1106
|
-
source: a.npxPackage ? ('npx ' + a.npxPackage) : undefined,
|
|
1107
|
-
enabled: a.available !== false,
|
|
1108
|
-
status: a.available !== false ? 'loaded' : (a.npxInstallable ? 'installable via npx' : 'not found'),
|
|
1109
|
-
}));
|
|
1110
|
-
return [
|
|
1111
|
-
PageHeader({ compact: true, dense: true, title: 'Plugins', lede: 'Discovered agent CLIs — agentgui\'s real extensibility surface (no freddie-style plugin host).' }),
|
|
1112
|
-
PluginsConfig({
|
|
1113
|
-
plugins: list,
|
|
1114
|
-
selected: state.plugins.selected,
|
|
1115
|
-
loading: !!state.agentsLoading,
|
|
1116
|
-
error: state.agentsError,
|
|
1117
|
-
onSelect: (name) => { state.plugins.selected = name; render(); },
|
|
1118
|
-
// No real enable/disable exists - re-run discovery so a just-installed
|
|
1119
|
-
// CLI's availability reflects immediately instead of a fake toggle.
|
|
1120
|
-
onToggle: () => { withBusy(null, () => loadAgents(), 'checking…'); },
|
|
1121
|
-
onReload: () => loadAgents(),
|
|
1122
|
-
}),
|
|
1123
|
-
];
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
1083
|
// --- files (folder browser) ---
|
|
1127
1084
|
async function loadDir(dirPath, { fromHash = false } = {}) {
|
|
1128
1085
|
state.files = state.files || {};
|
|
@@ -1303,7 +1260,9 @@ async function runBulkDelete() {
|
|
|
1303
1260
|
state.files.dialog = null;
|
|
1304
1261
|
restoreFileDialogFocus(d._trigger);
|
|
1305
1262
|
marked.clear(); state.files._lastMarkIdx = null;
|
|
1306
|
-
|
|
1263
|
+
const successMsg = 'deleted ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries');
|
|
1264
|
+
announce(successMsg);
|
|
1265
|
+
toast({ message: successMsg, kind: 'success' });
|
|
1307
1266
|
// Patch the visible list immediately instead of waiting on a second full
|
|
1308
1267
|
// directory round-trip - the server already confirmed every deletion.
|
|
1309
1268
|
const gone = new Set(targets.map((e) => e.path || e.name));
|
|
@@ -1353,7 +1312,9 @@ async function runBulkMove(destDir) {
|
|
|
1353
1312
|
state.files.dialog = null;
|
|
1354
1313
|
restoreFileDialogFocus(d._trigger);
|
|
1355
1314
|
marked.clear(); state.files._lastMarkIdx = null;
|
|
1356
|
-
|
|
1315
|
+
const successMsg = 'moved ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries');
|
|
1316
|
+
announce(successMsg);
|
|
1317
|
+
toast({ message: successMsg, kind: 'success' });
|
|
1357
1318
|
// Moved entries leave the current dir - patch them out immediately rather
|
|
1358
1319
|
// than waiting on a second full directory round-trip.
|
|
1359
1320
|
const gone = new Set(targets.map((e) => e.path || e.name));
|
|
@@ -1389,8 +1350,11 @@ async function runFileMutation(fn, doneMsg, patch) {
|
|
|
1389
1350
|
restoreFileDialogFocus(d._trigger);
|
|
1390
1351
|
announce(doneMsg);
|
|
1391
1352
|
// A soft-delete's trashId (if this mutation was a delete) rides the
|
|
1392
|
-
// return value straight to the undo-toast caller
|
|
1353
|
+
// return value straight to the undo-toast caller - that banner is its own
|
|
1354
|
+
// dedicated real-undo-action UI, not a generic dismiss-only confirmation,
|
|
1355
|
+
// so it stays separate from toast() rather than being replaced by it.
|
|
1393
1356
|
if (result && result.trashId) offerUndoDelete(result.trashId, doneMsg);
|
|
1357
|
+
else toast({ message: doneMsg, kind: 'success' });
|
|
1394
1358
|
if (patch) {
|
|
1395
1359
|
// Patch the visible list immediately, matching the bulk-delete/move
|
|
1396
1360
|
// pattern, instead of stalling the dialog on a second full round-trip.
|
|
@@ -2544,16 +2508,6 @@ function chatMain() {
|
|
|
2544
2508
|
messages: state.chat.messages,
|
|
2545
2509
|
busy: state.chat.busy,
|
|
2546
2510
|
draft: state.chat.draft,
|
|
2547
|
-
// Scroll-position overview strip alongside the thread.
|
|
2548
|
-
showMinimap: true,
|
|
2549
|
-
// @-mention file autocomplete: reuse the Files tab's own data source
|
|
2550
|
-
// (state.files.entries, populated by loadDir/listDir) rather than a new
|
|
2551
|
-
// fetch. Scoped to whatever directory Files last listed - if the user
|
|
2552
|
-
// hasn't opened Files yet this is empty and the composer simply shows
|
|
2553
|
-
// no mention suggestions until they do (no fake/synthetic file list).
|
|
2554
|
-
mentionFiles: (state.files.entries || [])
|
|
2555
|
-
.filter((e) => e && e.path)
|
|
2556
|
-
.map((e) => ({ path: e.path, isDir: e.type === 'dir' })),
|
|
2557
2511
|
// Idle never reads 'resuming…' (nothing is in flight - the continuation
|
|
2558
2512
|
// fact lives in the composer context line and banner); a remotely-stopped
|
|
2559
2513
|
// turn reads 'stopped', not a normal finish.
|
|
@@ -2585,7 +2539,7 @@ function chatMain() {
|
|
|
2585
2539
|
state.selectedModel || null,
|
|
2586
2540
|
{
|
|
2587
2541
|
label: state.chatCwd ? pathBasename(state.chatCwd) : 'server default',
|
|
2588
|
-
title: 'change working directory',
|
|
2542
|
+
title: state.chatCwd ? 'change working directory' : ('change working directory (default: ' + (state.serverHome?.cwd || '…') + ')'),
|
|
2589
2543
|
onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); requestAnimationFrame(() => { const inp = document.querySelector('.chat-cwd-input, .agentchat-cwd-input'); if (inp) inp.focus(); }); },
|
|
2590
2544
|
},
|
|
2591
2545
|
userTurnCount > 0 ? plural(userTurnCount, 'turn') : null,
|
|
@@ -2625,6 +2579,7 @@ function chatMain() {
|
|
|
2625
2579
|
onArmEdit: (m) => armEditAndResend(m),
|
|
2626
2580
|
onEditMessage: (m) => armEditAndResend(m),
|
|
2627
2581
|
cwd: state.chatCwd,
|
|
2582
|
+
defaultCwd: state.serverHome?.cwd || null,
|
|
2628
2583
|
cwdEditing: !!state.cwdEditing,
|
|
2629
2584
|
cwdDraft: state.cwdDraft,
|
|
2630
2585
|
cwdError: state.cwdError || null,
|
|
@@ -3619,18 +3574,51 @@ function resumeInChat(sess, { fromHash = false } = {}) {
|
|
|
3619
3574
|
B.getSessionEvents(state.backend, sidToLoad).then(evs => {
|
|
3620
3575
|
// Only populate if still on the same resume (user may have switched).
|
|
3621
3576
|
if (state.chat.resumeSid !== sidToLoad || state.chat.messages.length) return;
|
|
3577
|
+
// Replay the FULL event history (not a fixed-size slice of raw events -
|
|
3578
|
+
// a slice taken before filtering to human/assistant turns can leave only
|
|
3579
|
+
// a couple of visible messages on a long thread). The kit's own
|
|
3580
|
+
// shownMessages/onShowEarlier windowing (wired above) handles the
|
|
3581
|
+
// "load earlier" pagination the same way History's eventsLimit does.
|
|
3622
3582
|
const msgs = [];
|
|
3623
|
-
|
|
3583
|
+
let cur = null; // the in-progress assistant message, so interleaved
|
|
3584
|
+
// tool_use/tool_result/text events land on one bubble
|
|
3585
|
+
// instead of one bubble per event.
|
|
3586
|
+
let totalCost = 0; // ccsniff's stored 'result' events already carry the
|
|
3587
|
+
// real total_cost_usd (as e.cost) - the live path
|
|
3588
|
+
// sums these as they stream in, but a resumed
|
|
3589
|
+
// historical session never re-derived it, leaving
|
|
3590
|
+
// totalCost stuck at 0 regardless of turn/tool count.
|
|
3591
|
+
for (const e of (evs || [])) {
|
|
3624
3592
|
if (e.type === 'human' || e.role === 'user') {
|
|
3593
|
+
cur = null;
|
|
3625
3594
|
const text = e.text || e.content || '';
|
|
3626
3595
|
if (text) msgs.push({ id: 'rh' + e.ts, role: 'user', content: text, time: e.ts, historical: true });
|
|
3596
|
+
} else if (e.type === 'tool_use') {
|
|
3597
|
+
if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
|
|
3598
|
+
cur.parts.push(toolPart({ name: e.tool, input: e.toolInput, id: e.toolUseId || e.id }));
|
|
3599
|
+
} else if (e.type === 'tool_result') {
|
|
3600
|
+
if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
|
|
3601
|
+
applyToolResult(cur.parts, { tool_use_id: e.toolUseId || e.id, content: e.text, is_error: e.isError });
|
|
3602
|
+
} else if (e.type === 'result') {
|
|
3603
|
+
if (typeof e.cost === 'number') totalCost += e.cost;
|
|
3627
3604
|
} else if (e.type === 'assistant' || e.role === 'assistant') {
|
|
3628
3605
|
const text = e.text || '';
|
|
3629
|
-
if (text)
|
|
3606
|
+
if (!text) continue;
|
|
3607
|
+
if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
|
|
3608
|
+
appendText(cur.parts, text);
|
|
3630
3609
|
}
|
|
3631
3610
|
}
|
|
3632
3611
|
if (state.chat.resumeSid === sidToLoad && !state.chat.messages.length) {
|
|
3612
|
+
// Attach the summed cost to the last message's costUsd (rather than a
|
|
3613
|
+
// side-channel field) so computeTotalCost() - which every subsequent
|
|
3614
|
+
// send/discard recomputes from message.costUsd - naturally includes
|
|
3615
|
+
// it instead of silently resetting it back to 0 on the next turn.
|
|
3616
|
+
if (totalCost > 0) {
|
|
3617
|
+
const last = msgs[msgs.length - 1];
|
|
3618
|
+
if (last) last.costUsd = (last.costUsd || 0) + totalCost;
|
|
3619
|
+
}
|
|
3633
3620
|
state.chat.messages = msgs;
|
|
3621
|
+
state.chat.totalCost = computeTotalCost();
|
|
3634
3622
|
}
|
|
3635
3623
|
}).catch(() => {}) // history may not be available; silent fail
|
|
3636
3624
|
.finally(() => {
|
|
@@ -3888,9 +3876,9 @@ function settingsMain() {
|
|
|
3888
3876
|
render();
|
|
3889
3877
|
},
|
|
3890
3878
|
}),
|
|
3891
|
-
state.backendStatus === 'connecting' ? h('
|
|
3892
|
-
state.backendStatus === 'ok' ? h('
|
|
3893
|
-
state.backendStatus === 'failed' ? h('
|
|
3879
|
+
state.backendStatus === 'connecting' ? h('span', { key: 'bst-connecting', class: 'ds-status-chip', role: 'status' }, 'connecting…') : null,
|
|
3880
|
+
state.backendStatus === 'ok' ? h('span', { key: 'bst-ok', class: 'ds-status-chip ds-status-chip-ok', role: 'status' }, 'connected') : null,
|
|
3881
|
+
state.backendStatus === 'failed' ? h('span', { key: 'bst-failed', class: 'ds-status-chip ds-status-chip-error', role: 'alert' }, 'connection failed - check the URL') : null,
|
|
3894
3882
|
(state.confirmingBackend !== undefined && state.confirmingBackend === state.backendDraft && isValid && state.backendDraft !== state.backend)
|
|
3895
3883
|
? h('p', { key: 'bcw', class: 't-meta field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript - press save again to confirm') : null,
|
|
3896
3884
|
healthSummary(),
|
|
@@ -4067,6 +4055,15 @@ function agentsPanel() {
|
|
|
4067
4055
|
const title = (state.agentsLoading && !state.agents.length)
|
|
4068
4056
|
? 'agents · loading…'
|
|
4069
4057
|
: 'agents · ' + installed.length + '/' + state.agents.length + ' installed';
|
|
4058
|
+
// A 16-agent list with only ~4 installed is dominated by disabled-looking
|
|
4059
|
+
// rows; default to hiding not-installed/non-npx-installable agents so the
|
|
4060
|
+
// list reads as "what can I use" rather than a wall of grey entries. The
|
|
4061
|
+
// toggle stays visible (and the count in its label) so nothing is hidden
|
|
4062
|
+
// silently.
|
|
4063
|
+
const hiddenCount = state.agents.filter(a => a.available === false && !a.npxInstallable).length;
|
|
4064
|
+
const visibleAgents = state.hideUnavailableAgents
|
|
4065
|
+
? state.agents.filter(a => a.available !== false || a.npxInstallable)
|
|
4066
|
+
: state.agents;
|
|
4070
4067
|
return Panel({
|
|
4071
4068
|
id: 'agents',
|
|
4072
4069
|
title,
|
|
@@ -4076,14 +4073,22 @@ function agentsPanel() {
|
|
|
4076
4073
|
// manual start/stop controls by design. This note makes that explicit so
|
|
4077
4074
|
// a 'stopped' row doesn't read as a missing action.
|
|
4078
4075
|
hasAcp ? h('p', { key: 'acpnote', class: 't-meta agentgui-field-mb' }, 'ACP agents start on demand and restart automatically; selecting one launches it.') : null,
|
|
4079
|
-
h('div', { key: 'agrefreshrow', class: 'agentgui-field-mb' },
|
|
4080
|
-
|
|
4081
|
-
|
|
4076
|
+
h('div', { key: 'agrefreshrow', class: 'agentgui-field-mb' }, [
|
|
4077
|
+
Btn({ key: 'agrefresh', onClick: () => loadAgents(), disabled: state.agentsLoading, children: state.agentsLoading ? 'refreshing…' : 'refresh' }),
|
|
4078
|
+
hiddenCount > 0 ? Checkbox({
|
|
4079
|
+
key: 'aghidetoggle',
|
|
4080
|
+
checked: !!state.hideUnavailableAgents,
|
|
4081
|
+
label: 'hide unavailable (' + hiddenCount + ')',
|
|
4082
|
+
onChange: (v) => { state.hideUnavailableAgents = v; render(); },
|
|
4083
|
+
}) : null,
|
|
4084
|
+
].filter(Boolean)),
|
|
4085
|
+
...(visibleAgents.length
|
|
4086
|
+
? visibleAgents.map((a, i) => {
|
|
4082
4087
|
const acp = acpStatusFor(a.id);
|
|
4083
4088
|
const avail = a.available !== false;
|
|
4084
4089
|
const usable = avail || a.npxInstallable; // selectable from this row
|
|
4085
4090
|
const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
|
|
4086
|
-
if (!avail) bits.push(a.npxInstallable ? 'installs automatically when used' : 'not installed');
|
|
4091
|
+
if (!avail) bits.push(a.npxInstallable ? 'installs automatically when used (via npx)' : ('not installed' + (a.cmd ? ' - install the "' + a.cmd + '" CLI to enable' : '')));
|
|
4087
4092
|
if (acp) bits.push(acp.healthy ? 'connected' : (acp.running ? 'connecting' : 'offline'));
|
|
4088
4093
|
if (acp && acp.restartCount >= 1) bits.push('restarted ' + acp.restartCount + (acp.restartCount === 1 ? ' time' : ' times'));
|
|
4089
4094
|
if (acp && !acp.healthy && acp.providerInfo?.error) {
|
|
@@ -4100,9 +4105,13 @@ function agentsPanel() {
|
|
|
4100
4105
|
// Rail tone keeps its GUI-wide meaning: green=ok/selected,
|
|
4101
4106
|
// flame=error/unavailable. Selection is shown via `active`, not by
|
|
4102
4107
|
// borrowing purple (purple is reserved for subagents).
|
|
4103
|
-
//
|
|
4104
|
-
//
|
|
4105
|
-
|
|
4108
|
+
// flame is a REAL error signal (Row's shared rail renders it as an
|
|
4109
|
+
// sr-only "error" a11y label) - "not installed" is the normal,
|
|
4110
|
+
// expected state for most of the 16-agent list and must not carry
|
|
4111
|
+
// that label. Only an ACP agent that is actually running-unhealthy
|
|
4112
|
+
// (started, but connection/auth failed) is a genuine error; a
|
|
4113
|
+
// merely-absent binary with no restart in flight is not.
|
|
4114
|
+
rail: (acp && !acp.healthy) ? 'flame' : (a.id === state.selectedAgent ? 'green' : undefined),
|
|
4106
4115
|
active: a.id === state.selectedAgent,
|
|
4107
4116
|
// Non-installable agents are genuinely inert: mark them disabled (no
|
|
4108
4117
|
// click, no button role) instead of looking clickable but doing nothing.
|
|
@@ -4117,15 +4126,20 @@ function agentsPanel() {
|
|
|
4117
4126
|
: (!avail ? [Btn({ key: 'agrecheck', onClick: (e) => { e.stopPropagation(); withBusy(e.currentTarget, () => loadAgents(), 'checking…'); }, children: 're-check' })] : undefined),
|
|
4118
4127
|
});
|
|
4119
4128
|
})
|
|
4120
|
-
// The empty array means one of
|
|
4121
|
-
// read as a broken registry
|
|
4129
|
+
// The empty array means one of several things; never let an in-flight
|
|
4130
|
+
// load read as a broken registry, and never let the hide-unavailable
|
|
4131
|
+
// filter read as "no agents" either.
|
|
4122
4132
|
: [state.agentsLoading
|
|
4123
4133
|
? AgentListSkeleton({ rows: 5 })
|
|
4124
4134
|
: (state.agentsError
|
|
4125
4135
|
? h('div', { key: 'agfail', class: 't-meta empty-state' },
|
|
4126
4136
|
h('span', { key: 'agfailtxt' }, 'the agent list failed to load'),
|
|
4127
4137
|
Btn({ key: 'agretry2', onClick: (e) => withBusy(e.currentTarget, () => loadAgents(), 'retrying…'), children: 'retry' }))
|
|
4128
|
-
:
|
|
4138
|
+
: (state.agents.length
|
|
4139
|
+
? h('div', { key: 'agallhidden', class: 't-meta empty-state' },
|
|
4140
|
+
h('span', { key: 'agallhiddentxt' }, 'all agents are hidden by the unavailable filter'),
|
|
4141
|
+
Btn({ key: 'agshowall', onClick: () => { state.hideUnavailableAgents = false; render(); }, children: 'show all' }))
|
|
4142
|
+
: h('p', { key: 'none', class: 't-meta' }, 'no agents loaded')))]),
|
|
4129
4143
|
].filter(Boolean),
|
|
4130
4144
|
});
|
|
4131
4145
|
}
|
|
@@ -4337,25 +4351,6 @@ async function loadAgents() {
|
|
|
4337
4351
|
}
|
|
4338
4352
|
}
|
|
4339
4353
|
|
|
4340
|
-
// Models tab data load: models.availability WS handler composes agent
|
|
4341
|
-
// registry + provider key presence into ModelsConfig's expected shape. See
|
|
4342
|
-
// lib/ws-handlers-util.js for exactly what each field means in agentgui (no
|
|
4343
|
-
// freddie-style per-mode probe matrix - one real 'cli' mode per model).
|
|
4344
|
-
async function loadModelsAvailability() {
|
|
4345
|
-
state.models.loading = true;
|
|
4346
|
-
state.models.error = null;
|
|
4347
|
-
render();
|
|
4348
|
-
try {
|
|
4349
|
-
state.models.data = await B.getModelsAvailability(state.backend);
|
|
4350
|
-
state.models.loading = false;
|
|
4351
|
-
render();
|
|
4352
|
-
} catch (e) {
|
|
4353
|
-
state.models.loading = false;
|
|
4354
|
-
state.models.error = errText(e) || 'failed to load model availability';
|
|
4355
|
-
render();
|
|
4356
|
-
}
|
|
4357
|
-
}
|
|
4358
|
-
|
|
4359
4354
|
// Boot-time automatic retries with backoff when the first agents fetch fails
|
|
4360
4355
|
// (the server may still be warming up).
|
|
4361
4356
|
async function retryLoadAgents() {
|
|
@@ -4440,6 +4435,9 @@ async function init() {
|
|
|
4440
4435
|
render();
|
|
4441
4436
|
if (!(await loadAgents())) retryLoadAgents();
|
|
4442
4437
|
startBuildFreshnessPoll();
|
|
4438
|
+
// Fetch once: lets the cwd "use default" button show what it resolves to
|
|
4439
|
+
// via a tooltip, instead of only revealing it after the click.
|
|
4440
|
+
B.getHome(state.backend).then(h => { state.serverHome = h; render(); }).catch(() => {});
|
|
4443
4441
|
|
|
4444
4442
|
const hp = readHash();
|
|
4445
4443
|
const bootTab = hp.tab || (hp.sid ? 'history' : 'chat');
|
package/site/app/js/backend.js
CHANGED
|
@@ -398,6 +398,13 @@ export async function listAgents(base) {
|
|
|
398
398
|
return agents || [];
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
+
// Server's home/STARTUP_CWD - fetched once so "use default" can show what it
|
|
402
|
+
// actually resolves to (a tooltip) before the user clicks it, instead of only
|
|
403
|
+
// after.
|
|
404
|
+
export async function getHome(base) {
|
|
405
|
+
return wsCall(base, 'home', {});
|
|
406
|
+
}
|
|
407
|
+
|
|
401
408
|
export async function listActiveChats(base) {
|
|
402
409
|
try { const { sessions } = await wsCall(base, 'chat.active', {}); return sessions || []; }
|
|
403
410
|
catch { return []; }
|
|
@@ -428,25 +435,6 @@ export async function listAgentModels(base, agentId) {
|
|
|
428
435
|
} catch { return []; }
|
|
429
436
|
}
|
|
430
437
|
|
|
431
|
-
// Composed model-availability view for the Models tab (ModelsConfig
|
|
432
|
-
// component): per-agent CLI availability + models, in the shape ModelsConfig
|
|
433
|
-
// expects ({timestamp, providers, sampler, summary}). See lib/ws-handlers-
|
|
434
|
-
// util.js models.availability for what each field really means in agentgui
|
|
435
|
-
// (no probed per-mode matrix like freddie - one real 'cli' mode per model).
|
|
436
|
-
export async function getModelsAvailability(base) {
|
|
437
|
-
return wsCall(base, 'models.availability', {});
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Provider API-key configs (masked) for the Models tab's provider auth info -
|
|
441
|
-
// backs the existing Settings 'keys' surface, reused here as-is.
|
|
442
|
-
export async function getAuthConfigs(base) {
|
|
443
|
-
return wsCall(base, 'auth.configs', {});
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
export async function saveAuthConfig(base, providerId, apiKey, defaultModel) {
|
|
447
|
-
return wsCall(base, 'auth.save', { providerId, apiKey, defaultModel });
|
|
448
|
-
}
|
|
449
|
-
|
|
450
438
|
// ---------- Git / worktrees (WS) ----------
|
|
451
439
|
|
|
452
440
|
export async function gitStatus(base, { cwd } = {}) {
|