agentgui 1.0.940 → 1.0.941

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,45 @@ 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
+ const region = document.querySelector('#agentgui-main');
154
+ if (!region) return;
155
+ const heading = region.querySelector('h1, h2');
156
+ const target = heading || region;
157
+ if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
158
+ try { target.focus({ preventScroll: true }); } catch {}
159
+ });
128
160
  }
129
161
 
130
162
  async function refreshActive() {
131
- state.active = await B.listActiveChats(state.backend);
163
+ try { state.active = await B.listActiveChats(state.backend); } catch { return; }
132
164
  render();
133
165
  }
134
166
  function startActivePolling() {
135
167
  if (state.activeTimer) return;
136
168
  refreshActive();
137
- state.activeTimer = setInterval(refreshActive, 3000);
169
+ // Small jitter so many tabs don't hit the server in lockstep.
170
+ state.activeTimer = setInterval(refreshActive, 3000 + Math.floor(Math.random() * 600));
138
171
  }
139
172
  function stopActivePolling() {
140
173
  if (state.activeTimer) { clearInterval(state.activeTimer); state.activeTimer = null; }
141
174
  }
175
+
176
+ // Re-render once a minute so relative timestamps ("5s ago") don't sit frozen
177
+ // between events. Cheap: scheduleRender coalesces via rAF.
178
+ let _relTick = null;
179
+ function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => scheduleRender(), 30000); }
142
180
  async function stopActiveChat(sid) {
143
181
  try { await B.cancelChat(state.backend, sid); } catch {}
144
182
  refreshActive();
@@ -158,6 +196,8 @@ function openLiveStream() {
158
196
  } else if (kind === 'event' && data) {
159
197
  if (state.selectedSid && data.sid === state.selectedSid) {
160
198
  state.events.push(data);
199
+ // Cap retained events so a long live session can't grow unbounded.
200
+ if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
161
201
  }
162
202
  const arr = Array.isArray(state.sessions) ? state.sessions : [];
163
203
  const sess = arr.find(s => s.sid === data.sid);
@@ -167,11 +207,13 @@ function openLiveStream() {
167
207
  if (data.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
168
208
  if (data.isError) sess.errors = (sess.errors || 0) + 1;
169
209
  } else {
170
- refreshHistory();
210
+ // Unknown session: a burst of events for a new session would trigger
211
+ // a full session-list refetch per event — debounce it into one.
212
+ debouncedRefreshHistory();
171
213
  return;
172
214
  }
173
215
  } else if (kind === 'conversation') {
174
- refreshHistory();
216
+ debouncedRefreshHistory();
175
217
  return;
176
218
  } else if (kind === 'error' && data) {
177
219
  state.live.error = data.error || 'stream error';
@@ -208,12 +250,19 @@ function view() {
208
250
  : (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
209
251
  : (ok ? (state.health.ws === 'reconnecting' ? '◌ ws reconnecting' : '● connected') : '○ offline');
210
252
  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);
253
+ // Split the leading status glyph ( ) from the words: glyph is decorative
254
+ // (aria-hidden), only the text is announced, so AT reads "live" not "black circle live".
255
+ const glyphMatch = dotText.match(/^([●◌○])\s*(.*)$/);
256
+ const dotGlyph = glyphMatch ? glyphMatch[1] : '';
257
+ const dotLabel = glyphMatch ? glyphMatch[2] : dotText;
258
+ const dot = h('span', { key: 'dot', class: 'status-dot' + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
259
+ dotGlyph ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
260
+ h('span', { key: 'dl' }, dotLabel));
212
261
 
213
262
  const topbar = Topbar({
214
263
  brand: 'agentgui',
215
264
  leaf: state.tab,
216
- items: [['chat', '#'], ['history', '#'], ['settings', '#']],
265
+ items: [['chat', buildHash('chat', null) || '#'], ['history', buildHash('history', null)], ['settings', buildHash('settings', null)]],
217
266
  active: state.tab,
218
267
  onNav: (label) => navTo(label),
219
268
  });
@@ -272,9 +321,11 @@ function view() {
272
321
  right: [agentLabel],
273
322
  });
274
323
 
324
+ // The design system now owns the full-height column + inner scroll for
325
+ // .app-main, so chat just needs to be a flex column that fills it.
275
326
  const mainStyle = state.tab === 'chat'
276
- ? 'min-height:0;height:100%;display:flex;flex-direction:column'
277
- : 'min-height:0;height:100%;overflow:auto';
327
+ ? 'min-height:0;display:flex;flex-direction:column;flex:1'
328
+ : 'min-height:0';
278
329
  const shortcutsHint = state.showShortcuts
279
330
  ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
280
331
  children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
@@ -306,6 +357,20 @@ function toolSummary(block) {
306
357
  return '⌘ ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
307
358
  }
308
359
 
360
+ function toolResultSummary(block) {
361
+ const c = block?.content ?? block?.output ?? block;
362
+ let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
363
+ s = s.replace(/\s+/g, ' ').trim();
364
+ return (block?.is_error ? '⚠ ' : '') + s.slice(0, 160);
365
+ }
366
+
367
+ function errText(e) {
368
+ if (e == null) return 'unknown error';
369
+ if (typeof e === 'string') return e;
370
+ if (e.message) return e.message;
371
+ try { return JSON.stringify(e); } catch { return String(e); }
372
+ }
373
+
309
374
  function chatMain() {
310
375
  const lastIdx = state.chat.messages.length - 1;
311
376
  const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
@@ -332,7 +397,7 @@ function chatMain() {
332
397
  : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
333
398
  const composer = ChatComposer({
334
399
  value: state.chat.draft,
335
- disabled: state.chat.busy || !canSend(),
400
+ disabled: !canSend(),
336
401
  placeholder,
337
402
  onInput: (v) => { state.chat.draft = v; render(); },
338
403
  onSend: (v) => { state.chat.draft = v; sendChat(); },
@@ -341,24 +406,31 @@ function chatMain() {
341
406
  const banners = [];
342
407
  if (state.chat.resumeSid) {
343
408
  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' })));
409
+ h('span', { key: 'rbtxt', class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
410
+ Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: '× clear' })));
411
+ if (state.chat.resumeNote) {
412
+ banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
413
+ }
346
414
  }
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));
415
+ banners.push(cwdBanner());
357
416
  if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
358
417
  banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
359
418
  children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
360
419
  }
420
+ if (state.confirmingNewChat) {
421
+ banners.push(Alert({ key: 'confnew', kind: 'warn', title: 'Clear chat history?',
422
+ children: [
423
+ h('span', { key: 'cntxt' }, 'This cannot be undone. '),
424
+ Btn({ key: 'cnyes', danger: true, onClick: newChat, children: 'clear' }),
425
+ Btn({ key: 'cnno', onClick: () => { state.confirmingNewChat = false; render(); }, children: 'cancel' })] }));
426
+ }
427
+ // Last stream error surfaced as a proper Alert instead of raw JSON in the bubble.
428
+ const lastErr = state.chat.messages.length ? state.chat.messages[state.chat.messages.length - 1].error : null;
429
+ if (lastErr && !state.chat.busy) {
430
+ banners.push(Alert({ key: 'chaterr', kind: 'error', title: 'Stream error', children: lastErr }));
431
+ }
361
432
  return [
433
+ offlineBanner(),
362
434
  ...banners,
363
435
  Chat({
364
436
  title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
@@ -369,8 +441,36 @@ function chatMain() {
369
441
  ].filter(Boolean);
370
442
  }
371
443
 
444
+ function offlineBanner() {
445
+ if (state.health.status === 'ok' || state.health.status === 'unknown') return null;
446
+ return Alert({ key: 'offline', kind: 'error', title: 'Backend unreachable',
447
+ children: 'agentgui can\'t reach the server (' + (state.health.error || state.health.status) + '). Chat and history actions will fail until it reconnects.' });
448
+ }
449
+
450
+ function cwdBanner() {
451
+ if (state.cwdEditing) {
452
+ return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Set working directory' },
453
+ TextField({ key: 'cwdfield', label: 'working directory (blank = server default)', value: state.cwdDraft ?? state.chatCwd ?? '',
454
+ placeholder: 'absolute path', onInput: (v) => { state.cwdDraft = v; } }),
455
+ Btn({ key: 'cwdsave', primary: true, onClick: () => {
456
+ state.chatCwd = (state.cwdDraft ?? '').trim();
457
+ if (state.chatCwd) lsSet('agentgui.cwd', state.chatCwd); else lsRemove('agentgui.cwd');
458
+ state.cwdEditing = false; state.cwdDraft = undefined; render();
459
+ }, children: 'save' }),
460
+ Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
461
+ }
462
+ return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Working directory' },
463
+ h('span', { class: 'lede' }, state.chatCwd ? '▣ cwd: ' + state.chatCwd : '▣ cwd: server default'),
464
+ Btn({ key: 'cwdset', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); }, children: state.chatCwd ? 'change' : 'set' }),
465
+ state.chatCwd ? Btn({ key: 'cwdclr', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); }, children: '× default' }) : null);
466
+ }
467
+
372
468
  function newChat() {
373
- if (state.chat.messages.length && !confirm('Clear chat history? This cannot be undone.')) return;
469
+ if (state.chat.messages.length && !state.confirmingNewChat) {
470
+ state.confirmingNewChat = true; render();
471
+ return;
472
+ }
473
+ state.confirmingNewChat = false;
374
474
  state.chat.abort?.abort();
375
475
  state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
376
476
  try { localStorage.removeItem(CHAT_KEY); } catch {}
@@ -425,10 +525,12 @@ async function sendChat() {
425
525
  })) {
426
526
  if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
427
527
  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(); }
528
+ else if (ev.type === 'tool_result') { cur.parts.push(' ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
529
+ else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
530
+ else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
429
531
  }
430
532
  } catch (e) {
431
- if (e.name !== 'AbortError') cur.content += '\n[error] ' + e.message;
533
+ if (e.name !== 'AbortError') cur.error = errText(e.message);
432
534
  } finally {
433
535
  state.chat.busy = false;
434
536
  state.chat.abort = null;
@@ -439,12 +541,25 @@ async function sendChat() {
439
541
  }
440
542
 
441
543
  // ── history ────────────────────────────────────────────────────────────────
544
+ function reconnectAlert() {
545
+ if (!state.live.error) return null;
546
+ return Alert({
547
+ key: 'liveerr',
548
+ kind: 'error',
549
+ title: 'Live stream disconnected',
550
+ children: [h('span', { key: 'lemsg' }, state.live.error + ' — '), Btn({ key: 'reco', onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
551
+ });
552
+ }
553
+
442
554
  function historyMain() {
443
555
  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
- })];
556
+ return [
557
+ reconnectAlert(),
558
+ PageHeader({
559
+ title: '§ history',
560
+ lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
561
+ }),
562
+ ].filter(Boolean);
448
563
  }
449
564
 
450
565
  const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
@@ -457,52 +572,73 @@ function historyMain() {
457
572
  lede,
458
573
  });
459
574
 
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
575
  const actions = h('div', { key: 'acts', class: 'history-actions' },
470
576
  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' }),
577
+ Btn({ key: 'copy', onClick: copySid, children: copyToast || '⎘ copy sid' }),
472
578
  );
473
579
 
474
580
  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…') })];
581
+ // Distinguish "still loading" from "genuinely empty" so a 0-event session
582
+ // doesn't spin forever.
583
+ const body = state.eventsLoaded
584
+ ? h('p', { key: 'noev', class: 'lede empty-state', role: 'status' }, 'no events in this session')
585
+ : h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…');
586
+ return [reconnectAlert(), head, actions, Panel({ title: 'events', children: body })].filter(Boolean);
476
587
  }
477
588
 
478
589
  if (!state.expandedEvents) state.expandedEvents = new Set();
590
+ const total = state.events.length;
591
+ const shown = state.events.slice(-300);
592
+ const hiddenCount = total - shown.length;
479
593
  return [
594
+ reconnectAlert(),
480
595
  head,
481
596
  actions,
482
597
  Panel({
483
- title: state.events.length + ' events',
598
+ title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
484
599
  children: EventList({
485
- items: state.events.slice(-300).map((e, i) => {
486
- const idx = e.i ?? i;
600
+ items: shown.map((e, i) => {
601
+ // Stable key: prefer the server-assigned event index, else the
602
+ // event timestamp + position, never a bare array index (which
603
+ // collides between loaded and live-pushed events).
604
+ const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
487
605
  const role = e.role || '?';
488
606
  const type = e.type || '?';
489
607
  const tool = e.tool ? ' · ⌘ ' + e.tool : '';
490
608
  const errMark = e.isError ? ' · ⚠' : '';
491
609
  const raw = e.text || '';
492
610
  const text = raw.replace(/\s+/g, ' ').trim();
493
- const expanded = state.expandedEvents.has(idx);
611
+ const expanded = state.expandedEvents.has(key);
494
612
  const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
495
613
  return {
496
- key: 'ev' + idx,
497
- code: String(idx + 1).padStart(4, '0'),
614
+ key,
615
+ code: String(total - shown.length + i + 1).padStart(4, '0'),
498
616
  title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
499
617
  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(); },
618
+ onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
501
619
  };
502
620
  }),
503
621
  }),
504
622
  }),
505
- ];
623
+ ].filter(Boolean);
624
+ }
625
+
626
+ let copyToast = null;
627
+ function copySid() {
628
+ const sid = state.selectedSid;
629
+ if (!sid) return;
630
+ const done = () => { copyToast = '✓ copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
631
+ if (navigator.clipboard?.writeText) {
632
+ navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
633
+ } else {
634
+ // Fallback for insecure (http) origins where navigator.clipboard is absent.
635
+ try {
636
+ const ta = document.createElement('textarea');
637
+ ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
638
+ document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
639
+ done();
640
+ } catch { copyToast = '⚠ copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
641
+ }
506
642
  }
507
643
 
508
644
  function resumeInChat(sess) {
@@ -511,7 +647,13 @@ function resumeInChat(sess) {
511
647
  state.chat.resumeSid = sess?.sid || state.selectedSid;
512
648
  state.chat.messages = [];
513
649
  state.chat.draft = '';
514
- // Only claude-code supports --resume by sid here.
650
+ // Only claude-code supports --resume by sid; warn if we have to switch the
651
+ // user's selected agent rather than silently discarding it.
652
+ if (state.selectedAgent && state.selectedAgent !== 'claude-code') {
653
+ state.chat.resumeNote = 'Switched to Claude Code — only it supports resuming a session by id.';
654
+ } else {
655
+ state.chat.resumeNote = null;
656
+ }
515
657
  if (state.selectedAgent !== 'claude-code') selectAgent('claude-code');
516
658
  render();
517
659
  }
@@ -577,7 +719,7 @@ function historySide() {
577
719
  const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
578
720
  const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
579
721
  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] : '')),
722
+ h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
581
723
  Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: '◼ stop' }));
582
724
  }),
583
725
  })
@@ -603,7 +745,10 @@ function historySide() {
603
745
  searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
604
746
  ? h('p', { key: 'nomatch', class: 'lede empty-state' }, 'no matches for "' + state.searchQ + '"')
605
747
  : null,
606
- state.searchQ && (searching || state.searchBusy)
748
+ state.searchQ.trim().length === 1
749
+ ? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
750
+ : null,
751
+ state.searchQ
607
752
  ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
608
753
  : null,
609
754
  !searching && projects.length > 1
@@ -749,6 +894,7 @@ async function refreshHistory() {
749
894
  render();
750
895
  }
751
896
  }
897
+ const debouncedRefreshHistory = debounce(refreshHistory, 500);
752
898
 
753
899
  async function runSearch() {
754
900
  const q = state.searchQ.trim();
@@ -757,7 +903,7 @@ async function runSearch() {
757
903
  state.searchBusy = true;
758
904
  render();
759
905
  try {
760
- state.searchHits = await B.searchHistory(state.backend, q, 50);
906
+ state.searchHits = await B.searchHistory(state.backend, q, 60);
761
907
  } catch (e) {
762
908
  state.searchHits = { query: q, results: [], error: e.message };
763
909
  } finally {
@@ -770,16 +916,22 @@ const debouncedSearch = debounce(runSearch, 300);
770
916
  async function loadSession(sid) {
771
917
  state.selectedSid = sid;
772
918
  state.events = [];
773
- writeHash(sid);
919
+ state.eventsLoaded = false;
920
+ state.expandedEvents = new Set(); // don't carry expansion to the new session
921
+ writeHash(sid, { push: true });
774
922
  render();
775
- try { state.events = await B.getSessionEvents(state.backend, sid); render(); }
776
- catch (e) {
923
+ try {
924
+ state.events = await B.getSessionEvents(state.backend, sid);
925
+ state.eventsLoaded = true;
926
+ render();
927
+ } catch (e) {
777
928
  state.events = [{
778
929
  ts: Date.now(),
779
930
  role: 'error',
780
931
  type: 'fetch',
781
932
  text: 'Failed to load session: ' + e.message + ' — retry via sidebar',
782
933
  }];
934
+ state.eventsLoaded = true;
783
935
  render();
784
936
  }
785
937
  }
@@ -801,13 +953,26 @@ async function init() {
801
953
  render();
802
954
  } catch (e) { console.warn('agents fetch failed:', e.message); }
803
955
 
804
- const initialSid = readHash();
956
+ const { sid: initialSid, tab: initialTab } = readHash();
805
957
  if (initialSid) {
806
958
  navTo('history');
807
959
  await refreshHistory();
808
960
  await loadSession(initialSid);
961
+ } else if (initialTab && initialTab !== state.tab) {
962
+ navTo(initialTab);
809
963
  }
810
964
 
965
+ registerWsStatusOnce();
966
+ startActivePolling(); // surface running chats on any tab, not just history
967
+ startRelTimeTick();
968
+ }
969
+
970
+ // init() runs both at boot and on every saveBackend(); registering the WS
971
+ // status listener inside it leaked a listener per save. Register exactly once.
972
+ let wsStatusRegistered = false;
973
+ function registerWsStatusOnce() {
974
+ if (wsStatusRegistered) return;
975
+ wsStatusRegistered = true;
811
976
  B.onWsStatus?.((s) => {
812
977
  if (s === 'closed' || s === 'error') {
813
978
  if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
@@ -819,6 +984,19 @@ async function init() {
819
984
 
820
985
  restoreChat();
821
986
  render = mount(document.getElementById('app'), view);
987
+
988
+ // Re-render on resize so isNarrow()/truncate() reflect the current width
989
+ // (they read window.innerWidth only at render time).
990
+ window.addEventListener('resize', debounce(() => scheduleRender(), 150));
991
+
992
+ // Browser Back/forward: re-sync tab + selected session from the hash.
993
+ window.addEventListener('popstate', () => {
994
+ const { sid, tab } = readHash();
995
+ if (sid && sid !== state.selectedSid) { state.tab = 'history'; loadSession(sid); }
996
+ else if (!sid && tab && tab !== state.tab) navTo(tab);
997
+ else if (!sid && !tab && state.tab !== 'chat') navTo('chat');
998
+ });
999
+
822
1000
  window.__agentgui = { state, render };
823
1001
 
824
1002
  // Keyboard shortcuts. 'g' then c/h/s switches tabs; 'n' new chat; '/' focuses
@@ -27,14 +27,17 @@ function withToken(url) {
27
27
  return url + (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(tok);
28
28
  }
29
29
 
30
+ function lsGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
31
+ function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
32
+
30
33
  export function getBackend() {
31
34
  const u = new URL(location.href);
32
35
  const fromQs = u.searchParams.get('backend');
33
- if (fromQs) { localStorage.setItem(KEY, fromQs); return fromQs; }
34
- return localStorage.getItem(KEY) || DEFAULT_BACKEND;
36
+ if (fromQs) { lsSet(KEY, fromQs); return fromQs; }
37
+ return lsGet(KEY) || DEFAULT_BACKEND;
35
38
  }
36
39
 
37
- export function setBackend(url) { localStorage.setItem(KEY, url); }
40
+ export function setBackend(url) { lsSet(KEY, url); }
38
41
 
39
42
  export async function probeBackend(base) {
40
43
  try {
@@ -262,25 +265,31 @@ export async function* streamChat(base, { model, messages, signal, agentId, resu
262
265
  const sessionId = started?.sessionId;
263
266
  if (!sessionId) { yield { type: 'error', error: 'no sessionId from server' }; return; }
264
267
 
268
+ const finish = () => { done = true; if (resolveWait) { resolveWait(); resolveWait = null; } };
269
+
265
270
  const unsub = addSessionListener(sessionId, (ev) => {
266
271
  if (ev.type === 'streaming_progress') {
267
272
  const block = ev.block;
268
273
  if (block?.type === 'text' && block.text) push({ type: 'text', text: block.text });
269
274
  else if (block?.type === 'tool_use') push({ type: 'tool', block });
270
- else if (block?.type === 'tool_result') push({ type: 'tool', block });
275
+ else if (block?.type === 'tool_result') push({ type: 'tool_result', block });
271
276
  else if (block?.type === 'result') push({ type: 'result', block });
272
277
  } else if (ev.type === 'streaming_complete') {
273
- done = true;
274
- if (resolveWait) { resolveWait(); resolveWait = null; }
278
+ finish();
275
279
  } else if (ev.type === 'streaming_error') {
276
280
  errored = ev.error || 'streaming error';
277
- done = true;
278
- if (resolveWait) { resolveWait(); resolveWait = null; }
281
+ finish();
279
282
  }
280
283
  });
281
284
 
282
- // Wire AbortSignal to chat.cancel.
283
- const onAbort = () => { wsCall(base, 'chat.cancel', { sessionId }).catch(() => {}); };
285
+ // If the websocket drops mid-stream, streaming_complete will never arrive —
286
+ // surface an error and end the iterator instead of hanging forever.
287
+ const onWs = (s) => { if ((s === 'closed' || s === 'error') && !done) { errored = errored || 'connection lost during stream'; finish(); } };
288
+ const unsubWs = onWsStatus ? onWsStatus(onWs) : null;
289
+
290
+ // Wire AbortSignal to chat.cancel — and end the iterator immediately so the
291
+ // caller's busy state clears even if the server never emits a final event.
292
+ const onAbort = () => { wsCall(base, 'chat.cancel', { sessionId }).catch(() => {}); finish(); };
284
293
  if (signal) {
285
294
  if (signal.aborted) onAbort();
286
295
  else signal.addEventListener('abort', onAbort, { once: true });
@@ -297,6 +306,7 @@ export async function* streamChat(base, { model, messages, signal, agentId, resu
297
306
  if (errored) yield { type: 'error', error: errored };
298
307
  } finally {
299
308
  unsub();
309
+ if (typeof unsubWs === 'function') unsubWs();
300
310
  if (signal) signal.removeEventListener?.('abort', onAbort);
301
311
  }
302
312
  }