agentgui 1.0.1006 → 1.0.1008
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 -13
- package/package.json +1 -1
- package/scripts/validate-mutations.mjs +12 -0
- package/site/app/js/app.js +33 -12
package/AGENTS.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# AgentGUI — Agent Notes
|
|
2
2
|
|
|
3
|
+
## GUI completion sweep (2026-07-02) — twenty-fourth run
|
|
4
|
+
|
|
5
|
+
`gui-completion` workflow wf_4a731841-832 (33 agents, 17 confirmed). Landed 5 of the smaller-scope findings: Live-tab dashboard state (sort/filter/errors-only) now round-trips through the URL hash (`lsort=`/`lfilter=`/`lerr=1`) and localStorage — previously `lv.filter` wasn't persisted anywhere and the whole dashboard view reverted to defaults on reload. Shortcuts overlay (`?`) now wraps its content in the kit's existing `FocusTrap` component instead of a bare `role=dialog` div — Tab could previously escape into the page behind the overlay. History event rows expose copy even when collapsed (was expanded-only). Settings agents panel gives a not-installed direct-runner agent (claude-code/agy) a "re-check" button, matching the ACP rows' "restart" affordance. **Caught and fixed a real CSRF-guard test gap**: adding the regression test for the upload-PUT route (`scripts/validate-mutations.mjs`), the first draft accidentally included the `Authorization` header — matching every other case in the file but testing the wrong scenario (a stolen-token request, not real cross-site CSRF) — and passed trivially at 200. Corrected to omit auth (the path a real cross-site attacker is actually limited to); the server's existing guard correctly rejects it. **Rule for any CSRF/auth regression test: match the real attacker's capability set exactly — copying a nearby test's header pattern can silently test the wrong threat model.** 12 findings deferred to their own re-scoped PRD row (mid-thread retry/fork, file-management move UX with folder-tree picker, event-level permalinks, rolling context-size indicator — each needs a larger architecture change than a targeted fix). No kit changes this run. Pushed `d3e2d1e`. 28/28 tests, 17/17 validate-mutations, 0 console errors.
|
|
6
|
+
|
|
3
7
|
## GUI perceived-performance sweep (2026-07-02) — twenty-third run
|
|
4
8
|
|
|
5
9
|
Small, targeted run landing the 22nd-run's deferred perceived-perf findings. `index.html` now `modulepreload`s the marked/dompurify ESM CDN modules (was preconnect-only) so the fetch starts in parallel with the kit/app module graph instead of waiting for `markdown.js`'s dynamic import on the first chat response's escaped-text-to-rendered-markdown swap. Settings server panel shows "checking…" instead of "unknown" for version/uptime/clients/projects-folder during the very first health probe (new `state.healthChecking` flag around `recheckHealth`'s await — "unknown" reads as a permanent broken-server fact, not in-flight loading). Settings agents panel title shows "agents · loading…" instead of the literal "agents · 0/0 installed" while the initial fetch is in flight. Still deferred (its own PRD row, re-scoped not dropped): first-render network-gating — the shell paints nothing between the boot-splash and health-probe completion, needing a render-architecture change (skeleton/optimistic shell) bigger than this run's slice. No kit changes this run. agentgui pushed `6a045ba`, 28/28 tests, 0 console errors.
|
|
@@ -75,19 +79,7 @@ Fluid-column + drag-resize density layer (workflow `gui-screen-realestate.js`, 5
|
|
|
75
79
|
Visual/layout/motion parity vs claude.ai/code (workflow `gui-claude-code-parity.js`, 66 agents -> 37 gaps). Flat full-width chat turns (`.chat-msg-flat` at `--measure`), single bordered composer rounded-rect, `.chat-tool` status-toned cards, `WorkspaceShell mainFlush`, command-center `SessionDashboard`, reduced-motion-guarded transitions. **Load-bearing webjsx rule: never pass a conditional `x?h():null` positionally — build the array and `.filter(Boolean)` it; ANY null among VElement siblings crashes `applyDiff` (reading 'key'); `Btn` spreads array children.** Full detail in rs-learn (recall "agentgui 10th run parity").
|
|
76
80
|
## GUI logic+predictability sweep (2026-06-10) — ninth maximum-effort run
|
|
77
81
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
**Interaction lifecycles.** Kit `ConfirmDialog`/`PromptDialog` gain `error` (inside `.ds-modal-body`, role=alert) + `busy`/`busyLabel` (disables actions AND Escape/backdrop close via `data-busy`); Backdrop's Escape listener is **document-level** (focus can sit outside the dialog) and focus-restore fires only on genuine close (queueMicrotask remount guard) - the app's global Escape ladder ALSO closes `state.files.dialog` as belt-and-braces (a witness caught Escape dead with focus on H1). Per-session stop: `state.live.stopping` Set + kit `session.stopping` ('stopping...', disabled); bulk stops await `Promise.allSettled`, announce 'stopped N of M', partial failures keep their selection. Upload: one drain loop, concurrent drops append, 409 rows get `replace` (overwrite=1) + dismiss (kit `UploadProgress {actions,onDismiss}`). new-chat/backend-change/stop confirms are arm-only with bound values + 4s reset; chips never clobber a typed draft.
|
|
81
|
-
|
|
82
|
-
**Realtime truth.** Dashboard joins on `realSid = claudeSessionId || sessionId` - server `chat.active` rows now carry `claudeSessionId`; `streaming_cancelled` is broadcast (an aborted turn never also completes; backend maps it to `{type:'cancelled'}`, app marks the turn 'stopped'); a 60s per-session terminal-frame buffer replays on re-subscribe (`replayedTerminal`) so a turn finishing during a ws drop cannot hang busy; `chat_active_changed` hints (started/ended/cancelled) trigger immediate refreshActive. Error status derives from recency (not cumulative count - a session is not 'error' forever); stale derivation honors any session's running tool (per-sid `toolRunning` off SSE); SSE counter increments dedup by `ev.i` maxI; counters take max(index,tally) and never move backwards; clock-skew clamps to 'just now'; external (non-owned) sessions render as read-only dashboard cards; sorts compare real timestamps with sid tiebreaker; tally pruned 24h/200-LRU. persistChat: 500ms debounce, 100-msg cap, >4KB tool payloads trimmed (`truncatedForStorage`), quota failure banners once, cross-tab `storage` listener with ts guard ('updated in another tab' banner instead of clobber).
|
|
83
|
-
|
|
84
|
-
**Input ergonomics.** IME guards (composer, PromptDialog, SearchInput - `isComposing || keyCode===229` never sends). Escape ladder: shortcuts overlay > file dialog > confirmingEdit > new-chat arm > stop-arms > stop generation (and Esc in composer cancels while busy). Window-level dragover/drop preventDefault outside `.ds-dropzone` (a dropped file never navigates the page). Composer: `onPasteFiles`/`onDropFiles` kit props (image paste with no handler -> polite transient note), `.chat-composer-hint` 'Enter to send · Shift+Enter for a new line', context `bits` may carry per-bit `onClick` (cwd is its own target; agent/model inert). `Mod+Shift+L` focuses composer from anywhere (the one chord BEFORE the modifier early-return). cwd validates on blur (debounced `/api/stat` -> kit `cwdError`/`cwdChecking`). Streaming re-render pauses while a selection is held inside the thread (kit `hasSelectionInside` + autoscroll pause).
|
|
85
|
-
|
|
86
|
-
**Scale.** `AgentChat {shownMessages,onShowEarlier}` windowed thread (`MESSAGE_CAP=100`, keyed 'show N earlier turns' row); >20k streaming part renders a 4k tail window ('streaming · N KB so far'); history events sliced to last 5000 in-app (server adds a `?limit=N[&before=]` slicing shim in server.js - ccsniff itself has no limit support, do NOT edit node_modules); markdown loader failure no longer latches degraded (30s retry, cache never stores escaped fallback).
|
|
87
|
-
|
|
88
|
-
**Consistency/IA.** ONE `fmtDuration` (kit sessions.js) + ONE `fmtBytes` (= fmtFileSize, `0 B` not em-dash) kit-wide with app fallbacks; SessionCard `title` shares the rails' title expression; `session.external` + `session.elapsedMs`; STREAM_WORD `offline`; coarse-pointer always-visible file row actions; idle status never reads 'resuming...' (plain 'continues session <sid8>' in composer context); `/` focuses the files/live filter; running panel links 'open in live'; settings reordered backend -> server -> agents -> appearance -> keyboard -> data; crumb says 'settings' and Files crumb carries the parent trail.
|
|
89
|
-
|
|
90
|
-
**Witness.** localhost:3000/gm/ 0 console/page errors across chat/files/live/history/settings; composer hint renders; rename dialog opens + Escape closes + focus restores; 64-row files grid + crumb trail; 420px chat+live no h-scroll; decorative-glyph count 0 (kept middot/ellipsis/dash). Joint risk to re-check after parallel agent runs: prop-name contracts (this run caught app `onPasteImage` vs kit `onPasteFiles`, and backend missing the new `streaming_cancelled` type).
|
|
82
|
+
78-agent workflow, 67 confirmed findings, all implemented (interaction lifecycles, realtime truth, input ergonomics, scale, consistency). **Escape ladder order: shortcuts overlay > file dialog > confirmingEdit > new-chat arm > stop-arms > stop generation** (still binding - later runs extend the ladder, never reorder it). Full detail in rs-learn (recall "agentgui 9th run logic predictability sweep").
|
|
91
83
|
|
|
92
84
|
## GUI completion sweep (2026-06-10) — eighth maximum-effort run
|
|
93
85
|
|
package/package.json
CHANGED
|
@@ -39,6 +39,18 @@ await check('mkdir-ads-colon', 400, await post('/api/mkdir', { dir: ROOT, name:
|
|
|
39
39
|
// one). On a PASSWORD-gated server the auth wall rejects it first (401);
|
|
40
40
|
// open servers hit the CSRF guard (403). Either way the mutation is refused.
|
|
41
41
|
await check('csrf-cross-site-form', process.env.PASSWORD ? 401 : 403, await post('/api/delete', 'path=x', { 'Content-Type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'cross-site' }));
|
|
42
|
+
// The upload PUT is octet-stream, not a form-postable content-type, but the
|
|
43
|
+
// CSRF guard's stated reasoning (rides the same-origin SPA fetch) only holds
|
|
44
|
+
// if a cross-site PUT is actually rejected the same way POST/DELETE are.
|
|
45
|
+
// Deliberately omits AUTH here (unlike every other case in this file): the
|
|
46
|
+
// guard's `authed` bypass is legitimate for a Bearer token an attacker page
|
|
47
|
+
// cannot read, but including it here would test the wrong thing (a stolen-
|
|
48
|
+
// token scenario, not CSRF) - this asserts the cookie/no-credential path a
|
|
49
|
+
// real cross-site attacker is actually limited to.
|
|
50
|
+
await check('csrf-cross-site-upload', process.env.PASSWORD ? 401 : 403,
|
|
51
|
+
await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT) + '&name=csrf-probe.txt', {
|
|
52
|
+
method: 'PUT', headers: { 'Sec-Fetch-Site': 'cross-site' }, body: 'x',
|
|
53
|
+
}).then(async r => ({ status: r.status, body: await r.text() })));
|
|
42
54
|
// round trip
|
|
43
55
|
await check('mkdir-ok', 200, await post('/api/mkdir', { dir: ROOT, name: '_wtest' }));
|
|
44
56
|
const up = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(join(ROOT, '_wtest')) + '&name=up.txt', { method: 'PUT', headers: AUTH, body: 'hello upload' });
|
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, Checkbox, ShortcutList } = 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, ShortcutList, FocusTrap } = 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
|
|
@@ -51,7 +51,7 @@ const ARM_RESET_MS = 4000;
|
|
|
51
51
|
|
|
52
52
|
// Full routable param set. Every view-defining piece of state round-trips
|
|
53
53
|
// through the hash so reload and Back/forward restore the exact view.
|
|
54
|
-
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter'];
|
|
54
|
+
const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter', 'lsort', 'lfilter', 'lerr'];
|
|
55
55
|
function readHash() {
|
|
56
56
|
const hash = location.hash || '';
|
|
57
57
|
const out = {};
|
|
@@ -82,6 +82,12 @@ function buildHash() {
|
|
|
82
82
|
if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
|
|
83
83
|
}
|
|
84
84
|
if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
|
|
85
|
+
if (tab === 'live') {
|
|
86
|
+
const lv = state.live || {};
|
|
87
|
+
if (lv.sort && lv.sort !== 'status') parts.push('lsort=' + encodeURIComponent(lv.sort));
|
|
88
|
+
if (lv.filter) parts.push('lfilter=' + encodeURIComponent(lv.filter));
|
|
89
|
+
if (lv.errorsOnly) parts.push('lerr=1');
|
|
90
|
+
}
|
|
85
91
|
return parts.length ? '#' + parts.join('&') : '';
|
|
86
92
|
}
|
|
87
93
|
function writeHash({ push = false } = {}) {
|
|
@@ -602,10 +608,12 @@ function view() {
|
|
|
602
608
|
|
|
603
609
|
const shortcutsHint = state.showShortcuts
|
|
604
610
|
? h('div', { key: 'sc', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Keyboard shortcuts', class: 'ds-alert ds-alert--info shortcuts-dialog' },
|
|
605
|
-
|
|
606
|
-
h('
|
|
607
|
-
|
|
608
|
-
|
|
611
|
+
FocusTrap({ children: [
|
|
612
|
+
h('div', { key: 'sc-head', class: 'ds-alert-head' },
|
|
613
|
+
h('span', { key: 'sc-title', class: 'ds-alert-title' }, 'Keyboard shortcuts'),
|
|
614
|
+
h('button', { key: 'sc-close', type: 'button', class: 'ds-btn', 'aria-label': 'Close keyboard shortcuts', onClick: () => { state.showShortcuts = false; render(); announce('shortcuts closed'); } }, 'close')),
|
|
615
|
+
h('div', { key: 'sc-body', class: 'ds-alert-body' }, ShortcutList({ shortcuts: SHORTCUTS })),
|
|
616
|
+
] }))
|
|
609
617
|
: null;
|
|
610
618
|
const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab }, [shortcutsHint, ...mainContent()].filter(Boolean));
|
|
611
619
|
|
|
@@ -1467,7 +1475,8 @@ function filesMain() {
|
|
|
1467
1475
|
const LIVE_PREFS_KEY = 'agentgui.live';
|
|
1468
1476
|
const FILES_PREFS_KEY = 'agentgui.files';
|
|
1469
1477
|
function persistLivePrefs() {
|
|
1470
|
-
lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly }));
|
|
1478
|
+
lsSet(LIVE_PREFS_KEY, JSON.stringify({ sort: state.live.sort || 'status', errorsOnly: !!state.live.errorsOnly, filter: state.live.filter || '' }));
|
|
1479
|
+
if (state.tab === 'live') writeHash();
|
|
1471
1480
|
}
|
|
1472
1481
|
function persistFilesPrefs() {
|
|
1473
1482
|
lsSet(FILES_PREFS_KEY, JSON.stringify({ sort: state.files.sort || 'name', sortDir: state.files.sortDir || 'asc', density: state.files.density || 'list' }));
|
|
@@ -1475,7 +1484,7 @@ function persistFilesPrefs() {
|
|
|
1475
1484
|
function hydratePrefs() {
|
|
1476
1485
|
try {
|
|
1477
1486
|
const lv = JSON.parse(lsGet(LIVE_PREFS_KEY) || 'null');
|
|
1478
|
-
if (lv) { if (lv.sort) state.live.sort = lv.sort; state.live.errorsOnly = !!lv.errorsOnly; }
|
|
1487
|
+
if (lv) { if (lv.sort) state.live.sort = lv.sort; state.live.errorsOnly = !!lv.errorsOnly; if (lv.filter) state.live.filter = lv.filter; }
|
|
1479
1488
|
} catch {}
|
|
1480
1489
|
try {
|
|
1481
1490
|
const fp = JSON.parse(lsGet(FILES_PREFS_KEY) || 'null');
|
|
@@ -1700,7 +1709,7 @@ function liveMain() {
|
|
|
1700
1709
|
streamState,
|
|
1701
1710
|
activeSid: activeCard ? activeCard.sid : state.chat.resumeSid, // W13
|
|
1702
1711
|
sort: { value: sortKey, onChange: (v) => { state.live.sort = v; persistLivePrefs(); render(); } },
|
|
1703
|
-
filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
|
|
1712
|
+
filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; persistLivePrefs(); render(); } },
|
|
1704
1713
|
errorsOnly: !!lv.errorsOnly,
|
|
1705
1714
|
onErrorsOnly: (on) => { state.live.errorsOnly = on; persistLivePrefs(); render(); },
|
|
1706
1715
|
selectable: true,
|
|
@@ -2730,10 +2739,13 @@ function historyMain() {
|
|
|
2730
2739
|
rail,
|
|
2731
2740
|
expanded, // disclosure state -> kit Row sets aria-expanded
|
|
2732
2741
|
highlight: q || undefined,
|
|
2733
|
-
|
|
2742
|
+
// Copy is available whether or not the row is expanded - a user
|
|
2743
|
+
// scanning collapsed rows for a specific payload shouldn't have
|
|
2744
|
+
// to expand every row first just to copy one.
|
|
2745
|
+
actions: [{
|
|
2734
2746
|
label: 'copy', title: 'copy event',
|
|
2735
2747
|
onClick: () => copyText(full || raw || ('(' + type + ')'), 'event copied'),
|
|
2736
|
-
}]
|
|
2748
|
+
}],
|
|
2737
2749
|
title: expanded ? (typePrefix + (text || '(' + type + ')')) : (collapsedTitle || typePrefix + '(' + type + ')'),
|
|
2738
2750
|
detail: expanded && e.toolInput ? JSON.stringify(e.toolInput, null, 2) : undefined,
|
|
2739
2751
|
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
@@ -3211,7 +3223,11 @@ function agentsPanel() {
|
|
|
3211
3223
|
onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
|
|
3212
3224
|
right: (acp && !acp.healthy)
|
|
3213
3225
|
? [Btn({ key: 'acprestart', onClick: (e) => { e.stopPropagation(); B.restartAcpAgent(state.backend, a.id).then(() => loadAgents()); }, children: 'restart' })]
|
|
3214
|
-
|
|
3226
|
+
// Direct-runner agents (claude-code/agy) have no restart, but a
|
|
3227
|
+
// 'not installed' verdict can go stale if the user installs the
|
|
3228
|
+
// CLI mid-session - give it the same recheck affordance ACP rows
|
|
3229
|
+
// get via 'restart', instead of only static prose.
|
|
3230
|
+
: (!avail ? [Btn({ key: 'agrecheck', onClick: (e) => { e.stopPropagation(); loadAgents(); }, children: 're-check' })] : undefined),
|
|
3215
3231
|
});
|
|
3216
3232
|
})
|
|
3217
3233
|
// The empty array means one of three things; never let an in-flight load
|
|
@@ -3501,6 +3517,11 @@ async function init() {
|
|
|
3501
3517
|
}
|
|
3502
3518
|
if (bootTab === 'history' && hp.q) state.searchQ = hp.q;
|
|
3503
3519
|
if (bootTab === 'history' && hp.project) state.projectFilter = hp.project;
|
|
3520
|
+
if (bootTab === 'live') {
|
|
3521
|
+
if (hp.lsort) state.live.sort = hp.lsort;
|
|
3522
|
+
if (hp.lfilter) state.live.filter = hp.lfilter;
|
|
3523
|
+
if (hp.lerr) state.live.errorsOnly = true;
|
|
3524
|
+
}
|
|
3504
3525
|
navTo(bootTab, { push: false });
|
|
3505
3526
|
if (bootTab === 'history' && state.searchQ.trim().length >= 2) runSearch();
|
|
3506
3527
|
if (bootTab === 'settings' && hp.section) focusSettingsSection(hp.section);
|