agentgui 1.0.953 → 1.0.955

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, AgentChat, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert } = C;
6
+ const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, BreadcrumbPath, EmptyState, FileViewer, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane } = C;
7
7
 
8
8
  const state = {
9
9
  backend: B.getBackend(),
@@ -29,6 +29,7 @@ const state = {
29
29
  active: [],
30
30
  activeTimer: null,
31
31
  eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
32
+ files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
32
33
  };
33
34
 
34
35
  function readHash() {
@@ -219,6 +220,14 @@ function navTo(tab) {
219
220
  } else if (prev === 'history') {
220
221
  closeLiveStream();
221
222
  }
223
+ // The conversation column now lives on the chat tab too, so populate the
224
+ // session list when entering chat if it hasn't been loaded yet (it used to
225
+ // load only on a History visit, leaving the rail empty on a fresh chat).
226
+ if (tab === 'chat' && !state.sessions.length && !state.historyError) refreshHistory();
227
+ // Kick the first directory load on entering Files HERE (an effect), not inside
228
+ // filesMain (which runs during render) - a render-time fetch is fragile under
229
+ // a double-render and re-enters render() while building the tree.
230
+ if (tab === 'files' && !state.files.path && !state.files.loading && !state.files.error) loadDir('');
222
231
  writeHash(state.selectedSid && tab === 'history' ? state.selectedSid : null);
223
232
  announce('Now on ' + tab + ' tab');
224
233
  render();
@@ -252,9 +261,21 @@ function syncAriaCurrent() {
252
261
  });
253
262
  }
254
263
 
264
+ // A cheap signature of the active-chats list so an unchanged 3s poll does not
265
+ // trigger a full ConversationList + dashboard rebuild every tick (the rail
266
+ // re-renders every row on each render). Only re-render when the set actually
267
+ // changed (or while on the live tab, where elapsed advances every second).
268
+ function activeSig(list) {
269
+ return (Array.isArray(list) ? list : []).map(a => a.sessionId + ':' + (a.model || '') + ':' + (a.startedAt || '')).sort().join('|');
270
+ }
255
271
  async function refreshActive() {
256
- try { state.active = await B.listActiveChats(state.backend); } catch { return; }
257
- render();
272
+ let next;
273
+ try { next = await B.listActiveChats(state.backend); } catch { return; }
274
+ const changed = activeSig(next) !== activeSig(state.active);
275
+ state.active = next;
276
+ // The live tab needs the steady elapsed tick regardless; elsewhere only
277
+ // re-render when the active set genuinely changed.
278
+ if (changed || state.tab === 'live') render();
258
279
  }
259
280
  function startActivePolling() {
260
281
  if (state.activeTimer) return;
@@ -270,6 +291,17 @@ function stopActivePolling() {
270
291
  // between events. Cheap: scheduleRender coalesces via rAF.
271
292
  let _relTick = null;
272
293
  function startRelTimeTick() { if (!_relTick) _relTick = setInterval(() => { if (state.tab === 'history' || (Array.isArray(state.active) && state.active.length)) scheduleRender(); }, 30000); }
294
+ // The live dashboard's per-card elapsed must advance every second, not in 3s
295
+ // poll-sized jumps - a frozen counter reads as a stalled session. A dedicated
296
+ // 1s tick runs ONLY while the live tab is showing running sessions, so it costs
297
+ // nothing on other tabs (and stops when there's nothing in flight).
298
+ let _liveTick = null;
299
+ function startLiveTick() {
300
+ if (_liveTick) return;
301
+ _liveTick = setInterval(() => {
302
+ if (state.tab === 'live' && Array.isArray(state.active) && state.active.length) scheduleRender();
303
+ }, 1000);
304
+ }
273
305
  async function stopActiveChat(sid) {
274
306
  try { await B.cancelChat(state.backend, sid); } catch {}
275
307
  refreshActive();
@@ -361,20 +393,9 @@ function view() {
361
393
  h('span', { key: 'dd', class: 'status-dot-disc', 'aria-hidden': 'true' }),
362
394
  h('span', { key: 'dl' }, dotLabel));
363
395
 
364
- const topbar = Topbar({
365
- brand: 'agentgui',
366
- leaf: state.tab,
367
- items: [['chat', buildHash('chat', null) || '#'], ['history', buildHash('history', null)], ['settings', buildHash('settings', null)]],
368
- active: state.tab,
369
- onNav: (label) => navTo(label),
370
- });
371
-
372
- // The crumb's right slot carries the live/connection status dot on every tab.
373
- const crumbRight = [dot];
374
-
375
396
  // Give the crumb contextual content on the left so it isn't a bare bar holding
376
- // only the dot: on history it names the selected session (or "all sessions"),
377
- // on chat it names the active agent, on settings it's "configuration".
397
+ // only the dot: on history/chat it names the selected session/agent, on files
398
+ // the current dir, on live the live count, on settings "configuration".
378
399
  let crumbLeaf = '';
379
400
  if (state.tab === 'history') {
380
401
  const sel = state.selectedSid && (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
@@ -383,18 +404,14 @@ function view() {
383
404
  : 'all sessions';
384
405
  } else if (state.tab === 'chat') {
385
406
  crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
407
+ } else if (state.tab === 'files') {
408
+ crumbLeaf = truncate(state.files?.path || 'files', 24, 64);
409
+ } else if (state.tab === 'live') {
410
+ crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
386
411
  } else if (state.tab === 'settings') {
387
412
  crumbLeaf = 'configuration';
388
413
  }
389
- const crumb = Crumb({
390
- trail: [],
391
- leaf: crumbLeaf,
392
- right: crumbRight,
393
- });
394
-
395
- // Sidebar is contextual: history shows the session list; chat/settings have no
396
- // sidebar (the topbar already provides primary nav) so main content gets full width.
397
- const side = state.tab === 'history' ? historySide() : null;
414
+ const crumb = Crumb({ trail: [], leaf: crumbLeaf, right: [dot] });
398
415
 
399
416
  const agentLabel = state.selectedAgent
400
417
  ? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
@@ -404,28 +421,400 @@ function view() {
404
421
  right: [agentLabel, 'press ? for shortcuts'],
405
422
  });
406
423
 
407
- // The design system now owns the full-height column + inner scroll for
408
- // .app-main, so chat just needs to be a flex column that fills it.
409
424
  const mainStyle = state.tab === 'chat'
410
425
  ? 'min-height:0;display:flex;flex-direction:column;flex:1'
411
426
  : 'min-height:0';
412
427
  const shortcutsHint = state.showShortcuts
413
428
  ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
414
- children: 'g then c/h/s - chat/history/settings · n - new chat · / - focus search/composer · ? - toggle this · Esc - blur field' })
429
+ children: 'g then c/h/f/l/s - chat/history/files/live/settings · n - new chat · / - focus search/composer · ? - toggle this · Esc - blur field' })
415
430
  : null;
416
431
  const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
417
- // narrow drives the DS main-column class; the history sidebar itself collapses
418
- // behind the DS .app-side-toggle hamburger (provided by AppShell when a side
419
- // exists), so mobile users get a togglable session list.
420
- return AppShell({ topbar, crumb, side, main, status, narrow: isNarrow() });
432
+
433
+ // Claude-Desktop three-column shell: a persistent left rail (workspace nav), an
434
+ // optional sessions column (chat + history share the conversation list), the
435
+ // main content, and an optional right context pane. The rail replaces the old
436
+ // Topbar tabs; it collapses to icon-only and, on mobile, behind its own toggle.
437
+ const rail = workspaceRail();
438
+ const sessions = (state.tab === 'chat' || state.tab === 'history') ? sessionsColumn() : null;
439
+ // Right context pane only on the chat tab: agent/model/cwd + a live count of
440
+ // running tool calls in the current turn.
441
+ const pane = state.tab === 'chat' ? ContextPane({
442
+ agent: state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : '',
443
+ model: state.selectedModel || '',
444
+ cwd: state.chatCwd || '',
445
+ toolCount: runningToolCount(),
446
+ usage: state.chat.usage || null,
447
+ onSetCwd: () => { navTo('files'); },
448
+ }) : null;
449
+ return WorkspaceShell({ rail, sessions, main, pane, crumb, status, narrow: isNarrow() });
450
+ }
451
+
452
+ // The left workspace rail: brand, New chat action, and the primary view nav.
453
+ function workspaceRail() {
454
+ const liveCount = (Array.isArray(state.active) ? state.active.length : 0);
455
+ const items = [
456
+ { key: 'chat', label: 'Chat', icon: 'forum', active: state.tab === 'chat', onClick: () => navTo('chat') },
457
+ { key: 'history', label: 'History', icon: 'thread', active: state.tab === 'history', onClick: () => navTo('history') },
458
+ { key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
459
+ { key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, onClick: () => navTo('live') },
460
+ { key: 'settings', label: 'Settings', icon: 'settings', active: state.tab === 'settings', onClick: () => navTo('settings') },
461
+ ];
462
+ return WorkspaceRail({
463
+ brand: 'agentgui',
464
+ action: { label: 'New chat', icon: 'pencil', onClick: () => { navTo('chat'); newChat(); } },
465
+ items,
466
+ });
467
+ }
468
+
469
+ // Bucket a session's last-active timestamp into a Claude-Desktop-style date
470
+ // group label (Today / Yesterday / This week / Earlier). Pure date math against
471
+ // local midnight so the labels match the user's calendar, not a rolling 24h.
472
+ function dateGroupLabel(ts) {
473
+ if (!ts) return 'Earlier';
474
+ const now = new Date();
475
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
476
+ const d = ts;
477
+ if (d >= startOfToday) return 'Today';
478
+ if (d >= startOfToday - 86400000) return 'Yesterday';
479
+ if (d >= startOfToday - 7 * 86400000) return 'This week';
480
+ return 'Earlier';
481
+ }
482
+ const DATE_GROUP_ORDER = ['Running', 'Today', 'Yesterday', 'This week', 'Earlier'];
483
+
484
+ // Build the kit ConversationList `groups` from the visible sessions: running
485
+ // chats are pinned to a "Running" section at the top (a live workspace surfaces
486
+ // in-flight work first), the rest bucket by recency. Returns { items, groups }.
487
+ function sessionGroups(sessionsView) {
488
+ const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
489
+ const buckets = new Map();
490
+ for (const s of sessionsView) {
491
+ const label = runningSids.has(s.sid) ? 'Running' : dateGroupLabel(s.last);
492
+ if (!buckets.has(label)) buckets.set(label, []);
493
+ buckets.get(label).push(s.sid);
494
+ }
495
+ const groups = DATE_GROUP_ORDER
496
+ .filter(l => buckets.has(l))
497
+ .map(l => ({ label: l, sids: buckets.get(l) }));
498
+ return groups;
499
+ }
500
+
501
+ // The sessions column (conversation list) shared by chat + history. Selecting a
502
+ // row on the chat tab resumes it in chat; on the history tab it loads its events.
503
+ function sessionsColumn() {
504
+ // When a search is active (>=2 chars with hits), the rail shows the search
505
+ // results instead of the time-sorted session list - on BOTH tabs, so chat-tab
506
+ // search is no longer a dead control that runs a query nothing displays.
507
+ const searching = !!(state.searchHits && state.searchQ.trim().length >= 2);
508
+ if (searching) {
509
+ const hits = (state.searchHits.results || []).slice(0, state.sessionsLimit);
510
+ const items = hits.map((r, i) => ({
511
+ sid: r.sid,
512
+ title: r.snippet || r.title || r.sid,
513
+ project: projectLabel(r.project) || '',
514
+ time: r.ts ? fmtRelTime(r.ts) : '',
515
+ running: false,
516
+ unread: false,
517
+ rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
518
+ _focusEventI: r.i, _focusEventTs: r.ts,
519
+ }));
520
+ return ConversationList({
521
+ sessions: items,
522
+ selected: state.selectedSid,
523
+ search: {
524
+ value: state.searchQ,
525
+ placeholder: 'Search conversations',
526
+ onInput: (v) => { state.searchQ = v; if (v.trim().length >= 2) debouncedSearch(); else { state.searchHits = null; } render(); },
527
+ },
528
+ onNew: () => { navTo('chat'); newChat(); },
529
+ onSelect: (s) => {
530
+ if (state.tab === 'chat') resumeInChat({ sid: s.sid });
531
+ else loadSession(s.sid, { focusEventI: s._focusEventI, focusEventTs: s._focusEventTs });
532
+ },
533
+ loading: state.searchBusy,
534
+ error: state.searchHits.error || null,
535
+ emptyText: 'No matches for "' + state.searchQ + '"',
536
+ });
537
+ }
538
+ const sessionsView = visibleSessions();
539
+ const sliced = sessionsView.slice(0, state.sessionsLimit);
540
+ const runningSids = new Set((Array.isArray(state.active) ? state.active : []).map(a => a.sessionId));
541
+ const items = sliced.map((s) => ({
542
+ sid: s.sid,
543
+ title: projectLabel(s.title) || projectLabel(s.project) || s.sid,
544
+ project: projectLabel(s.project) || '',
545
+ time: fmtRelTime(s.last),
546
+ agent: undefined,
547
+ running: runningSids.has(s.sid),
548
+ unread: false,
549
+ rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
550
+ }));
551
+ return ConversationList({
552
+ sessions: items,
553
+ // Today/Yesterday/This-week + a pinned Running section, the Claude-Desktop
554
+ // Chats shape. Groups reference sids the kit maps back to the items above.
555
+ groups: sessionGroups(sliced),
556
+ selected: state.selectedSid,
557
+ search: {
558
+ value: state.searchQ,
559
+ placeholder: 'Search conversations',
560
+ onInput: (v) => { state.searchQ = v; if (v.trim().length >= 2) debouncedSearch(); else { state.searchHits = null; } render(); },
561
+ },
562
+ onNew: () => { navTo('chat'); newChat(); },
563
+ onSelect: (s) => { if (state.tab === 'chat') resumeInChat({ sid: s.sid }); else loadSession(s.sid); },
564
+ loading: state.tab === 'history' && !state.sessions.length && !state.historyError,
565
+ error: state.historyError,
566
+ emptyText: 'No conversations yet',
567
+ });
421
568
  }
422
569
 
423
570
  function mainContent() {
424
571
  if (state.tab === 'chat') return chatMain();
425
572
  if (state.tab === 'history') return historyMain();
573
+ if (state.tab === 'files') return filesMain();
574
+ if (state.tab === 'live') return liveMain();
426
575
  return settingsMain();
427
576
  }
428
577
 
578
+ // --- files (folder browser) ---
579
+ async function loadDir(dirPath) {
580
+ state.files = state.files || {};
581
+ state.files.loading = true; state.files.error = null; render();
582
+ try {
583
+ const j = await B.listDir(state.backend, dirPath || '');
584
+ state.files.path = j.path;
585
+ state.files.segments = j.segments || [];
586
+ state.files.entries = j.entries || [];
587
+ state.files.roots = j.roots || [];
588
+ state.files.error = null;
589
+ } catch (e) {
590
+ state.files.error = e.message;
591
+ state.files.entries = [];
592
+ }
593
+ state.files.loading = false; render();
594
+ }
595
+
596
+ // Map a file extension to a Prism language hint for the code preview.
597
+ const PREVIEW_LANG = {
598
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx',
599
+ ts: 'typescript', tsx: 'tsx', py: 'python', rb: 'ruby', rs: 'rust',
600
+ go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
601
+ cs: 'csharp', php: 'php', sh: 'bash', css: 'css', html: 'markup',
602
+ xml: 'markup', svg: 'markup', json: 'json', yml: 'yaml', yaml: 'yaml',
603
+ toml: 'toml', sql: 'sql', md: 'markdown',
604
+ };
605
+
606
+ // Load the raw content of the currently-selected text/code preview file.
607
+ // Images don't fetch here (the FileViewer renders them via /api/image url).
608
+ async function loadPreviewContent(file) {
609
+ state.files = state.files || {};
610
+ const p = state.files.previewState = { loading: true, error: null, content: '', truncated: false, path: file.path };
611
+ render();
612
+ try {
613
+ const { content, truncated } = await B.readFile(state.backend, file.path);
614
+ // Bail if the user moved to another file while this was in flight.
615
+ if (state.files.preview?.path !== file.path) return;
616
+ p.content = content; p.truncated = truncated;
617
+ } catch (e) {
618
+ if (state.files.preview?.path !== file.path) return;
619
+ p.error = e.message;
620
+ }
621
+ p.loading = false; render();
622
+ }
623
+
624
+ function closePreview() {
625
+ if (!state.files) return;
626
+ state.files.preview = null;
627
+ state.files.previewState = null;
628
+ render();
629
+ }
630
+
631
+ // Render the file preview modal (FileViewer) when state.files.preview is set.
632
+ // Returns null when nothing is selected. All children inside each h() call here
633
+ // are either all-unkeyed or single nodes (no keyed/null mixing).
634
+ function filePreview() {
635
+ const f = state.files || {};
636
+ const file = f.preview;
637
+ if (!file) return null;
638
+ const isImage = file.type === 'image';
639
+ let body;
640
+ if (isImage) {
641
+ body = FilePreviewMedia({ src: B.imageUrl(state.backend, file.path), type: 'image', name: file.name });
642
+ } else {
643
+ const ps = f.previewState;
644
+ // The load is kicked from onOpen (an effect); as a safety net for a preview
645
+ // set by another path, SCHEDULE (not call) a load so render stays pure.
646
+ if (!ps || ps.path !== file.path) { Promise.resolve().then(() => { if (state.files.preview?.path === file.path && state.files.previewState?.path !== file.path) loadPreviewContent(file); }); }
647
+ if (!ps || ps.loading || ps.path !== file.path) {
648
+ body = h('div', { class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ size: 'sm' }), 'loading…');
649
+ } else if (ps.error) {
650
+ body = Alert({ kind: 'warn', title: 'Cannot preview file', children: ps.error });
651
+ } else {
652
+ const ext = (file.name.split('.').pop() || '').toLowerCase();
653
+ const lang = PREVIEW_LANG[ext];
654
+ body = lang
655
+ ? FilePreviewCode({ content: ps.content, lang })
656
+ : FilePreviewText({ content: ps.content, truncated: ps.truncated });
657
+ }
658
+ }
659
+ return FileViewer({ file, body, onClose: closePreview });
660
+ }
661
+
662
+ // Go up one directory from the current path (the FileGrid Backspace affordance).
663
+ function fileUp() {
664
+ const f = state.files || {};
665
+ const segs = (f.segments || []);
666
+ if (segs.length <= 1) { loadDir(''); return; }
667
+ const up = segs.slice(0, segs.length - 1);
668
+ const joined = /^[A-Za-z]:$/.test(up[0]) ? up[0] + '\\' + up.slice(1).join('\\') : '/' + up.join('/');
669
+ loadDir(joined);
670
+ }
671
+
672
+ function filesMain() {
673
+ const f = state.files || {};
674
+ // The initial dir load is kicked from navTo('files') (an effect), not here,
675
+ // so this view function stays pure. A direct deep-link to files still loads:
676
+ // init()/navTo handles the entry. As a safety net for a render that arrives
677
+ // before navTo's effect (e.g. hash deep-link), schedule (not call) a load.
678
+ if (!f.path && !f.loading && !f.error) { Promise.resolve().then(() => { if (!state.files.path && !state.files.loading) loadDir(''); }); }
679
+ const crumb = BreadcrumbPath({
680
+ root: 'root',
681
+ segments: f.segments || [],
682
+ onNav: (i) => {
683
+ // i=0 is the synthetic root button -> reload the default root; otherwise
684
+ // rebuild the absolute path up to the clicked segment.
685
+ if (i === 0) { loadDir(''); return; }
686
+ const segs = (f.segments || []).slice(0, i);
687
+ // Reconstruct a drive-aware absolute path from the segments.
688
+ const joined = segs.length && /^[A-Za-z]:$/.test(segs[0]) ? segs[0] + '\\' + segs.slice(1).join('\\') : '/' + segs.join('/');
689
+ loadDir(joined);
690
+ },
691
+ });
692
+ // Sort + filter the entries client-side (the server returns one directory at a
693
+ // time, so this is bounded by dir size). Map modified to BOTH a display string
694
+ // and an epoch (modifiedTs) so the kit's modified-sort orders by real time.
695
+ const mapped = (f.entries || []).map((e) => {
696
+ const ts = e.modified ? Date.parse(e.modified) : 0;
697
+ return { name: e.name, type: e.type, size: e.size, modified: ts ? fmtRelTime(ts) : null, modifiedTs: ts, path: e.path };
698
+ });
699
+ const filtered = f.filter
700
+ ? mapped.filter(e => e.name.toLowerCase().includes(f.filter.toLowerCase()))
701
+ : mapped;
702
+ const sorted = sortFiles(filtered, f.sort || 'name', f.sortDir || 'asc');
703
+ let body;
704
+ if (f.error) {
705
+ body = Alert({ key: 'ferr', kind: 'warn', title: 'Cannot list directory', children: f.error });
706
+ } else {
707
+ body = FileGrid({
708
+ files: sorted,
709
+ loading: f.loading,
710
+ emptyText: f.filter ? 'no files match "' + f.filter + '"' : 'empty directory',
711
+ sort: { key: f.sort || 'name', dir: f.sortDir || 'asc', onSort: (k) => {
712
+ // Click the active column to flip direction; a new column resets to asc.
713
+ if (state.files.sort === k) state.files.sortDir = state.files.sortDir === 'asc' ? 'desc' : 'asc';
714
+ else { state.files.sort = k; state.files.sortDir = 'asc'; }
715
+ render();
716
+ } },
717
+ filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: (v) => { state.files.filter = v; render(); } },
718
+ onUp: fileUp,
719
+ onOpen: (file) => {
720
+ if (file.type === 'dir') loadDir(file.path);
721
+ else {
722
+ state.files.preview = file;
723
+ // Kick the content fetch from the handler (an effect), not from inside
724
+ // filePreview() during render. Images don't fetch (rendered via url).
725
+ if (file.type !== 'image') loadPreviewContent(file);
726
+ render();
727
+ }
728
+ },
729
+ });
730
+ }
731
+ // Roots picker: when the server allows more than one root, surface them as a
732
+ // pill row so every allowed root is reachable in one click (not only by
733
+ // breadcrumb-walking down from the first). Always-present toolbar (incl. at the
734
+ // synthetic root) so path context + the cwd action never vanish.
735
+ const roots = Array.isArray(f.roots) ? f.roots : [];
736
+ const rootsRow = roots.length > 1
737
+ ? h('div', { key: 'froots', class: 'pill-row', role: 'group', 'aria-label': 'Jump to an allowed root' },
738
+ ...roots.map((r) => pillButton('root-' + r, truncate(projectLabel(r) || r, 16, 28), f.path === r, r, () => loadDir(r))))
739
+ : null;
740
+ const targetCwd = f.path || (roots.length === 1 ? roots[0] : '');
741
+ const toolbar = h('div', { key: 'ftb', class: 'ds-file-toolbar' },
742
+ h('div', { key: 'ftl', class: 'ds-file-toolbar-left' }, crumb),
743
+ h('div', { key: 'ftr', class: 'ds-file-toolbar-right' },
744
+ targetCwd
745
+ ? Btn({ onClick: () => { state.chatCwd = targetCwd; lsSet('agentgui.cwd', targetCwd); announce('working directory set to ' + targetCwd); navTo('chat'); }, children: 'use as chat cwd' })
746
+ : null));
747
+ return [
748
+ offlineBanner(),
749
+ PageHeader({ compact: true, title: 'files', lede: 'browse the server filesystem within the allowed roots. click a folder to open it; pick one as the chat working directory.' }),
750
+ rootsRow,
751
+ toolbar,
752
+ h('div', { key: 'fbody' }, body),
753
+ // Preview overlay (FileViewer modal) when a non-dir file is selected. Wrap
754
+ // in a keyed node to match the keyed siblings in this array.
755
+ f.preview ? h('div', { key: 'fprev' }, filePreview()) : null,
756
+ ].filter(Boolean);
757
+ }
758
+
759
+ // Stop every in-flight chat at once (the dashboard "stop all" bulk control).
760
+ async function stopAllActive(sessions) {
761
+ const sids = (Array.isArray(sessions) ? sessions : (state.active || [])).map(s => s.sid || s.sessionId).filter(Boolean);
762
+ await Promise.all(sids.map(sid => B.cancelChat(state.backend, sid).catch(() => {})));
763
+ refreshActive();
764
+ }
765
+
766
+ // --- live (multi-session dashboard) ---
767
+ function liveMain() {
768
+ const offline = state.health.status !== 'ok' && state.health.status !== 'unknown';
769
+ const bySid = state.sessionsBySid || new Map();
770
+ const sessions = (Array.isArray(state.active) ? state.active : []).map((r) => {
771
+ // Live activity tallies come from the session row the SSE stream keeps
772
+ // updating (events/tools/errors/last), so a card shows motion, not just a
773
+ // start offset. Absent (a brand-new chat not yet in the history list) -> null.
774
+ const sess = bySid.get(r.sessionId);
775
+ const counterBits = [];
776
+ if (sess) {
777
+ if (sess.events != null) counterBits.push(sess.events + ' ev');
778
+ if (sess.tools) counterBits.push(sess.tools + ' tools');
779
+ if (sess.errors) counterBits.push(sess.errors + ' err');
780
+ }
781
+ // Current tool: while a turn is busy in the in-page chat for this sid, the
782
+ // trailing assistant message carries a running tool part.
783
+ let currentTool = '';
784
+ if (state.chat.resumeSid === r.sessionId && state.chat.busy) {
785
+ const msgs = state.chat.messages || [];
786
+ const last = msgs[msgs.length - 1];
787
+ const running = last && Array.isArray(last.parts) && last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').slice(-1)[0];
788
+ if (running) currentTool = running.name || '';
789
+ }
790
+ return {
791
+ sid: r.sessionId,
792
+ agent: agentById(r.agentId)?.name || r.agentId || 'agent',
793
+ model: r.model || '',
794
+ cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
795
+ elapsed: r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) + 's' : '',
796
+ counter: counterBits.length ? counterBits.join(' · ') : null,
797
+ lastActivity: sess && sess.last ? fmtRelTime(sess.last) : '',
798
+ currentTool,
799
+ status: (sess && sess.errors) ? 'error' : 'running',
800
+ };
801
+ });
802
+ return [
803
+ offlineBanner(),
804
+ PageHeader({ compact: true, title: 'live sessions', lede: 'every in-flight agent session, managed at once. stop, open, resume, or jump to events per session.' }),
805
+ SessionDashboard({
806
+ sessions,
807
+ offline,
808
+ emptyText: 'No live sessions - start a chat or run a local agent.',
809
+ onStop: (s) => stopActiveChat(s.sid),
810
+ onStopAll: (all) => stopAllActive(all),
811
+ onOpen: (s) => { resumeInChat({ sid: s.sid }); },
812
+ onResume: (s) => { resumeInChat({ sid: s.sid }); },
813
+ onView: (s) => { navTo('history'); loadSession(s.sid); },
814
+ }),
815
+ ].filter(Boolean);
816
+ }
817
+
429
818
  // --- chat ---
430
819
  function canSend() {
431
820
  return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
@@ -451,6 +840,16 @@ function toolLabel(inp) {
451
840
  // Build a structured tool part the kit's ToolCallNode renders as a collapsible
452
841
  // card (name + label + full args, status icon). tool_result is matched back to
453
842
  // this part by tool_use id so the result lands inside the same card.
843
+ // Count tool calls still running in the current live turn. Only meaningful
844
+ // while the chat is busy; scans the trailing assistant message's parts.
845
+ function runningToolCount() {
846
+ if (!state.chat || !state.chat.busy) return 0;
847
+ const msgs = Array.isArray(state.chat.messages) ? state.chat.messages : [];
848
+ const last = msgs[msgs.length - 1];
849
+ if (!last || last.role !== 'assistant' || !Array.isArray(last.parts)) return 0;
850
+ return last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').length;
851
+ }
852
+
454
853
  function toolPart(block) {
455
854
  const name = block.name || block.kind || 'tool';
456
855
  const input = block.input || block.rawInput || {};
@@ -578,6 +977,11 @@ function chatMain() {
578
977
  'Find and explain the main entry point',
579
978
  ],
580
979
  onSuggestionClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
980
+ // Per-message actions: copy any message; retry the last assistant turn;
981
+ // edit-and-resend a user message (drops everything after it, refills draft).
982
+ onCopyMessage: (m) => copyMessageText(m),
983
+ onRetryMessage: () => retryLastTurn(),
984
+ onEditMessage: (m) => editAndResend(m),
581
985
  cwd: state.chatCwd,
582
986
  cwdEditing: !!state.cwdEditing,
583
987
  cwdDraft: state.cwdDraft,
@@ -634,7 +1038,7 @@ function newChat() {
634
1038
  }
635
1039
  state.confirmingNewChat = false;
636
1040
  state.chat.abort?.abort();
637
- state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
1041
+ state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, usage: null };
638
1042
  lsRemove(CHAT_KEY);
639
1043
  render();
640
1044
  }
@@ -686,6 +1090,59 @@ function restoreChat() {
686
1090
  } catch {}
687
1091
  }
688
1092
 
1093
+ // Flatten a message's content (string content or md/text/tool parts) to plain
1094
+ // text for the copy action.
1095
+ function messageToText(m) {
1096
+ if (m.content) return m.content;
1097
+ if (!Array.isArray(m.parts)) return '';
1098
+ return m.parts.map((p) => {
1099
+ if (typeof p === 'string') return p;
1100
+ if (p.kind === 'md' || p.kind === 'text') return p.text || '';
1101
+ if (p.kind === 'tool') return '[tool: ' + (p.name || '') + (p.label ? ' ' + p.label : '') + ']';
1102
+ return '';
1103
+ }).filter(Boolean).join('\n');
1104
+ }
1105
+ function copyMessageText(m) {
1106
+ const text = messageToText(m);
1107
+ if (!text) return;
1108
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(() => announce('message copied')).catch(() => {});
1109
+ else {
1110
+ try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); announce('message copied'); } catch {}
1111
+ }
1112
+ }
1113
+ // Retry the last assistant turn: drop it and re-send the preceding user message.
1114
+ function retryLastTurn() {
1115
+ if (!canSend() && !state.chat.busy) { /* allow when idle */ }
1116
+ if (state.chat.busy) return;
1117
+ const msgs = state.chat.messages;
1118
+ // Find the trailing assistant turn and the user message before it.
1119
+ let ai = msgs.length - 1;
1120
+ while (ai >= 0 && msgs[ai].role !== 'assistant') ai--;
1121
+ if (ai < 0) return;
1122
+ let ui = ai - 1;
1123
+ while (ui >= 0 && msgs[ui].role !== 'user') ui--;
1124
+ if (ui < 0) return;
1125
+ const userText = msgs[ui].content || '';
1126
+ // Drop the user+assistant pair (and anything after) and resend the user text.
1127
+ state.chat.messages = msgs.slice(0, ui);
1128
+ state.chat.draft = userText;
1129
+ persistChat();
1130
+ if (canSend()) sendChat();
1131
+ else render();
1132
+ }
1133
+ // Edit-and-resend a user message: truncate the transcript at it, refill the
1134
+ // composer with its text, and let the user revise before sending.
1135
+ function editAndResend(m) {
1136
+ if (state.chat.busy) return;
1137
+ const idx = state.chat.messages.indexOf(m);
1138
+ if (idx < 0) return;
1139
+ state.chat.messages = state.chat.messages.slice(0, idx);
1140
+ state.chat.draft = m.content || messageToText(m);
1141
+ persistChat();
1142
+ render();
1143
+ requestAnimationFrame(() => focusComposer());
1144
+ }
1145
+
689
1146
  async function sendChat() {
690
1147
  const text = (state.chat.draft || '').trim();
691
1148
  if (!text || !canSend()) return;
@@ -724,7 +1181,23 @@ async function sendChat() {
724
1181
  else if (ev.type === 'text') { appendText(cur.parts, ev.text); scheduleStreamRender(); }
725
1182
  else if (ev.type === 'tool') { cur.parts.push(toolPart(ev.block)); scheduleStreamRender(); }
726
1183
  else if (ev.type === 'tool_result') { applyToolResult(cur.parts, ev.block); scheduleStreamRender(); }
727
- else if (ev.type === 'result') { /* terminal usage/summary block - already reflected via text */ }
1184
+ else if (ev.type === 'result') {
1185
+ // The terminal result block carries claude's turn usage (token counts,
1186
+ // cost, turns, duration). The prose is already streamed via text events;
1187
+ // capture the usage so the ContextPane can surface it (was dropped).
1188
+ const b = ev.block || {};
1189
+ const u = b.usage || {};
1190
+ const inTok = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0);
1191
+ const outTok = u.output_tokens || 0;
1192
+ state.chat.usage = {
1193
+ inputTokens: inTok || null,
1194
+ outputTokens: outTok || null,
1195
+ costUsd: typeof b.total_cost_usd === 'number' ? b.total_cost_usd : null,
1196
+ turns: b.num_turns != null ? b.num_turns : null,
1197
+ durationMs: b.duration_ms != null ? b.duration_ms : null,
1198
+ };
1199
+ scheduleStreamRender();
1200
+ }
728
1201
  else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
729
1202
  }
730
1203
  } catch (e) {
@@ -944,9 +1417,11 @@ function runningPanel() {
944
1417
  children: running.map((r) => {
945
1418
  const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
946
1419
  const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
1420
+ // All three children must be keyed VElements (mixing a keyed span with an
1421
+ // unkeyed one crashes webjsx applyDiff "reading 'key'").
947
1422
  return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
948
1423
  h('span', { key: 'rd-' + r.sessionId, class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
949
- h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
1424
+ h('span', { key: 'rl-' + r.sessionId, class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
950
1425
  Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
951
1426
  }),
952
1427
  });
@@ -1154,6 +1629,13 @@ function settingsMain() {
1154
1629
  }),
1155
1630
  ]),
1156
1631
  }),
1632
+ Panel({
1633
+ title: 'appearance',
1634
+ children: [
1635
+ h('div', { key: 'apl', class: 'lede', style: 'margin-bottom:.5em' }, 'theme - follows the OS in auto, or pick light/dark.'),
1636
+ ThemeToggle({ key: 'tt' }),
1637
+ ],
1638
+ }),
1157
1639
  agentsPanel(),
1158
1640
  preferencesPanel(),
1159
1641
  ]),
@@ -1259,12 +1741,14 @@ async function refreshHistory() {
1259
1741
  writeHash(null);
1260
1742
  }
1261
1743
  state.historyError = null;
1262
- render();
1263
1744
  } catch (e) {
1745
+ // Only a genuine fetch/list failure is a history error. A render exception
1746
+ // must not masquerade as one (it would poison the sessions panel with a
1747
+ // render-stack string and never clear), so render() lives outside this try.
1264
1748
  state.historyError = e.message;
1265
1749
  console.warn('history fetch failed:', e.message);
1266
- render();
1267
1750
  }
1751
+ render();
1268
1752
  }
1269
1753
  const debouncedRefreshHistory = debounce(refreshHistory, 500);
1270
1754
 
@@ -1379,6 +1863,10 @@ async function init() {
1379
1863
  registerWsStatusOnce();
1380
1864
  startActivePolling(); // surface running chats on any tab, not just history
1381
1865
  startRelTimeTick();
1866
+ startLiveTick(); // 1s elapsed advance on the live dashboard
1867
+ // The conversation column shows on the default chat tab, so load the session
1868
+ // list at boot (unless a history deep-link already triggered a refresh).
1869
+ if (state.tab === 'chat' && !state.sessions.length) refreshHistory();
1382
1870
  }
1383
1871
 
1384
1872
  // init() runs both at boot and on every saveBackend(); registering the WS
@@ -1437,6 +1925,8 @@ window.addEventListener('keydown', (e) => {
1437
1925
  gPending = false;
1438
1926
  if (e.key === 'c') { navTo('chat'); return; }
1439
1927
  if (e.key === 'h') { navTo('history'); return; }
1928
+ if (e.key === 'f') { navTo('files'); return; }
1929
+ if (e.key === 'l') { navTo('live'); return; }
1440
1930
  if (e.key === 's') { navTo('settings'); return; }
1441
1931
  return;
1442
1932
  }