agentgui 1.0.945 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.945",
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",
@@ -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