agentgui 1.0.949 → 1.0.951

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # AgentGUI — Agent Notes
2
2
 
3
+ ## GUI-audit closure (2026-06-04 fan-out) — second maximum-effort sweep
4
+
5
+ The reusable `.claude/workflows/gui-audit.js` ran a 64-agent fan-out (9 per-surface reviewers -> adversarial verify -> synthesize) producing 44 adversarially-confirmed findings; the deduped 38-item punch-list is in `AUDIT-PUNCHLIST.md`. All were fixed across three surfaces and pushed (agentgui `eb1eab3`, kit `anentrypoint-design` `bb776aa` -> CI publishes to npm/unpkg).
6
+
7
+ - **Server `lib/http-handler.js`:** `/api/image` allowlist no longer includes `os.homedir()` (was a broad image-shaped read of all of `$HOME`); unknown extensions 403 instead of an `application/octet-stream` fallback. Always emits `Referrer-Policy: no-referrer` (so a `?token=` credential can't leak via `Referer`) and a **CSP** (`default-src 'self'`; script/style self+unsafe-inline+unpkg+jsdelivr; `style-src` also `fonts.googleapis.com`, `font-src` also `fonts.gstatic.com` — the kit's Google Fonts, a regression caught live; `connect-src` self+cdns+ws/wss; `object-src 'none'`). `lib/ws-handlers-util.js` validates a client-supplied chat `cwd` is an existing directory (`fs.statSync`) before spawn.
8
+ - **App `site/app/js/app.js`:** live SSE events are `{sid, payload:fl}` (ccsniff `store.js`) — unwrap `data.payload` (counters were reading `undefined`); dedupe by `.i`; `state.sessionsBySid` Map for O(1) per-event lookup; `scrollChatToBottom` targets `.agentchat-thread` (cached) not the dead `.chat-thread`; resume state cleared on switch off claude-code and banner/status/forwarded-arg all gated on claude-code; backend URL normalized to its origin on save; clear-local-data `location.reload()`s; status vocab unified (`connected`/`offline`, `db online/offline/unknown`, `running healthy`); agentsPanel rail `green`/`flame` (purple reserved for subagents); a non-resume warning banner for non-claude-code multi-turn chats.
9
+ - **Kit `c:\dev\anentrypoint-design`:** `AgentChat` renders the live-streaming turn's prose as cheap inline text and promotes to full markdown only once settled (MdNode was re-parsing the whole growing answer every frame, O(n²)); head sub-line derives its busy label from the `status` prop; `Row`/`EventList` gained an `expanded` prop driving `aria-expanded`; `CodeNode` highlight cache keys on the full code not its length; `chat.css` responsive `@media` for the control cluster + `min(60ch,60vw)` cwd clamp + `.agentchat-cwd-btn:focus-visible`; `app-shell.css` 44×44 min tap targets (side-toggle + landscape composer send); box-drawing comment glyphs -> ASCII.
10
+
3
11
  ## Architecture (2026-05-19 pivot — single surface)
4
12
 
5
13
  One surface. `server.js` serves `site/app/` under `BASE_URL` (default `/gm`) and mounts `ccsniff`'s `/v1/history/*` Express router in-process at both `/` and `BASE_URL`. The legacy `static/` tree and the legacy `lib/routes-*`/`lib/db-queries-*`/`lib/jsonl-watcher.js` modules are gone. `acptoapi` is no longer used by this project.
@@ -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.
@@ -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
- // and the user home; override/add via IMAGE_ROOTS (path-separated).
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
- const contentType = mimeTypes[ext] || 'application/octet-stream';
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 }); }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.949",
3
+ "version": "1.0.951",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -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
- requestAnimationFrame(() => {
135
- // The design system's chat scroll container is .chat-thread; fall back to
136
- // the main region only if it's not mounted yet.
137
- const scroller = document.querySelector('.chat-thread')
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
- if (scroller) scroller.scrollTop = scroller.scrollHeight;
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
- state.events.push(data);
284
- // Cap retained events so a long live session can't grow unbounded.
285
- if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
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 arr = Array.isArray(state.sessions) ? state.sessions : [];
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 = data.ts || Date.now();
292
- if (data.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
293
- if (data.isError) sess.errors = (sess.errors || 0) + 1;
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 ? 'live' : 'offline'],
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 target = id ? [...parts].reverse().find(p => p && p.kind === 'tool' && p._id === id) : null;
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
- if (state.chat.resumeSid) {
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) + '... via --resume'
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 ? 'resume' : 'ready')),
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
- if (state.chat.busy) { state.chat.busy = false; render(); }
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
- resumeSid: state.chat.resumeSid || undefined,
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.content += ev.text; scheduleStreamRender(); }
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
- const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
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 isTool = type === 'tool_use' || !!e.tool;
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 { new URL(s.startsWith('http') ? s : 'http://' + s); return true; }
1001
- catch { return false; }
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
- B.setBackend(state.backendDraft);
1013
- state.backend = state.backendDraft;
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
- if (hh.db) bits.push(['db ' + (hh.db.ok ? 'ok' : 'down'), 'History database status']);
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). Leaves the page reload to re-init from defaults.
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
- render();
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·healthy' : (acp.running ? 'running' : 'stopped'));
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
- rail: a.id === state.selectedAgent ? 'green' : (avail ? 'purple' : 'flame'),
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
- /* ── landscape orientation: reduce vertical space for composer ──────────── */
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: 36px; max-height: 120px; }
2370
- .ds-247420 .chat-composer .send, .ds-247420 .chat-composer button { width: 36px; height: 36px; font-size: 14px; }
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) - 32px) / 2); left: 10px;
3033
+ position: fixed; top: calc((var(--app-topbar-h) - 44px) / 2); left: 10px;
3032
3034
  z-index: 51;
3033
- width: 36px; height: 36px;
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
- max-width: 60ch;
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