agentgui 1.0.940 → 1.0.942

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.
@@ -31,12 +31,27 @@ const state = {
31
31
  };
32
32
 
33
33
  function readHash() {
34
- const m = (location.hash || '').match(/sid=([^&]+)/);
35
- return m ? decodeURIComponent(m[1]) : null;
34
+ const hash = location.hash || '';
35
+ const sidM = hash.match(/sid=([^&]+)/);
36
+ const tabM = hash.match(/tab=([^&]+)/);
37
+ return {
38
+ sid: sidM ? decodeURIComponent(sidM[1]) : null,
39
+ tab: tabM ? decodeURIComponent(tabM[1]) : null,
40
+ };
36
41
  }
37
- function writeHash(sid) {
38
- const h = sid ? '#sid=' + encodeURIComponent(sid) : '';
39
- if (location.hash !== h) history.replaceState(null, '', location.pathname + location.search + h);
42
+ function buildHash(tab, sid) {
43
+ const parts = [];
44
+ if (tab && tab !== 'chat') parts.push('tab=' + encodeURIComponent(tab));
45
+ if (sid) parts.push('sid=' + encodeURIComponent(sid));
46
+ return parts.length ? '#' + parts.join('&') : '';
47
+ }
48
+ function writeHash(sid, { push = false } = {}) {
49
+ const h = buildHash(state.tab, sid);
50
+ const url = location.pathname + location.search + h;
51
+ if (location.hash === h) return;
52
+ // pushState for session selection so Back returns to the session list;
53
+ // replaceState for tab-only changes.
54
+ (push ? history.pushState : history.replaceState).call(history, null, '', url);
40
55
  }
41
56
  function fmtRelTime(ts) {
42
57
  if (!ts) return '';
@@ -55,7 +70,8 @@ function scheduleRender() {
55
70
  requestAnimationFrame(() => { renderScheduled = false; render(); });
56
71
  }
57
72
 
58
- function isNarrow() { return typeof window !== 'undefined' && window.innerWidth < 768; }
73
+ const NARROW_BP = 640; // unified with the CSS touch-target breakpoint in index.html
74
+ function isNarrow() { return typeof window !== 'undefined' && window.innerWidth < NARROW_BP; }
59
75
  function truncate(str, mobileLen, desktopLen) {
60
76
  const s = String(str ?? '');
61
77
  const max = isNarrow() ? mobileLen : desktopLen;
@@ -66,6 +82,9 @@ function debounce(fn, ms) {
66
82
  return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
67
83
  }
68
84
 
85
+ function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
86
+ function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
87
+
69
88
  function pillButton(key, label, active, title, onClick) {
70
89
  return h('button', {
71
90
  key,
@@ -79,8 +98,11 @@ function pillButton(key, label, active, title, onClick) {
79
98
 
80
99
  function scrollChatToBottom() {
81
100
  requestAnimationFrame(() => {
82
- const el = document.querySelector('#agentgui-main') || document.querySelector('[role="main"]') || document.getElementById('app');
83
- const scroller = el?.querySelector('[data-chat-scroll]') || el;
101
+ // The design system's chat scroll container is .chat-thread; fall back to
102
+ // the main region only if it's not mounted yet.
103
+ const scroller = document.querySelector('.chat-thread')
104
+ || document.querySelector('#agentgui-main')
105
+ || document.getElementById('app');
84
106
  if (scroller) scroller.scrollTop = scroller.scrollHeight;
85
107
  });
86
108
  }
@@ -116,29 +138,64 @@ function agentAvailable(id) { const a = agentById(id); return !a || a.available
116
138
  function navTo(tab) {
117
139
  const prev = state.tab;
118
140
  state.tab = tab;
141
+ // Live history SSE is only needed on the history tab; active-chat polling
142
+ // runs globally (started at boot) so running chats show on every tab.
119
143
  if (tab === 'history') {
120
144
  refreshHistory();
121
145
  openLiveStream();
122
- startActivePolling();
123
146
  } else if (prev === 'history') {
124
147
  closeLiveStream();
125
- stopActivePolling();
126
148
  }
149
+ writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
127
150
  render();
151
+ // Move focus into the new region for keyboard/AT users.
152
+ requestAnimationFrame(() => {
153
+ syncAriaCurrent();
154
+ const region = document.querySelector('#agentgui-main');
155
+ if (!region) return;
156
+ const heading = region.querySelector('h1, h2');
157
+ const target = heading || region;
158
+ if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
159
+ // Mark as programmatically focused so CSS can suppress the focus ring — we
160
+ // move focus here for AT, but a visible green outline box around the heading
161
+ // reads as an accidental border to sighted users.
162
+ target.setAttribute('data-prog-focus', '');
163
+ try { target.focus({ preventScroll: true }); } catch {}
164
+ const clear = () => { target.removeAttribute('data-prog-focus'); target.removeEventListener('blur', clear); };
165
+ target.addEventListener('blur', clear);
166
+ });
167
+ }
168
+
169
+ // The DS Topbar derives aria-current from href↔location.hash matching, which
170
+ // drifts from our hash-based active tab (e.g. aria-current lands on "settings"
171
+ // while we're on "chat"). Re-assert aria-current on the actually-active tab.
172
+ function syncAriaCurrent() {
173
+ const links = document.querySelectorAll('.app-topbar nav a');
174
+ links.forEach((a) => {
175
+ const isActive = a.classList.contains('active');
176
+ if (isActive) a.setAttribute('aria-current', 'page');
177
+ else a.removeAttribute('aria-current');
178
+ });
128
179
  }
129
180
 
130
181
  async function refreshActive() {
131
- state.active = await B.listActiveChats(state.backend);
182
+ try { state.active = await B.listActiveChats(state.backend); } catch { return; }
132
183
  render();
133
184
  }
134
185
  function startActivePolling() {
135
186
  if (state.activeTimer) return;
136
187
  refreshActive();
137
- state.activeTimer = setInterval(refreshActive, 3000);
188
+ // Small jitter so many tabs don't hit the server in lockstep.
189
+ state.activeTimer = setInterval(refreshActive, 3000 + Math.floor(Math.random() * 600));
138
190
  }
139
191
  function stopActivePolling() {
140
192
  if (state.activeTimer) { clearInterval(state.activeTimer); state.activeTimer = null; }
141
193
  }
194
+
195
+ // Re-render once a minute so relative timestamps ("5s ago") don't sit frozen
196
+ // between events. Cheap: scheduleRender coalesces via rAF.
197
+ let _relTick = null;
198
+ function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => scheduleRender(), 30000); }
142
199
  async function stopActiveChat(sid) {
143
200
  try { await B.cancelChat(state.backend, sid); } catch {}
144
201
  refreshActive();
@@ -158,6 +215,8 @@ function openLiveStream() {
158
215
  } else if (kind === 'event' && data) {
159
216
  if (state.selectedSid && data.sid === state.selectedSid) {
160
217
  state.events.push(data);
218
+ // Cap retained events so a long live session can't grow unbounded.
219
+ if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
161
220
  }
162
221
  const arr = Array.isArray(state.sessions) ? state.sessions : [];
163
222
  const sess = arr.find(s => s.sid === data.sid);
@@ -167,11 +226,13 @@ function openLiveStream() {
167
226
  if (data.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
168
227
  if (data.isError) sess.errors = (sess.errors || 0) + 1;
169
228
  } else {
170
- refreshHistory();
229
+ // Unknown session: a burst of events for a new session would trigger
230
+ // a full session-list refetch per event — debounce it into one.
231
+ debouncedRefreshHistory();
171
232
  return;
172
233
  }
173
234
  } else if (kind === 'conversation') {
174
- refreshHistory();
235
+ debouncedRefreshHistory();
175
236
  return;
176
237
  } else if (kind === 'error' && data) {
177
238
  state.live.error = data.error || 'stream error';
@@ -208,12 +269,21 @@ function view() {
208
269
  : (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
209
270
  : (ok ? (state.health.ws === 'reconnecting' ? '◌ ws reconnecting' : '● connected') : '○ offline');
210
271
  const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
211
- const dot = h('span', { key: 'dot', class: 'status-dot' + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' }, dotText);
272
+ // Split the leading status glyph ( ) from the words: glyph is decorative
273
+ // (aria-hidden), only the text is announced, so AT reads "live" not "black circle live".
274
+ const glyphMatch = dotText.match(/^([●◌○])\s*(.*)$/);
275
+ const dotGlyph = glyphMatch ? glyphMatch[1] : '';
276
+ const dotLabel = glyphMatch ? glyphMatch[2] : dotText;
277
+ // When live, the CSS .status-dot-live::before draws the (pulsing) dot, so the
278
+ // literal glyph would render a second dot — only emit the glyph when NOT live.
279
+ const dot = h('span', { key: 'dot', class: 'status-dot' + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
280
+ (!dotLive && dotGlyph) ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
281
+ h('span', { key: 'dl' }, dotLabel));
212
282
 
213
283
  const topbar = Topbar({
214
284
  brand: 'agentgui',
215
285
  leaf: state.tab,
216
- items: [['chat', '#'], ['history', '#'], ['settings', '#']],
286
+ items: [['chat', buildHash('chat', null) || '#'], ['history', buildHash('history', null)], ['settings', buildHash('settings', null)]],
217
287
  active: state.tab,
218
288
  onNav: (label) => navTo(label),
219
289
  });
@@ -272,16 +342,17 @@ function view() {
272
342
  right: [agentLabel],
273
343
  });
274
344
 
345
+ // The design system now owns the full-height column + inner scroll for
346
+ // .app-main, so chat just needs to be a flex column that fills it.
275
347
  const mainStyle = state.tab === 'chat'
276
- ? 'min-height:0;height:100%;display:flex;flex-direction:column'
277
- : 'min-height:0;height:100%;overflow:auto';
348
+ ? 'min-height:0;display:flex;flex-direction:column;flex:1'
349
+ : 'min-height:0';
278
350
  const shortcutsHint = state.showShortcuts
279
351
  ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
280
352
  children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
281
353
  : null;
282
354
  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));
283
- // settings reads better centered in a measure; chat + history use full width.
284
- return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
355
+ return AppShell({ topbar, crumb, side, main, status, narrow: false });
285
356
  }
286
357
 
287
358
  function mainContent() {
@@ -306,6 +377,20 @@ function toolSummary(block) {
306
377
  return '⌘ ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
307
378
  }
308
379
 
380
+ function toolResultSummary(block) {
381
+ const c = block?.content ?? block?.output ?? block;
382
+ let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
383
+ s = s.replace(/\s+/g, ' ').trim();
384
+ return (block?.is_error ? '⚠ ' : '') + s.slice(0, 160);
385
+ }
386
+
387
+ function errText(e) {
388
+ if (e == null) return 'unknown error';
389
+ if (typeof e === 'string') return e;
390
+ if (e.message) return e.message;
391
+ try { return JSON.stringify(e); } catch { return String(e); }
392
+ }
393
+
309
394
  function chatMain() {
310
395
  const lastIdx = state.chat.messages.length - 1;
311
396
  const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
@@ -332,7 +417,7 @@ function chatMain() {
332
417
  : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
333
418
  const composer = ChatComposer({
334
419
  value: state.chat.draft,
335
- disabled: state.chat.busy || !canSend(),
420
+ disabled: !canSend(),
336
421
  placeholder,
337
422
  onInput: (v) => { state.chat.draft = v; render(); },
338
423
  onSend: (v) => { state.chat.draft = v; sendChat(); },
@@ -341,36 +426,79 @@ function chatMain() {
341
426
  const banners = [];
342
427
  if (state.chat.resumeSid) {
343
428
  banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
344
- h('span', { class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
345
- Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; render(); }, children: '× clear' })));
429
+ h('span', { key: 'rbtxt', class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
430
+ Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: '× clear' })));
431
+ if (state.chat.resumeNote) {
432
+ banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
433
+ }
346
434
  }
347
- banners.push(h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Working directory' },
348
- h('span', { class: 'lede' }, state.chatCwd ? '▣ cwd: ' + state.chatCwd : '▣ cwd: server default'),
349
- Btn({ key: 'cwdset', onClick: () => {
350
- const v = prompt('Working directory for new chats (absolute path; blank = server default):', state.chatCwd || '');
351
- if (v === null) return;
352
- state.chatCwd = v.trim();
353
- if (state.chatCwd) localStorage.setItem('agentgui.cwd', state.chatCwd); else localStorage.removeItem('agentgui.cwd');
354
- render();
355
- }, children: state.chatCwd ? 'change' : 'set' }),
356
- state.chatCwd ? Btn({ key: 'cwdclr', onClick: () => { state.chatCwd = ''; localStorage.removeItem('agentgui.cwd'); render(); }, children: '× default' }) : null));
435
+ banners.push(cwdBanner());
357
436
  if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
358
437
  banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
359
438
  children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
360
439
  }
440
+ if (state.confirmingNewChat) {
441
+ banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
442
+ children: [
443
+ h('span', { key: 'cntxt' }, 'This cannot be undone. '),
444
+ Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
445
+ Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
446
+ }
447
+ // Last stream error surfaced as a proper Alert instead of raw JSON in the bubble.
448
+ const lastErr = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1].error : null;
449
+ if (lastErr && !state.chat.busy) {
450
+ banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error', children: lastErr }));
451
+ }
361
452
  return [
453
+ offlineBanner(),
362
454
  ...banners,
363
455
  Chat({
364
456
  title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
365
- sub: state.chat.busy ? 'streaming…' : undefined,
457
+ // Explicit human-readable sub; the DS default ("NN msgs", zero-padded)
458
+ // leaks an event-list style into chat. Hide it when there are no messages
459
+ // (the empty-state already says "no messages yet").
460
+ sub: state.chat.busy
461
+ ? 'streaming…'
462
+ : (state.chat.messages.length
463
+ ? state.chat.messages.length + (state.chat.messages.length === 1 ? ' message' : ' messages')
464
+ : ''),
366
465
  messages: msgs,
367
466
  composer,
368
467
  }),
369
468
  ].filter(Boolean);
370
469
  }
371
470
 
471
+ function offlineBanner() {
472
+ if (state.health.status === 'ok' || state.health.status === 'unknown') return null;
473
+ return Alert({ key: 'offline', kind: 'error', title: 'Backend unreachable',
474
+ children: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
475
+ }
476
+
477
+ function cwdBanner() {
478
+ if (state.cwdEditing) {
479
+ return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Set working directory' },
480
+ TextField({ key: 'cwdfield', label: 'working directory (blank = server default)', value: state.cwdDraft ?? state.chatCwd ?? '',
481
+ placeholder: 'absolute path', onInput: (v) => { state.cwdDraft = v; } }),
482
+ Btn({ key: 'cwdsave', primary: true, onClick: () => {
483
+ state.chatCwd = (state.cwdDraft ?? '').trim();
484
+ if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
485
+ state.cwdEditing = false; state.cwdDraft = undefined; render();
486
+ }, children: 'save' }),
487
+ Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
488
+ }
489
+ return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
490
+ h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
491
+ state.chatCwd ? '▣ ' + truncate(state.chatCwd, 28, 60) : '▣ cwd: server default'),
492
+ h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
493
+ state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, '× default') : null);
494
+ }
495
+
372
496
  function newChat() {
373
- if (state.chat.messages.length && !confirm('Clear chat history? This cannot be undone.')) return;
497
+ if (state.chat.messages.length && !state.confirmingNewChat) {
498
+ state.confirmingNewChat = true; render();
499
+ return;
500
+ }
501
+ state.confirmingNewChat = false;
374
502
  state.chat.abort?.abort();
375
503
  state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
376
504
  try { localStorage.removeItem(CHAT_KEY); } catch {}
@@ -425,10 +553,12 @@ async function sendChat() {
425
553
  })) {
426
554
  if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
427
555
  else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
428
- else if (ev.type === 'error') { cur.content += '\n[error] ' + JSON.stringify(ev.error); render(); }
556
+ else if (ev.type === 'tool_result') { cur.parts.push(' ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
557
+ else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
558
+ else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
429
559
  }
430
560
  } catch (e) {
431
- if (e.name !== 'AbortError') cur.content += '\n[error] ' + e.message;
561
+ if (e.name !== 'AbortError') cur.error = errText(e.message);
432
562
  } finally {
433
563
  state.chat.busy = false;
434
564
  state.chat.abort = null;
@@ -439,70 +569,113 @@ async function sendChat() {
439
569
  }
440
570
 
441
571
  // ── history ────────────────────────────────────────────────────────────────
572
+ function reconnectAlert() {
573
+ if (!state.live.error) return null;
574
+ return Alert({
575
+ key: 'liveerr',
576
+ kind: 'error',
577
+ title: 'Live stream disconnected',
578
+ children: [h('span', { key: 'lemsg' }, state.live.error + ' — '), Btn({ key: 'reco', onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
579
+ });
580
+ }
581
+
442
582
  function historyMain() {
443
583
  if (!state.selectedSid) {
444
- return [PageHeader({
445
- title: '§ history',
446
- lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
447
- })];
584
+ const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
585
+ return [
586
+ reconnectAlert(),
587
+ PageHeader({
588
+ title: '§ history',
589
+ lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
590
+ }),
591
+ h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
592
+ h('div', { key: 'ge', class: 'history-empty-glyph', 'aria-hidden': 'true' }, '§'),
593
+ h('p', { key: 'gt', class: 'history-empty-title' },
594
+ count ? 'Select a session to view its events' : 'No sessions yet'),
595
+ h('p', { key: 'gs', class: 'lede history-empty-sub' },
596
+ count
597
+ ? count + ' session' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
598
+ : 'Start a chat or run a local coding agent — its session will appear here live.')),
599
+ ].filter(Boolean);
448
600
  }
449
601
 
450
602
  const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
451
603
  const lede = sess
452
- ? (sess.project || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
604
+ ? (projectLabel(sess.project) || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
453
605
  : state.selectedSid;
454
606
 
455
607
  const head = PageHeader({
456
- title: '§ ' + truncate(sess?.title || state.selectedSid, 40, 80),
608
+ title: '§ ' + truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
457
609
  lede,
458
610
  });
459
611
 
460
- if (!state.selectedSid) {
461
- return [head, state.live.error ? Alert({
462
- key: 'err',
463
- kind: 'error',
464
- title: 'Connection lost',
465
- children: [state.live.error, ' — ', Btn({ onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
466
- }) : null];
467
- }
468
-
469
612
  const actions = h('div', { key: 'acts', class: 'history-actions' },
470
613
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: '▶ open in chat' }),
471
- Btn({ key: 'copy', onClick: () => { try { navigator.clipboard.writeText(state.selectedSid); } catch {} }, children: '⎘ copy sid' }),
614
+ Btn({ key: 'copy', onClick: copySid, children: copyToast || '⎘ copy sid' }),
472
615
  );
473
616
 
474
617
  if (state.events.length === 0) {
475
- return [head, actions, Panel({ title: 'events', children: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…') })];
618
+ // Distinguish "still loading" from "genuinely empty" so a 0-event session
619
+ // doesn't spin forever.
620
+ const body = state.eventsLoaded
621
+ ? h('p', { key: 'noev', class: 'lede empty-state', role: 'status' }, 'no events in this session')
622
+ : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
623
+ return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
476
624
  }
477
625
 
478
626
  if (!state.expandedEvents) state.expandedEvents = new Set();
627
+ const total = state.events.length;
628
+ const shown = state.events.slice(-300);
629
+ const hiddenCount = total - shown.length;
479
630
  return [
631
+ reconnectAlert(),
480
632
  head,
481
633
  actions,
482
634
  Panel({
483
- title: state.events.length + ' events',
635
+ title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
484
636
  children: EventList({
485
- items: state.events.slice(-300).map((e, i) => {
486
- const idx = e.i ?? i;
637
+ items: shown.map((e, i) => {
638
+ // Stable key: prefer the server-assigned event index, else the
639
+ // event timestamp + position, never a bare array index (which
640
+ // collides between loaded and live-pushed events).
641
+ const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
487
642
  const role = e.role || '?';
488
643
  const type = e.type || '?';
489
644
  const tool = e.tool ? ' · ⌘ ' + e.tool : '';
490
645
  const errMark = e.isError ? ' · ⚠' : '';
491
646
  const raw = e.text || '';
492
647
  const text = raw.replace(/\s+/g, ' ').trim();
493
- const expanded = state.expandedEvents.has(idx);
648
+ const expanded = state.expandedEvents.has(key);
494
649
  const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
495
650
  return {
496
- key: 'ev' + idx,
497
- code: String(idx + 1).padStart(4, '0'),
651
+ key,
652
+ code: String(total - shown.length + i + 1).padStart(4, '0'),
498
653
  title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
499
654
  sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark + (raw.length > 220 ? ' · ' + (expanded ? 'click to collapse' : 'click to expand') : ''),
500
- onClick: () => { expanded ? state.expandedEvents.delete(idx) : state.expandedEvents.add(idx); render(); },
655
+ onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
501
656
  };
502
657
  }),
503
658
  }),
504
659
  }),
505
- ];
660
+ ].filter(Boolean);
661
+ }
662
+
663
+ let copyToast = null;
664
+ function copySid() {
665
+ const sid = state.selectedSid;
666
+ if (!sid) return;
667
+ const done = () => { copyToast = '✓ copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
668
+ if (navigator.clipboard?.writeText) {
669
+ navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
670
+ } else {
671
+ // Fallback for insecure (http) origins where navigator.clipboard is absent.
672
+ try {
673
+ const ta = document.createElement('textarea');
674
+ ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
675
+ document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
676
+ done();
677
+ } catch { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
678
+ }
506
679
  }
507
680
 
508
681
  function resumeInChat(sess) {
@@ -511,7 +684,13 @@ function resumeInChat(sess) {
511
684
  state.chat.resumeSid = sess?.sid || state.selectedSid;
512
685
  state.chat.messages = [];
513
686
  state.chat.draft = '';
514
- // Only claude-code supports --resume by sid here.
687
+ // Only claude-code supports --resume by sid; warn if we have to switch the
688
+ // user's selected agent rather than silently discarding it.
689
+ if (state.selectedAgent && state.selectedAgent !== 'claude-code') {
690
+ state.chat.resumeNote = 'Switched to Claude Code — only it supports resuming a session by id.';
691
+ } else {
692
+ state.chat.resumeNote = null;
693
+ }
515
694
  if (state.selectedAgent !== 'claude-code') selectAgent('claude-code');
516
695
  render();
517
696
  }
@@ -526,6 +705,16 @@ function visibleSessions() {
526
705
  return filtered.slice().sort((a, b) => (b.last || 0) - (a.last || 0));
527
706
  }
528
707
 
708
+ // ccsniff derives `project` from the ~/.claude/projects dir name, which encodes
709
+ // the cwd as a dash-joined path (e.g. "-config-workspace-agentgui"). Show the
710
+ // last meaningful segment ("agentgui") rather than the raw slug.
711
+ function projectLabel(project) {
712
+ if (!project) return '';
713
+ if (/[/\\]/.test(project)) return project.split(/[/\\]/).filter(Boolean).pop() || project;
714
+ const segs = project.split('-').filter(Boolean);
715
+ return segs.length ? segs[segs.length - 1] : project;
716
+ }
717
+
529
718
  function uniqueProjects() {
530
719
  const arr = Array.isArray(state.sessions) ? state.sessions : [];
531
720
  const seen = new Map();
@@ -557,7 +746,7 @@ function historySide() {
557
746
  Row({
558
747
  key: 'sess' + s.sid,
559
748
  rank: String(i + 1).padStart(3, '0'),
560
- title: (s.isSubagent ? '↳ ' : '') + (s.title || s.project || s.sid),
749
+ title: (s.isSubagent ? '↳ ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
561
750
  sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
562
751
  rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
563
752
  active: s.sid === state.selectedSid,
@@ -577,7 +766,7 @@ function historySide() {
577
766
  const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
578
767
  const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
579
768
  return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
580
- h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split('/').slice(-1)[0] : '')),
769
+ h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
581
770
  Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: '◼ stop' }));
582
771
  }),
583
772
  })
@@ -603,14 +792,17 @@ function historySide() {
603
792
  searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
604
793
  ? h('p', { key: 'nomatch', class: 'lede empty-state' }, 'no matches for "' + state.searchQ + '"')
605
794
  : null,
606
- state.searchQ && (searching || state.searchBusy)
795
+ state.searchQ.trim().length === 1
796
+ ? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
797
+ : null,
798
+ state.searchQ
607
799
  ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
608
800
  : null,
609
801
  !searching && projects.length > 1
610
802
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
611
803
  pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
612
804
  ...projects.slice(0, 8).map(([name, count]) =>
613
- pillButton('p'+name, truncate(name, 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
805
+ pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
614
806
  : null,
615
807
  !searching && subagentCount
616
808
  ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
@@ -669,6 +861,7 @@ function settingsMain() {
669
861
  title: '⌘ settings',
670
862
  lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=… or the field below persists via localStorage.',
671
863
  }),
864
+ h('div', { key: 'settings-grid', class: 'settings-grid' }, [
672
865
  Panel({
673
866
  title: 'backend',
674
867
  children: h('form', {
@@ -702,6 +895,7 @@ function settingsMain() {
702
895
  ]),
703
896
  }),
704
897
  agentsPanel(),
898
+ ]),
705
899
  ];
706
900
  }
707
901
 
@@ -749,6 +943,7 @@ async function refreshHistory() {
749
943
  render();
750
944
  }
751
945
  }
946
+ const debouncedRefreshHistory = debounce(refreshHistory, 500);
752
947
 
753
948
  async function runSearch() {
754
949
  const q = state.searchQ.trim();
@@ -757,7 +952,7 @@ async function runSearch() {
757
952
  state.searchBusy = true;
758
953
  render();
759
954
  try {
760
- state.searchHits = await B.searchHistory(state.backend, q, 50);
955
+ state.searchHits = await B.searchHistory(state.backend, q, 60);
761
956
  } catch (e) {
762
957
  state.searchHits = { query: q, results: [], error: e.message };
763
958
  } finally {
@@ -768,18 +963,26 @@ async function runSearch() {
768
963
  const debouncedSearch = debounce(runSearch, 300);
769
964
 
770
965
  async function loadSession(sid) {
966
+ // Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
967
+ if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
771
968
  state.selectedSid = sid;
772
969
  state.events = [];
773
- writeHash(sid);
970
+ state.eventsLoaded = false;
971
+ state.expandedEvents = new Set(); // don't carry expansion to the new session
972
+ writeHash(sid, { push: true });
774
973
  render();
775
- try { state.events = await B.getSessionEvents(state.backend, sid); render(); }
776
- catch (e) {
974
+ try {
975
+ state.events = await B.getSessionEvents(state.backend, sid);
976
+ state.eventsLoaded = true;
977
+ render();
978
+ } catch (e) {
777
979
  state.events = [{
778
980
  ts: Date.now(),
779
981
  role: 'error',
780
982
  type: 'fetch',
781
983
  text: 'Failed to load session: ' + e.message + ' — retry via sidebar',
782
984
  }];
985
+ state.eventsLoaded = true;
783
986
  render();
784
987
  }
785
988
  }
@@ -801,13 +1004,26 @@ async function init() {
801
1004
  render();
802
1005
  } catch (e) { console.warn('agents fetch failed:', e.message); }
803
1006
 
804
- const initialSid = readHash();
1007
+ const { sid: initialSid, tab: initialTab } = readHash();
805
1008
  if (initialSid) {
806
1009
  navTo('history');
807
1010
  await refreshHistory();
808
1011
  await loadSession(initialSid);
1012
+ } else if (initialTab && initialTab !== state.tab) {
1013
+ navTo(initialTab);
809
1014
  }
810
1015
 
1016
+ registerWsStatusOnce();
1017
+ startActivePolling(); // surface running chats on any tab, not just history
1018
+ startRelTimeTick();
1019
+ }
1020
+
1021
+ // init() runs both at boot and on every saveBackend(); registering the WS
1022
+ // status listener inside it leaked a listener per save. Register exactly once.
1023
+ let wsStatusRegistered = false;
1024
+ function registerWsStatusOnce() {
1025
+ if (wsStatusRegistered) return;
1026
+ wsStatusRegistered = true;
811
1027
  B.onWsStatus?.((s) => {
812
1028
  if (s === 'closed' || s === 'error') {
813
1029
  if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
@@ -819,6 +1035,20 @@ async function init() {
819
1035
 
820
1036
  restoreChat();
821
1037
  render = mount(document.getElementById('app'), view);
1038
+ requestAnimationFrame(syncAriaCurrent);
1039
+
1040
+ // Re-render on resize so isNarrow()/truncate() reflect the current width
1041
+ // (they read window.innerWidth only at render time).
1042
+ window.addEventListener('resize', debounce(() => scheduleRender(), 150));
1043
+
1044
+ // Browser Back/forward: re-sync tab + selected session from the hash.
1045
+ window.addEventListener('popstate', () => {
1046
+ const { sid, tab } = readHash();
1047
+ if (sid && sid !== state.selectedSid) { state.tab = 'history'; loadSession(sid); }
1048
+ else if (!sid && tab && tab !== state.tab) navTo(tab);
1049
+ else if (!sid && !tab && state.tab !== 'chat') navTo('chat');
1050
+ });
1051
+
822
1052
  window.__agentgui = { state, render };
823
1053
 
824
1054
  // Keyboard shortcuts. 'g' then c/h/s switches tabs; 'n' new chat; '/' focuses