agentgui 1.0.1115 → 1.0.1117
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/AGENTS.md +5 -1
- package/lib/ws-handlers-util.js +8 -1
- package/package.json +2 -4
- package/site/app/js/app.js +144 -29
- package/site/app/js/backend.js +7 -0
- 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/AGENTS.md
CHANGED
|
@@ -8,6 +8,10 @@ ACP lifecycle lives in `lib/acp-sdk-manager.js` alone; no other module spawns or
|
|
|
8
8
|
|
|
9
9
|
Same-origin app auth must never use the `Authorization` header when an upstream proxy may own Basic auth — a `Bearer` header overwrites the browser's cached `Authorization: Basic` credentials, and nginx `auth_basic` only accepts `Basic`, so the request is rejected at the proxy before it ever reaches agentgui. Use the **`?token=` query param** (`withToken()`, exactly like the WS / EventSource / image / download URLs) instead — it coexists with upstream Basic auth, and agentgui accepts `?token=` on every HTTP route, plus the `agentgui_token` cookie.
|
|
10
10
|
|
|
11
|
+
## CRITICAL — no Chrome/Puppeteer/Playwright dependency anywhere in this repo
|
|
12
|
+
|
|
13
|
+
Never add `puppeteer`/`puppeteer-core`/`playwright`/`playwright-core` as a dependency, and never hand-roll a raw headless-Chrome launch in a script. Live browser verification for this project is done via the gm skill's `browser` verb (direct CDP, no relay), not by this repo owning its own browser-automation dependency. `scripts/capture-screenshots.mjs` (puppeteer-core-driven, dead code — not wired into any CI workflow) was removed for this reason.
|
|
14
|
+
|
|
11
15
|
## Standing engineering rules
|
|
12
16
|
|
|
13
17
|
- **All GUI/design decisions live in the kit (`../design`), none in agentgui.** New surface styling is a kit CSS rule, never an inline `<style>` or `style=` prop in agentgui. A new kit component must be re-exported through `src/components.js`'s barrel to be consumable — adding it to a component file alone leaves it invisible to the built bundle even with 0 lint errors; grep the built dist for the export name before wiring the app against it. New CSS class tokens in the kit must carry a registered family prefix (`ds-`, `app-`, `ws-`, `chat-`, etc. — see `scripts/lint-classes.mjs`'s `PREFIXES`/`FROZEN` lists) or the build's lint-classes check fails; a legacy bare name like `chip` being grandfathered on the FROZEN list does not cover new sub-tokens off it (e.g. a new `chip-remove` class still needs a `ds-` prefix).
|
|
@@ -79,7 +83,7 @@ Two runner protocols exist. **Direct** (`lib/claude-runner-direct.js`): claude-c
|
|
|
79
83
|
|
|
80
84
|
## CI / GitHub Actions
|
|
81
85
|
|
|
82
|
-
|
|
86
|
+
Any CI step that spawns the agentgui server must invoke it with `bun`, not `node` (`--ignore-scripts` npm installs leave `better-sqlite3` uncompiled, so `bun:sqlite`'s fallback also fails under Node).
|
|
83
87
|
|
|
84
88
|
## GM Plugin Autonomy Blocker
|
|
85
89
|
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -87,6 +87,9 @@ export function register(router, deps) {
|
|
|
87
87
|
features: a.supportedFeatures || [],
|
|
88
88
|
available: registry.isAvailable(a.id),
|
|
89
89
|
npxInstallable: !!a.npxPackage,
|
|
90
|
+
// The CLI binary name a manual (non-npx) install would need - lets the
|
|
91
|
+
// settings panel say what to install instead of just "not installed".
|
|
92
|
+
cmd: a.command || null,
|
|
90
93
|
}));
|
|
91
94
|
return { agents };
|
|
92
95
|
});
|
|
@@ -355,7 +358,11 @@ export function register(router, deps) {
|
|
|
355
358
|
if (p?.file) args.push('--', assertSafeRelPath(p.file, 'file'));
|
|
356
359
|
try {
|
|
357
360
|
const { stdout } = await execFileP('git', args, { cwd });
|
|
358
|
-
|
|
361
|
+
// git prints "Binary files a/x and b/x differ" for binary paths instead
|
|
362
|
+
// of a unified diff - an empty `diff` string then reads to the client as
|
|
363
|
+
// "no diff to show" with no explanation. Detect it so the UI can say why.
|
|
364
|
+
const binary = /^Binary files .* differ$/m.test(stdout);
|
|
365
|
+
return { diff: stdout, binary, file: p?.file || null, staged: !!p?.staged };
|
|
359
366
|
} catch (e) { err(500, (e.stderr || e.message || 'git diff failed').trim()); }
|
|
360
367
|
});
|
|
361
368
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentgui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1117",
|
|
4
4
|
"description": "Multi-agent ACP client with real-time communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "electron/main.js",
|
|
@@ -34,13 +34,11 @@
|
|
|
34
34
|
"fsbrowse": "latest",
|
|
35
35
|
"lru-cache": "^11.2.7",
|
|
36
36
|
"opencode-ai": "^1.2.15",
|
|
37
|
-
"puppeteer-core": "^24.37.5",
|
|
38
37
|
"webjsx": "^0.0.73",
|
|
39
38
|
"ws": "^8.14.2",
|
|
40
39
|
"xstate": "^5.32.0"
|
|
41
40
|
},
|
|
42
41
|
"devDependencies": {
|
|
43
|
-
"electron": "^35.0.0"
|
|
44
|
-
"playwright": "^1.59.1"
|
|
42
|
+
"electron": "^35.0.0"
|
|
45
43
|
}
|
|
46
44
|
}
|
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, ModelsConfig, PluginsConfig } = C;
|
|
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, ModelsConfig, PluginsConfig } = 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,6 +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 },
|
|
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',
|
|
47
55
|
// Models tab: composed agent/provider availability (models.availability WS
|
|
48
56
|
// handler), fed into the design SDK's ModelsConfig component.
|
|
49
57
|
models: { data: null, loading: false, error: null, selectedProviderId: null },
|
|
@@ -177,6 +185,12 @@ function lsGet(k) { try { return localStorage.getItem(k); } catch { return null;
|
|
|
177
185
|
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
|
|
178
186
|
function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
|
|
179
187
|
|
|
188
|
+
function dismissOnboarding() {
|
|
189
|
+
state.showOnboarding = false;
|
|
190
|
+
lsSet('agentgui.onboarded', '1');
|
|
191
|
+
render();
|
|
192
|
+
}
|
|
193
|
+
|
|
180
194
|
// A single visually-hidden aria-live region for transient announcements (tab
|
|
181
195
|
// changes, etc.) so screen-reader users hear context that's otherwise conveyed
|
|
182
196
|
// only by focus movement or color.
|
|
@@ -627,13 +641,19 @@ const SHORTCUTS = [
|
|
|
627
641
|
|
|
628
642
|
function view() {
|
|
629
643
|
const ok = state.health.status === 'ok';
|
|
630
|
-
|
|
631
|
-
|
|
644
|
+
// history/live both read the SSE stream, not the REST health poll - a tab
|
|
645
|
+
// that shows its own "connecting to live stream" widget must agree with the
|
|
646
|
+
// header badge, since they are the same underlying connection. Showing the
|
|
647
|
+
// REST-derived "connected" here while the tab's own widget says otherwise
|
|
648
|
+
// is exactly the mismatch this guards against.
|
|
649
|
+
const streamTab = state.tab === 'history' || state.tab === 'live';
|
|
650
|
+
const liveActive = streamTab && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
|
|
651
|
+
const dotLabel = streamTab
|
|
632
652
|
? (state.live.error
|
|
633
653
|
? 'stream: ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
|
|
634
654
|
: (liveActive ? 'stream: live · ' + state.live.eventCount : (state.live.connected ? 'stream: live' : 'stream: connecting…')))
|
|
635
655
|
: (ok ? (state.health.ws === 'reconnecting' ? 'connecting…' : 'connected') : 'offline');
|
|
636
|
-
const dotLive =
|
|
656
|
+
const dotLive = streamTab ? (liveActive || state.live.connected) : ok;
|
|
637
657
|
// The status dot is drawn entirely by CSS (.status-dot::before) - a small
|
|
638
658
|
// colored disc, real product design, not a text glyph. State drives its colour
|
|
639
659
|
// via the modifier class; the label carries only words so AT reads "live", and
|
|
@@ -659,7 +679,15 @@ function view() {
|
|
|
659
679
|
? truncate(projectLabel(sel?.title) || projectLabel(sel?.project) || state.selectedSid, 24, 48)
|
|
660
680
|
: 'all sessions';
|
|
661
681
|
} else if (state.tab === 'chat') {
|
|
662
|
-
|
|
682
|
+
// A resumed conversation loses the agent picker's own visual weight (it
|
|
683
|
+
// collapses to two small <select>s once turns exist) - the only other
|
|
684
|
+
// place the bound agent showed was this same tiny crumb text, easy to
|
|
685
|
+
// miss. Render it as a real badge on resumed threads so "which agent is
|
|
686
|
+
// this" reads as a persistent, visible indicator, not header trivia.
|
|
687
|
+
const chatAgentName = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : null;
|
|
688
|
+
crumbLeaf = chatAgentName
|
|
689
|
+
? (state.chat.resumeSid ? Badge({ children: 'agent: ' + chatAgentName, tone: 'neutral' }) : chatAgentName)
|
|
690
|
+
: 'no agent';
|
|
663
691
|
} else if (state.tab === 'files') {
|
|
664
692
|
// ONE breadcrumb owner: the in-page BreadcrumbPath is the interactive
|
|
665
693
|
// navigator, so the top crumb names only the tab (mirroring live/settings).
|
|
@@ -684,8 +712,13 @@ function view() {
|
|
|
684
712
|
: 'no agent';
|
|
685
713
|
// The default (same-origin) backend is implementation detail, not status -
|
|
686
714
|
// the footer names a backend only when the user pointed at a custom one.
|
|
715
|
+
// On history/live, the persistent footer chip must agree with the crumb dot
|
|
716
|
+
// and the tab's own stream widget - reporting REST-health "connected" here
|
|
717
|
+
// while the Live tab's widget says "connecting to live stream" indefinitely
|
|
718
|
+
// is the exact contradiction users can't resolve into a real signal.
|
|
719
|
+
const footerConnLabel = streamTab ? (dotLive ? 'connected' : 'connecting…') : (ok ? 'connected' : 'offline');
|
|
687
720
|
const status = Status({
|
|
688
|
-
left: [state.backend || null,
|
|
721
|
+
left: [state.backend || null, footerConnLabel].filter(Boolean),
|
|
689
722
|
right: [agentLabel, 'press ? for shortcuts'],
|
|
690
723
|
});
|
|
691
724
|
|
|
@@ -698,7 +731,18 @@ function view() {
|
|
|
698
731
|
h('div', { key: 'sc-body', class: 'ds-alert-body' }, ShortcutList({ shortcuts: SHORTCUTS })),
|
|
699
732
|
] }))
|
|
700
733
|
: null;
|
|
701
|
-
|
|
734
|
+
// One-time welcome naming what each tab is for - a brand-new user's only
|
|
735
|
+
// other guidance is installHint (zero-agents case) or the calm chat empty
|
|
736
|
+
// state (agents-but-no-conversation case); neither explains History/Files/
|
|
737
|
+
// Live exist at all. Dismissed once, ever, via localStorage.
|
|
738
|
+
const onboardingBanner = state.showOnboarding
|
|
739
|
+
? Alert({
|
|
740
|
+
key: 'onboarding', kind: 'info', title: 'Welcome to AgentGUI',
|
|
741
|
+
onDismiss: dismissOnboarding,
|
|
742
|
+
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.',
|
|
743
|
+
})
|
|
744
|
+
: null;
|
|
745
|
+
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
746
|
|
|
703
747
|
// Claude-Desktop three-column shell: a persistent left rail (workspace nav), an
|
|
704
748
|
// optional sessions column (chat + history share the conversation list), the
|
|
@@ -993,8 +1037,9 @@ async function loadGitPanel() {
|
|
|
993
1037
|
async function loadGitDiff(file) {
|
|
994
1038
|
state.git.diffLoading = true; state.git.diffError = null; render();
|
|
995
1039
|
try {
|
|
996
|
-
const { diff } = await B.gitDiff(state.backend, { file: file || undefined });
|
|
1040
|
+
const { diff, binary } = await B.gitDiff(state.backend, { file: file || undefined });
|
|
997
1041
|
state.git.diff = diff || '(no changes)';
|
|
1042
|
+
state.git.diffBinary = !!binary;
|
|
998
1043
|
state.git.file = file || '';
|
|
999
1044
|
} catch (e) {
|
|
1000
1045
|
state.git.diffError = e.message || 'Could not load diff.';
|
|
@@ -1054,7 +1099,7 @@ function gitMain() {
|
|
|
1054
1099
|
GitStatusPanel({ files: g.files, active: g.file, onFileClick: (f) => loadGitDiff(f.path) }) }),
|
|
1055
1100
|
Panel({ id: 'git-diff', title: g.file ? ('diff: ' + g.file) : 'diff', children:
|
|
1056
1101
|
g.diffError ? h('p', { key: 'de', class: 't-meta field-error' }, g.diffError)
|
|
1057
|
-
: GitDiffView({ diff: g.diff || '', filename: g.file }) }),
|
|
1102
|
+
: GitDiffView({ diff: g.diff || '', filename: g.file, binary: !!g.diffBinary }) }),
|
|
1058
1103
|
Panel({ id: 'git-log', title: 'recent commits', children:
|
|
1059
1104
|
!g.commits.length ? h('p', { key: 'nc', class: 't-meta' }, g.loading ? 'loading…' : 'no commits')
|
|
1060
1105
|
: h('ul', { key: 'cl', class: 'git-commit-list' }, g.commits.map(c =>
|
|
@@ -1303,7 +1348,9 @@ async function runBulkDelete() {
|
|
|
1303
1348
|
state.files.dialog = null;
|
|
1304
1349
|
restoreFileDialogFocus(d._trigger);
|
|
1305
1350
|
marked.clear(); state.files._lastMarkIdx = null;
|
|
1306
|
-
|
|
1351
|
+
const successMsg = 'deleted ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries');
|
|
1352
|
+
announce(successMsg);
|
|
1353
|
+
toast({ message: successMsg, kind: 'success' });
|
|
1307
1354
|
// Patch the visible list immediately instead of waiting on a second full
|
|
1308
1355
|
// directory round-trip - the server already confirmed every deletion.
|
|
1309
1356
|
const gone = new Set(targets.map((e) => e.path || e.name));
|
|
@@ -1353,7 +1400,9 @@ async function runBulkMove(destDir) {
|
|
|
1353
1400
|
state.files.dialog = null;
|
|
1354
1401
|
restoreFileDialogFocus(d._trigger);
|
|
1355
1402
|
marked.clear(); state.files._lastMarkIdx = null;
|
|
1356
|
-
|
|
1403
|
+
const successMsg = 'moved ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries');
|
|
1404
|
+
announce(successMsg);
|
|
1405
|
+
toast({ message: successMsg, kind: 'success' });
|
|
1357
1406
|
// Moved entries leave the current dir - patch them out immediately rather
|
|
1358
1407
|
// than waiting on a second full directory round-trip.
|
|
1359
1408
|
const gone = new Set(targets.map((e) => e.path || e.name));
|
|
@@ -1389,8 +1438,11 @@ async function runFileMutation(fn, doneMsg, patch) {
|
|
|
1389
1438
|
restoreFileDialogFocus(d._trigger);
|
|
1390
1439
|
announce(doneMsg);
|
|
1391
1440
|
// A soft-delete's trashId (if this mutation was a delete) rides the
|
|
1392
|
-
// return value straight to the undo-toast caller
|
|
1441
|
+
// return value straight to the undo-toast caller - that banner is its own
|
|
1442
|
+
// dedicated real-undo-action UI, not a generic dismiss-only confirmation,
|
|
1443
|
+
// so it stays separate from toast() rather than being replaced by it.
|
|
1393
1444
|
if (result && result.trashId) offerUndoDelete(result.trashId, doneMsg);
|
|
1445
|
+
else toast({ message: doneMsg, kind: 'success' });
|
|
1394
1446
|
if (patch) {
|
|
1395
1447
|
// Patch the visible list immediately, matching the bulk-delete/move
|
|
1396
1448
|
// pattern, instead of stalling the dialog on a second full round-trip.
|
|
@@ -2585,7 +2637,7 @@ function chatMain() {
|
|
|
2585
2637
|
state.selectedModel || null,
|
|
2586
2638
|
{
|
|
2587
2639
|
label: state.chatCwd ? pathBasename(state.chatCwd) : 'server default',
|
|
2588
|
-
title: 'change working directory',
|
|
2640
|
+
title: state.chatCwd ? 'change working directory' : ('change working directory (default: ' + (state.serverHome?.cwd || '…') + ')'),
|
|
2589
2641
|
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
2642
|
},
|
|
2591
2643
|
userTurnCount > 0 ? plural(userTurnCount, 'turn') : null,
|
|
@@ -2625,6 +2677,7 @@ function chatMain() {
|
|
|
2625
2677
|
onArmEdit: (m) => armEditAndResend(m),
|
|
2626
2678
|
onEditMessage: (m) => armEditAndResend(m),
|
|
2627
2679
|
cwd: state.chatCwd,
|
|
2680
|
+
defaultCwd: state.serverHome?.cwd || null,
|
|
2628
2681
|
cwdEditing: !!state.cwdEditing,
|
|
2629
2682
|
cwdDraft: state.cwdDraft,
|
|
2630
2683
|
cwdError: state.cwdError || null,
|
|
@@ -3619,18 +3672,51 @@ function resumeInChat(sess, { fromHash = false } = {}) {
|
|
|
3619
3672
|
B.getSessionEvents(state.backend, sidToLoad).then(evs => {
|
|
3620
3673
|
// Only populate if still on the same resume (user may have switched).
|
|
3621
3674
|
if (state.chat.resumeSid !== sidToLoad || state.chat.messages.length) return;
|
|
3675
|
+
// Replay the FULL event history (not a fixed-size slice of raw events -
|
|
3676
|
+
// a slice taken before filtering to human/assistant turns can leave only
|
|
3677
|
+
// a couple of visible messages on a long thread). The kit's own
|
|
3678
|
+
// shownMessages/onShowEarlier windowing (wired above) handles the
|
|
3679
|
+
// "load earlier" pagination the same way History's eventsLimit does.
|
|
3622
3680
|
const msgs = [];
|
|
3623
|
-
|
|
3681
|
+
let cur = null; // the in-progress assistant message, so interleaved
|
|
3682
|
+
// tool_use/tool_result/text events land on one bubble
|
|
3683
|
+
// instead of one bubble per event.
|
|
3684
|
+
let totalCost = 0; // ccsniff's stored 'result' events already carry the
|
|
3685
|
+
// real total_cost_usd (as e.cost) - the live path
|
|
3686
|
+
// sums these as they stream in, but a resumed
|
|
3687
|
+
// historical session never re-derived it, leaving
|
|
3688
|
+
// totalCost stuck at 0 regardless of turn/tool count.
|
|
3689
|
+
for (const e of (evs || [])) {
|
|
3624
3690
|
if (e.type === 'human' || e.role === 'user') {
|
|
3691
|
+
cur = null;
|
|
3625
3692
|
const text = e.text || e.content || '';
|
|
3626
3693
|
if (text) msgs.push({ id: 'rh' + e.ts, role: 'user', content: text, time: e.ts, historical: true });
|
|
3694
|
+
} else if (e.type === 'tool_use') {
|
|
3695
|
+
if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
|
|
3696
|
+
cur.parts.push(toolPart({ name: e.tool, input: e.toolInput, id: e.toolUseId || e.id }));
|
|
3697
|
+
} else if (e.type === 'tool_result') {
|
|
3698
|
+
if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
|
|
3699
|
+
applyToolResult(cur.parts, { tool_use_id: e.toolUseId || e.id, content: e.text, is_error: e.isError });
|
|
3700
|
+
} else if (e.type === 'result') {
|
|
3701
|
+
if (typeof e.cost === 'number') totalCost += e.cost;
|
|
3627
3702
|
} else if (e.type === 'assistant' || e.role === 'assistant') {
|
|
3628
3703
|
const text = e.text || '';
|
|
3629
|
-
if (text)
|
|
3704
|
+
if (!text) continue;
|
|
3705
|
+
if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
|
|
3706
|
+
appendText(cur.parts, text);
|
|
3630
3707
|
}
|
|
3631
3708
|
}
|
|
3632
3709
|
if (state.chat.resumeSid === sidToLoad && !state.chat.messages.length) {
|
|
3710
|
+
// Attach the summed cost to the last message's costUsd (rather than a
|
|
3711
|
+
// side-channel field) so computeTotalCost() - which every subsequent
|
|
3712
|
+
// send/discard recomputes from message.costUsd - naturally includes
|
|
3713
|
+
// it instead of silently resetting it back to 0 on the next turn.
|
|
3714
|
+
if (totalCost > 0) {
|
|
3715
|
+
const last = msgs[msgs.length - 1];
|
|
3716
|
+
if (last) last.costUsd = (last.costUsd || 0) + totalCost;
|
|
3717
|
+
}
|
|
3633
3718
|
state.chat.messages = msgs;
|
|
3719
|
+
state.chat.totalCost = computeTotalCost();
|
|
3634
3720
|
}
|
|
3635
3721
|
}).catch(() => {}) // history may not be available; silent fail
|
|
3636
3722
|
.finally(() => {
|
|
@@ -3888,9 +3974,9 @@ function settingsMain() {
|
|
|
3888
3974
|
render();
|
|
3889
3975
|
},
|
|
3890
3976
|
}),
|
|
3891
|
-
state.backendStatus === 'connecting' ? h('
|
|
3892
|
-
state.backendStatus === 'ok' ? h('
|
|
3893
|
-
state.backendStatus === 'failed' ? h('
|
|
3977
|
+
state.backendStatus === 'connecting' ? h('span', { key: 'bst-connecting', class: 'ds-status-chip', role: 'status' }, 'connecting…') : null,
|
|
3978
|
+
state.backendStatus === 'ok' ? h('span', { key: 'bst-ok', class: 'ds-status-chip ds-status-chip-ok', role: 'status' }, 'connected') : null,
|
|
3979
|
+
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
3980
|
(state.confirmingBackend !== undefined && state.confirmingBackend === state.backendDraft && isValid && state.backendDraft !== state.backend)
|
|
3895
3981
|
? 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
3982
|
healthSummary(),
|
|
@@ -4067,6 +4153,15 @@ function agentsPanel() {
|
|
|
4067
4153
|
const title = (state.agentsLoading && !state.agents.length)
|
|
4068
4154
|
? 'agents · loading…'
|
|
4069
4155
|
: 'agents · ' + installed.length + '/' + state.agents.length + ' installed';
|
|
4156
|
+
// A 16-agent list with only ~4 installed is dominated by disabled-looking
|
|
4157
|
+
// rows; default to hiding not-installed/non-npx-installable agents so the
|
|
4158
|
+
// list reads as "what can I use" rather than a wall of grey entries. The
|
|
4159
|
+
// toggle stays visible (and the count in its label) so nothing is hidden
|
|
4160
|
+
// silently.
|
|
4161
|
+
const hiddenCount = state.agents.filter(a => a.available === false && !a.npxInstallable).length;
|
|
4162
|
+
const visibleAgents = state.hideUnavailableAgents
|
|
4163
|
+
? state.agents.filter(a => a.available !== false || a.npxInstallable)
|
|
4164
|
+
: state.agents;
|
|
4070
4165
|
return Panel({
|
|
4071
4166
|
id: 'agents',
|
|
4072
4167
|
title,
|
|
@@ -4076,14 +4171,22 @@ function agentsPanel() {
|
|
|
4076
4171
|
// manual start/stop controls by design. This note makes that explicit so
|
|
4077
4172
|
// a 'stopped' row doesn't read as a missing action.
|
|
4078
4173
|
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
|
-
|
|
4174
|
+
h('div', { key: 'agrefreshrow', class: 'agentgui-field-mb' }, [
|
|
4175
|
+
Btn({ key: 'agrefresh', onClick: () => loadAgents(), disabled: state.agentsLoading, children: state.agentsLoading ? 'refreshing…' : 'refresh' }),
|
|
4176
|
+
hiddenCount > 0 ? Checkbox({
|
|
4177
|
+
key: 'aghidetoggle',
|
|
4178
|
+
checked: !!state.hideUnavailableAgents,
|
|
4179
|
+
label: 'hide unavailable (' + hiddenCount + ')',
|
|
4180
|
+
onChange: (v) => { state.hideUnavailableAgents = v; render(); },
|
|
4181
|
+
}) : null,
|
|
4182
|
+
].filter(Boolean)),
|
|
4183
|
+
...(visibleAgents.length
|
|
4184
|
+
? visibleAgents.map((a, i) => {
|
|
4082
4185
|
const acp = acpStatusFor(a.id);
|
|
4083
4186
|
const avail = a.available !== false;
|
|
4084
4187
|
const usable = avail || a.npxInstallable; // selectable from this row
|
|
4085
4188
|
const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
|
|
4086
|
-
if (!avail) bits.push(a.npxInstallable ? 'installs automatically when used' : 'not installed');
|
|
4189
|
+
if (!avail) bits.push(a.npxInstallable ? 'installs automatically when used (via npx)' : ('not installed' + (a.cmd ? ' - install the "' + a.cmd + '" CLI to enable' : '')));
|
|
4087
4190
|
if (acp) bits.push(acp.healthy ? 'connected' : (acp.running ? 'connecting' : 'offline'));
|
|
4088
4191
|
if (acp && acp.restartCount >= 1) bits.push('restarted ' + acp.restartCount + (acp.restartCount === 1 ? ' time' : ' times'));
|
|
4089
4192
|
if (acp && !acp.healthy && acp.providerInfo?.error) {
|
|
@@ -4100,9 +4203,13 @@ function agentsPanel() {
|
|
|
4100
4203
|
// Rail tone keeps its GUI-wide meaning: green=ok/selected,
|
|
4101
4204
|
// flame=error/unavailable. Selection is shown via `active`, not by
|
|
4102
4205
|
// borrowing purple (purple is reserved for subagents).
|
|
4103
|
-
//
|
|
4104
|
-
//
|
|
4105
|
-
|
|
4206
|
+
// flame is a REAL error signal (Row's shared rail renders it as an
|
|
4207
|
+
// sr-only "error" a11y label) - "not installed" is the normal,
|
|
4208
|
+
// expected state for most of the 16-agent list and must not carry
|
|
4209
|
+
// that label. Only an ACP agent that is actually running-unhealthy
|
|
4210
|
+
// (started, but connection/auth failed) is a genuine error; a
|
|
4211
|
+
// merely-absent binary with no restart in flight is not.
|
|
4212
|
+
rail: (acp && !acp.healthy) ? 'flame' : (a.id === state.selectedAgent ? 'green' : undefined),
|
|
4106
4213
|
active: a.id === state.selectedAgent,
|
|
4107
4214
|
// Non-installable agents are genuinely inert: mark them disabled (no
|
|
4108
4215
|
// click, no button role) instead of looking clickable but doing nothing.
|
|
@@ -4117,15 +4224,20 @@ function agentsPanel() {
|
|
|
4117
4224
|
: (!avail ? [Btn({ key: 'agrecheck', onClick: (e) => { e.stopPropagation(); withBusy(e.currentTarget, () => loadAgents(), 'checking…'); }, children: 're-check' })] : undefined),
|
|
4118
4225
|
});
|
|
4119
4226
|
})
|
|
4120
|
-
// The empty array means one of
|
|
4121
|
-
// read as a broken registry
|
|
4227
|
+
// The empty array means one of several things; never let an in-flight
|
|
4228
|
+
// load read as a broken registry, and never let the hide-unavailable
|
|
4229
|
+
// filter read as "no agents" either.
|
|
4122
4230
|
: [state.agentsLoading
|
|
4123
4231
|
? AgentListSkeleton({ rows: 5 })
|
|
4124
4232
|
: (state.agentsError
|
|
4125
4233
|
? h('div', { key: 'agfail', class: 't-meta empty-state' },
|
|
4126
4234
|
h('span', { key: 'agfailtxt' }, 'the agent list failed to load'),
|
|
4127
4235
|
Btn({ key: 'agretry2', onClick: (e) => withBusy(e.currentTarget, () => loadAgents(), 'retrying…'), children: 'retry' }))
|
|
4128
|
-
:
|
|
4236
|
+
: (state.agents.length
|
|
4237
|
+
? h('div', { key: 'agallhidden', class: 't-meta empty-state' },
|
|
4238
|
+
h('span', { key: 'agallhiddentxt' }, 'all agents are hidden by the unavailable filter'),
|
|
4239
|
+
Btn({ key: 'agshowall', onClick: () => { state.hideUnavailableAgents = false; render(); }, children: 'show all' }))
|
|
4240
|
+
: h('p', { key: 'none', class: 't-meta' }, 'no agents loaded')))]),
|
|
4129
4241
|
].filter(Boolean),
|
|
4130
4242
|
});
|
|
4131
4243
|
}
|
|
@@ -4440,6 +4552,9 @@ async function init() {
|
|
|
4440
4552
|
render();
|
|
4441
4553
|
if (!(await loadAgents())) retryLoadAgents();
|
|
4442
4554
|
startBuildFreshnessPoll();
|
|
4555
|
+
// Fetch once: lets the cwd "use default" button show what it resolves to
|
|
4556
|
+
// via a tooltip, instead of only revealing it after the click.
|
|
4557
|
+
B.getHome(state.backend).then(h => { state.serverHome = h; render(); }).catch(() => {});
|
|
4443
4558
|
|
|
4444
4559
|
const hp = readHash();
|
|
4445
4560
|
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 []; }
|