agentgui 1.0.939 → 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.
Files changed (46) hide show
  1. package/AGENTS.md +12 -7
  2. package/lib/claude-runner-agents.js +25 -0
  3. package/lib/ws-handlers-util.js +27 -1
  4. package/package.json +1 -1
  5. package/server.js +10 -1
  6. package/site/app/index.html +14 -37
  7. package/site/app/js/app.js +506 -104
  8. package/site/app/js/backend.js +44 -32
  9. package/site/app/vendor/anentrypoint-design/247420.css +274 -86
  10. package/site/app/vendor/anentrypoint-design/247420.js +12 -12
  11. package/site/app/vendor/cdn/dompurify.js +9 -0
  12. package/site/app/vendor/cdn/fonts/1291de6d401a.woff2 +0 -0
  13. package/site/app/vendor/cdn/fonts/1ba89a87e0b8.woff2 +0 -0
  14. package/site/app/vendor/cdn/fonts/3644d51c507b.woff2 +0 -0
  15. package/site/app/vendor/cdn/fonts/4b91d2650dc2.woff2 +0 -0
  16. package/site/app/vendor/cdn/fonts/530d036ba64a.woff2 +0 -0
  17. package/site/app/vendor/cdn/fonts/570a2bdd8f8b.woff2 +0 -0
  18. package/site/app/vendor/cdn/fonts/5dd6d880fee9.woff2 +0 -0
  19. package/site/app/vendor/cdn/fonts/62de9143afe3.woff2 +0 -0
  20. package/site/app/vendor/cdn/fonts/64884efa2f11.woff2 +0 -0
  21. package/site/app/vendor/cdn/fonts/68cd7063be2e.woff2 +0 -0
  22. package/site/app/vendor/cdn/fonts/6c252abcf99b.woff2 +0 -0
  23. package/site/app/vendor/cdn/fonts/71e69e06516a.woff2 +0 -0
  24. package/site/app/vendor/cdn/fonts/9ea68c62083f.woff2 +0 -0
  25. package/site/app/vendor/cdn/fonts/c010f9b7d6b2.woff2 +0 -0
  26. package/site/app/vendor/cdn/fonts/d69723fc74be.woff2 +0 -0
  27. package/site/app/vendor/cdn/fonts/fonts.css +459 -0
  28. package/site/app/vendor/cdn/marked.js +8 -0
  29. package/site/app/vendor/cdn/prismjs/components/prism-bash.min.js +1 -0
  30. package/site/app/vendor/cdn/prismjs/components/prism-clike.min.js +1 -0
  31. package/site/app/vendor/cdn/prismjs/components/prism-core.min.js +1 -0
  32. package/site/app/vendor/cdn/prismjs/components/prism-css.min.js +1 -0
  33. package/site/app/vendor/cdn/prismjs/components/prism-diff.min.js +1 -0
  34. package/site/app/vendor/cdn/prismjs/components/prism-go.min.js +1 -0
  35. package/site/app/vendor/cdn/prismjs/components/prism-javascript.min.js +1 -0
  36. package/site/app/vendor/cdn/prismjs/components/prism-json.min.js +1 -0
  37. package/site/app/vendor/cdn/prismjs/components/prism-jsx.min.js +1 -0
  38. package/site/app/vendor/cdn/prismjs/components/prism-markdown.min.js +1 -0
  39. package/site/app/vendor/cdn/prismjs/components/prism-markup.min.js +1 -0
  40. package/site/app/vendor/cdn/prismjs/components/prism-python.min.js +1 -0
  41. package/site/app/vendor/cdn/prismjs/components/prism-rust.min.js +1 -0
  42. package/site/app/vendor/cdn/prismjs/components/prism-sql.min.js +1 -0
  43. package/site/app/vendor/cdn/prismjs/components/prism-toml.min.js +1 -0
  44. package/site/app/vendor/cdn/prismjs/components/prism-tsx.min.js +1 -0
  45. package/site/app/vendor/cdn/prismjs/components/prism-typescript.min.js +1 -0
  46. package/site/app/vendor/cdn/prismjs/components/prism-yaml.min.js +1 -0
@@ -10,8 +10,11 @@ const state = {
10
10
  backendDraft: B.getBackend(),
11
11
  health: { status: 'unknown' },
12
12
  tab: 'chat',
13
- models: [],
14
- selectedModel: '',
13
+ agents: [],
14
+ selectedAgent: localStorage.getItem('agentgui.agent') || '',
15
+ agentModels: [],
16
+ selectedModel: localStorage.getItem('agentgui.model') || '',
17
+ chatCwd: localStorage.getItem('agentgui.cwd') || '',
15
18
  chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
16
19
  sessions: [],
17
20
  selectedSid: null,
@@ -23,15 +26,32 @@ const state = {
23
26
  sessionsLimit: 60,
24
27
  projectFilter: '',
25
28
  live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0 },
29
+ active: [],
30
+ activeTimer: null,
26
31
  };
27
32
 
28
33
  function readHash() {
29
- const m = (location.hash || '').match(/sid=([^&]+)/);
30
- 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
+ };
31
41
  }
32
- function writeHash(sid) {
33
- const h = sid ? '#sid=' + encodeURIComponent(sid) : '';
34
- 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);
35
55
  }
36
56
  function fmtRelTime(ts) {
37
57
  if (!ts) return '';
@@ -50,7 +70,8 @@ function scheduleRender() {
50
70
  requestAnimationFrame(() => { renderScheduled = false; render(); });
51
71
  }
52
72
 
53
- 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; }
54
75
  function truncate(str, mobileLen, desktopLen) {
55
76
  const s = String(str ?? '');
56
77
  const max = isNarrow() ? mobileLen : desktopLen;
@@ -61,6 +82,9 @@ function debounce(fn, ms) {
61
82
  return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
62
83
  }
63
84
 
85
+ function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
86
+ function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
87
+
64
88
  function pillButton(key, label, active, title, onClick) {
65
89
  return h('button', {
66
90
  key,
@@ -74,8 +98,11 @@ function pillButton(key, label, active, title, onClick) {
74
98
 
75
99
  function scrollChatToBottom() {
76
100
  requestAnimationFrame(() => {
77
- const el = document.querySelector('#agentgui-main') || document.querySelector('[role="main"]') || document.getElementById('app');
78
- 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');
79
106
  if (scroller) scroller.scrollTop = scroller.scrollHeight;
80
107
  });
81
108
  }
@@ -85,16 +112,74 @@ function timeNow() {
85
112
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
86
113
  }
87
114
 
115
+ async function selectAgent(id) {
116
+ state.selectedAgent = id;
117
+ localStorage.setItem('agentgui.agent', id);
118
+ state.agentModels = [];
119
+ state.selectedModel = '';
120
+ render();
121
+ const models = await B.listAgentModels(state.backend, id);
122
+ if (state.selectedAgent !== id) return; // changed while loading
123
+ state.agentModels = models;
124
+ const saved = localStorage.getItem('agentgui.model');
125
+ state.selectedModel = (saved && models.some(m => m.id === saved)) ? saved : (models[0]?.id || '');
126
+ render();
127
+ }
128
+
129
+ function selectModel(id) {
130
+ state.selectedModel = id;
131
+ localStorage.setItem('agentgui.model', id);
132
+ render();
133
+ }
134
+
135
+ function agentById(id) { return state.agents.find(a => a.id === id); }
136
+ function agentAvailable(id) { const a = agentById(id); return !a || a.available !== false; }
137
+
88
138
  function navTo(tab) {
89
139
  const prev = state.tab;
90
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.
91
143
  if (tab === 'history') {
92
144
  refreshHistory();
93
145
  openLiveStream();
94
146
  } else if (prev === 'history') {
95
147
  closeLiveStream();
96
148
  }
149
+ writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
97
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
+ });
160
+ }
161
+
162
+ async function refreshActive() {
163
+ try { state.active = await B.listActiveChats(state.backend); } catch { return; }
164
+ render();
165
+ }
166
+ function startActivePolling() {
167
+ if (state.activeTimer) return;
168
+ refreshActive();
169
+ // Small jitter so many tabs don't hit the server in lockstep.
170
+ state.activeTimer = setInterval(refreshActive, 3000 + Math.floor(Math.random() * 600));
171
+ }
172
+ function stopActivePolling() {
173
+ if (state.activeTimer) { clearInterval(state.activeTimer); state.activeTimer = null; }
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); }
180
+ async function stopActiveChat(sid) {
181
+ try { await B.cancelChat(state.backend, sid); } catch {}
182
+ refreshActive();
98
183
  }
99
184
 
100
185
  function openLiveStream() {
@@ -111,6 +196,8 @@ function openLiveStream() {
111
196
  } else if (kind === 'event' && data) {
112
197
  if (state.selectedSid && data.sid === state.selectedSid) {
113
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);
114
201
  }
115
202
  const arr = Array.isArray(state.sessions) ? state.sessions : [];
116
203
  const sess = arr.find(s => s.sid === data.sid);
@@ -120,11 +207,13 @@ function openLiveStream() {
120
207
  if (data.type === 'tool_use') sess.tools = (sess.tools || 0) + 1;
121
208
  if (data.isError) sess.errors = (sess.errors || 0) + 1;
122
209
  } else {
123
- 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();
124
213
  return;
125
214
  }
126
215
  } else if (kind === 'conversation') {
127
- refreshHistory();
216
+ debouncedRefreshHistory();
128
217
  return;
129
218
  } else if (kind === 'error' && data) {
130
219
  state.live.error = data.error || 'stream error';
@@ -161,26 +250,50 @@ function view() {
161
250
  : (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
162
251
  : (ok ? (state.health.ws === 'reconnecting' ? '◌ ws reconnecting' : '● connected') : '○ offline');
163
252
  const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
164
- 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));
165
261
 
166
262
  const topbar = Topbar({
167
263
  brand: 'agentgui',
168
264
  leaf: state.tab,
169
- items: [['chat', '#'], ['history', '#'], ['settings', '#']],
265
+ items: [['chat', buildHash('chat', null) || '#'], ['history', buildHash('history', null)], ['settings', buildHash('settings', null)]],
170
266
  active: state.tab,
171
267
  onNav: (label) => navTo(label),
172
268
  });
173
269
 
270
+ const agentOptions = state.agents.map(a => ({
271
+ value: a.id,
272
+ label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
273
+ disabled: a.available === false && !a.npxInstallable,
274
+ }));
275
+ const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
276
+
174
277
  const crumbRight = state.tab === 'chat'
175
278
  ? [h('div', { key: 'cc', class: 'chat-controls' },
176
279
  Select({
177
- key: 'modelsel',
178
- value: state.selectedModel,
179
- placeholder: '— model —',
180
- title: 'Select AI model',
181
- options: state.models.map(m => ({ value: m.id, label: m.id })),
182
- onChange: (v) => { state.selectedModel = v; render(); },
280
+ key: 'agentsel',
281
+ value: state.selectedAgent,
282
+ placeholder: '— agent —',
283
+ title: 'Select coding agent',
284
+ options: agentOptions,
285
+ onChange: (v) => { selectAgent(v); },
183
286
  }),
287
+ showModelPicker
288
+ ? Select({
289
+ key: 'modelsel',
290
+ value: state.selectedModel,
291
+ placeholder: '— model —',
292
+ title: 'Select model for this agent',
293
+ options: state.agentModels.map(m => ({ value: m.id, label: m.name || m.id })),
294
+ onChange: (v) => { selectModel(v); },
295
+ })
296
+ : null,
184
297
  state.chat.busy
185
298
  ? Btn({ key: 'stop', onClick: cancelChat, children: '◼ stop', title: 'Stop streaming' })
186
299
  : Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
@@ -200,15 +313,24 @@ function view() {
200
313
  // sidebar (the topbar already provides primary nav) so main content gets full width.
201
314
  const side = state.tab === 'history' ? historySide() : null;
202
315
 
316
+ const agentLabel = state.selectedAgent
317
+ ? '⌘ ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
318
+ : '○ no agent';
203
319
  const status = Status({
204
320
  left: [state.backend || 'same-origin', ok ? '● live' : '○ offline'],
205
- right: [state.selectedModel ? '⌘ ' + state.selectedModel : '○ no model'],
321
+ right: [agentLabel],
206
322
  });
207
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.
208
326
  const mainStyle = state.tab === 'chat'
209
- ? 'min-height:0;height:100%;display:flex;flex-direction:column'
210
- : 'min-height:0;height:100%;overflow:auto';
211
- const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, mainContent());
327
+ ? 'min-height:0;display:flex;flex-direction:column;flex:1'
328
+ : 'min-height:0';
329
+ const shortcutsHint = state.showShortcuts
330
+ ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
331
+ children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
332
+ : null;
333
+ 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));
212
334
  // settings reads better centered in a measure; chat + history use full width.
213
335
  return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
214
336
  }
@@ -220,41 +342,98 @@ function mainContent() {
220
342
  }
221
343
 
222
344
  // ── chat ───────────────────────────────────────────────────────────────────
345
+ function canSend() {
346
+ return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
347
+ }
348
+
349
+ function toolSummary(block) {
350
+ const name = block.name || block.kind || 'tool';
351
+ let arg = '';
352
+ const inp = block.input || block.rawInput;
353
+ if (inp && typeof inp === 'object') {
354
+ arg = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
355
+ if (!arg) { try { arg = JSON.stringify(inp).slice(0, 120); } catch {} }
356
+ }
357
+ return '⌘ ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
358
+ }
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
+
223
374
  function chatMain() {
224
375
  const lastIdx = state.chat.messages.length - 1;
376
+ const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
225
377
  const msgs = state.chat.messages.map((m, i) => {
226
378
  const isAssistant = m.role === 'assistant';
227
379
  const isStreaming = state.chat.busy && i === lastIdx && isAssistant;
228
- const isEmptyStreaming = isStreaming && !m.content;
380
+ const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
381
+ const isEmptyStreaming = isStreaming && !m.content && !hasParts;
382
+ const parts = [];
383
+ if (m.content) parts.push({ kind: isAssistant ? 'md' : 'text', text: m.content });
384
+ if (hasParts) for (const p of m.parts) parts.push({ kind: 'text', text: p });
229
385
  return {
230
386
  key: m.id || String(i),
231
387
  who: isAssistant ? 'them' : 'you',
232
- name: isAssistant ? (state.selectedModel || 'agent') : 'you',
388
+ name: isAssistant ? agentName : 'you',
233
389
  time: m.time || '',
234
390
  typing: isEmptyStreaming,
235
- parts: isEmptyStreaming
236
- ? undefined
237
- : [{ kind: isAssistant ? 'md' : 'text', text: m.content || '' }],
391
+ parts: isEmptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
238
392
  };
239
393
  });
240
394
 
395
+ const placeholder = !state.selectedAgent
396
+ ? 'choose an agent first'
397
+ : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
241
398
  const composer = ChatComposer({
242
399
  value: state.chat.draft,
243
- disabled: state.chat.busy,
244
- placeholder: state.selectedModel ? 'message…' : 'choose a model first',
400
+ disabled: !canSend(),
401
+ placeholder,
245
402
  onInput: (v) => { state.chat.draft = v; render(); },
246
403
  onSend: (v) => { state.chat.draft = v; sendChat(); },
247
404
  });
248
405
 
249
- const resumeBanner = state.chat.resumeSid
250
- ? h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
251
- h('span', { class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + ' via claude --resume'),
252
- Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; render(); }, children: '× clear' }))
253
- : null;
406
+ const banners = [];
407
+ if (state.chat.resumeSid) {
408
+ banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
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
+ }
414
+ }
415
+ banners.push(cwdBanner());
416
+ if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
417
+ banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
418
+ children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
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
+ }
254
432
  return [
255
- resumeBanner,
433
+ offlineBanner(),
434
+ ...banners,
256
435
  Chat({
257
- title: (state.selectedModel || 'agent') + (state.chat.resumeSid ? ' · resume' : ''),
436
+ title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
258
437
  sub: state.chat.busy ? 'streaming…' : undefined,
259
438
  messages: msgs,
260
439
  composer,
@@ -262,56 +441,125 @@ function chatMain() {
262
441
  ].filter(Boolean);
263
442
  }
264
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
+
265
468
  function newChat() {
266
- if (!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;
267
474
  state.chat.abort?.abort();
268
475
  state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
476
+ try { localStorage.removeItem(CHAT_KEY); } catch {}
269
477
  render();
270
478
  }
271
479
 
272
480
  function cancelChat() { state.chat.abort?.abort(); }
273
481
 
482
+ const CHAT_KEY = 'agentgui.chat';
483
+ function persistChat() {
484
+ try {
485
+ const msgs = state.chat.messages.map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
486
+ if (!msgs.length) { localStorage.removeItem(CHAT_KEY); return; }
487
+ localStorage.setItem(CHAT_KEY, JSON.stringify({ messages: msgs, resumeSid: state.chat.resumeSid, agent: state.selectedAgent, model: state.selectedModel }));
488
+ } catch {}
489
+ }
490
+ function restoreChat() {
491
+ try {
492
+ const raw = localStorage.getItem(CHAT_KEY);
493
+ if (!raw) return;
494
+ const saved = JSON.parse(raw);
495
+ if (Array.isArray(saved.messages) && saved.messages.length) {
496
+ state.chat.messages = saved.messages.map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
497
+ state.chat.resumeSid = saved.resumeSid || null;
498
+ }
499
+ } catch {}
500
+ }
501
+
274
502
  async function sendChat() {
275
503
  const text = (state.chat.draft || '').trim();
276
- if (!text || !state.selectedModel || state.chat.busy) return;
504
+ if (!text || !canSend()) return;
277
505
  const t = timeNow();
278
506
  const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
279
- const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t };
507
+ const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t, parts: [] };
280
508
  state.chat.messages = [...state.chat.messages, userMsg, curMsg];
281
509
  state.chat.draft = '';
282
510
  state.chat.busy = true;
283
511
  const ctrl = new AbortController();
284
512
  state.chat.abort = ctrl;
513
+ persistChat();
285
514
  render();
286
515
  scrollChatToBottom();
287
516
  const cur = state.chat.messages[state.chat.messages.length - 1];
288
517
  try {
289
518
  for await (const ev of B.streamChat(state.backend, {
290
- model: state.selectedModel,
519
+ agentId: state.selectedAgent,
520
+ model: state.selectedModel || undefined,
521
+ cwd: state.chatCwd || undefined,
291
522
  messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
292
523
  signal: ctrl.signal,
293
524
  resumeSid: state.chat.resumeSid || undefined,
294
525
  })) {
295
526
  if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
296
- if (ev.type === 'error') { cur.content += '\n[error] ' + JSON.stringify(ev.error); render(); }
527
+ else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
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(); }
297
531
  }
298
532
  } catch (e) {
299
- if (e.name !== 'AbortError') cur.content += '\n[error] ' + e.message;
533
+ if (e.name !== 'AbortError') cur.error = errText(e.message);
300
534
  } finally {
301
535
  state.chat.busy = false;
302
536
  state.chat.abort = null;
537
+ persistChat();
303
538
  render();
304
539
  scrollChatToBottom();
305
540
  }
306
541
  }
307
542
 
308
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
+
309
554
  function historyMain() {
310
555
  if (!state.selectedSid) {
311
- return [PageHeader({
312
- title: '§ history',
313
- lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
314
- })];
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);
315
563
  }
316
564
 
317
565
  const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
@@ -324,46 +572,73 @@ function historyMain() {
324
572
  lede,
325
573
  });
326
574
 
327
- if (!state.selectedSid) {
328
- return [head, state.live.error ? Alert({
329
- key: 'err',
330
- kind: 'error',
331
- title: 'Connection lost',
332
- children: [state.live.error, ' — ', Btn({ onClick: openLiveStream, children: 'reconnect', title: 'Reconnect to history stream' })],
333
- }) : null];
334
- }
335
-
336
575
  const actions = h('div', { key: 'acts', class: 'history-actions' },
337
576
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: '▶ open in chat' }),
338
- Btn({ key: 'copy', onClick: () => { try { navigator.clipboard.writeText(state.selectedSid); } catch {} }, children: '⎘ copy sid' }),
577
+ Btn({ key: 'copy', onClick: copySid, children: copyToast || '⎘ copy sid' }),
339
578
  );
340
579
 
341
580
  if (state.events.length === 0) {
342
- 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);
343
587
  }
344
588
 
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;
345
593
  return [
594
+ reconnectAlert(),
346
595
  head,
347
596
  actions,
348
597
  Panel({
349
- title: state.events.length + ' events',
598
+ title: total + ' events' + (hiddenCount > 0 ? ' (showing last 300)' : ''),
350
599
  children: EventList({
351
- items: state.events.slice(-300).map((e, 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);
352
605
  const role = e.role || '?';
353
606
  const type = e.type || '?';
354
607
  const tool = e.tool ? ' · ⌘ ' + e.tool : '';
355
608
  const errMark = e.isError ? ' · ⚠' : '';
356
- const text = (e.text || '').replace(/\s+/g, ' ').trim();
609
+ const raw = e.text || '';
610
+ const text = raw.replace(/\s+/g, ' ').trim();
611
+ const expanded = state.expandedEvents.has(key);
612
+ const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
357
613
  return {
358
- key: 'ev' + (e.i ?? i),
359
- code: String((e.i ?? i) + 1).padStart(4, '0'),
360
- title: text.slice(0, 220) || '(' + type + ')',
361
- sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark,
614
+ key,
615
+ code: String(total - shown.length + i + 1).padStart(4, '0'),
616
+ title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
617
+ sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark + (raw.length > 220 ? ' · ' + (expanded ? 'click to collapse' : 'click to expand') : ''),
618
+ onClick: () => { expanded ? state.expandedEvents.delete(key) : state.expandedEvents.add(key); render(); },
362
619
  };
363
620
  }),
364
621
  }),
365
622
  }),
366
- ];
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
+ }
367
642
  }
368
643
 
369
644
  function resumeInChat(sess) {
@@ -372,8 +647,14 @@ function resumeInChat(sess) {
372
647
  state.chat.resumeSid = sess?.sid || state.selectedSid;
373
648
  state.chat.messages = [];
374
649
  state.chat.draft = '';
375
- // Default to claude-code if no model yet (only claude supports --resume by sid here).
376
- if (!state.selectedModel || state.selectedModel !== 'claude-code') state.selectedModel = 'claude-code';
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
+ }
657
+ if (state.selectedAgent !== 'claude-code') selectAgent('claude-code');
377
658
  render();
378
659
  }
379
660
 
@@ -427,8 +708,22 @@ function historySide() {
427
708
  );
428
709
  const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
429
710
  const projects = uniqueProjects();
711
+ const running = Array.isArray(state.active) ? state.active : [];
430
712
 
431
713
  return [
714
+ running.length
715
+ ? Panel({
716
+ key: 'runningPanel',
717
+ title: '▶ running · ' + running.length,
718
+ children: running.map((r, i) => {
719
+ const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
720
+ const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
721
+ return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
722
+ h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
723
+ Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: '◼ stop' }));
724
+ }),
725
+ })
726
+ : null,
432
727
  Panel({
433
728
  title: searching
434
729
  ? 'matches · ' + (state.searchHits.results?.length || 0)
@@ -441,11 +736,20 @@ function historySide() {
441
736
  value: state.searchQ,
442
737
  onInput: (v) => { state.searchQ = v; debouncedSearch(); },
443
738
  }),
739
+ state.searchBusy
740
+ ? h('div', { key: 'searchbusy', class: 'lede empty-state', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching…')
741
+ : null,
444
742
  searching && state.searchHits.error
445
743
  ? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
446
744
  : null,
447
- state.searchQ && searching
448
- ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; render(); }, children: '× clear search' })
745
+ searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
746
+ ? h('p', { key: 'nomatch', class: 'lede empty-state' }, 'no matches for "' + state.searchQ + '"')
747
+ : null,
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
752
+ ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
449
753
  : null,
450
754
  !searching && projects.length > 1
451
755
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
@@ -466,7 +770,7 @@ function historySide() {
466
770
  : null,
467
771
  ],
468
772
  }),
469
- ];
773
+ ].filter(Boolean);
470
774
  }
471
775
 
472
776
  // ── settings ───────────────────────────────────────────────────────────────
@@ -476,14 +780,16 @@ function isValidUrl(s) {
476
780
  catch { return false; }
477
781
  }
478
782
 
479
- function saveBackend() {
480
- if (!isValidUrl(state.backendDraft)) return;
481
- if (!confirm('Reconnect to new backend? Current session will be lost.')) return;
783
+ async function saveBackend() {
784
+ if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
482
785
  B.setBackend(state.backendDraft);
483
786
  state.backend = state.backendDraft;
484
787
  state.health = { status: 'unknown' };
788
+ state.backendStatus = 'connecting';
789
+ render();
790
+ await init();
791
+ state.backendStatus = state.health.status === 'ok' ? 'ok' : 'failed';
485
792
  render();
486
- init();
487
793
  }
488
794
 
489
795
  function healthSummary() {
@@ -525,36 +831,57 @@ function settingsMain() {
525
831
  onInput: (v) => { state.backendDraft = v; render(); },
526
832
  }),
527
833
  !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, '⚠ Invalid URL format') : null,
834
+ state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '◌ connecting…') : null,
835
+ state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '● connected') : null,
836
+ state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, '○ connection failed — check the URL') : null,
528
837
  healthSummary(),
529
838
  Btn({
530
839
  key: 'savebtn',
531
840
  type: 'submit',
532
841
  primary: true,
533
- disabled: !isValid,
842
+ disabled: !isValid || state.backendDraft === state.backend || state.backendStatus === 'connecting',
534
843
  onClick: (e) => { e.preventDefault(); saveBackend(); },
535
- children: 'save + reconnect',
844
+ children: state.backendStatus === 'connecting' ? 'connecting…' : 'save + reconnect',
536
845
  title: isValid ? 'Save backend URL and reconnect' : 'Fix URL format first',
537
846
  }),
538
847
  ]),
539
848
  }),
540
- Panel({
541
- title: 'models',
542
- children: state.models.length
543
- ? state.models.slice(0, 40).map((m, i) =>
544
- Row({
545
- key: 'm' + i,
546
- rank: String(i + 1).padStart(3, '0'),
547
- title: m.id,
548
- sub: m.name ? (m.name + ' · ' + (m.protocol || 'agent')) : (m.protocol || 'agent'),
549
- rail: m.id === state.selectedModel ? 'green' : 'purple',
550
- onClick: () => { state.selectedModel = m.id; render(); },
551
- })
552
- )
553
- : h('p', { key: 'none', class: 'lede' }, 'no models loaded'),
554
- }),
849
+ agentsPanel(),
555
850
  ];
556
851
  }
557
852
 
853
+ function acpStatusFor(agentId) {
854
+ const acp = Array.isArray(state.health.acp) ? state.health.acp : [];
855
+ return acp.find(a => a.id === agentId) || null;
856
+ }
857
+
858
+ function agentsPanel() {
859
+ const installed = state.agents.filter(a => a.available !== false);
860
+ return Panel({
861
+ title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
862
+ children: state.agents.length
863
+ ? state.agents.map((a, i) => {
864
+ const acp = acpStatusFor(a.id);
865
+ const avail = a.available !== false;
866
+ const bits = [a.protocol || 'agent'];
867
+ if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
868
+ if (acp) bits.push(acp.healthy ? 'running·healthy' : (acp.running ? 'running' : 'stopped'));
869
+ if (acp && acp.port) bits.push('port ' + acp.port);
870
+ if (acp && acp.restartCount) bits.push(acp.restartCount + ' restarts');
871
+ return Row({
872
+ key: 'ag' + a.id,
873
+ rank: String(i + 1).padStart(3, '0'),
874
+ title: a.name + (avail ? '' : ' ·'),
875
+ sub: bits.join(' · '),
876
+ rail: a.id === state.selectedAgent ? 'green' : (avail ? 'purple' : 'flame'),
877
+ active: a.id === state.selectedAgent,
878
+ onClick: () => { if (avail || a.npxInstallable) { navTo('chat'); selectAgent(a.id); } },
879
+ });
880
+ })
881
+ : h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
882
+ });
883
+ }
884
+
558
885
  // ── data ──────────────────────────────────────────────────────────────────
559
886
  async function refreshHistory() {
560
887
  try {
@@ -567,14 +894,20 @@ async function refreshHistory() {
567
894
  render();
568
895
  }
569
896
  }
897
+ const debouncedRefreshHistory = debounce(refreshHistory, 500);
570
898
 
571
899
  async function runSearch() {
572
- if (!state.searchQ.trim()) { state.searchHits = null; render(); return; }
900
+ const q = state.searchQ.trim();
901
+ if (!q) { state.searchHits = null; state.searchBusy = false; render(); return; }
902
+ if (q.length < 2) { state.searchHits = null; state.searchBusy = false; render(); return; }
903
+ state.searchBusy = true;
904
+ render();
573
905
  try {
574
- state.searchHits = await B.searchHistory(state.backend, state.searchQ, 50);
575
- render();
906
+ state.searchHits = await B.searchHistory(state.backend, q, 60);
576
907
  } catch (e) {
577
- state.searchHits = { query: state.searchQ, results: [], error: e.message };
908
+ state.searchHits = { query: q, results: [], error: e.message };
909
+ } finally {
910
+ state.searchBusy = false;
578
911
  render();
579
912
  }
580
913
  }
@@ -583,16 +916,22 @@ const debouncedSearch = debounce(runSearch, 300);
583
916
  async function loadSession(sid) {
584
917
  state.selectedSid = sid;
585
918
  state.events = [];
586
- writeHash(sid);
919
+ state.eventsLoaded = false;
920
+ state.expandedEvents = new Set(); // don't carry expansion to the new session
921
+ writeHash(sid, { push: true });
587
922
  render();
588
- try { state.events = await B.getSessionEvents(state.backend, sid); render(); }
589
- catch (e) {
923
+ try {
924
+ state.events = await B.getSessionEvents(state.backend, sid);
925
+ state.eventsLoaded = true;
926
+ render();
927
+ } catch (e) {
590
928
  state.events = [{
591
929
  ts: Date.now(),
592
930
  role: 'error',
593
931
  type: 'fetch',
594
932
  text: 'Failed to load session: ' + e.message + ' — retry via sidebar',
595
933
  }];
934
+ state.eventsLoaded = true;
596
935
  render();
597
936
  }
598
937
  }
@@ -606,18 +945,34 @@ async function init() {
606
945
  }
607
946
  render();
608
947
  try {
609
- state.models = await B.listModels(state.backend);
610
- if (!state.selectedModel && state.models[0]) state.selectedModel = state.models[0].id;
948
+ state.agents = await B.listAgents(state.backend);
949
+ // Restore the saved agent if still present; else first available, else first.
950
+ let target = state.agents.find(a => a.id === state.selectedAgent);
951
+ if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
952
+ if (target) await selectAgent(target.id);
611
953
  render();
612
- } catch (e) { console.warn('models fetch failed:', e.message); }
954
+ } catch (e) { console.warn('agents fetch failed:', e.message); }
613
955
 
614
- const initialSid = readHash();
956
+ const { sid: initialSid, tab: initialTab } = readHash();
615
957
  if (initialSid) {
616
958
  navTo('history');
617
959
  await refreshHistory();
618
960
  await loadSession(initialSid);
961
+ } else if (initialTab && initialTab !== state.tab) {
962
+ navTo(initialTab);
619
963
  }
620
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;
621
976
  B.onWsStatus?.((s) => {
622
977
  if (s === 'closed' || s === 'error') {
623
978
  if (state.health.status === 'ok') { state.health = { ...state.health, ws: 'reconnecting' }; render(); }
@@ -627,6 +982,53 @@ async function init() {
627
982
  });
628
983
  }
629
984
 
985
+ restoreChat();
630
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
+
631
1000
  window.__agentgui = { state, render };
1001
+
1002
+ // Keyboard shortcuts. 'g' then c/h/s switches tabs; 'n' new chat; '/' focuses
1003
+ // search (history) or composer (chat). Ignored while typing in a field.
1004
+ let gPending = false;
1005
+ function focusComposer() {
1006
+ const el = document.querySelector('#agentgui-main textarea, #agentgui-main [contenteditable="true"], #agentgui-main input[type="text"]');
1007
+ el?.focus();
1008
+ }
1009
+ function focusSearch() {
1010
+ const el = document.querySelector('#app input[type="search"]');
1011
+ el?.focus();
1012
+ }
1013
+ window.addEventListener('keydown', (e) => {
1014
+ const t = e.target;
1015
+ const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
1016
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1017
+ if (typing) {
1018
+ if (e.key === 'Escape') t.blur();
1019
+ return;
1020
+ }
1021
+ if (gPending) {
1022
+ gPending = false;
1023
+ if (e.key === 'c') { navTo('chat'); return; }
1024
+ if (e.key === 'h') { navTo('history'); return; }
1025
+ if (e.key === 's') { navTo('settings'); return; }
1026
+ return;
1027
+ }
1028
+ if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
1029
+ if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
1030
+ if (e.key === '/') { e.preventDefault(); state.tab === 'history' ? focusSearch() : focusComposer(); return; }
1031
+ if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
1032
+ });
1033
+
632
1034
  init();