agentgui 1.0.945 → 1.0.947

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
@@ -10,7 +10,7 @@ When `PASSWORD` env var is set, every HTTP route is gated by `lib/http-handler.j
10
10
  - `site/app/js/backend.js` — same-origin WS/HTTP client (`DEFAULT_BACKEND = ''`); the transport glue that wires the kit; `?backend=` query override for cross-origin debugging
11
11
  - `site/app/js/app.js` — webjsx view + state; renders the `AgentChat` kit (from `anentrypoint-design`) for the chat surface and wires agentgui's WS/ccsniff state as kit callbacks; history/settings remain agentgui-local. Exposes `window.__agentgui`
12
12
 
13
- The chat GUI lives in the design kit, not in agentgui. The reusable multi-agent chat surface is the `AgentChat` component in `anentrypoint-design` (`src/components/agent-chat.js`); agentgui keeps only the transport glue (WS `backend.js`, ccsniff history wiring, agent orchestration) and passes state + callbacks into the kit. To change chat UI, edit the kit and push it (CI publishes to npm unpkg `@latest`), not agentgui.
13
+ The chat GUI lives in the design kit, not in agentgui. The reusable multi-agent chat surface is the `AgentChat` component in `anentrypoint-design` (`src/components/agent-chat.js`); agentgui keeps only the transport glue (WS `backend.js`, ccsniff history wiring, agent orchestration) and passes state + callbacks into the kit. To change chat UI, edit the kit and push it (CI publishes to npm -> unpkg `@latest`), not agentgui.
14
14
  - `server.js` — boots ACP/agents/websocket plugins, mounts `createHistoryRouter()` from `ccsniff` at `/`, serves `site/app/` as static root
15
15
  - Plugins kept (lib/plugins/): acp, agents, database, files, stream, websocket, workflow
16
16
 
@@ -60,7 +60,7 @@ Pattern: all imports in lib/plugins/* must be either built-in (crypto, fs, path,
60
60
 
61
61
  ## Plugin Tool Provisioning on Windows
62
62
 
63
- **`lib/tool-spawner.js` must iterate bun npx fallback and match cross-platform command-not-found errors.**
63
+ **`lib/tool-spawner.js` must iterate bun -> npx fallback and match cross-platform command-not-found errors.**
64
64
 
65
65
  On Windows hosts without bun installed, the auto-provisioner on startup and 6h periodic update checker failed silently when spawnBunxProc tried `bun.cmd` directly. The missing command error came via process stderr+close, not the 'error' event, so simple ENOENT detection was insufficient. Additionally, the error message format differs by OS: Windows shows "'bun.cmd' is not recognized as an internal or external command", Linux/Mac show "command not found" or "cannot find".
66
66
 
@@ -72,7 +72,7 @@ Pattern: When a binary might not exist on all platforms, use a runner fallback s
72
72
 
73
73
  **gm plugin's pre-tool-use-hook.js enforces "must invoke gm:gm first" gate, blocking multi-tool autonomy. Hook content is NOT sourced from gm-starter/hooks/ files — it is templated from somewhere else.**
74
74
 
75
- The gm plugin enforces a gate via `.gm/needs-gm` marker that requires invoking `gm:gm` before any other tool use, which fragments multi-tool autonomous sessions in agentgui. A bypass patch was committed to c:/dev/gm (commit e300acf7, origin/main) in gm-starter/hooks/{pre-tool-use,prompt-submit}-hook.js to skip the gate when `.gm/prd.yml` exists, but it did NOT propagate after `/plugin update gm` (cache hash changed 495e36843d77 075e64d58498 but hook content unchanged).
75
+ The gm plugin enforces a gate via `.gm/needs-gm` marker that requires invoking `gm:gm` before any other tool use, which fragments multi-tool autonomous sessions in agentgui. A bypass patch was committed to c:/dev/gm (commit e300acf7, origin/main) in gm-starter/hooks/{pre-tool-use,prompt-submit}-hook.js to skip the gate when `.gm/prd.yml` exists, but it did NOT propagate after `/plugin update gm` (cache hash changed 495e36843d77 -> 075e64d58498 but hook content unchanged).
76
76
 
77
77
  The actual hook content is generated/templated from c:/dev/gm, likely from `lib/cli-adapter.js` or `platforms/cli-config-shared.js` or `lib/template-builder.js`, not from gm-starter/hooks/. Next session must:
78
78
  1. Locate the real hook generator in the gm codebase
@@ -99,33 +99,19 @@ The function previously returned "Model: X." when agentId was 'claude-code' and
99
99
 
100
100
  **WebSocket `/sync` endpoint — message ordering requires registering handler BEFORE sending.**
101
101
 
102
- Server sends `sync_connected` with `clientId` on connect. Legacy handler (`lib/ws-legacy-handlers.js`) handles `pingpong`, `subscribesubscription_confirmed`, `get_subscriptionssubscriptions`, `unsubscribe`, `latency_report`. All responses use codec encode/decode (`lib/codec.js`). Pattern: queue outbound messages and use a waiters array + sequential promises to avoid race between send and handler registration. Test structure: `const queued = []; let waiting; ws.on('message', ...); queued.forEach(msg => ws.send(msg)); waiting.resolve(...)` ensures the handler is live before messages flow.
102
+ Server sends `sync_connected` with `clientId` on connect. Legacy handler (`lib/ws-legacy-handlers.js`) handles `ping->pong`, `subscribe->subscription_confirmed`, `get_subscriptions->subscriptions`, `unsubscribe`, `latency_report`. All responses use codec encode/decode (`lib/codec.js`). Pattern: queue outbound messages and use a waiters array + sequential promises to avoid race between send and handler registration. Test structure: `const queued = []; let waiting; ws.on('message', ...); queued.forEach(msg => ws.send(msg)); waiting.resolve(...)` ensures the handler is live before messages flow.
103
103
 
104
- ## better-sqlite3 & Node v24 Startup (2026-05-03)
104
+ ## better-sqlite3 & Node v24 Startup
105
105
 
106
- **Node v24 has no prebuilt binary for better-sqlite3 (module version 137); npm rebuild silently fails.**
107
-
108
- When npm install runs on Node v24, the postinstall hook for better-sqlite3 silently fails because no prebuilt `.node` binary exists for that module version. The server then fails to require better-sqlite3 and crashes. npm rebuild also silently fails.
109
-
110
- Fix: compile from source in postinstall:
111
- ```json
112
- "postinstall": "node scripts/patch-fsbrowse.js && node scripts/copy-vendor.js && (cd node_modules/better-sqlite3 && node-gyp rebuild 2>/dev/null) || true"
113
- ```
114
-
115
- Paired changes:
116
- - `package.json` `start` script: `"bun server.js || node server.js"` (prefer bun, fall back to node)
117
- - `bin/gmgui.cjs` runtime detection: `spawnSync('bun', ['--version'], { shell: true })` to auto-detect bun availability, fallback to node (lines 46-47)
118
- - better-sqlite3 bumped ^12.6.2 → ^12.9.0
119
-
120
- bun start was already working (bun has native sqlite support via `bun:sqlite`). The node path was broken due to the missing native binding. Only compile from source fixes the node path.
106
+ Node v24 lacks a prebuilt better-sqlite3 binary; the node path needs a from-source `postinstall` compile, and `start` is `bun server.js || node server.js`. `database.js` tries `bun:sqlite` first. Detail (postinstall command, paired package.json/bin changes, CI must-run-under-bun) in rs-learn (recall "agentgui startup better-sqlite3 node v24 bun").
121
107
 
122
108
  ## webjsx applyDiff Array Keying (2026-05-04)
123
109
 
124
110
  **Children arrays that mix keyed VElements with raw text/numbers crash in `vendor/webjsx/applyDiff.js`.**
125
111
 
126
- `webjsx` requires all siblings in an array prop (children, right, left, breadcrumbs, etc.) to either ALL be VElements with keys or ALL be non-keyed. Passing `Crumb({ right: [h('span', { key: 'a' }, 'A'), h('span', { key: 'b' }, 'B'), 'connected'] })` crashes on render with `TypeError: Cannot read properties of undefined (reading 'key')` at line 247420.js when the render loop tries to read `.key` on the bare string.
112
+ `webjsx` requires all siblings in an array prop (children, right, left, breadcrumbs, etc.) to either ALL be VElements with keys or ALL be non-keyed. Passing `Crumb({ right: [h('span', { key: 'a' }, 'A'), h('span', { key: 'b' }, 'B'), 'connected'] })` crashes on render with `TypeError: Cannot read properties of undefined (reading 'key')` at line 247420.js when the render loop tries to read `.key` on the bare string.
127
113
 
128
- Fix: wrap any bare strings as keyed VElements: `h('span', { key: 'dot' }, 'connected')`. Rule: if ANY sibling in an array has a key, ALL siblings must be VElements (no raw strings or numbers).
114
+ Fix: wrap any bare strings as keyed VElements: `h('span', { key: 'dot' }, 'connected')`. Rule: if ANY sibling in an array has a key, ALL siblings must be VElements (no raw strings or numbers).
129
115
 
130
116
  Surfaced 2026-05-04 while validating the chat surface in `site/app/`. Fix applied to crumbRight construction in `site/app/js/app.js` view().
131
117
 
@@ -143,7 +129,7 @@ Event dispatch loop:
143
129
 
144
130
  Throttle renders via `requestAnimationFrame` to avoid event storm during burst loads.
145
131
 
146
- **First request to `/v1/history/*` triggers loadOnce() that walks all JSONL files under ~/.claude/projects** (env default; override with `CLAUDE_PROJECTS_DIR`). In our test env: 299 files, 80MB, 69k events 30-90s startup latency. Health check timeouts during this window are normal and expected. Subsequent requests are fast (cached index).
132
+ **First request to `/v1/history/*` triggers loadOnce() that walks all JSONL files under ~/.claude/projects** (env default; override with `CLAUDE_PROJECTS_DIR`). In our test env: 299 files, 80MB, 69k events -> 30-90s startup latency. Health check timeouts during this window are normal and expected. Subsequent requests are fast (cached index).
147
133
 
148
134
  The endpoints are served by ccsniff's Express router mounted in-process from `server.js`. No external proxy.
149
135
 
@@ -153,10 +139,18 @@ The GUI loads `anentrypoint-design` from `https://unpkg.com/anentrypoint-design@
153
139
 
154
140
  ## Agent/model/session management (2026-05-28)
155
141
 
156
- - `agents.list` (WS) returns `available` + `npxInstallable` per agent; `agents.models` returns model choices (claude-code sonnet/opus/haiku). The chat picker is **agent-then-model**, not a flat model list. Unavailable agents are disabled/gated.
142
+ - `agents.list` (WS) returns `available` + `npxInstallable` per agent; `agents.models` returns model choices (claude-code -> sonnet/opus/haiku). The chat picker is **agent-then-model**, not a flat model list. Unavailable agents are disabled/gated.
157
143
  - `chat.sendMessage` accepts `cwd` (defaults to STARTUP_CWD) and `model`/`agentId` separately. `chat.active` (WS) lists in-flight chats with agentId/model/cwd/startedAt/pid; the history tab polls it (3s) and shows a running panel with per-session stop.
158
144
  - Client (`app.js`): chat transcript persists to `localStorage[agentgui.chat]` and restores on load; tool_use/result events render as chat parts; keyboard shortcuts (g+c/h/s, n, /, ?); settings has an agents-status panel from `health.acp[]`.
159
145
 
146
+ ## Kit Row rail + rank contract
147
+
148
+ The design-kit `Row` (`anentrypoint-design` `src/components/content.js`) renders a leading status **rail** from a `rail` prop (`green` | `purple` | `flame`, via a `.row.rail-<tone>::before` bar) and accepts `rank` as an alias for the leading `code` index. A `state: 'disabled'` Row is inert — no `onClick`/`role=button`/tab-stop, `aria-disabled=true`. agentgui's history sessions, search results, and agents panel pass `rail` + `rank` and rely on this; the rendering reaches the live GUI only after the kit publishes to npm -> unpkg `@latest`. agentgui keeps the rail semantics consistent everywhere: **green = selected/ok, purple = subagent, flame = error/unavailable**.
149
+
150
+ ## GUI glyph policy
151
+
152
+ GUI source keeps typographic product characters — the middot separator `·`, the ellipsis `…`, and em/en dashes in prose — as established design (AGENTS.md itself uses `·`). It carries **no** decorative tells: arrows, box-drawing, bullets, status dots, checks. Status is words + the CSS-drawn `.status-dot-disc`. Convert any arrow/box-drawing/bullet glyph to ASCII on sight (`->`, `// --- x ---`, `-`/`*`).
153
+
160
154
  ## DS CSS cascade — overriding component styles (2026-05-28)
161
155
 
162
156
  **`installStyles()` injects DS CSS into a runtime `<style>` after the head `<style>`, so local overrides need `!important` or higher specificity than the DS's `.ds-247420`-prefixed rules.** Full detail (font vars `--ff-display`/`--ff-mono`, the `.chat-head .sub` "00 msgs" quirk, EventList `.row[role=button]`, `[data-prog-focus]` focus suppression, `projectLabel()` for cwd slugs) is in rs-learn (recall "agentgui GUI styling DS cascade installStyles").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.945",
3
+ "version": "1.0.947",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -19,6 +19,18 @@
19
19
  --agentgui-fg: #e6e6ea;
20
20
  --agentgui-accent: #50c878;
21
21
  --agentgui-warn: #ff6b6b;
22
+ /* status-dot state colours, brightened for >=3:1 contrast on the dark bg */
23
+ --agentgui-status-connecting: #f0b84a;
24
+ --agentgui-status-offline: #e06b66;
25
+ /* unified muted text fallback (was an inconsistent mix of #888/#999) */
26
+ --agentgui-muted: #8a8f98;
27
+ }
28
+
29
+ /* visually-hidden but screen-reader-available (aria-live announcer, labels) */
30
+ .sr-only {
31
+ position: absolute !important; width: 1px; height: 1px;
32
+ padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0);
33
+ white-space: nowrap; border: 0;
22
34
  }
23
35
  html, body {
24
36
  margin: 0;
@@ -80,8 +92,8 @@
80
92
  background: var(--accent, var(--agentgui-accent, #50c878));
81
93
  animation: agentgui-pulse 2s infinite;
82
94
  }
83
- .status-dot.is-connecting .status-dot-disc { background: #d9a441; }
84
- .status-dot.is-offline .status-dot-disc { background: #c0504d; animation: none; }
95
+ .status-dot.is-connecting .status-dot-disc { background: var(--agentgui-status-connecting); }
96
+ .status-dot.is-offline .status-dot-disc { background: var(--agentgui-status-offline); animation: none; }
85
97
  @keyframes agentgui-pulse {
86
98
  0% { box-shadow: 0 0 0 0 rgba(80, 200, 120, .5); }
87
99
  70% { box-shadow: 0 0 0 6px rgba(80, 200, 120, 0); }
@@ -159,7 +171,7 @@
159
171
 
160
172
  /* empty-state: keep it from wrapping awkwardly in the narrow sidebar */
161
173
  .empty-state { white-space: normal; }
162
- p.empty-state { text-align: left; padding: .6em 0; color: var(--fg-3, #888); }
174
+ p.empty-state { text-align: left; padding: .6em 0; color: var(--fg-3, var(--agentgui-muted)); }
163
175
 
164
176
  /* generic interactive focus ring */
165
177
  button:focus-visible, [tabindex]:focus-visible, a:focus-visible {
@@ -198,7 +210,7 @@
198
210
  }
199
211
  .cwd-bar-text {
200
212
  font-family: var(--ff-mono, ui-monospace, monospace);
201
- color: var(--fg-3, #999); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60vw;
213
+ color: var(--fg-3, var(--agentgui-muted)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60vw;
202
214
  }
203
215
  .cwd-bar-btn {
204
216
  cursor: pointer; font: inherit; line-height: 1.3;
@@ -213,9 +225,8 @@
213
225
  /* History no-session empty state: fill the void with a centered prompt. */
214
226
  .history-empty {
215
227
  display: flex; flex-direction: column; align-items: center; justify-content: center;
216
- gap: .4em; text-align: center; min-height: 50vh; color: var(--fg-3, #888);
228
+ gap: .4em; text-align: center; min-height: 50vh; color: var(--fg-3, var(--agentgui-muted));
217
229
  }
218
- .history-empty-glyph { font-size: 3rem; opacity: .25; line-height: 1; }
219
230
  .history-empty-title { margin: 0; font-size: 1.05rem; color: var(--fg-2, var(--agentgui-fg)); }
220
231
  .history-empty-sub { margin: 0; max-width: 42ch; }
221
232
 
@@ -252,10 +263,21 @@
252
263
  .chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
253
264
  .chat-composer textarea:not(:focus) { overflow-y: hidden; }
254
265
 
266
+ /* tablet: keep the chat control cluster from wrapping the status dot alone */
267
+ @media (max-width: 900px) {
268
+ .chat-controls select,
269
+ .chat-controls .select,
270
+ .chat-controls [role="combobox"] { min-width: 110px; max-width: 170px; }
271
+ }
272
+
255
273
  /* touch targets on small screens */
256
274
  @media (max-width: 640px) {
257
275
  .pill { min-height: 36px; padding: .4em .8em; }
276
+ .history-actions { gap: .75em; }
258
277
  .history-actions .btn, .history-actions button { min-height: 44px; }
278
+ /* cwd path can't eat the whole row beside its label + buttons on a phone */
279
+ .cwd-bar-text { max-width: 42vw; }
280
+ .cwd-bar { gap: .3em; }
259
281
  }
260
282
 
261
283
  @media print {
@@ -270,7 +292,6 @@
270
292
  </style>
271
293
  </head>
272
294
  <body>
273
- <a href="#agentgui-main" class="skip-link">Skip to main content</a>
274
295
  <div id="app"></div>
275
296
  <script type="module" src="./js/app.js"></script>
276
297
  </body>
@@ -85,6 +85,24 @@ function debounce(fn, ms) {
85
85
  function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
86
86
  function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
87
87
 
88
+ // A single visually-hidden aria-live region for transient announcements (tab
89
+ // changes, etc.) so screen-reader users hear context that's otherwise conveyed
90
+ // only by focus movement or color.
91
+ let _announcer = null;
92
+ function announce(msg) {
93
+ if (typeof document === 'undefined') return;
94
+ if (!_announcer) {
95
+ _announcer = document.createElement('div');
96
+ _announcer.setAttribute('aria-live', 'polite');
97
+ _announcer.setAttribute('role', 'status');
98
+ _announcer.className = 'sr-only';
99
+ document.body.appendChild(_announcer);
100
+ }
101
+ // Clear then set on the next frame so repeated identical messages re-announce.
102
+ _announcer.textContent = '';
103
+ requestAnimationFrame(() => { if (_announcer) _announcer.textContent = msg; });
104
+ }
105
+
88
106
  function pillButton(key, label, active, title, onClick) {
89
107
  return h('button', {
90
108
  key,
@@ -113,15 +131,22 @@ function timeNow() {
113
131
  }
114
132
 
115
133
  async function selectAgent(id) {
134
+ // Re-selecting the same agent would needlessly refetch models and reset the
135
+ // current model selection — no-op early.
136
+ if (id === state.selectedAgent && state.agentModels.length) return;
116
137
  state.selectedAgent = id;
117
138
  localStorage.setItem('agentgui.agent', id);
118
139
  state.agentModels = [];
119
140
  state.selectedModel = '';
141
+ state.modelsLoading = true;
120
142
  render();
121
143
  const models = await B.listAgentModels(state.backend, id);
122
144
  if (state.selectedAgent !== id) return; // changed while loading
145
+ state.modelsLoading = false;
123
146
  state.agentModels = models;
124
147
  const saved = localStorage.getItem('agentgui.model');
148
+ // Only restore a saved model that the NEW agent actually offers; otherwise
149
+ // fall back to its first model (never carry a stale model from a prior agent).
125
150
  state.selectedModel = (saved && models.some(m => m.id === saved)) ? saved : (models[0]?.id || '');
126
151
  render();
127
152
  }
@@ -158,6 +183,9 @@ function sortedAgents() {
158
183
 
159
184
  function navTo(tab) {
160
185
  const prev = state.tab;
186
+ // Leaving chat clears any pending new-chat confirmation so it doesn't linger
187
+ // as a stale banner when the user returns.
188
+ if (prev === 'chat' && tab !== 'chat') state.confirmingNewChat = false;
161
189
  state.tab = tab;
162
190
  // Live history SSE is only needed on the history tab; active-chat polling
163
191
  // runs globally (started at boot) so running chats show on every tab.
@@ -168,6 +196,7 @@ function navTo(tab) {
168
196
  closeLiveStream();
169
197
  }
170
198
  writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
199
+ announce('Now on ' + tab + ' tab');
171
200
  render();
172
201
  // Move focus into the new region for keyboard/AT users.
173
202
  requestAnimationFrame(() => {
@@ -187,7 +216,7 @@ function navTo(tab) {
187
216
  });
188
217
  }
189
218
 
190
- // The DS Topbar derives aria-current from hreflocation.hash matching, which
219
+ // The DS Topbar derives aria-current from href<->location.hash matching, which
191
220
  // drifts from our hash-based active tab (e.g. aria-current lands on "settings"
192
221
  // while we're on "chat"). Re-assert aria-current on the actually-active tab.
193
222
  function syncAriaCurrent() {
@@ -308,15 +337,26 @@ function view() {
308
337
  onNav: (label) => navTo(label),
309
338
  });
310
339
 
311
- // The agent/model picker + new/stop now live inside the AgentChat kit (chat
312
- // tab), so the crumb carries only the status dot on every tab.
340
+ // The crumb's right slot carries the live/connection status dot on every tab.
313
341
  const crumbRight = [dot];
314
342
 
315
- // Topbar already shows "agentgui / <tab>"; the crumb is reserved for contextual
316
- // controls (model picker, new/stop, live status) so it doesn't duplicate the path.
343
+ // Give the crumb contextual content on the left so it isn't a bare bar holding
344
+ // only the dot: on history it names the selected session (or "all sessions"),
345
+ // on chat it names the active agent, on settings it's "configuration".
346
+ let crumbLeaf = '';
347
+ if (state.tab === 'history') {
348
+ const sel = state.selectedSid && (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
349
+ crumbLeaf = state.selectedSid
350
+ ? truncate(projectLabel(sel?.title) || projectLabel(sel?.project) || state.selectedSid, 24, 48)
351
+ : 'all sessions';
352
+ } else if (state.tab === 'chat') {
353
+ crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
354
+ } else if (state.tab === 'settings') {
355
+ crumbLeaf = 'configuration';
356
+ }
317
357
  const crumb = Crumb({
318
358
  trail: [],
319
- leaf: '',
359
+ leaf: crumbLeaf,
320
360
  right: crumbRight,
321
361
  });
322
362
 
@@ -329,7 +369,7 @@ function view() {
329
369
  : 'no agent';
330
370
  const status = Status({
331
371
  left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
332
- right: [agentLabel],
372
+ right: [agentLabel, 'press ? for shortcuts'],
333
373
  });
334
374
 
335
375
  // The design system now owns the full-height column + inner scroll for
@@ -342,7 +382,10 @@ function view() {
342
382
  children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
343
383
  : null;
344
384
  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));
345
- return AppShell({ topbar, crumb, side, main, status, narrow: false });
385
+ // narrow drives the DS main-column class; the history sidebar itself collapses
386
+ // behind the DS .app-side-toggle hamburger (provided by AppShell when a side
387
+ // exists), so mobile users get a togglable session list.
388
+ return AppShell({ topbar, crumb, side, main, status, narrow: isNarrow() });
346
389
  }
347
390
 
348
391
  function mainContent() {
@@ -351,7 +394,7 @@ function mainContent() {
351
394
  return settingsMain();
352
395
  }
353
396
 
354
- // ── chat ───────────────────────────────────────────────────────────────────
397
+ // --- chat ---
355
398
  function canSend() {
356
399
  return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
357
400
  }
@@ -389,12 +432,13 @@ function chatMain() {
389
432
  // above the thread. The kit holds no agentgui-specific banner logic.
390
433
  const banners = [];
391
434
  if (state.chat.resumeSid) {
435
+ // The agent-switched note (if any) lives inside the resume banner rather
436
+ // than as a separate stacked Alert, so resume context reads as one block.
392
437
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
393
- h('span', { key: 'rbtxt', class: 'lede' }, 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
438
+ h('span', { key: 'rbtxt', class: 'lede' },
439
+ 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'
440
+ + (state.chat.resumeNote ? ' — ' + state.chat.resumeNote : '')),
394
441
  Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
395
- if (state.chat.resumeNote) {
396
- banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
397
- }
398
442
  }
399
443
  if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
400
444
  banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
@@ -407,9 +451,16 @@ function chatMain() {
407
451
  Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
408
452
  Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
409
453
  }
410
- const lastErr = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1].error : null;
454
+ if (state.cwdError) {
455
+ banners.push(Alert({ key: 'cwderr', kind: 'warn', title: 'Invalid working directory', children: state.cwdError }));
456
+ }
457
+ const lastMsg = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1] : null;
458
+ const lastErr = lastMsg ? lastMsg.error : null;
411
459
  if (lastErr && !state.chat.busy) {
412
- banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error', children: lastErr }));
460
+ banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error',
461
+ children: [
462
+ h('span', { key: 'cetxt' }, lastErr + ' '),
463
+ Btn({ key: 'cedismiss', onClick: () => { if (lastMsg) { delete lastMsg.error; persistChat(); render(); } }, children: 'dismiss' })] }));
413
464
  }
414
465
 
415
466
  const placeholder = !state.selectedAgent
@@ -426,10 +477,13 @@ function chatMain() {
426
477
  selectedAgent: state.selectedAgent,
427
478
  models: state.agentModels,
428
479
  selectedModel: state.selectedModel,
480
+ modelsLoading: !!state.modelsLoading,
429
481
  messages: state.chat.messages,
430
482
  busy: state.chat.busy,
431
483
  draft: state.chat.draft,
432
- status: state.chat.busy ? 'streaming…' : (state.chat.resumeSid ? 'resume' : 'ready'),
484
+ status: state.chat.busy
485
+ ? (state.health.ws === 'reconnecting' ? 'reconnecting…' : 'streaming…')
486
+ : (state.modelsLoading ? 'loading models…' : (state.chat.resumeSid ? 'resume' : 'ready')),
433
487
  agentName,
434
488
  placeholder,
435
489
  canSend: canSend(),
@@ -451,13 +505,23 @@ function chatMain() {
451
505
  if (was !== now) render();
452
506
  },
453
507
  onSend: (v) => { state.chat.draft = v; sendChat(); },
454
- onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); },
508
+ onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
455
509
  onCwdSave: () => {
456
- state.chatCwd = (state.cwdDraft ?? '').trim();
510
+ const path = (state.cwdDraft ?? '').trim();
511
+ // A relative cwd would resolve against the server process dir, not what
512
+ // the user means — require an absolute path (POSIX /…, UNC \\…, or
513
+ // Windows drive X:\…). Blank is valid (server default).
514
+ if (path && !/^([/\\]|[A-Za-z]:[/\\])/.test(path)) {
515
+ state.cwdError = 'enter an absolute path (e.g. /home/you/proj or C:\\proj) or leave blank';
516
+ render();
517
+ return;
518
+ }
519
+ state.cwdError = null;
520
+ state.chatCwd = path;
457
521
  if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
458
522
  state.cwdEditing = false; state.cwdDraft = undefined; render();
459
523
  },
460
- onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); },
524
+ onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; render(); },
461
525
  onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
462
526
  onCwdDraft: (v) => { state.cwdDraft = v; },
463
527
  }),
@@ -485,7 +549,12 @@ function newChat() {
485
549
  render();
486
550
  }
487
551
 
488
- function cancelChat() { state.chat.abort?.abort(); }
552
+ function cancelChat() {
553
+ // Clear busy immediately so the composer re-enables and the "stop" button
554
+ // flips back to "new" without waiting for the stream's finally block.
555
+ state.chat.abort?.abort();
556
+ if (state.chat.busy) { state.chat.busy = false; render(); }
557
+ }
489
558
 
490
559
  const CHAT_KEY = 'agentgui.chat';
491
560
  function persistChat() {
@@ -548,7 +617,7 @@ async function sendChat() {
548
617
  }
549
618
  }
550
619
 
551
- // ── history ────────────────────────────────────────────────────────────────
620
+ // --- history ---
552
621
  function reconnectAlert() {
553
622
  if (!state.live.error) return null;
554
623
  return Alert({
@@ -588,7 +657,7 @@ function historyMain() {
588
657
  lede,
589
658
  });
590
659
 
591
- const actions = h('div', { key: 'acts', class: 'history-actions' },
660
+ const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' },
592
661
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
593
662
  Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
594
663
  );
@@ -597,7 +666,9 @@ function historyMain() {
597
666
  // Distinguish "still loading" from "genuinely empty" so a 0-event session
598
667
  // doesn't spin forever.
599
668
  const body = state.eventsLoaded
600
- ? h('p', { key: 'noev', class: 'lede empty-state', role: 'status' }, 'no events in this session')
669
+ ? h('div', { key: 'noev', class: 'lede empty-state', role: 'status' },
670
+ h('span', { key: 'noevtxt' }, 'no events in this session — '),
671
+ Btn({ key: 'reload', onClick: () => loadSession(state.selectedSid), children: 'reload' }))
601
672
  : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
602
673
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
603
674
  }
@@ -611,13 +682,17 @@ function historyMain() {
611
682
  head,
612
683
  actions,
613
684
  Panel({
614
- title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
685
+ title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300; ' + hiddenCount + ' older not rendered)' : ''),
615
686
  children: EventList({
616
687
  items: shown.map((e, i) => {
617
688
  // Stable key: prefer the server-assigned event index, else the
618
689
  // event timestamp + position, never a bare array index (which
619
690
  // collides between loaded and live-pushed events).
620
- const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
691
+ // Stable key: server event index when present, else ts + the event's
692
+ // ABSOLUTE position in state.events (not the sliced-view index, which
693
+ // shifts when live events append and would collide loaded vs live rows).
694
+ const absIdx = total - shown.length + i;
695
+ const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
621
696
  const role = e.role || '?';
622
697
  const type = e.type || '?';
623
698
  const tool = e.tool ? ' · tool: ' + e.tool : '';
@@ -628,9 +703,12 @@ function historyMain() {
628
703
  const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
629
704
  return {
630
705
  key,
631
- code: String(total - shown.length + i + 1).padStart(4, '0'),
706
+ code: String(absIdx + 1).padStart(4, '0'),
632
707
  title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
633
- sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark + (raw.length > 220 ? ' · ' + (expanded ? 'click to collapse' : 'click to expand') : ''),
708
+ // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
709
+ // Every row is click-to-expand, so always show the affordance word
710
+ // (not only when text overflows 220 chars).
711
+ sub: (e.ts ? new Date(e.ts).toLocaleString() : 'no time') + ' · ' + role + ' · ' + type + tool + errMark + ' · ' + (expanded ? 'collapse' : 'expand'),
634
712
  onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
635
713
  };
636
714
  }),
@@ -640,20 +718,22 @@ function historyMain() {
640
718
  }
641
719
 
642
720
  let copyToast = null;
721
+ // Hold the toast long enough to read (2.5s); the copy button label is inside a
722
+ // role=status region (history-actions) so AT announces the change.
723
+ function setCopyToast(msg) { copyToast = msg; render(); setTimeout(() => { copyToast = null; render(); }, 2500); }
643
724
  function copySid() {
644
725
  const sid = state.selectedSid;
645
726
  if (!sid) return;
646
- const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
647
727
  if (navigator.clipboard?.writeText) {
648
- navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
728
+ navigator.clipboard.writeText(sid).then(() => setCopyToast('copied')).catch(() => setCopyToast('copy failed'));
649
729
  } else {
650
730
  // Fallback for insecure (http) origins where navigator.clipboard is absent.
651
731
  try {
652
732
  const ta = document.createElement('textarea');
653
733
  ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
654
734
  document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
655
- done();
656
- } catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
735
+ setCopyToast('copied');
736
+ } catch { setCopyToast('copy failed'); }
657
737
  }
658
738
  }
659
739
 
@@ -713,11 +793,14 @@ function historySide() {
713
793
  const rows = searching
714
794
  ? visible.map((r, i) =>
715
795
  Row({
716
- key: 'sr' + i,
796
+ // sid can repeat across hits, so key on sid+position; rank is the
797
+ // absolute result position (stable across the 60-row slice).
798
+ key: 'sr-' + (r.sid || '?') + '-' + i,
717
799
  rank: String(i + 1).padStart(3, '0'),
718
800
  title: r.snippet || '(no snippet)',
719
801
  sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
720
- rail: 'purple',
802
+ // Rail carries the same semantics as session rows: error > subagent > normal.
803
+ rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
721
804
  onClick: () => loadSession(r.sid),
722
805
  })
723
806
  )
@@ -725,8 +808,10 @@ function historySide() {
725
808
  Row({
726
809
  key: 'sess' + s.sid,
727
810
  rank: String(i + 1).padStart(3, '0'),
728
- title: (s.isSubagent ? '- ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
729
- sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
811
+ // Subagent is conveyed by the purple rail, not a "- " text prefix.
812
+ title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
813
+ // Always show the error count so 0 reads as "no errors", not "untracked".
814
+ sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools · ' + (s.errors || 0) + ' err',
730
815
  rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
731
816
  active: s.sid === state.selectedSid,
732
817
  onClick: () => loadSession(s.sid),
@@ -758,8 +843,8 @@ function historySide() {
758
843
  children: [
759
844
  SearchInput({
760
845
  key: 'searchInput',
761
- placeholder: 'search sessions…',
762
- 'aria-label': 'Search sessions by text or project',
846
+ placeholder: 'search event text…',
847
+ 'aria-label': 'Search event text across sessions',
763
848
  value: state.searchQ,
764
849
  onInput: (v) => { state.searchQ = v; debouncedSearch(); },
765
850
  }),
@@ -787,7 +872,7 @@ function historySide() {
787
872
  !searching && subagentCount
788
873
  ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
789
874
  h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
790
- 'show subagents (' + subagentCount + ')')
875
+ 'show subagents (' + (state.showSubagents ? subagentCount + ' shown' : subagentCount + ' hidden') + ')')
791
876
  : null,
792
877
  state.historyError
793
878
  ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
@@ -795,12 +880,17 @@ function historySide() {
795
880
  !searching && truncatedBy > 0
796
881
  ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
797
882
  : null,
883
+ // Search is server-capped at 60 hits; there is no deeper page, so tell
884
+ // the user the result set is truncated rather than silently hiding it.
885
+ searching && truncatedBy > 0
886
+ ? h('p', { key: 'searchmore', class: 'lede empty-state' }, 'showing first 60 matches (' + truncatedBy + ' more — refine your search)')
887
+ : null,
798
888
  ],
799
889
  }),
800
890
  ].filter(Boolean);
801
891
  }
802
892
 
803
- // ── settings ───────────────────────────────────────────────────────────────
893
+ // --- settings ---
804
894
  function isValidUrl(s) {
805
895
  if (!s) return true; // blank = same-origin is valid
806
896
  try { new URL(s.startsWith('http') ? s : 'http://' + s); return true; }
@@ -809,6 +899,12 @@ function isValidUrl(s) {
809
899
 
810
900
  async function saveBackend() {
811
901
  if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
902
+ // Switching backend orphans the local chat transcript (it belongs to the old
903
+ // server's sessions). Confirm once if there's a transcript to lose.
904
+ if (state.chat.messages.length && !state.confirmingBackend) {
905
+ state.confirmingBackend = true; render(); return;
906
+ }
907
+ state.confirmingBackend = false;
812
908
  B.setBackend(state.backendDraft);
813
909
  state.backend = state.backendDraft;
814
910
  state.health = { status: 'unknown' };
@@ -822,14 +918,15 @@ async function saveBackend() {
822
918
  function healthSummary() {
823
919
  const hh = state.health || {};
824
920
  const ok = hh.status === 'ok';
921
+ // Each chip carries a title so its meaning isn't left to inference.
825
922
  const bits = [];
826
- bits.push(hh.status || 'unknown');
827
- if (hh.version) bits.push('v' + hh.version);
828
- if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
829
- if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
830
- if (hh.db) bits.push('db ' + (hh.db.ok ? 'ok' : 'down'));
831
- return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : '') },
832
- ...bits.map((b, i) => h('span', { key: 'hb' + i, class: 'health-chip' }, b)));
923
+ bits.push([hh.status || 'unknown', 'Backend connection status']);
924
+ if (hh.version) bits.push(['v' + hh.version, 'Server version']);
925
+ if (typeof hh.agents === 'number') bits.push([hh.agents + ' agents', 'Agents registered on the server']);
926
+ if (typeof hh.activeExecutions === 'number') bits.push([hh.activeExecutions + ' active', 'Chats currently executing']);
927
+ if (hh.db) bits.push(['db ' + (hh.db.ok ? 'ok' : 'down'), 'History database status']);
928
+ return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : ''), role: 'group', 'aria-label': 'Backend health' },
929
+ ...bits.map(([b, t], i) => h('span', { key: 'hb' + i, class: 'health-chip', title: t }, b)));
833
930
  }
834
931
 
835
932
  function settingsMain() {
@@ -861,6 +958,7 @@ function settingsMain() {
861
958
  state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
862
959
  state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
863
960
  state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
961
+ state.confirmingBackend ? h('p', { key: 'bcw', class: 'lede field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript — press save again to confirm') : null,
864
962
  healthSummary(),
865
963
  Btn({
866
964
  key: 'savebtn',
@@ -874,10 +972,40 @@ function settingsMain() {
874
972
  ]),
875
973
  }),
876
974
  agentsPanel(),
975
+ preferencesPanel(),
877
976
  ]),
878
977
  ];
879
978
  }
880
979
 
980
+ function clearLocalData() {
981
+ if (!state.confirmingClearData) { state.confirmingClearData = true; render(); return; }
982
+ state.confirmingClearData = false;
983
+ // Wipe agentgui's own localStorage keys (chat transcript, agent/model/cwd,
984
+ // backend). Leaves the page reload to re-init from defaults.
985
+ for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend']) lsRemove(k);
986
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
987
+ render();
988
+ }
989
+
990
+ function preferencesPanel() {
991
+ const hh = state.health || {};
992
+ return Panel({
993
+ title: 'preferences',
994
+ children: [
995
+ h('div', { key: 'ver', class: 'lede' }, 'server ' + (hh.version ? 'v' + hh.version : 'version unknown') + (window.__SERVER_VERSION ? ' · build ' + window.__SERVER_VERSION : '')),
996
+ h('div', { key: 'sc', class: 'lede', style: 'margin:.5em 0' },
997
+ 'keyboard: g then c/h/s — switch tabs · n — new chat · / — focus search/composer · ? — toggle hint · Esc — blur field'),
998
+ state.confirmingClearData
999
+ ? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
1000
+ children: [
1001
+ h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, and backend from this browser. This cannot be undone. '),
1002
+ Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' }),
1003
+ Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' })] })
1004
+ : Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
1005
+ ],
1006
+ });
1007
+ }
1008
+
881
1009
  function acpStatusFor(agentId) {
882
1010
  const acp = Array.isArray(state.health.acp) ? state.health.acp : [];
883
1011
  return acp.find(a => a.id === agentId) || null;
@@ -891,6 +1019,7 @@ function agentsPanel() {
891
1019
  ? state.agents.map((a, i) => {
892
1020
  const acp = acpStatusFor(a.id);
893
1021
  const avail = a.available !== false;
1022
+ const usable = avail || a.npxInstallable; // selectable from this row
894
1023
  const bits = [a.protocol || 'agent'];
895
1024
  if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
896
1025
  if (acp) bits.push(acp.healthy ? 'running·healthy' : (acp.running ? 'running' : 'stopped'));
@@ -899,18 +1028,21 @@ function agentsPanel() {
899
1028
  return Row({
900
1029
  key: 'ag' + a.id,
901
1030
  rank: String(i + 1).padStart(3, '0'),
902
- title: a.name + (avail ? '' : ' ·'),
1031
+ title: a.name,
903
1032
  sub: bits.join(' · '),
904
1033
  rail: a.id === state.selectedAgent ? 'green' : (avail ? 'purple' : 'flame'),
905
1034
  active: a.id === state.selectedAgent,
906
- onClick: () => { if (avail || a.npxInstallable) { navTo('chat'); selectAgent(a.id); } },
1035
+ // Non-installable agents are genuinely inert: mark them disabled (no
1036
+ // click, no button role) instead of looking clickable but doing nothing.
1037
+ state: usable ? 'default' : 'disabled',
1038
+ onClick: usable ? () => { navTo('chat'); selectAgent(a.id); } : undefined,
907
1039
  });
908
1040
  })
909
1041
  : h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
910
1042
  });
911
1043
  }
912
1044
 
913
- // ── data ──────────────────────────────────────────────────────────────────
1045
+ // --- data ---
914
1046
  async function refreshHistory() {
915
1047
  try {
916
1048
  state.sessions = await B.listSessions(state.backend);
@@ -950,6 +1082,11 @@ async function loadSession(sid) {
950
1082
  state.expandedEvents = new Set(); // don't carry expansion to the new session
951
1083
  writeHash(sid, { push: true });
952
1084
  render();
1085
+ // Bring the now-active sidebar row into view (deep-link / back-forward may
1086
+ // select a row that's scrolled out of the session list).
1087
+ requestAnimationFrame(() => {
1088
+ document.querySelector('.app-side .row.active')?.scrollIntoView({ block: 'nearest' });
1089
+ });
953
1090
  try {
954
1091
  state.events = await B.getSessionEvents(state.backend, sid);
955
1092
  state.eventsLoaded = true;
@@ -1058,7 +1195,13 @@ window.addEventListener('keydown', (e) => {
1058
1195
  }
1059
1196
  if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
1060
1197
  if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
1061
- if (e.key === '/') { e.preventDefault(); state.tab === 'history' ? focusSearch() : focusComposer(); return; }
1198
+ if (e.key === '/') {
1199
+ // / focuses search on history, composer on chat; on settings there is no
1200
+ // such field, so ignore it cleanly rather than focusing nothing.
1201
+ if (state.tab === 'history') { e.preventDefault(); focusSearch(); announce('search focused'); }
1202
+ else if (state.tab === 'chat') { e.preventDefault(); focusComposer(); announce('composer focused'); }
1203
+ return;
1204
+ }
1062
1205
  if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
1063
1206
  });
1064
1207