agentgui 1.0.949 → 1.0.950
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.
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
All findings are pre-confirmed (isReal: true). Here is the deduped, prioritized punch-list grouped by fixLocation, ordered by severity then user impact.
|
|
2
|
+
|
|
3
|
+
## SERVER (lib/) — highest impact: security correctness
|
|
4
|
+
|
|
5
|
+
1. **[HIGH · security] /api/image broad arbitrary-file-read** — `lib/http-handler.js:144-162`: drop `os.homedir()` from `allowRoots` (restrict to `CLAUDE_PROJECTS_DIR` + `IMAGE_ROOTS`) and 403 any path whose extension isn't in the image `mimeTypes` map instead of falling back to `application/octet-stream`.
|
|
6
|
+
2. **[HIGH · security] ?token= credential leak, no Referrer-Policy/CSP** — `lib/http-handler.js:56-61`: always `res.setHeader('Referrer-Policy','no-referrer')`, add a CSP restricting img/connect/script-src to self + unpkg/jsdelivr, and migrate token→httpOnly SameSite=Strict cookie so the password leaves the URL/history/logs.
|
|
7
|
+
3. **[MEDIUM · robustness] cwd unvalidated before spawn** — `lib/ws-handlers-util.js:70`: when `p?.cwd` is set, `fs.statSync(cwd).isDirectory()` check and `err(400,'no such directory')` before `runClaudeWithStreaming`; optionally confine to an allowlist root.
|
|
8
|
+
|
|
9
|
+
## APP (site/app/) — chat correctness, then history, then settings
|
|
10
|
+
|
|
11
|
+
4. **[HIGH · chat] Live SSE event read flat but arrives nested** — `app.js:281-293`: unwrap `const ev = data.payload || data;` and use `ev` for `state.events.push` and the tool_use/isError counters (keep `data.sid` for routing). Core live-history regression.
|
|
12
|
+
5. **[MEDIUM · chat] resumeSid persists across agent switch** — `app.js:149-168`: clear `resumeSid`/`resumeNote` in `selectAgent` when `id !== 'claude-code'`, and gate the banner (474), status sub (527), and forwarded arg (662) on `selectedAgent === 'claude-code'` (stale sid wrongly triggers agy `--continue`).
|
|
13
|
+
6. **[MEDIUM · chat] scrollChatToBottom queries dead `.chat-thread`** — `app.js:133-142`: prefer `.agentchat-thread` (the kit's real scroller), keep `.chat-thread`/`#agentgui-main` as fallbacks.
|
|
14
|
+
7. **[MEDIUM · history] Live events not deduped vs snapshot** — `app.js:282-285`: after the unwrap (#4), guard `state.events.push` with `ev.i != null && state.events.some(e => e.i === ev.i)` and count each `ev.i` once via a per-session Set.
|
|
15
|
+
8. **[MEDIUM · settings] Schemeless backend URL validated-but-stored-raw** — `app.js:998-1013`: normalize on save (`new URL(...).origin`), validate/store/use the same canonical string so `localhost:3000` can't resolve against the page origin.
|
|
16
|
+
9. **[MEDIUM · settings] clear-local-data leaves in-memory state that re-persists** — `app.js:1090-1098`: zero `selectedAgent`/`selectedModel`/`chatCwd`/`backend`/`backendDraft`/`agentModels` (don't re-call `B.setBackend('')` which rewrites the key), or `location.reload()` after clearing.
|
|
17
|
+
10. **[LOW · chat] Aborted-empty turn not pruned from in-memory array** — `app.js:599-604`: in `cancelChat`, `state.chat.messages = state.chat.messages.filter(m => !isEmptyTurn(m))` so `messages.length`-derived UI isn't off-by-one (persist only filters its snapshot, never the live array).
|
|
18
|
+
11. **[LOW · history] Search-hit focus scroll races live append** — `app.js:1228-1234`: snapshot the target event's `.i`/`.ts`, re-derive `rowPos` inside the rAF from current `state.events`, bail if it moved.
|
|
19
|
+
12. **[LOW · history] Search results ignore subagent/project filters** — `app.js:904-921`: apply the same `showSubagents`/`projectFilter` gating as `visibleSessions` before slicing (or label the header "incl. hidden").
|
|
20
|
+
13. **[LOW · settings] preferencesPanel cwd "(current: X)" can misrepresent running chats** — `app.js:1108-1109`: relabel as "default for new chats" or derive the displayed cwd from in-flight `chat.active` ctrl.cwd.
|
|
21
|
+
|
|
22
|
+
## APP — consistency / vocabulary (low, batch together)
|
|
23
|
+
|
|
24
|
+
14. **[MEDIUM · consistency] Status bar says 'live' where crumb/health say 'connected'** — `app.js:387`: use `ok ? 'connected' : 'offline'`; reserve 'live' for the history SSE label (335).
|
|
25
|
+
15. **[MEDIUM · consistency] agentsPanel rail inverts purple=subagent contract** — `app.js:1152`: `rail: avail ? 'green' : 'flame'` (selection already shown via `active`); purple must stay subagent-only.
|
|
26
|
+
16. **[LOW · settings] db chip absent on partial /health + 'N active' is dead text** — `app.js:1022-1040`: always render `db unknown` when `hh.db` absent; make the active-executions chip a button routing to history's running panel.
|
|
27
|
+
17. **[LOW · consistency] Resume label noun vs gerund** — `app.js:527`: `'resuming…'` to match banner + sibling gerund statuses.
|
|
28
|
+
18. **[LOW · consistency] Literal '...' in resume banner** — `app.js:479`: drop the trailing `...` (it's a fixed 8-char slice, not a truncation) or use `…`.
|
|
29
|
+
19. **[LOW · consistency] ACP 'running·healthy' tight middot vs spaced separator** — `app.js:1144`: `'running healthy'` so `·` keeps one meaning.
|
|
30
|
+
20. **[LOW · consistency] db 'ok/down' reuses vocabulary the connection chip translated away** — `app.js:1037`: `db online/offline` to match the connection register.
|
|
31
|
+
|
|
32
|
+
## KIT (c:\dev\anentrypoint-design) — requires build + publish to unpkg
|
|
33
|
+
|
|
34
|
+
21. **[HIGH · perf] Streaming re-parses full growing markdown every rAF (O(n²))** — `chat.js:82-90` MdNode: parse markdown once on turn-complete; render the live turn as cheap inline/plain text while streaming (gate on `isStreaming`), promote to `{kind:'md'}` only when settled.
|
|
35
|
+
22. **[HIGH · responsive] Chat control cluster + cwd bar have no media queries** — `chat.css`: add `@media` breakpoints for `.agentchat-controls`/`.agentchat-cwd-text` (the 360/640/900 rules in index.html target dead `.chat-controls`/`.cwd-bar`; also delete that dead CSS in `index.html`).
|
|
36
|
+
23. **[MEDIUM · a11y] EventList Row toggle never emits aria-expanded** — `content.js:23,39-50`: add an `expanded` prop → `aria-expanded` on the button branch; app passes the boolean at `app.js:796-806`.
|
|
37
|
+
24. **[MEDIUM · responsive] cwd `max-width:60ch` overflows phones** — `chat.css:59-64`: `max-width: min(60ch, 60vw)` (+ optional 640px 42vw clamp).
|
|
38
|
+
25. **[MEDIUM · responsive] Hamburger 36px + drawer rows ~40px + send button under 44px** — `247420.css` source: raise `.app-side-toggle` and `.app-side a` and the small/landscape composer `send` to min 44×44 (WCAG 2.5.5).
|
|
39
|
+
26. **[MEDIUM · security] marked/dompurify imported from unpinned CDN** — `markdown.js:9-26`: vendor marked+dompurify locally (dynamic import can't carry SRI) and ship a `script-src 'self'` CSP; success/fallback sanitization paths are already safe.
|
|
40
|
+
27. **[LOW · perf] CodeNode highlight cache key uses code LENGTH not content** — `chat.js:95-97`: key on full `p.code` (or export+use `simpleHash`) so same-length blocks re-highlight.
|
|
41
|
+
28. **[LOW · a11y] `.agentchat-cwd-btn` has no :focus-visible** — `chat.css`: add `:focus-visible` ring mirroring `.agentchat-empty-suggestion`.
|
|
42
|
+
29. **[LOW · a11y] Native select pickers have title-only accessible name** — `agent-chat.js:36-48`: pass `label:` (visible or sr-only) to the agent/model `Select`.
|
|
43
|
+
30. **[LOW · consistency] Head sub-line re-derives 'streaming…' ignoring status prop** — `agent-chat.js:175`: derive from the same `status` prop (`busy ? (status||'streaming…') : …`) so reconnecting-while-streaming reads one word.
|
|
44
|
+
31. **[LOW · glyph] Box-drawing dividers in OS-kit comments** — `src/kits/os/app-panes.css:4,51,114`: replace `──` with `---` (comment-only; not the shipped AgentChat surface).
|
|
45
|
+
|
|
46
|
+
## BOTH (coordinated app + kit + publish)
|
|
47
|
+
|
|
48
|
+
32. **[HIGH · chat] Text and tool parts don't interleave — all prose renders before every tool card** — `app.js:673-675` + `agent-chat.js:114-124`: model the assistant turn as one ordered `parts[]` (append/extend `{kind:'md'}` segments and tool parts in arrival order; tool boundary closes the md segment); drop the forced `m.content` prepend in the kit and render `parts` strictly in order. This is the core chat-jank fix and supersedes the standalone perf concern in #21 about ordering.
|
|
49
|
+
33. **[MEDIUM · chat] Orphan tool_result renders as detached bottom card** — `app.js:446-457`: ids already match on both runner paths, so harden the client fallback — attach an unmatched result to the most recent running tool part (or insert after the last tool part) rather than pushing to array end. Compounds #32.
|
|
50
|
+
34. **[MEDIUM · history] Same event kinds render differently in chat vs history** — `app.js:773-808` + kit ToolCallNode: generalize the kit's ToolCallNode so EventList rows accept structured `{kind:'tool',...}` parts, then route history tool_use/tool_result through the same `toolPart`/`applyToolResult` pairing chat uses (preserve green/purple/flame rails).
|
|
51
|
+
|
|
52
|
+
## PERF — confirmed no-fix (mitigating context)
|
|
53
|
+
|
|
54
|
+
- `app.js:264,1306` relTime/resize full re-render: acceptable (rAF-coalesced); resolves once #36 row-build is lazy.
|
|
55
|
+
- `app.js` webjsx keying: stable, no collision — this is why the perf findings stay at vnode-build cost, not DOM teardown.
|
|
56
|
+
|
|
57
|
+
## APP — remaining perf (high/medium)
|
|
58
|
+
|
|
59
|
+
35. **[HIGH · perf] Full app tree re-renders per rAF during stream** — `app.js:78-86`: scope the streaming re-render to the active transcript/bubble rather than rebuilding Topbar/Crumb/Status/panels every token (pairs with kit #21).
|
|
60
|
+
36. **[HIGH · perf] EventList computes `full`+JSON.stringify for all ~300 rows every frame** — `app.js:790`: build `full` (and `JSON.stringify(e.toolInput,...)`) only when the row is `expanded`.
|
|
61
|
+
37. **[MEDIUM · perf] O(sessions) linear scan per live event** — `app.js:287-299`: build `state.sessionsBySid = new Map(...)` on each refresh, `.get(data.sid)` instead of `arr.find`.
|
|
62
|
+
38. **[MEDIUM · perf] scrollChatToBottom nests a 2nd rAF + re-querySelector per frame** — `app.js:133-142`: cache the scroller (re-query only if detached), drop the inner rAF (caller already in a frame). Folds into #6.
|
package/lib/http-handler.js
CHANGED
|
@@ -21,6 +21,26 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
21
21
|
} // else: no ACAO header -> browsers block cross-origin reads (same-origin still works)
|
|
22
22
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
23
23
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
24
|
+
// The password can ride in a ?token= query param (EventSource/deep-links
|
|
25
|
+
// can't set headers), so a navigation away from the app would leak it in
|
|
26
|
+
// the Referer header to the destination. Strip the referrer on every
|
|
27
|
+
// response so the credential never crosses an origin boundary that way.
|
|
28
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
29
|
+
// CSP: the markdown stack (marked/dompurify/prismjs) and the DS kit load
|
|
30
|
+
// from unpkg/jsdelivr; everything else is self. connect-src also allows
|
|
31
|
+
// self for the same-origin WS/SSE. 'unsafe-inline' on style/script is
|
|
32
|
+
// required by the inlined head bootstrap + DS runtime <style> injection.
|
|
33
|
+
res.setHeader('Content-Security-Policy', [
|
|
34
|
+
"default-src 'self'",
|
|
35
|
+
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net",
|
|
36
|
+
"style-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://fonts.googleapis.com",
|
|
37
|
+
"img-src 'self' data: blob:",
|
|
38
|
+
"font-src 'self' data: https://unpkg.com https://cdn.jsdelivr.net https://fonts.gstatic.com",
|
|
39
|
+
"connect-src 'self' https://unpkg.com https://cdn.jsdelivr.net ws: wss:",
|
|
40
|
+
"object-src 'none'",
|
|
41
|
+
"base-uri 'self'",
|
|
42
|
+
"frame-ancestors 'self'",
|
|
43
|
+
].join('; '));
|
|
24
44
|
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
|
|
25
45
|
if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
|
|
26
46
|
|
|
@@ -140,10 +160,11 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
140
160
|
// arbitrary-file-read of any image-extensioned path on the host (the
|
|
141
161
|
// prior `includes('..')` guard is a no-op after path.normalize resolves
|
|
142
162
|
// the segments). Allowed roots: the Claude projects dir (history images)
|
|
143
|
-
//
|
|
163
|
+
// only; add more via IMAGE_ROOTS (path-separated). The user home is NOT
|
|
164
|
+
// a default root - it covers ~/.ssh, ~/.aws, dotfiles etc., so an image
|
|
165
|
+
// route reaching all of home is a broad read of anything image-shaped.
|
|
144
166
|
const allowRoots = [
|
|
145
167
|
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
146
|
-
os.homedir(),
|
|
147
168
|
...(process.env.IMAGE_ROOTS ? process.env.IMAGE_ROOTS.split(path.delimiter) : []),
|
|
148
169
|
].map(r => path.normalize(r));
|
|
149
170
|
const norm = isWindows ? normalizedPath.toLowerCase() : normalizedPath;
|
|
@@ -156,7 +177,10 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
156
177
|
if (!fs.existsSync(normalizedPath)) { res.writeHead(404); res.end('Not found'); return; }
|
|
157
178
|
const ext = path.extname(normalizedPath).toLowerCase();
|
|
158
179
|
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
|
|
159
|
-
|
|
180
|
+
// Only serve known image types - never an octet-stream fallback, which
|
|
181
|
+
// would turn this into a generic file reader for any extension.
|
|
182
|
+
const contentType = mimeTypes[ext];
|
|
183
|
+
if (!contentType) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
160
184
|
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
|
|
161
185
|
res.end(fs.readFileSync(normalizedPath));
|
|
162
186
|
} catch (err) { sendJSON(req, res, 400, { error: err.message }); }
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -70,6 +70,14 @@ export function register(router, deps) {
|
|
|
70
70
|
const cwd = p?.cwd || STARTUP_CWD;
|
|
71
71
|
const resumeSessionId = p?.resumeSid || p?.resumeSessionId || undefined;
|
|
72
72
|
if (!registry.has(agentId)) err(404, `Unknown agentId: ${agentId}`);
|
|
73
|
+
// A client-supplied cwd that doesn't exist would have the agent CLI spawn
|
|
74
|
+
// fail opaquely (or inherit STARTUP_CWD on some runners). Validate up front
|
|
75
|
+
// so the caller gets a clear 400 instead of a confusing mid-stream error.
|
|
76
|
+
if (p?.cwd) {
|
|
77
|
+
let st = null;
|
|
78
|
+
try { st = fs.statSync(cwd); } catch (_) {}
|
|
79
|
+
if (!st || !st.isDirectory()) err(400, `cwd is not an existing directory: ${cwd}`);
|
|
80
|
+
}
|
|
73
81
|
|
|
74
82
|
const sessionId = 'chat-' + crypto.randomBytes(8).toString('hex');
|
|
75
83
|
// Auto-subscribe the originating ws so it receives its own broadcasts.
|
package/package.json
CHANGED
package/site/app/js/app.js
CHANGED
|
@@ -130,15 +130,19 @@ function pillButton(key, label, active, title, onClick) {
|
|
|
130
130
|
}, label);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
let _chatScroller = null;
|
|
133
134
|
function scrollChatToBottom() {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
// The kit's real scroll container is .agentchat-thread; the old .chat-thread
|
|
136
|
+
// selector matched nothing, so auto-scroll silently never fired. Cache the
|
|
137
|
+
// node and re-query only if it detached. Callers already run inside a frame
|
|
138
|
+
// (or right after render), so no nested rAF here.
|
|
139
|
+
if (!_chatScroller || !_chatScroller.isConnected) {
|
|
140
|
+
_chatScroller = document.querySelector('.agentchat-thread')
|
|
141
|
+
|| document.querySelector('.chat-thread')
|
|
138
142
|
|| document.querySelector('#agentgui-main')
|
|
139
143
|
|| document.getElementById('app');
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
}
|
|
145
|
+
if (_chatScroller) _chatScroller.scrollTop = _chatScroller.scrollHeight;
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
function timeNow() {
|
|
@@ -152,6 +156,10 @@ async function selectAgent(id) {
|
|
|
152
156
|
if (id === state.selectedAgent && state.agentModels.length) return;
|
|
153
157
|
state.selectedAgent = id;
|
|
154
158
|
lsSet('agentgui.agent', id);
|
|
159
|
+
// Resume state is claude-code-only (it threads claude's --resume sid). Switching
|
|
160
|
+
// to any other agent must clear it, or the resume banner keeps showing and a
|
|
161
|
+
// stale sid gets forwarded - which makes agy wrongly run --continue.
|
|
162
|
+
if (id !== 'claude-code') { state.chat.resumeSid = null; state.chat.resumeNote = null; }
|
|
155
163
|
state.agentModels = [];
|
|
156
164
|
state.selectedModel = '';
|
|
157
165
|
state.modelsLoading = true;
|
|
@@ -261,7 +269,7 @@ function stopActivePolling() {
|
|
|
261
269
|
// Re-render once a minute so relative timestamps ("5s ago") don't sit frozen
|
|
262
270
|
// between events. Cheap: scheduleRender coalesces via rAF.
|
|
263
271
|
let _relTick = null;
|
|
264
|
-
function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => scheduleRender(), 30000); }
|
|
272
|
+
function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => { if (state.tab === 'history' || (Array.isArray(state.active) && state.active.length)) scheduleRender(); }, 30000); }
|
|
265
273
|
async function stopActiveChat(sid) {
|
|
266
274
|
try { await B.cancelChat(state.backend, sid); } catch {}
|
|
267
275
|
refreshActive();
|
|
@@ -279,18 +287,26 @@ function openLiveStream() {
|
|
|
279
287
|
if (!state.live.connected) state.live.connected = true;
|
|
280
288
|
if (state.live.error) { state.live.error = null; state.live.reconnects++; }
|
|
281
289
|
} else if (kind === 'event' && data) {
|
|
290
|
+
// ccsniff's stream frame is { sid, payload: <flattened event> } - the
|
|
291
|
+
// real event (type/isError/i/ts) lives under .payload, not on the
|
|
292
|
+
// wrapper. Reading the wrapper gave every counter `undefined`, so live
|
|
293
|
+
// tool/error tallies never moved. Route by data.sid, read ev for the rest.
|
|
294
|
+
const ev = data.payload || data;
|
|
282
295
|
if (state.selectedSid && data.sid === state.selectedSid) {
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
if (
|
|
296
|
+
// Dedupe against the snapshot/prior pushes by event index - a
|
|
297
|
+
// reconnect or overlap would otherwise double-append the same event.
|
|
298
|
+
if (ev.i == null || !state.events.some(e => e.i === ev.i)) {
|
|
299
|
+
state.events.push(ev);
|
|
300
|
+
// Cap retained events so a long live session can't grow unbounded.
|
|
301
|
+
if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
|
|
302
|
+
}
|
|
286
303
|
}
|
|
287
|
-
const
|
|
288
|
-
const sess = arr.find(s => s.sid === data.sid);
|
|
304
|
+
const sess = state.sessionsBySid ? state.sessionsBySid.get(data.sid) : null;
|
|
289
305
|
if (sess) {
|
|
290
306
|
sess.events = (sess.events || 0) + 1;
|
|
291
|
-
sess.last =
|
|
292
|
-
if (
|
|
293
|
-
if (
|
|
307
|
+
sess.last = ev.ts || Date.now();
|
|
308
|
+
if (ev.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
|
|
309
|
+
if (ev.isError) sess.errors = (sess.errors || 0) + 1;
|
|
294
310
|
} else {
|
|
295
311
|
// Unknown session: a burst of events for a new session would trigger
|
|
296
312
|
// a full session-list refetch per event - debounce it into one.
|
|
@@ -384,7 +400,7 @@ function view() {
|
|
|
384
400
|
? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
|
|
385
401
|
: 'no agent';
|
|
386
402
|
const status = Status({
|
|
387
|
-
left: [state.backend || 'same-origin', ok ? '
|
|
403
|
+
left: [state.backend || 'same-origin', ok ? 'connected' : 'offline'],
|
|
388
404
|
right: [agentLabel, 'press ? for shortcuts'],
|
|
389
405
|
});
|
|
390
406
|
|
|
@@ -397,7 +413,7 @@ function view() {
|
|
|
397
413
|
? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
|
|
398
414
|
children: 'g then c/h/s - chat/history/settings · n - new chat · / - focus search/composer · ? - toggle this · Esc - blur field' })
|
|
399
415
|
: null;
|
|
400
|
-
const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
|
|
416
|
+
const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
|
|
401
417
|
// narrow drives the DS main-column class; the history sidebar itself collapses
|
|
402
418
|
// behind the DS .app-side-toggle hamburger (provided by AppShell when a side
|
|
403
419
|
// exists), so mobile users get a togglable session list.
|
|
@@ -441,13 +457,23 @@ function toolPart(block) {
|
|
|
441
457
|
};
|
|
442
458
|
}
|
|
443
459
|
|
|
460
|
+
// Append a streamed text delta as an interleaved markdown part, coalescing
|
|
461
|
+
// consecutive text into the trailing md part so prose and tool cards keep their
|
|
462
|
+
// arrival order within the turn (text -> tool -> text).
|
|
463
|
+
function appendText(parts, text) {
|
|
464
|
+
const last = parts[parts.length - 1];
|
|
465
|
+
if (last && last.kind === "md") last.text += text;
|
|
466
|
+
else parts.push({ kind: "md", text });
|
|
467
|
+
}
|
|
468
|
+
|
|
444
469
|
// Apply a tool_result to its matching tool part (by id), else append a
|
|
445
470
|
// standalone tool_result part. Sets the card's result + done/error status.
|
|
446
471
|
function applyToolResult(parts, block) {
|
|
447
472
|
const id = block.tool_use_id || block.id || null;
|
|
448
473
|
const content = block?.content ?? block?.output ?? block;
|
|
449
474
|
const isError = !!(block?.is_error);
|
|
450
|
-
const
|
|
475
|
+
const byId = id ? [...parts].reverse().find(p => p && p.kind === 'tool' && p._id === id) : null;
|
|
476
|
+
const target = byId || [...parts].reverse().find(p => p && p.kind === 'tool' && p.status === 'running');
|
|
451
477
|
if (target) {
|
|
452
478
|
target.result = content;
|
|
453
479
|
target.status = isError ? 'error' : 'done';
|
|
@@ -471,12 +497,22 @@ function chatMain() {
|
|
|
471
497
|
// stream error) are pre-built here and handed to the kit, which renders them
|
|
472
498
|
// above the thread. The kit holds no agentgui-specific banner logic.
|
|
473
499
|
const banners = [];
|
|
474
|
-
|
|
500
|
+
// Non-claude-code agents cannot --resume by id, so a multi-turn chat with one
|
|
501
|
+
// silently loses prior context. Warn once the conversation is past its first
|
|
502
|
+
// turn so the user knows this agent is not carrying history forward.
|
|
503
|
+
{
|
|
504
|
+
const userTurns = state.chat.messages.filter(m => m.role === 'user').length;
|
|
505
|
+
if (state.selectedAgent && state.selectedAgent !== 'claude-code' && userTurns > 1 && !state.chat.resumeSid) {
|
|
506
|
+
banners.push(Alert({ key: 'nocontinuity', kind: 'info', title: (agentById(state.selectedAgent)?.name || state.selectedAgent) + ' does not resume across turns',
|
|
507
|
+
children: 'This agent starts fresh each message - it will not remember earlier turns in this chat. Use Claude Code for a continuous conversation.' }));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (state.chat.resumeSid && state.selectedAgent === 'claude-code') {
|
|
475
511
|
// The agent-switched note (if any) lives inside the resume banner rather
|
|
476
512
|
// than as a separate stacked Alert, so resume context reads as one block.
|
|
477
513
|
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
478
514
|
h('span', { key: 'rbtxt', class: 'lede' },
|
|
479
|
-
'resuming session ' + state.chat.resumeSid.slice(0, 8) + '
|
|
515
|
+
'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'
|
|
480
516
|
+ (state.chat.resumeNote ? ' - ' + state.chat.resumeNote : '')),
|
|
481
517
|
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
|
|
482
518
|
}
|
|
@@ -524,7 +560,7 @@ function chatMain() {
|
|
|
524
560
|
draft: state.chat.draft,
|
|
525
561
|
status: state.chat.busy
|
|
526
562
|
? (state.health.ws === 'reconnecting' ? 'reconnecting…' : 'streaming…')
|
|
527
|
-
: (state.modelsLoading ? 'loading models…' : (state.chat.resumeSid ? '
|
|
563
|
+
: (state.modelsLoading ? 'loading models…' : ((state.chat.resumeSid && state.selectedAgent === 'claude-code') ? 'resuming…' : 'ready')),
|
|
528
564
|
agentName,
|
|
529
565
|
placeholder,
|
|
530
566
|
canSend: canSend(),
|
|
@@ -600,7 +636,13 @@ function cancelChat() {
|
|
|
600
636
|
// Clear busy immediately so the composer re-enables and the "stop" button
|
|
601
637
|
// flips back to "new" without waiting for the stream's finally block.
|
|
602
638
|
state.chat.abort?.abort();
|
|
603
|
-
|
|
639
|
+
// Drop a trailing empty assistant shell (aborted before any content) from the
|
|
640
|
+
// live array so the message count + suggestions-empty check stay correct; the
|
|
641
|
+
// paired user message is intentionally kept.
|
|
642
|
+
const msgs = state.chat.messages;
|
|
643
|
+
if (msgs.length && isEmptyTurn(msgs[msgs.length - 1])) msgs.pop();
|
|
644
|
+
if (state.chat.busy) { state.chat.busy = false; }
|
|
645
|
+
render();
|
|
604
646
|
}
|
|
605
647
|
|
|
606
648
|
const CHAT_KEY = 'agentgui.chat';
|
|
@@ -659,7 +701,9 @@ async function sendChat() {
|
|
|
659
701
|
cwd: state.chatCwd || undefined,
|
|
660
702
|
messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
|
|
661
703
|
signal: ctrl.signal,
|
|
662
|
-
|
|
704
|
+
// Only claude-code consumes a resume sid; never forward a stale one to
|
|
705
|
+
// another agent (it makes agy spuriously run --continue).
|
|
706
|
+
resumeSid: (state.selectedAgent === 'claude-code' && state.chat.resumeSid) || undefined,
|
|
663
707
|
})) {
|
|
664
708
|
if (ev.type === 'session') {
|
|
665
709
|
// Remember the server's session id so the NEXT turn resumes this
|
|
@@ -670,7 +714,7 @@ async function sendChat() {
|
|
|
670
714
|
persistChat();
|
|
671
715
|
}
|
|
672
716
|
}
|
|
673
|
-
else if (ev.type === 'text') { cur.
|
|
717
|
+
else if (ev.type === 'text') { appendText(cur.parts, ev.text); scheduleStreamRender(); }
|
|
674
718
|
else if (ev.type === 'tool') { cur.parts.push(toolPart(ev.block)); scheduleStreamRender(); }
|
|
675
719
|
else if (ev.type === 'tool_result') { applyToolResult(cur.parts, ev.block); scheduleStreamRender(); }
|
|
676
720
|
else if (ev.type === 'result') { /* terminal usage/summary block - already reflected via text */ }
|
|
@@ -787,16 +831,18 @@ function historyMain() {
|
|
|
787
831
|
const raw = e.text || '';
|
|
788
832
|
const text = raw.replace(/\s+/g, ' ').trim();
|
|
789
833
|
const expanded = state.expandedEvents.has(key);
|
|
790
|
-
|
|
834
|
+
// Only build the expanded body (JSON.stringify tool input) when the row is
|
|
835
|
+
// expanded - doing it for all ~300 rows every frame wastes work mid-stream.
|
|
836
|
+
const full = expanded ? (e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw) : '';
|
|
791
837
|
// Rail tone matches the session/agents rail semantics so an event's
|
|
792
838
|
// kind is visible at a glance, consistent across the GUI:
|
|
793
839
|
// flame = error, purple = tool_use, green = normal turn.
|
|
794
|
-
const
|
|
795
|
-
const rail = e.isError ? 'flame' : (isTool ? 'purple' : 'green');
|
|
840
|
+
const rail = e.isError ? 'flame' : 'green';
|
|
796
841
|
return {
|
|
797
842
|
key,
|
|
798
843
|
code: String(absIdx + 1).padStart(4, '0'),
|
|
799
844
|
rail,
|
|
845
|
+
expanded, // disclosure state -> kit Row sets aria-expanded
|
|
800
846
|
title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
|
|
801
847
|
// Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
|
|
802
848
|
// Every row is click-to-expand, so always show the affordance word
|
|
@@ -997,8 +1043,19 @@ function historySide() {
|
|
|
997
1043
|
// --- settings ---
|
|
998
1044
|
function isValidUrl(s) {
|
|
999
1045
|
if (!s) return true; // blank = same-origin is valid
|
|
1000
|
-
try {
|
|
1001
|
-
|
|
1046
|
+
try {
|
|
1047
|
+
const u = new URL(s.startsWith('http') ? s : 'http://' + s);
|
|
1048
|
+
return u.protocol === 'http:' || u.protocol === 'https:'; // reject ftp:/ws:/etc
|
|
1049
|
+
} catch { return false; }
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Canonicalize a backend URL to its origin so a schemeless 'localhost:3000'
|
|
1053
|
+
// (which would otherwise be stored raw and resolved against the page origin)
|
|
1054
|
+
// becomes the same string we validated. Blank stays blank (same-origin).
|
|
1055
|
+
function normalizeBackend(s) {
|
|
1056
|
+
if (!s) return '';
|
|
1057
|
+
try { return new URL(s.startsWith('http') ? s : 'http://' + s).origin; }
|
|
1058
|
+
catch { return s; }
|
|
1002
1059
|
}
|
|
1003
1060
|
|
|
1004
1061
|
async function saveBackend() {
|
|
@@ -1009,8 +1066,10 @@ async function saveBackend() {
|
|
|
1009
1066
|
state.confirmingBackend = true; render(); return;
|
|
1010
1067
|
}
|
|
1011
1068
|
state.confirmingBackend = false;
|
|
1012
|
-
|
|
1013
|
-
state.
|
|
1069
|
+
const canonical = normalizeBackend(state.backendDraft);
|
|
1070
|
+
state.backendDraft = canonical;
|
|
1071
|
+
B.setBackend(canonical);
|
|
1072
|
+
state.backend = canonical;
|
|
1014
1073
|
state.health = { status: 'unknown' };
|
|
1015
1074
|
state.backendStatus = 'connecting';
|
|
1016
1075
|
render();
|
|
@@ -1034,7 +1093,11 @@ function healthSummary() {
|
|
|
1034
1093
|
if (hh.version) bits.push(['v' + hh.version, 'Server version']);
|
|
1035
1094
|
if (typeof hh.agents === 'number') bits.push([hh.agents + ' agents', 'Agents registered on the server']);
|
|
1036
1095
|
if (typeof hh.activeExecutions === 'number') bits.push([hh.activeExecutions + ' active', 'Chats currently executing']);
|
|
1037
|
-
|
|
1096
|
+
// db chip uses the same online/offline register as the connection chip
|
|
1097
|
+
// (not ok/down, which the connection chip already translated away). Always
|
|
1098
|
+
// render it: when /health is partial and hh.db is absent, show 'db unknown'
|
|
1099
|
+
// rather than dropping the chip and leaving the db state ambiguous.
|
|
1100
|
+
bits.push(['db ' + (hh.db ? (hh.db.ok ? 'online' : 'offline') : 'unknown'), 'History database status']);
|
|
1038
1101
|
return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : ''), role: 'group', 'aria-label': 'Backend health' },
|
|
1039
1102
|
...bits.map(([b, t], i) => h('span', { key: 'hb' + i, class: 'health-chip', title: t }, b)));
|
|
1040
1103
|
}
|
|
@@ -1091,10 +1154,14 @@ function clearLocalData() {
|
|
|
1091
1154
|
if (!state.confirmingClearData) { state.confirmingClearData = true; render(); return; }
|
|
1092
1155
|
state.confirmingClearData = false;
|
|
1093
1156
|
// Wipe agentgui's own localStorage keys (chat transcript, agent/model/cwd,
|
|
1094
|
-
// backend).
|
|
1157
|
+
// backend). Then reload: clearing only the keys but leaving the in-memory
|
|
1158
|
+
// selectedAgent/Model/chatCwd/backend would let the next interaction
|
|
1159
|
+
// re-persist them, so the "clear" wouldn't survive. A reload re-inits from
|
|
1160
|
+
// defaults with the keys gone.
|
|
1161
|
+
state.chat.abort?.abort(); // stop any in-flight stream before we drop the page
|
|
1095
1162
|
for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend']) lsRemove(k);
|
|
1096
1163
|
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
|
|
1097
|
-
|
|
1164
|
+
location.reload();
|
|
1098
1165
|
}
|
|
1099
1166
|
|
|
1100
1167
|
function preferencesPanel() {
|
|
@@ -1141,7 +1208,7 @@ function agentsPanel() {
|
|
|
1141
1208
|
const usable = avail || a.npxInstallable; // selectable from this row
|
|
1142
1209
|
const bits = [a.protocol || 'agent'];
|
|
1143
1210
|
if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
|
|
1144
|
-
if (acp) bits.push(acp.healthy ? 'running
|
|
1211
|
+
if (acp) bits.push(acp.healthy ? 'running healthy' : (acp.running ? 'running' : 'stopped'));
|
|
1145
1212
|
if (acp && acp.port) bits.push('port ' + acp.port);
|
|
1146
1213
|
if (acp && acp.restartCount) bits.push(acp.restartCount + ' restarts');
|
|
1147
1214
|
return Row({
|
|
@@ -1149,7 +1216,10 @@ function agentsPanel() {
|
|
|
1149
1216
|
rank: String(i + 1).padStart(3, '0'),
|
|
1150
1217
|
title: a.name,
|
|
1151
1218
|
sub: bits.join(' · '),
|
|
1152
|
-
|
|
1219
|
+
// Rail tone keeps its GUI-wide meaning: green=ok/selected,
|
|
1220
|
+
// flame=error/unavailable. Selection is shown via `active`, not by
|
|
1221
|
+
// borrowing purple (purple is reserved for subagents).
|
|
1222
|
+
rail: (a.id === state.selectedAgent || avail) ? 'green' : 'flame',
|
|
1153
1223
|
active: a.id === state.selectedAgent,
|
|
1154
1224
|
// Non-installable agents are genuinely inert: mark them disabled (no
|
|
1155
1225
|
// click, no button role) instead of looking clickable but doing nothing.
|
|
@@ -1166,6 +1236,9 @@ function agentsPanel() {
|
|
|
1166
1236
|
async function refreshHistory() {
|
|
1167
1237
|
try {
|
|
1168
1238
|
state.sessions = await B.listSessions(state.backend);
|
|
1239
|
+
// Index by sid so each live SSE event is an O(1) lookup, not an O(sessions)
|
|
1240
|
+
// linear scan per event during a burst load.
|
|
1241
|
+
state.sessionsBySid = new Map((state.sessions || []).map(s => [s.sid, s]));
|
|
1169
1242
|
state.historyError = null;
|
|
1170
1243
|
render();
|
|
1171
1244
|
} catch (e) {
|
|
@@ -2363,11 +2363,13 @@
|
|
|
2363
2363
|
.ds-247420 .chat-composer textarea { padding: 10px 12px; }
|
|
2364
2364
|
}
|
|
2365
2365
|
|
|
2366
|
-
/*
|
|
2366
|
+
/* --- landscape orientation: reduce vertical space for composer --- */
|
|
2367
2367
|
@media (max-height: 500px) and (orientation: landscape) {
|
|
2368
2368
|
.ds-247420 .chat-composer { padding: 6px 0; }
|
|
2369
|
-
.ds-247420 .chat-composer textarea { min-height:
|
|
2370
|
-
|
|
2369
|
+
.ds-247420 .chat-composer textarea { min-height: 44px; max-height: 120px; }
|
|
2370
|
+
/* Keep the send button at the 44x44 minimum tap target even in the
|
|
2371
|
+
space-constrained landscape layout. */
|
|
2372
|
+
.ds-247420 .chat-composer .send, .ds-247420 .chat-composer button { width: 44px; height: 44px; font-size: 14px; }
|
|
2371
2373
|
}
|
|
2372
2374
|
|
|
2373
2375
|
/* ============================================================
|
|
@@ -3028,9 +3030,11 @@
|
|
|
3028
3030
|
/* Mobile nav toggle (hamburger) — hidden on desktop, shown ≤900px */
|
|
3029
3031
|
.ds-247420 .app-side-toggle {
|
|
3030
3032
|
display: none;
|
|
3031
|
-
position: fixed; top: calc((var(--app-topbar-h) -
|
|
3033
|
+
position: fixed; top: calc((var(--app-topbar-h) - 44px) / 2); left: 10px;
|
|
3032
3034
|
z-index: 51;
|
|
3033
|
-
|
|
3035
|
+
/* 44x44 minimum hit area (WCAG 2.5.5 / Apple HIG) - 36px is below the
|
|
3036
|
+
reliable-tap threshold on touch. */
|
|
3037
|
+
width: 44px; height: 44px;
|
|
3034
3038
|
align-items: center; justify-content: center;
|
|
3035
3039
|
font-size: 18px; line-height: 1;
|
|
3036
3040
|
background: var(--bg-2); color: var(--fg);
|
|
@@ -4631,6 +4635,22 @@
|
|
|
4631
4635
|
}
|
|
4632
4636
|
.ds-247420 .agentchat-controls .select,
|
|
4633
4637
|
.ds-247420 .agentchat-controls [role="combobox"] { min-width: 130px; max-width: 240px; }
|
|
4638
|
+
.ds-247420 .agentchat-controls .ds-select { min-width: 130px; max-width: 240px; }
|
|
4639
|
+
|
|
4640
|
+
/* Narrow viewports: let the agent/model selects share the row two-up, then
|
|
4641
|
+
stack full-width on the smallest screens, and drop the status to its own
|
|
4642
|
+
line so nothing is squeezed below a usable tap size. */
|
|
4643
|
+
@media (max-width: 640px) {
|
|
4644
|
+
.ds-247420 .agentchat-controls .select,
|
|
4645
|
+
.ds-247420 .agentchat-controls .ds-select,
|
|
4646
|
+
.ds-247420 .agentchat-controls [role="combobox"] { flex: 1 1 45%; max-width: none; }
|
|
4647
|
+
.ds-247420 .agentchat-status { margin-left: 0; flex-basis: 100%; }
|
|
4648
|
+
}
|
|
4649
|
+
@media (max-width: 360px) {
|
|
4650
|
+
.ds-247420 .agentchat-controls .select,
|
|
4651
|
+
.ds-247420 .agentchat-controls .ds-select,
|
|
4652
|
+
.ds-247420 .agentchat-controls [role="combobox"] { flex-basis: 100%; }
|
|
4653
|
+
}
|
|
4634
4654
|
|
|
4635
4655
|
.ds-247420 .agentchat-status {
|
|
4636
4656
|
display: inline-flex;
|
|
@@ -4672,7 +4692,9 @@
|
|
|
4672
4692
|
overflow: hidden;
|
|
4673
4693
|
text-overflow: ellipsis;
|
|
4674
4694
|
white-space: nowrap;
|
|
4675
|
-
|
|
4695
|
+
/* 60ch overflows narrow phones; clamp to the viewport so the path ellipsizes
|
|
4696
|
+
instead of pushing the row wider than the screen. */
|
|
4697
|
+
max-width: min(60ch, 60vw);
|
|
4676
4698
|
}
|
|
4677
4699
|
.ds-247420 .agentchat-cwd-btn {
|
|
4678
4700
|
background: none;
|
|
@@ -4684,6 +4706,7 @@
|
|
|
4684
4706
|
font: inherit;
|
|
4685
4707
|
}
|
|
4686
4708
|
.ds-247420 .agentchat-cwd-btn:hover { border-color: var(--accent); }
|
|
4709
|
+
.ds-247420 .agentchat-cwd-btn:focus-visible { outline: 2px solid var(--accent, var(--fg)); outline-offset: 2px; }
|
|
4687
4710
|
.ds-247420 .agentchat-cwd-input {
|
|
4688
4711
|
flex: 1;
|
|
4689
4712
|
min-width: 12ch;
|
|
@@ -4774,6 +4797,18 @@
|
|
|
4774
4797
|
.ds-247420 .agentchat-working .chat-thinking-dots span { animation: none; opacity: .7; }
|
|
4775
4798
|
}
|
|
4776
4799
|
|
|
4800
|
+
/* Responsive: the control cluster + cwd bar must stay usable on phones/tablets.
|
|
4801
|
+
The cwd path can't eat the row beside its label + buttons on a narrow screen. */
|
|
4802
|
+
.ds-247420 .agentchat-controls { flex-wrap: wrap; }
|
|
4803
|
+
.ds-247420 .agentchat-cwd-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: min(60ch, 60vw); }
|
|
4804
|
+
@media (max-width: 900px) {
|
|
4805
|
+
.ds-247420 .agentchat-controls .ds-select { min-width: 110px; max-width: 180px; }
|
|
4806
|
+
}
|
|
4807
|
+
@media (max-width: 640px) {
|
|
4808
|
+
.ds-247420 .agentchat-cwd-text { max-width: 42vw; }
|
|
4809
|
+
.ds-247420 .agentchat-controls { gap: .4em; }
|
|
4810
|
+
}
|
|
4811
|
+
|
|
4777
4812
|
/* editor-primitives.css */
|
|
4778
4813
|
/* editor-primitives.css — chrome for in-engine editors, inspectors, IDEs,
|
|
4779
4814
|
debug HUDs. All rules under .ds-247420 scope (build prefixes). Tokens
|