agentgui 1.0.944 → 1.0.946

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
@@ -6,9 +6,11 @@ One surface. `server.js` serves `site/app/` under `BASE_URL` (default `/gm`) and
6
6
 
7
7
  When `PASSWORD` env var is set, every HTTP route is gated by `lib/http-handler.js` accepting **Basic auth**, **`Authorization: Bearer <pwd>`**, OR **`?token=<pwd>`** query param (added 2026-05-26 so `EventSource` and direct deep-links work — neither can set headers). WS `/sync` requires `?token=` only. The HTML head script injects `window.__BASE_URL`, `window.__SERVER_VERSION`, and `window.__WS_TOKEN`; `site/app/js/backend.js` reads `__WS_TOKEN` and threads it onto every fetch (Bearer header) / EventSource (qs) / WebSocket (qs).
8
8
 
9
- - `site/app/index.html` — shell + CSS, imports `anentrypoint-design` from unpkg
10
- - `site/app/js/backend.js` — same-origin client (`DEFAULT_BACKEND = ''`); `?backend=` query override for cross-origin debugging
11
- - `site/app/js/app.js` — webjsx view + state, kits-only rendering (PageHeader, SearchInput, TextField, EventList, Panel, Row, Section); exposes `window.__agentgui`
9
+ - `site/app/index.html` — shell + CSS; imports `anentrypoint-design` from `https://unpkg.com/anentrypoint-design@latest` at runtime (always-latest; requires network — the prior offline guarantee is intentionally dropped)
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
+ - `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
+
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.
12
14
  - `server.js` — boots ACP/agents/websocket plugins, mounts `createHistoryRouter()` from `ccsniff` at `/`, serves `site/app/` as static root
13
15
  - Plugins kept (lib/plugins/): acp, agents, database, files, stream, websocket, workflow
14
16
 
@@ -145,9 +147,9 @@ Throttle renders via `requestAnimationFrame` to avoid event storm during burst l
145
147
 
146
148
  The endpoints are served by ccsniff's Express router mounted in-process from `server.js`. No external proxy.
147
149
 
148
- ## Zero-runtime-CDN: vendored DS deps (2026-05-28)
150
+ ## Runtime CDN: GUI loaded from unpkg (2026-05-29)
149
151
 
150
- The GUI runs fully offline. `site/app/vendor/cdn/` holds marked, dompurify, prismjs components, and JetBrains+Space Grotesk woff2. The DS bundle (`anentrypoint-design/247420.js`) had its CDN string constants rewritten to local paths, AND its gzip+base64-embedded CSS blob (`Ha`) regenerated to point at local fonts. Full mechanism is in rs-learn (recall "DS bundle gzip Ha vendoring"). Witness offline by browser route-abort of non-origin hosts.
152
+ The GUI loads `anentrypoint-design` from `https://unpkg.com/anentrypoint-design@latest` at runtime (import map + CSS link in `index.html`) so agentgui always tracks the latest published kit with no hand-maintained vendored copy. This **requires a network connection** the earlier offline guarantee was intentionally dropped in favour of always-latest. The DS itself fetches its fonts from `unpkg.com/anentrypoint-design@latest/dist/fonts/` (set in the kit's `scripts/build.mjs` `FONT_BASE`). The `site/app/vendor/` tree is no longer imported; the old vendored DS bundle + `vendor/cdn/` assets are dead and may be removed. Witness the live GUI by confirming the import map resolves to unpkg and `.agentchat` renders with 0 console errors.
151
153
 
152
154
  ## Agent/model/session management (2026-05-28)
153
155
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## [Unreleased] - GUI moved into the anentrypoint-design kit, loaded from unpkg
2
+
3
+ - the chat GUI now lives in the design kit: added a reusable `AgentChat` component to anentrypoint-design (agent-then-model picker + AICat auto-scroll/thinking + tool parts + cwd bar + caret-stable composer), pure props-in/vnode-out with all transport as host callbacks. Published via the kit's CI to npm -> unpkg.
4
+ - site/app/index.html: imports anentrypoint-design from https://unpkg.com/anentrypoint-design@latest at runtime (always-latest), replacing the vendored bundle. Trades the offline guarantee for always-latest, per project decision.
5
+ - site/app/js/app.js: chatMain renders the AgentChat kit and wires agentgui state + WS/ccsniff glue as callbacks; the agent/model picker, cwd bar, and composer moved out of agentgui into the kit. backend.js (WS client) stays as the transport glue.
6
+ - verified end-to-end in a real browser: GUI sourced from unpkg, AgentChat renders, chat round-trips (reply streams into the kit's transcript), 0 console errors.
7
+
1
8
  ## [Unreleased] - live e2e chat verified + composer caret fix + ccsniff memory cap
2
9
 
3
10
  - verified the full GUI chat round-trip end-to-end in a real browser: compose -> WS chat.sendMessage -> claude-code -> streaming_progress -> transcript renders the streamed reply (not just the runner harness).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.944",
3
+ "version": "1.0.946",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -5,9 +5,13 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <title>agentgui</title>
7
7
  <meta name="description" content="agentgui — multi-agent client with same-origin server, in-process ccsniff history, and ACP chat.">
8
- <link rel="stylesheet" href="vendor/anentrypoint-design/247420.css">
8
+ <!-- The GUI lives in the anentrypoint-design kit and is loaded from unpkg at
9
+ its latest published version, so agentgui always tracks the kit without a
10
+ hand-maintained vendored copy. (This trades the prior offline guarantee for
11
+ always-latest, per project decision; a network connection is required.) -->
12
+ <link rel="stylesheet" href="https://unpkg.com/anentrypoint-design@latest/dist/247420.css">
9
13
  <script type="importmap">
10
- { "imports": { "anentrypoint-design": "./vendor/anentrypoint-design/247420.js" } }
14
+ { "imports": { "anentrypoint-design": "https://unpkg.com/anentrypoint-design@latest/dist/247420.js" } }
11
15
  </script>
12
16
  <style>
13
17
  :root {
@@ -15,6 +19,18 @@
15
19
  --agentgui-fg: #e6e6ea;
16
20
  --agentgui-accent: #50c878;
17
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;
18
34
  }
19
35
  html, body {
20
36
  margin: 0;
@@ -76,8 +92,8 @@
76
92
  background: var(--accent, var(--agentgui-accent, #50c878));
77
93
  animation: agentgui-pulse 2s infinite;
78
94
  }
79
- .status-dot.is-connecting .status-dot-disc { background: #d9a441; }
80
- .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; }
81
97
  @keyframes agentgui-pulse {
82
98
  0% { box-shadow: 0 0 0 0 rgba(80, 200, 120, .5); }
83
99
  70% { box-shadow: 0 0 0 6px rgba(80, 200, 120, 0); }
@@ -155,7 +171,7 @@
155
171
 
156
172
  /* empty-state: keep it from wrapping awkwardly in the narrow sidebar */
157
173
  .empty-state { white-space: normal; }
158
- 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)); }
159
175
 
160
176
  /* generic interactive focus ring */
161
177
  button:focus-visible, [tabindex]:focus-visible, a:focus-visible {
@@ -194,7 +210,7 @@
194
210
  }
195
211
  .cwd-bar-text {
196
212
  font-family: var(--ff-mono, ui-monospace, monospace);
197
- 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;
198
214
  }
199
215
  .cwd-bar-btn {
200
216
  cursor: pointer; font: inherit; line-height: 1.3;
@@ -209,9 +225,8 @@
209
225
  /* History no-session empty state: fill the void with a centered prompt. */
210
226
  .history-empty {
211
227
  display: flex; flex-direction: column; align-items: center; justify-content: center;
212
- 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));
213
229
  }
214
- .history-empty-glyph { font-size: 3rem; opacity: .25; line-height: 1; }
215
230
  .history-empty-title { margin: 0; font-size: 1.05rem; color: var(--fg-2, var(--agentgui-fg)); }
216
231
  .history-empty-sub { margin: 0; max-width: 42ch; }
217
232
 
@@ -248,10 +263,21 @@
248
263
  .chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
249
264
  .chat-composer textarea:not(:focus) { overflow-y: hidden; }
250
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
+
251
273
  /* touch targets on small screens */
252
274
  @media (max-width: 640px) {
253
275
  .pill { min-height: 36px; padding: .4em .8em; }
276
+ .history-actions { gap: .75em; }
254
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; }
255
281
  }
256
282
 
257
283
  @media print {
@@ -266,7 +292,6 @@
266
292
  </style>
267
293
  </head>
268
294
  <body>
269
- <a href="#agentgui-main" class="skip-link">Skip to main content</a>
270
295
  <div id="app"></div>
271
296
  <script type="module" src="./js/app.js"></script>
272
297
  </body>
@@ -3,7 +3,7 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
6
- const { AppShell, Topbar, Crumb, Side, Status, Chat, ChatComposer, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert } = C;
6
+ const { AppShell, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert } = C;
7
7
 
8
8
  const state = {
9
9
  backend: B.getBackend(),
@@ -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
  }
@@ -135,8 +160,32 @@ function selectModel(id) {
135
160
  function agentById(id) { return state.agents.find(a => a.id === id); }
136
161
  function agentAvailable(id) { const a = agentById(id); return !a || a.available !== false; }
137
162
 
163
+ // The four flagship orchestration targets surface first, then other available
164
+ // agents, then npx-installable, then not-installed — so the agents the GUI
165
+ // exists to drive are reachable without scanning a flat 17-item list. This
166
+ // ordering is agentgui's orchestration priority, so it stays in the host and is
167
+ // passed pre-sorted to the (app-agnostic) AgentChat kit.
168
+ const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
169
+ function sortedAgents() {
170
+ const rank = (a) => {
171
+ const primary = PRIMARY_AGENTS.indexOf(a.id);
172
+ const avail = a.available !== false;
173
+ if (primary !== -1 && avail) return primary;
174
+ if (avail) return 10;
175
+ if (a.npxInstallable) return 20;
176
+ return 30;
177
+ };
178
+ return state.agents
179
+ .map(a => ({ a, rank: rank(a) }))
180
+ .sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
181
+ .map(({ a }) => a);
182
+ }
183
+
138
184
  function navTo(tab) {
139
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;
140
189
  state.tab = tab;
141
190
  // Live history SSE is only needed on the history tab; active-chat polling
142
191
  // runs globally (started at boot) so running chats show on every tab.
@@ -147,6 +196,7 @@ function navTo(tab) {
147
196
  closeLiveStream();
148
197
  }
149
198
  writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
199
+ announce('Now on ' + tab + ' tab');
150
200
  render();
151
201
  // Move focus into the new region for keyboard/AT users.
152
202
  requestAnimationFrame(() => {
@@ -166,7 +216,7 @@ function navTo(tab) {
166
216
  });
167
217
  }
168
218
 
169
- // The DS Topbar derives aria-current from hreflocation.hash matching, which
219
+ // The DS Topbar derives aria-current from href<->location.hash matching, which
170
220
  // drifts from our hash-based active tab (e.g. aria-current lands on "settings"
171
221
  // while we're on "chat"). Re-assert aria-current on the actually-active tab.
172
222
  function syncAriaCurrent() {
@@ -287,61 +337,26 @@ function view() {
287
337
  onNav: (label) => navTo(label),
288
338
  });
289
339
 
290
- // The four flagship orchestration targets surface first, then the rest of the
291
- // available agents, then unavailable ones — so the agents the GUI exists to
292
- // drive are reachable without scanning a flat 17-item list. Ordering only;
293
- // the DS Select renders a plain option list.
294
- const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
295
- const agentRank = (a) => {
296
- const primary = PRIMARY_AGENTS.indexOf(a.id);
297
- const avail = a.available !== false;
298
- if (primary !== -1 && avail) return primary; // 0..3 flagship + available
299
- if (avail) return 10; // other available
300
- if (a.npxInstallable) return 20; // installable via npx
301
- return 30; // not installed
302
- };
303
- const agentOptions = state.agents
304
- .map(a => ({ a, rank: agentRank(a) }))
305
- .sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
306
- .map(({ a }) => ({
307
- value: a.id,
308
- label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
309
- disabled: a.available === false && !a.npxInstallable,
310
- }));
311
- const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
312
-
313
- const crumbRight = state.tab === 'chat'
314
- ? [h('div', { key: 'cc', class: 'chat-controls' },
315
- Select({
316
- key: 'agentsel',
317
- value: state.selectedAgent,
318
- placeholder: '— agent —',
319
- title: 'Select coding agent',
320
- options: agentOptions,
321
- onChange: (v) => { selectAgent(v); },
322
- }),
323
- showModelPicker
324
- ? Select({
325
- key: 'modelsel',
326
- value: state.selectedModel,
327
- placeholder: '— model —',
328
- title: 'Select model for this agent',
329
- options: state.agentModels.map(m => ({ value: m.id, label: m.name || m.id })),
330
- onChange: (v) => { selectModel(v); },
331
- })
332
- : null,
333
- state.chat.busy
334
- ? Btn({ key: 'stop', onClick: cancelChat, children: 'stop', title: 'Stop streaming' })
335
- : Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
336
- dot,
337
- )]
338
- : [dot];
339
-
340
- // Topbar already shows "agentgui / <tab>"; the crumb is reserved for contextual
341
- // controls (model picker, new/stop, live status) so it doesn't duplicate the path.
340
+ // The crumb's right slot carries the live/connection status dot on every tab.
341
+ const crumbRight = [dot];
342
+
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
+ }
342
357
  const crumb = Crumb({
343
358
  trail: [],
344
- leaf: '',
359
+ leaf: crumbLeaf,
345
360
  right: crumbRight,
346
361
  });
347
362
 
@@ -354,7 +369,7 @@ function view() {
354
369
  : 'no agent';
355
370
  const status = Status({
356
371
  left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
357
- right: [agentLabel],
372
+ right: [agentLabel, 'press ? for shortcuts'],
358
373
  });
359
374
 
360
375
  // The design system now owns the full-height column + inner scroll for
@@ -367,7 +382,10 @@ function view() {
367
382
  children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
368
383
  : null;
369
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));
370
- 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() });
371
389
  }
372
390
 
373
391
  function mainContent() {
@@ -376,7 +394,7 @@ function mainContent() {
376
394
  return settingsMain();
377
395
  }
378
396
 
379
- // ── chat ───────────────────────────────────────────────────────────────────
397
+ // --- chat ---
380
398
  function canSend() {
381
399
  return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
382
400
  }
@@ -407,56 +425,21 @@ function errText(e) {
407
425
  }
408
426
 
409
427
  function chatMain() {
410
- const lastIdx = state.chat.messages.length - 1;
411
428
  const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
412
- const msgs = state.chat.messages.map((m, i) => {
413
- const isAssistant = m.role === 'assistant';
414
- const isStreaming = state.chat.busy && i === lastIdx && isAssistant;
415
- const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
416
- const isEmptyStreaming = isStreaming && !m.content && !hasParts;
417
- const parts = [];
418
- if (m.content) parts.push({ kind: isAssistant ? 'md' : 'text', text: m.content });
419
- if (hasParts) for (const p of m.parts) parts.push({ kind: 'text', text: p });
420
- return {
421
- key: m.id || String(i),
422
- who: isAssistant ? 'them' : 'you',
423
- name: isAssistant ? agentName : 'you',
424
- time: m.time || '',
425
- typing: isEmptyStreaming,
426
- parts: isEmptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
427
- };
428
- });
429
-
430
- const placeholder = !state.selectedAgent
431
- ? 'choose an agent first'
432
- : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
433
- const composer = ChatComposer({
434
- value: state.chat.draft,
435
- disabled: !canSend(),
436
- placeholder,
437
- // The DS textarea is controlled and reads its live DOM value on send, so we
438
- // do NOT re-render on every keystroke (that fights the cursor and is wasteful).
439
- // Re-render only when the draft crosses empty<->non-empty, since that is the
440
- // only transition that changes the send button's disabled state.
441
- onInput: (v) => {
442
- const was = !!(state.chat.draft && state.chat.draft.trim());
443
- const now = !!(v && v.trim());
444
- state.chat.draft = v;
445
- if (was !== now) render();
446
- },
447
- onSend: (v) => { state.chat.draft = v; sendChat(); },
448
- });
449
429
 
430
+ // Banners agentgui owns (resume, agent-switched, unavailable, confirm-clear,
431
+ // stream error) are pre-built here and handed to the kit, which renders them
432
+ // above the thread. The kit holds no agentgui-specific banner logic.
450
433
  const banners = [];
451
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.
452
437
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
453
- 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 : '')),
454
441
  Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
455
- if (state.chat.resumeNote) {
456
- banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
457
- }
458
442
  }
459
- banners.push(cwdBanner());
460
443
  if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
461
444
  banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
462
445
  children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
@@ -468,26 +451,79 @@ function chatMain() {
468
451
  Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
469
452
  Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
470
453
  }
471
- // Last stream error surfaced as a proper Alert instead of raw JSON in the bubble.
472
- 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;
473
459
  if (lastErr && !state.chat.busy) {
474
- 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' })] }));
475
464
  }
465
+
466
+ const placeholder = !state.selectedAgent
467
+ ? 'choose an agent first'
468
+ : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
469
+
470
+ // The reusable AgentChat kit owns the agent/model picker, cwd bar, transcript
471
+ // (with AICat-style auto-scroll + thinking), and the caret-stable composer.
472
+ // agentgui supplies state and wires every server interaction as a callback.
476
473
  return [
477
474
  offlineBanner(),
478
- ...banners,
479
- Chat({
480
- title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
481
- // Explicit human-readable sub; the DS default ("NN msgs", zero-padded)
482
- // leaks an event-list style into chat. Hide it when there are no messages
483
- // (the empty-state already says "no messages yet").
484
- sub: state.chat.busy
485
- ? 'streaming…'
486
- : (state.chat.messages.length
487
- ? state.chat.messages.length + (state.chat.messages.length === 1 ? ' message' : ' messages')
488
- : ''),
489
- messages: msgs,
490
- composer,
475
+ AgentChat({
476
+ agents: sortedAgents(),
477
+ selectedAgent: state.selectedAgent,
478
+ models: state.agentModels,
479
+ selectedModel: state.selectedModel,
480
+ modelsLoading: !!state.modelsLoading,
481
+ messages: state.chat.messages,
482
+ busy: state.chat.busy,
483
+ draft: state.chat.draft,
484
+ status: state.chat.busy
485
+ ? (state.health.ws === 'reconnecting' ? 'reconnecting…' : 'streaming…')
486
+ : (state.modelsLoading ? 'loading models…' : (state.chat.resumeSid ? 'resume' : 'ready')),
487
+ agentName,
488
+ placeholder,
489
+ canSend: canSend(),
490
+ banners,
491
+ cwd: state.chatCwd,
492
+ cwdEditing: !!state.cwdEditing,
493
+ cwdDraft: state.cwdDraft,
494
+ onSelectAgent: (v) => selectAgent(v),
495
+ onSelectModel: (v) => selectModel(v),
496
+ onNewChat: newChat,
497
+ onStop: cancelChat,
498
+ onInput: (v) => {
499
+ // The kit's textarea is controlled and reads its live DOM value on send,
500
+ // so re-render only when the draft crosses empty<->non-empty (the only
501
+ // transition that toggles the send button's disabled state).
502
+ const was = !!(state.chat.draft && state.chat.draft.trim());
503
+ const now = !!(v && v.trim());
504
+ state.chat.draft = v;
505
+ if (was !== now) render();
506
+ },
507
+ onSend: (v) => { state.chat.draft = v; sendChat(); },
508
+ onCwdEdit: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); },
509
+ onCwdSave: () => {
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;
521
+ if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
522
+ state.cwdEditing = false; state.cwdDraft = undefined; render();
523
+ },
524
+ onCwdCancel: () => { state.cwdEditing = false; state.cwdDraft = undefined; state.cwdError = null; render(); },
525
+ onCwdClear: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); },
526
+ onCwdDraft: (v) => { state.cwdDraft = v; },
491
527
  }),
492
528
  ].filter(Boolean);
493
529
  }
@@ -498,24 +534,8 @@ function offlineBanner() {
498
534
  children: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
499
535
  }
500
536
 
501
- function cwdBanner() {
502
- if (state.cwdEditing) {
503
- return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Set working directory' },
504
- TextField({ key: 'cwdfield', label: 'working directory (blank = server default)', value: state.cwdDraft ?? state.chatCwd ?? '',
505
- placeholder: 'absolute path', onInput: (v) => { state.cwdDraft = v; } }),
506
- Btn({ key: 'cwdsave', primary: true, onClick: () => {
507
- state.chatCwd = (state.cwdDraft ?? '').trim();
508
- if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
509
- state.cwdEditing = false; state.cwdDraft = undefined; render();
510
- }, children: 'save' }),
511
- Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
512
- }
513
- return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
514
- h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
515
- state.chatCwd ? 'cwd: ' + truncate(state.chatCwd, 28, 60) : 'cwd: server default'),
516
- h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
517
- state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, 'use default') : null);
518
- }
537
+ // (The working-directory bar now lives in the AgentChat kit; agentgui wires its
538
+ // cwd state + handlers as kit callbacks in chatMain.)
519
539
 
520
540
  function newChat() {
521
541
  if (state.chat.messages.length && !state.confirmingNewChat) {
@@ -529,7 +549,12 @@ function newChat() {
529
549
  render();
530
550
  }
531
551
 
532
- 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
+ }
533
558
 
534
559
  const CHAT_KEY = 'agentgui.chat';
535
560
  function persistChat() {
@@ -592,7 +617,7 @@ async function sendChat() {
592
617
  }
593
618
  }
594
619
 
595
- // ── history ────────────────────────────────────────────────────────────────
620
+ // --- history ---
596
621
  function reconnectAlert() {
597
622
  if (!state.live.error) return null;
598
623
  return Alert({
@@ -632,7 +657,7 @@ function historyMain() {
632
657
  lede,
633
658
  });
634
659
 
635
- const actions = h('div', { key: 'acts', class: 'history-actions' },
660
+ const actions = h('div', { key: 'acts', class: 'history-actions', role: 'status', 'aria-live': 'polite' },
636
661
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
637
662
  Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
638
663
  );
@@ -641,7 +666,9 @@ function historyMain() {
641
666
  // Distinguish "still loading" from "genuinely empty" so a 0-event session
642
667
  // doesn't spin forever.
643
668
  const body = state.eventsLoaded
644
- ? 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' }))
645
672
  : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
646
673
  return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
647
674
  }
@@ -655,13 +682,17 @@ function historyMain() {
655
682
  head,
656
683
  actions,
657
684
  Panel({
658
- title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
685
+ title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300; ' + hiddenCount + ' older not rendered)' : ''),
659
686
  children: EventList({
660
687
  items: shown.map((e, i) => {
661
688
  // Stable key: prefer the server-assigned event index, else the
662
689
  // event timestamp + position, never a bare array index (which
663
690
  // collides between loaded and live-pushed events).
664
- 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;
665
696
  const role = e.role || '?';
666
697
  const type = e.type || '?';
667
698
  const tool = e.tool ? ' · tool: ' + e.tool : '';
@@ -672,9 +703,12 @@ function historyMain() {
672
703
  const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
673
704
  return {
674
705
  key,
675
- code: String(total - shown.length + i + 1).padStart(4, '0'),
706
+ code: String(absIdx + 1).padStart(4, '0'),
676
707
  title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
677
- 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'),
678
712
  onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
679
713
  };
680
714
  }),
@@ -684,20 +718,22 @@ function historyMain() {
684
718
  }
685
719
 
686
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); }
687
724
  function copySid() {
688
725
  const sid = state.selectedSid;
689
726
  if (!sid) return;
690
- const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
691
727
  if (navigator.clipboard?.writeText) {
692
- 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'));
693
729
  } else {
694
730
  // Fallback for insecure (http) origins where navigator.clipboard is absent.
695
731
  try {
696
732
  const ta = document.createElement('textarea');
697
733
  ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
698
734
  document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
699
- done();
700
- } catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
735
+ setCopyToast('copied');
736
+ } catch { setCopyToast('copy failed'); }
701
737
  }
702
738
  }
703
739
 
@@ -757,11 +793,14 @@ function historySide() {
757
793
  const rows = searching
758
794
  ? visible.map((r, i) =>
759
795
  Row({
760
- 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,
761
799
  rank: String(i + 1).padStart(3, '0'),
762
800
  title: r.snippet || '(no snippet)',
763
801
  sub: (r.project || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : ''),
764
- rail: 'purple',
802
+ // Rail carries the same semantics as session rows: error > subagent > normal.
803
+ rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
765
804
  onClick: () => loadSession(r.sid),
766
805
  })
767
806
  )
@@ -769,8 +808,10 @@ function historySide() {
769
808
  Row({
770
809
  key: 'sess' + s.sid,
771
810
  rank: String(i + 1).padStart(3, '0'),
772
- title: (s.isSubagent ? '- ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
773
- 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',
774
815
  rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
775
816
  active: s.sid === state.selectedSid,
776
817
  onClick: () => loadSession(s.sid),
@@ -802,8 +843,8 @@ function historySide() {
802
843
  children: [
803
844
  SearchInput({
804
845
  key: 'searchInput',
805
- placeholder: 'search sessions…',
806
- 'aria-label': 'Search sessions by text or project',
846
+ placeholder: 'search event text…',
847
+ 'aria-label': 'Search event text across sessions',
807
848
  value: state.searchQ,
808
849
  onInput: (v) => { state.searchQ = v; debouncedSearch(); },
809
850
  }),
@@ -831,7 +872,7 @@ function historySide() {
831
872
  !searching && subagentCount
832
873
  ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
833
874
  h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
834
- 'show subagents (' + subagentCount + ')')
875
+ 'show subagents (' + (state.showSubagents ? subagentCount + ' shown' : subagentCount + ' hidden') + ')')
835
876
  : null,
836
877
  state.historyError
837
878
  ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
@@ -839,12 +880,17 @@ function historySide() {
839
880
  !searching && truncatedBy > 0
840
881
  ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
841
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,
842
888
  ],
843
889
  }),
844
890
  ].filter(Boolean);
845
891
  }
846
892
 
847
- // ── settings ───────────────────────────────────────────────────────────────
893
+ // --- settings ---
848
894
  function isValidUrl(s) {
849
895
  if (!s) return true; // blank = same-origin is valid
850
896
  try { new URL(s.startsWith('http') ? s : 'http://' + s); return true; }
@@ -853,6 +899,12 @@ function isValidUrl(s) {
853
899
 
854
900
  async function saveBackend() {
855
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;
856
908
  B.setBackend(state.backendDraft);
857
909
  state.backend = state.backendDraft;
858
910
  state.health = { status: 'unknown' };
@@ -866,14 +918,15 @@ async function saveBackend() {
866
918
  function healthSummary() {
867
919
  const hh = state.health || {};
868
920
  const ok = hh.status === 'ok';
921
+ // Each chip carries a title so its meaning isn't left to inference.
869
922
  const bits = [];
870
- bits.push(hh.status || 'unknown');
871
- if (hh.version) bits.push('v' + hh.version);
872
- if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
873
- if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
874
- if (hh.db) bits.push('db ' + (hh.db.ok ? 'ok' : 'down'));
875
- return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : '') },
876
- ...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)));
877
930
  }
878
931
 
879
932
  function settingsMain() {
@@ -905,6 +958,7 @@ function settingsMain() {
905
958
  state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
906
959
  state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
907
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,
908
962
  healthSummary(),
909
963
  Btn({
910
964
  key: 'savebtn',
@@ -918,10 +972,40 @@ function settingsMain() {
918
972
  ]),
919
973
  }),
920
974
  agentsPanel(),
975
+ preferencesPanel(),
921
976
  ]),
922
977
  ];
923
978
  }
924
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
+
925
1009
  function acpStatusFor(agentId) {
926
1010
  const acp = Array.isArray(state.health.acp) ? state.health.acp : [];
927
1011
  return acp.find(a => a.id === agentId) || null;
@@ -935,6 +1019,7 @@ function agentsPanel() {
935
1019
  ? state.agents.map((a, i) => {
936
1020
  const acp = acpStatusFor(a.id);
937
1021
  const avail = a.available !== false;
1022
+ const usable = avail || a.npxInstallable; // selectable from this row
938
1023
  const bits = [a.protocol || 'agent'];
939
1024
  if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
940
1025
  if (acp) bits.push(acp.healthy ? 'running·healthy' : (acp.running ? 'running' : 'stopped'));
@@ -943,18 +1028,21 @@ function agentsPanel() {
943
1028
  return Row({
944
1029
  key: 'ag' + a.id,
945
1030
  rank: String(i + 1).padStart(3, '0'),
946
- title: a.name + (avail ? '' : ' ·'),
1031
+ title: a.name,
947
1032
  sub: bits.join(' · '),
948
1033
  rail: a.id === state.selectedAgent ? 'green' : (avail ? 'purple' : 'flame'),
949
1034
  active: a.id === state.selectedAgent,
950
- 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,
951
1039
  });
952
1040
  })
953
1041
  : h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
954
1042
  });
955
1043
  }
956
1044
 
957
- // ── data ──────────────────────────────────────────────────────────────────
1045
+ // --- data ---
958
1046
  async function refreshHistory() {
959
1047
  try {
960
1048
  state.sessions = await B.listSessions(state.backend);
@@ -994,6 +1082,11 @@ async function loadSession(sid) {
994
1082
  state.expandedEvents = new Set(); // don't carry expansion to the new session
995
1083
  writeHash(sid, { push: true });
996
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
+ });
997
1090
  try {
998
1091
  state.events = await B.getSessionEvents(state.backend, sid);
999
1092
  state.eventsLoaded = true;
@@ -1102,7 +1195,13 @@ window.addEventListener('keydown', (e) => {
1102
1195
  }
1103
1196
  if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
1104
1197
  if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
1105
- 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
+ }
1106
1205
  if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
1107
1206
  });
1108
1207