agentgui 1.0.937 → 1.0.939

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.
@@ -3,7 +3,7 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
6
- const { AppShell, Topbar, Crumb, Side, Status, Chat, ChatComposer, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList } = C;
6
+ const { AppShell, Topbar, Crumb, Side, Status, Chat, ChatComposer, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert } = C;
7
7
 
8
8
  const state = {
9
9
  backend: B.getBackend(),
@@ -50,6 +50,36 @@ function scheduleRender() {
50
50
  requestAnimationFrame(() => { renderScheduled = false; render(); });
51
51
  }
52
52
 
53
+ function isNarrow() { return typeof window !== 'undefined' && window.innerWidth < 768; }
54
+ function truncate(str, mobileLen, desktopLen) {
55
+ const s = String(str ?? '');
56
+ const max = isNarrow() ? mobileLen : desktopLen;
57
+ return s.length > max ? s.slice(0, max) + '…' : s;
58
+ }
59
+ function debounce(fn, ms) {
60
+ let t;
61
+ return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
62
+ }
63
+
64
+ function pillButton(key, label, active, title, onClick) {
65
+ return h('button', {
66
+ key,
67
+ type: 'button',
68
+ class: 'pill lede' + (active ? ' pill-active' : ''),
69
+ title,
70
+ 'aria-pressed': active ? 'true' : 'false',
71
+ onClick,
72
+ }, label);
73
+ }
74
+
75
+ function scrollChatToBottom() {
76
+ requestAnimationFrame(() => {
77
+ const el = document.querySelector('#agentgui-main') || document.querySelector('[role="main"]') || document.getElementById('app');
78
+ const scroller = el?.querySelector('[data-chat-scroll]') || el;
79
+ if (scroller) scroller.scrollTop = scroller.scrollHeight;
80
+ });
81
+ }
82
+
53
83
  function timeNow() {
54
84
  const d = new Date();
55
85
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
@@ -130,7 +160,8 @@ function view() {
130
160
  ? '◌ ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
131
161
  : (liveActive ? '● live · ' + state.live.eventCount : (state.live.connected ? '● live' : '◌ connecting…')))
132
162
  : (ok ? (state.health.ws === 'reconnecting' ? '◌ ws reconnecting' : '● connected') : '○ offline');
133
- const dot = h('span', { key: 'dot' }, dotText);
163
+ 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);
134
165
 
135
166
  const topbar = Topbar({
136
167
  brand: 'agentgui',
@@ -141,50 +172,45 @@ function view() {
141
172
  });
142
173
 
143
174
  const crumbRight = state.tab === 'chat'
144
- ? [
175
+ ? [h('div', { key: 'cc', class: 'chat-controls' },
145
176
  Select({
146
177
  key: 'modelsel',
147
178
  value: state.selectedModel,
148
179
  placeholder: '— model —',
180
+ title: 'Select AI model',
149
181
  options: state.models.map(m => ({ value: m.id, label: m.id })),
150
182
  onChange: (v) => { state.selectedModel = v; render(); },
151
183
  }),
152
184
  state.chat.busy
153
- ? Btn({ key: 'stop', onClick: cancelChat, children: '◼ stop' })
154
- : Btn({ key: 'new', onClick: newChat, children: '+ new' }),
185
+ ? Btn({ key: 'stop', onClick: cancelChat, children: '◼ stop', title: 'Stop streaming' })
186
+ : Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
155
187
  dot,
156
- ]
188
+ )]
157
189
  : [dot];
158
190
 
191
+ // Topbar already shows "agentgui / <tab>"; the crumb is reserved for contextual
192
+ // controls (model picker, new/stop, live status) so it doesn't duplicate the path.
159
193
  const crumb = Crumb({
160
- trail: ['agentgui'],
161
- leaf: state.tab,
194
+ trail: [],
195
+ leaf: '',
162
196
  right: crumbRight,
163
197
  });
164
198
 
165
- const navSide = Side({
166
- sections: [
167
- {
168
- group: 'navigate',
169
- items: [
170
- { glyph: '▣', label: 'chat', key: 'chat', active: state.tab === 'chat',
171
- onClick: (e) => { e.preventDefault(); navTo('chat'); } },
172
- { glyph: '§', label: 'history', key: 'history', active: state.tab === 'history',
173
- onClick: (e) => { e.preventDefault(); navTo('history'); } },
174
- { glyph: '⌘', label: 'settings', key: 'settings', active: state.tab === 'settings',
175
- onClick: (e) => { e.preventDefault(); navTo('settings'); } },
176
- ],
177
- },
178
- ],
179
- });
180
- const side = state.tab === 'history' ? historySide() : navSide;
199
+ // Sidebar is contextual: history shows the session list; chat/settings have no
200
+ // sidebar (the topbar already provides primary nav) so main content gets full width.
201
+ const side = state.tab === 'history' ? historySide() : null;
181
202
 
182
203
  const status = Status({
183
- left: [state.backend, ok ? '● live' : '○ offline'],
204
+ left: [state.backend || 'same-origin', ok ? '● live' : '○ offline'],
184
205
  right: [state.selectedModel ? '⌘ ' + state.selectedModel : '○ no model'],
185
206
  });
186
207
 
187
- return AppShell({ topbar, crumb, side, main: mainContent(), status });
208
+ 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());
212
+ // settings reads better centered in a measure; chat + history use full width.
213
+ return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
188
214
  }
189
215
 
190
216
  function mainContent() {
@@ -201,7 +227,7 @@ function chatMain() {
201
227
  const isStreaming = state.chat.busy && i === lastIdx && isAssistant;
202
228
  const isEmptyStreaming = isStreaming && !m.content;
203
229
  return {
204
- key: String(i),
230
+ key: m.id || String(i),
205
231
  who: isAssistant ? 'them' : 'you',
206
232
  name: isAssistant ? (state.selectedModel || 'agent') : 'you',
207
233
  time: m.time || '',
@@ -221,7 +247,7 @@ function chatMain() {
221
247
  });
222
248
 
223
249
  const resumeBanner = state.chat.resumeSid
224
- ? h('div', { key: 'rb', style: 'padding:.5em .75em;background:rgba(80,200,120,.1);border-radius:4px;display:flex;justify-content:space-between;align-items:center;margin-bottom:.5em' },
250
+ ? h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
225
251
  h('span', { class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via claude --resume'),
226
252
  Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; render(); }, children: '× clear' }))
227
253
  : null;
@@ -229,7 +255,7 @@ function chatMain() {
229
255
  resumeBanner,
230
256
  Chat({
231
257
  title: (state.selectedModel || 'agent') + (state.chat.resumeSid ? ' · resume' : ''),
232
- sub: state.chat.busy ? 'streaming…' : (state.chat.messages.length + ' messages'),
258
+ sub: state.chat.busy ? 'streaming…' : undefined,
233
259
  messages: msgs,
234
260
  composer,
235
261
  }),
@@ -237,6 +263,7 @@ function chatMain() {
237
263
  }
238
264
 
239
265
  function newChat() {
266
+ if (!confirm('Clear chat history? This cannot be undone.')) return;
240
267
  state.chat.abort?.abort();
241
268
  state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
242
269
  render();
@@ -248,13 +275,15 @@ async function sendChat() {
248
275
  const text = (state.chat.draft || '').trim();
249
276
  if (!text || !state.selectedModel || state.chat.busy) return;
250
277
  const t = timeNow();
251
- state.chat.messages.push({ role: 'user', content: text, time: t });
252
- state.chat.messages.push({ role: 'assistant', content: '', time: t });
278
+ 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 };
280
+ state.chat.messages = [...state.chat.messages, userMsg, curMsg];
253
281
  state.chat.draft = '';
254
282
  state.chat.busy = true;
255
283
  const ctrl = new AbortController();
256
284
  state.chat.abort = ctrl;
257
285
  render();
286
+ scrollChatToBottom();
258
287
  const cur = state.chat.messages[state.chat.messages.length - 1];
259
288
  try {
260
289
  for await (const ev of B.streamChat(state.backend, {
@@ -263,7 +292,7 @@ async function sendChat() {
263
292
  signal: ctrl.signal,
264
293
  resumeSid: state.chat.resumeSid || undefined,
265
294
  })) {
266
- if (ev.type === 'text') { cur.content += ev.text; render(); }
295
+ if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
267
296
  if (ev.type === 'error') { cur.content += '\n[error] ' + JSON.stringify(ev.error); render(); }
268
297
  }
269
298
  } catch (e) {
@@ -272,6 +301,7 @@ async function sendChat() {
272
301
  state.chat.busy = false;
273
302
  state.chat.abort = null;
274
303
  render();
304
+ scrollChatToBottom();
275
305
  }
276
306
  }
277
307
 
@@ -290,17 +320,26 @@ function historyMain() {
290
320
  : state.selectedSid;
291
321
 
292
322
  const head = PageHeader({
293
- title: '§ ' + (sess?.title || state.selectedSid).slice(0, 80),
323
+ title: '§ ' + truncate(sess?.title || state.selectedSid, 40, 80),
294
324
  lede,
295
325
  });
296
326
 
297
- const actions = h('div', { key: 'acts', style: 'display:flex;gap:.5em;padding:0 0 .75em 0' },
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
+ const actions = h('div', { key: 'acts', class: 'history-actions' },
298
337
  Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: '▶ open in chat' }),
299
338
  Btn({ key: 'copy', onClick: () => { try { navigator.clipboard.writeText(state.selectedSid); } catch {} }, children: '⎘ copy sid' }),
300
339
  );
301
340
 
302
341
  if (state.events.length === 0) {
303
- return [head, actions, Panel({ title: 'events', children: h('p', { class: 'lede' }, ' loading…') })];
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…') })];
304
343
  }
305
344
 
306
345
  return [
@@ -390,44 +429,38 @@ function historySide() {
390
429
  const projects = uniqueProjects();
391
430
 
392
431
  return [
393
- Side({
394
- sections: [
395
- {
396
- group: 'navigate',
397
- items: [
398
- { glyph: '▣', label: 'chat', key: 'chat', onClick: (e) => { e.preventDefault(); navTo('chat'); } },
399
- { glyph: '§', label: 'history', key: 'history', active: true },
400
- { glyph: '⌘', label: 'settings', key: 'settings', onClick: (e) => { e.preventDefault(); navTo('settings'); } },
401
- ],
402
- },
403
- ],
404
- }),
405
432
  Panel({
406
- title: searching ? 'matches' : ('sessions · ' + sessionsView.length + (subagentCount && !state.showSubagents ? ' (+'+subagentCount+' sub)' : '')),
433
+ title: searching
434
+ ? 'matches · ' + (state.searchHits.results?.length || 0)
435
+ : ('sessions · ' + sessionsView.length + (subagentCount && !state.showSubagents ? ' (+'+subagentCount+' sub)' : '')),
407
436
  children: [
408
437
  SearchInput({
409
438
  key: 'searchInput',
410
439
  placeholder: 'search sessions…',
440
+ 'aria-label': 'Search sessions by text or project',
411
441
  value: state.searchQ,
412
- onInput: (v) => { state.searchQ = v; runSearch(); },
442
+ onInput: (v) => { state.searchQ = v; debouncedSearch(); },
413
443
  }),
444
+ searching && state.searchHits.error
445
+ ? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
446
+ : null,
414
447
  state.searchQ && searching
415
448
  ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; render(); }, children: '× clear search' })
416
449
  : null,
417
450
  !searching && projects.length > 1
418
- ? h('div', { key: 'projfilter', style: 'display:flex;flex-wrap:wrap;gap:.25em;padding:.25em 0' },
419
- h('span', { key: 'allp', class: 'lede', style: 'cursor:pointer;padding:.15em .5em;border-radius:3px;' + (!state.projectFilter ? 'background:rgba(80,200,120,.15)' : ''), onClick: () => { state.projectFilter = ''; render(); } }, 'all'),
451
+ ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
452
+ pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
420
453
  ...projects.slice(0, 8).map(([name, count]) =>
421
- h('span', { key: 'p'+name, class: 'lede', style: 'cursor:pointer;padding:.15em .5em;border-radius:3px;' + (state.projectFilter === name ? 'background:rgba(80,200,120,.15)' : ''), title: name, onClick: () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); } }, (name.length > 20 ? name.slice(0, 20) + '…' : name) + ' (' + count + ')')))
454
+ pillButton('p'+name, truncate(name, 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
422
455
  : null,
423
456
  !searching && subagentCount
424
- ? h('label', { key: 'subtog', class: 'lede', style: 'display:flex;gap:.5em;align-items:center;padding:.25em 0' },
457
+ ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
425
458
  h('input', { type: 'checkbox', checked: state.showSubagents, onChange: (e) => { state.showSubagents = e.target.checked; render(); } }),
426
459
  'show subagents (' + subagentCount + ')')
427
460
  : null,
428
461
  state.historyError
429
- ? h('p', { key: 'err', class: 'lede' }, '⚠ ' + state.historyError)
430
- : (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede' }, 'no sessions yet')),
462
+ ? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, '⚠ ' + state.historyError)
463
+ : (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede empty-state' }, 'no sessions yet')),
431
464
  !searching && truncatedBy > 0
432
465
  ? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: '↓ show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
433
466
  : null,
@@ -437,8 +470,39 @@ function historySide() {
437
470
  }
438
471
 
439
472
  // ── settings ───────────────────────────────────────────────────────────────
473
+ function isValidUrl(s) {
474
+ if (!s) return true; // blank = same-origin is valid
475
+ try { new URL(s.startsWith('http') ? s : 'http://' + s); return true; }
476
+ catch { return false; }
477
+ }
478
+
479
+ function saveBackend() {
480
+ if (!isValidUrl(state.backendDraft)) return;
481
+ if (!confirm('Reconnect to new backend? Current session will be lost.')) return;
482
+ B.setBackend(state.backendDraft);
483
+ state.backend = state.backendDraft;
484
+ state.health = { status: 'unknown' };
485
+ render();
486
+ init();
487
+ }
488
+
489
+ function healthSummary() {
490
+ const hh = state.health || {};
491
+ const ok = hh.status === 'ok';
492
+ const dot = ok ? '●' : (hh.status === 'unknown' ? '◌' : '○');
493
+ const bits = [];
494
+ bits.push(dot + ' ' + (hh.status || 'unknown'));
495
+ if (hh.version) bits.push('v' + hh.version);
496
+ if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
497
+ if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
498
+ if (hh.db) bits.push('db ' + (hh.db.ok ? 'ok' : 'down'));
499
+ return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : '') },
500
+ ...bits.map((b, i) => h('span', { key: 'hb' + i, class: 'health-chip' }, b)));
501
+ }
502
+
440
503
  function settingsMain() {
441
504
  const ok = state.health.status === 'ok';
505
+ const isValid = isValidUrl(state.backendDraft);
442
506
  return [
443
507
  PageHeader({
444
508
  title: '⌘ settings',
@@ -446,29 +510,32 @@ function settingsMain() {
446
510
  }),
447
511
  Panel({
448
512
  title: 'backend',
449
- children: [
513
+ children: h('form', {
514
+ key: 'backendForm',
515
+ onSubmit: (e) => { e.preventDefault(); saveBackend(); },
516
+ }, [
450
517
  TextField({
451
518
  key: 'backendField',
452
519
  label: 'backend url',
453
520
  value: state.backendDraft,
454
521
  placeholder: '(blank = same origin)',
522
+ 'aria-describedby': !isValid ? 'backend-url-error' : undefined,
523
+ 'aria-invalid': !isValid ? 'true' : 'false',
524
+ title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
455
525
  onInput: (v) => { state.backendDraft = v; render(); },
456
526
  }),
457
- h('p', { key: 'hp', class: 'lede' }, (ok ? ' ' : ' ') + JSON.stringify(state.health)),
527
+ !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, '⚠ Invalid URL format') : null,
528
+ healthSummary(),
458
529
  Btn({
459
530
  key: 'savebtn',
531
+ type: 'submit',
460
532
  primary: true,
461
- onClick: (e) => {
462
- e.preventDefault();
463
- B.setBackend(state.backendDraft);
464
- state.backend = state.backendDraft;
465
- state.health = { status: 'unknown' };
466
- render();
467
- init();
468
- },
533
+ disabled: !isValid,
534
+ onClick: (e) => { e.preventDefault(); saveBackend(); },
469
535
  children: 'save + reconnect',
536
+ title: isValid ? 'Save backend URL and reconnect' : 'Fix URL format first',
470
537
  }),
471
- ],
538
+ ]),
472
539
  }),
473
540
  Panel({
474
541
  title: 'models',
@@ -511,6 +578,7 @@ async function runSearch() {
511
578
  render();
512
579
  }
513
580
  }
581
+ const debouncedSearch = debounce(runSearch, 300);
514
582
 
515
583
  async function loadSession(sid) {
516
584
  state.selectedSid = sid;
@@ -519,7 +587,12 @@ async function loadSession(sid) {
519
587
  render();
520
588
  try { state.events = await B.getSessionEvents(state.backend, sid); render(); }
521
589
  catch (e) {
522
- state.events = [{ ts: Date.now(), role: 'error', type: 'fetch', text: e.message }];
590
+ state.events = [{
591
+ ts: Date.now(),
592
+ role: 'error',
593
+ type: 'fetch',
594
+ text: 'Failed to load session: ' + e.message + ' — retry via sidebar',
595
+ }];
523
596
  render();
524
597
  }
525
598
  }