agentgui 1.0.956 → 1.0.957

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.
@@ -76,29 +76,11 @@
76
76
  /* (The skip link is rendered + styled by the design system's AppShell;
77
77
  agentgui carries no local .skip-link rule.) */
78
78
 
79
- /* status connection dot - a CSS-drawn disc (real UI affordance, not a text
80
- glyph). The .status-dot-disc child is always present; the parent's state
81
- class colours it, and only is-live pulses. */
79
+ /* The connection dot's disc + pulse are the KIT's canonical .status-dot-disc
80
+ (status-dot-live / -connecting / -error) - the app no longer overrides
81
+ them. Only the inline-flex wrapper layout for the crumb stays local since
82
+ the kit defines the disc, not this wrapper element. */
82
83
  .status-dot { display: inline-flex; align-items: center; gap: .4em; white-space: nowrap; }
83
- .status-dot-disc {
84
- flex: none; width: 8px; height: 8px; border-radius: 50%;
85
- background: var(--muted, #8a8f98);
86
- }
87
- .status-dot.is-live .status-dot-disc,
88
- .status-dot-live .status-dot-disc {
89
- background: var(--accent, var(--agentgui-accent, #50c878));
90
- animation: agentgui-pulse 2s infinite;
91
- }
92
- .status-dot.is-connecting .status-dot-disc { background: var(--agentgui-status-connecting); }
93
- .status-dot.is-offline .status-dot-disc { background: var(--agentgui-status-offline); animation: none; }
94
- @keyframes agentgui-pulse {
95
- 0% { box-shadow: 0 0 0 0 rgba(80, 200, 120, .5); }
96
- 70% { box-shadow: 0 0 0 6px rgba(80, 200, 120, 0); }
97
- 100% { box-shadow: 0 0 0 0 rgba(80, 200, 120, 0); }
98
- }
99
- @media (prefers-reduced-motion: reduce) {
100
- .status-dot-disc { animation: none !important; }
101
- }
102
84
 
103
85
  /* resume banner */
104
86
  .resume-banner {
@@ -3,7 +3,7 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
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;
6
+ const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane } = C;
7
7
 
8
8
  const state = {
9
9
  backend: B.getBackend(),
@@ -212,12 +212,14 @@ function navTo(tab) {
212
212
  // as a stale banner when the user returns.
213
213
  if (prev === 'chat' && tab !== 'chat') state.confirmingNewChat = false;
214
214
  state.tab = tab;
215
- // Live history SSE is only needed on the history tab; active-chat polling
216
- // runs globally (started at boot) so running chats show on every tab.
217
- if (tab === 'history') {
218
- refreshHistory();
215
+ // Live history SSE feeds both the History tab (event log) and the Live
216
+ // dashboard (per-session activity tally + stream-health signal); open it on
217
+ // either, close it when leaving both. Active-chat polling runs globally.
218
+ const wantsStream = (t) => t === 'history' || t === 'live';
219
+ if (wantsStream(tab)) {
220
+ if (tab === 'history') refreshHistory();
219
221
  openLiveStream();
220
- } else if (prev === 'history') {
222
+ } else if (wantsStream(prev)) {
221
223
  closeLiveStream();
222
224
  }
223
225
  // The conversation column now lives on the chat tab too, so populate the
@@ -333,6 +335,18 @@ function openLiveStream() {
333
335
  if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
334
336
  }
335
337
  }
338
+ // Per-sid live tally, kept independent of the history index so a
339
+ // brand-new chat (not yet in sessionsBySid) STILL shows motion on the
340
+ // dashboard. Keyed by sid; the dashboard reads this when no history row
341
+ // exists yet.
342
+ if (data.sid) {
343
+ state.live.tally = state.live.tally || new Map();
344
+ const t = state.live.tally.get(data.sid) || { events: 0, tools: 0, errors: 0, last: 0 };
345
+ t.events++; t.last = ev.ts || Date.now();
346
+ if (ev.type === 'tool_use') t.tools++;
347
+ if (ev.isError) t.errors++;
348
+ state.live.tally.set(data.sid, t);
349
+ }
336
350
  const sess = state.sessionsBySid ? state.sessionsBySid.get(data.sid) : null;
337
351
  if (sess) {
338
352
  sess.events = (sess.events || 0) + 1;
@@ -341,8 +355,10 @@ function openLiveStream() {
341
355
  if (ev.isError) sess.errors = (sess.errors || 0) + 1;
342
356
  } else {
343
357
  // Unknown session: a burst of events for a new session would trigger
344
- // a full session-list refetch per event - debounce it into one.
358
+ // a full session-list refetch per event - debounce it into one. The
359
+ // tally above already captured the motion, so the card is not frozen.
345
360
  debouncedRefreshHistory();
361
+ scheduleRender();
346
362
  return;
347
363
  }
348
364
  } else if (kind === 'conversation') {
@@ -387,10 +403,12 @@ function view() {
387
403
  // colored disc, real product design, not a text glyph. State drives its colour
388
404
  // via the modifier class; the label carries only words so AT reads "live", and
389
405
  // there are no literal status-glyph characters in the DOM.
390
- const dotState = state.live.error || (!dotLive) ? 'is-offline'
391
- : (dotLive && state.tab !== 'history' && state.health.ws === 'reconnecting' ? 'is-connecting' : 'is-live');
392
- const dot = h('span', { key: 'dot', class: 'status-dot ' + dotState + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
393
- h('span', { key: 'dd', class: 'status-dot-disc', 'aria-hidden': 'true' }),
406
+ // The disc carries the KIT modifier class directly (status-dot-live pulses,
407
+ // -connecting / -error are static) - one canonical disc, no app override.
408
+ const discClass = (state.live.error || !dotLive) ? 'status-dot-error'
409
+ : (dotLive && state.tab !== 'history' && state.health.ws === 'reconnecting' ? 'status-dot-connecting' : 'status-dot-live');
410
+ const dot = h('span', { key: 'dot', class: 'status-dot', role: 'status', 'aria-live': 'polite' },
411
+ h('span', { key: 'dd', class: 'status-dot-disc ' + discClass, 'aria-hidden': 'true' }),
394
412
  h('span', { key: 'dl' }, dotLabel));
395
413
 
396
414
  // Give the crumb contextual content on the left so it isn't a bare bar holding
@@ -436,17 +454,24 @@ function view() {
436
454
  // Topbar tabs; it collapses to icon-only and, on mobile, behind its own toggle.
437
455
  const rail = workspaceRail();
438
456
  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() });
457
+ // Right context pane. On chat it carries agent/model/cwd + the live running-tool
458
+ // count + last-turn usage. On files it hosts the inline file preview (split view).
459
+ // On other tabs it is null but the shell keeps the column TRACK (stableFrame) so
460
+ // the geometry does not re-flow on tab switch (the "separate pages" tell).
461
+ let pane = null;
462
+ if (state.tab === 'chat') {
463
+ pane = ContextPane({
464
+ agent: state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : '',
465
+ model: state.selectedModel || '',
466
+ cwd: state.chatCwd || '',
467
+ toolCount: runningToolCount(),
468
+ usage: state.chat.usage || null,
469
+ onSetCwd: () => { navTo('files'); },
470
+ });
471
+ } else if (state.tab === 'files' && state.files && state.files.preview && !isNarrow()) {
472
+ pane = filePreviewPane();
473
+ }
474
+ return WorkspaceShell({ rail, sessions, main, pane, crumb, status, narrow: isNarrow(), stableFrame: true });
450
475
  }
451
476
 
452
477
  // The left workspace rail: brand, New chat action, and the primary view nav.
@@ -463,6 +488,9 @@ function workspaceRail() {
463
488
  brand: 'agentgui',
464
489
  action: { label: 'New chat', icon: 'pencil', onClick: () => { navTo('chat'); newChat(); } },
465
490
  items,
491
+ // W18: a persistent theme toggle pinned to the rail bottom (the Claude-Desktop
492
+ // / cowork pinned-bottom affordance). ThemeToggle is already imported.
493
+ footer: ThemeToggle({ compact: true }),
466
494
  });
467
495
  }
468
496
 
@@ -550,6 +578,9 @@ function sessionsColumn() {
550
578
  }));
551
579
  return ConversationList({
552
580
  sessions: items,
581
+ // Per-tab caption: selecting a row does different things on chat vs history,
582
+ // so disambiguate the visually-identical rows (W17).
583
+ caption: state.tab === 'chat' ? 'Resume a conversation in chat' : 'Browse a session\'s events',
553
584
  // Today/Yesterday/This-week + a pinned Running section, the Claude-Desktop
554
585
  // Chats shape. Groups reference sids the kit maps back to the items above.
555
586
  groups: sessionGroups(sliced),
@@ -587,7 +618,10 @@ async function loadDir(dirPath) {
587
618
  state.files.roots = j.roots || [];
588
619
  state.files.error = null;
589
620
  } catch (e) {
590
- state.files.error = e.message;
621
+ // W9: translate HTTP status to plain, non-leaky copy.
622
+ state.files.error = e.status === 403
623
+ ? 'That folder is outside the allowed roots, or access is denied.'
624
+ : (e.status === 404 ? 'That folder no longer exists.' : (e.message || 'Could not list this directory.'));
591
625
  state.files.entries = [];
592
626
  }
593
627
  state.files.loading = false; render();
@@ -628,35 +662,69 @@ function closePreview() {
628
662
  render();
629
663
  }
630
664
 
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).
665
+ // Build the inner preview body (image / code / text / loading / error) shared by
666
+ // the modal FileViewer (<900px) and the inline FilePreviewPane (split view).
667
+ function filePreviewBody(file) {
668
+ const f = state.files || {};
669
+ if (file.type === 'image') {
670
+ return FilePreviewMedia({ src: B.imageUrl(state.backend, file.path), type: 'image', name: file.name });
671
+ }
672
+ const ps = f.previewState;
673
+ if (!ps || ps.path !== file.path) { Promise.resolve().then(() => { if (state.files.preview?.path === file.path && state.files.previewState?.path !== file.path) loadPreviewContent(file); }); }
674
+ if (!ps || ps.loading || ps.path !== file.path) {
675
+ return h('div', { class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ size: 'sm' }), 'loading…');
676
+ }
677
+ if (ps.error) {
678
+ return Alert({ kind: 'warn', title: 'Cannot preview file', children: ps.error });
679
+ }
680
+ const ext = (file.name.split('.').pop() || '').toLowerCase();
681
+ const lang = PREVIEW_LANG[ext];
682
+ return lang
683
+ ? FilePreviewCode({ content: ps.content, lang, filename: file.name })
684
+ : FilePreviewText({ content: ps.content, truncated: ps.truncated });
685
+ }
686
+
687
+ // The previewable (non-dir) neighbours of the current preview, in the same
688
+ // sorted+filtered order the grid shows, so prev/next step through files the way
689
+ // fsbrowse does. Returns { prev, next } file objects or null at the ends.
690
+ function previewNeighbours() {
691
+ const f = state.files || {};
692
+ const file = f.preview;
693
+ if (!file || !Array.isArray(f._sorted)) return { prev: null, next: null };
694
+ const list = f._sorted.filter(e => e.type !== 'dir');
695
+ const i = list.findIndex(e => e.path === file.path);
696
+ if (i === -1) return { prev: null, next: null };
697
+ return { prev: i > 0 ? list[i - 1] : null, next: i < list.length - 1 ? list[i + 1] : null };
698
+ }
699
+
700
+ function openPreview(file) {
701
+ state.files.preview = file;
702
+ if (file.type !== 'image') loadPreviewContent(file);
703
+ render();
704
+ }
705
+
706
+ // Render the file preview MODAL (FileViewer) - the <900px fallback when there is
707
+ // no room for the inline pane. Returns null when nothing is selected.
634
708
  function filePreview() {
635
709
  const f = state.files || {};
636
710
  const file = f.preview;
637
711
  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 });
712
+ const { prev, next } = previewNeighbours();
713
+ return FileViewer({ file, body: filePreviewBody(file), onClose: closePreview,
714
+ onPrev: prev ? () => openPreview(prev) : undefined,
715
+ onNext: next ? () => openPreview(next) : undefined });
716
+ }
717
+
718
+ // Render the inline file preview PANE (FilePreviewPane) for the WorkspaceShell
719
+ // pane slot - the split-view, claude-Desktop file-pane feel.
720
+ function filePreviewPane() {
721
+ const f = state.files || {};
722
+ const file = f.preview;
723
+ if (!file) return FilePreviewPane({});
724
+ const { prev, next } = previewNeighbours();
725
+ return FilePreviewPane({ file, body: filePreviewBody(file), onClose: closePreview,
726
+ onPrev: prev ? () => openPreview(prev) : undefined,
727
+ onNext: next ? () => openPreview(next) : undefined });
660
728
  }
661
729
 
662
730
  // Go up one directory from the current path (the FileGrid Backspace affordance).
@@ -700,6 +768,9 @@ function filesMain() {
700
768
  ? mapped.filter(e => e.name.toLowerCase().includes(f.filter.toLowerCase()))
701
769
  : mapped;
702
770
  const sorted = sortFiles(filtered, f.sort || 'name', f.sortDir || 'asc');
771
+ // Stash the sorted+filtered list so prev/next preview stepping walks the SAME
772
+ // order the grid shows (W6).
773
+ state.files._sorted = sorted;
703
774
  let body;
704
775
  if (f.error) {
705
776
  body = Alert({ key: 'ferr', kind: 'warn', title: 'Cannot list directory', children: f.error });
@@ -707,52 +778,60 @@ function filesMain() {
707
778
  body = FileGrid({
708
779
  files: sorted,
709
780
  loading: f.loading,
710
- emptyText: f.filter ? 'no files match "' + f.filter + '"' : 'empty directory',
781
+ shown: f.shown,
782
+ onShowMore: (n) => { state.files.shown = n; render(); },
783
+ emptyText: f.filter ? 'No files match "' + f.filter + '"' : 'Empty directory',
711
784
  sort: { key: f.sort || 'name', dir: f.sortDir || 'asc', onSort: (k) => {
712
785
  // Click the active column to flip direction; a new column resets to asc.
713
786
  if (state.files.sort === k) state.files.sortDir = state.files.sortDir === 'asc' ? 'desc' : 'asc';
714
787
  else { state.files.sort = k; state.files.sortDir = 'asc'; }
715
788
  render();
716
789
  } },
717
- filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: (v) => { state.files.filter = v; render(); } },
790
+ filter: { value: f.filter || '', placeholder: 'Filter files in this directory', onInput: (v) => { state.files.filter = v; state.files.shown = null; render(); } },
718
791
  onUp: fileUp,
719
792
  onOpen: (file) => {
720
793
  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();
794
+ else openPreview(file);
795
+ },
796
+ // W7: download is the one mutation-free row action the read-only server
797
+ // supports. rename/delete are a deliberate scope cut (no mutation endpoints
798
+ // - documented in PUNCHLIST-COHESION.md), so only download is wired.
799
+ onAction: (act, file) => {
800
+ if (act === 'download' && file.type !== 'dir') {
801
+ const a = document.createElement('a');
802
+ a.href = B.downloadUrl(state.backend, file.path);
803
+ a.download = file.name; document.body.appendChild(a); a.click(); a.remove();
727
804
  }
728
805
  },
729
806
  });
730
807
  }
731
808
  // 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.
809
+ // kit RootsPicker segmented control so every allowed root is reachable in one
810
+ // click (not only by breadcrumb-walking down from the first).
735
811
  const roots = Array.isArray(f.roots) ? f.roots : [];
736
812
  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))))
813
+ ? RootsPicker({
814
+ roots: roots.map((r) => ({ id: r, label: truncate(projectLabel(r) || r, 16, 28) })),
815
+ selected: f.path, onSelect: (r) => loadDir(r), label: 'Jump to an allowed root',
816
+ })
739
817
  : null;
740
818
  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));
819
+ // Kit FileToolbar (replaces the hand-built .ds-file-toolbar markup).
820
+ const toolbar = FileToolbar({
821
+ left: [crumb],
822
+ right: [targetCwd
823
+ ? Btn({ key: 'usecwd', onClick: () => { state.chatCwd = targetCwd; lsSet('agentgui.cwd', targetCwd); announce('working directory set to ' + targetCwd); navTo('chat'); }, children: 'use as chat cwd' })
824
+ : null].filter(Boolean),
825
+ });
747
826
  return [
748
827
  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,
828
+ 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.' }),
829
+ rootsRow ? h('div', { key: 'froots' }, rootsRow) : null,
830
+ h('div', { key: 'ftb' }, toolbar),
752
831
  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,
832
+ // Inline pane handles wide-screen preview; the modal is only the <900px
833
+ // fallback (the pane has no room there).
834
+ (f.preview && isNarrow()) ? h('div', { key: 'fprev' }, filePreview()) : null,
756
835
  ].filter(Boolean);
757
836
  }
758
837
 
@@ -763,21 +842,36 @@ async function stopAllActive(sessions) {
763
842
  refreshActive();
764
843
  }
765
844
 
845
+ // How long a session can go without activity (and without a running tool)
846
+ // before it is treated as STALE - alive but not making progress, so a stuck
847
+ // agent reads differently from a busy one.
848
+ const STALE_AFTER_MS = 45000;
849
+ // Rank for the error-then-stale-first ordering (W3).
850
+ const STATUS_RANK = { error: 0, stale: 1, running: 2 };
851
+
766
852
  // --- live (multi-session dashboard) ---
767
853
  function liveMain() {
768
854
  const offline = state.health.status !== 'ok' && state.health.status !== 'unknown';
769
855
  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.
856
+ const tally = state.live.tally || new Map();
857
+ const now = Date.now();
858
+ // Live-stream health: connected (recent event), connecting (opened, no event
859
+ // yet), or lost (errored). Feeds the dashboard header so "connected, 0 running"
860
+ // still tells the user the dashboard is listening.
861
+ const streamState = state.live.error ? 'lost' : (state.live.connected ? 'connected' : 'connecting');
862
+ let sessions = (Array.isArray(state.active) ? state.active : []).map((r) => {
863
+ // Activity tally: prefer the history-index row; fall back to the per-sid live
864
+ // tally so a brand-new chat not yet in the index still shows motion (W4).
774
865
  const sess = bySid.get(r.sessionId);
866
+ const t = tally.get(r.sessionId);
867
+ const events = sess && sess.events != null ? sess.events : (t ? t.events : null);
868
+ const tools = sess && sess.tools != null ? sess.tools : (t ? t.tools : 0);
869
+ const errors = sess && sess.errors != null ? sess.errors : (t ? t.errors : 0);
870
+ const lastTs = (sess && sess.last) || (t && t.last) || 0;
775
871
  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
- }
872
+ if (events != null) counterBits.push(events + ' ev');
873
+ if (tools) counterBits.push(tools + ' tools');
874
+ if (errors) counterBits.push(errors + ' err');
781
875
  // Current tool: while a turn is busy in the in-page chat for this sid, the
782
876
  // trailing assistant message carries a running tool part.
783
877
  let currentTool = '';
@@ -787,29 +881,62 @@ function liveMain() {
787
881
  const running = last && Array.isArray(last.parts) && last.parts.filter(p => p && p.kind === 'tool' && p.status === 'running').slice(-1)[0];
788
882
  if (running) currentTool = running.name || '';
789
883
  }
884
+ // W2: stale = no recent activity AND no running tool. Errors win over stale.
885
+ let status = 'running';
886
+ if (errors) status = 'error';
887
+ else if (!currentTool && lastTs && (now - lastTs) > STALE_AFTER_MS) status = 'stale';
790
888
  return {
791
889
  sid: r.sessionId,
792
890
  agent: agentById(r.agentId)?.name || r.agentId || 'agent',
793
891
  model: r.model || '',
794
892
  cwd: r.cwd ? r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '',
795
- elapsed: r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) + 's' : '',
893
+ elapsed: r.startedAt ? Math.round((now - r.startedAt) / 1000) + 's' : '',
796
894
  counter: counterBits.length ? counterBits.join(' · ') : null,
797
- lastActivity: sess && sess.last ? fmtRelTime(sess.last) : '',
895
+ lastActivity: lastTs ? fmtRelTime(lastTs) : '',
798
896
  currentTool,
799
- status: (sess && sess.errors) ? 'error' : 'running',
897
+ status,
800
898
  };
801
899
  });
900
+ // W12: in-dir filter + errors-only toggle.
901
+ const lv = state.live;
902
+ if (lv.errorsOnly) sessions = sessions.filter(s => s.status === 'error');
903
+ if (lv.filter) {
904
+ const q = lv.filter.toLowerCase();
905
+ sessions = sessions.filter(s => (s.agent + ' ' + s.model + ' ' + s.cwd).toLowerCase().includes(q));
906
+ }
907
+ // W3/W12: sort. Default floats error then stale to the front (triage surface).
908
+ const sortKey = lv.sort || 'status';
909
+ sessions.sort((a, b) => {
910
+ if (sortKey === 'elapsed') return (parseInt(b.elapsed) || 0) - (parseInt(a.elapsed) || 0);
911
+ if (sortKey === 'activity') return (a.lastActivity ? 0 : 1) - (b.lastActivity ? 0 : 1);
912
+ if (sortKey === 'errors') return (a.status === 'error' ? 0 : 1) - (b.status === 'error' ? 0 : 1);
913
+ return (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
914
+ });
915
+ const selected = lv.selected instanceof Set ? lv.selected : new Set();
802
916
  return [
803
917
  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.' }),
918
+ PageHeader({ compact: true, title: 'Live sessions', lede: 'Every in-flight agent session, managed at once. Stop, open, or jump to events per session.' }),
805
919
  SessionDashboard({
806
920
  sessions,
807
921
  offline,
922
+ streamState,
923
+ activeSid: state.chat.resumeSid, // W13
924
+ sort: { value: sortKey, onChange: (v) => { state.live.sort = v; render(); } },
925
+ filter: { value: lv.filter || '', placeholder: 'Filter sessions', onInput: (v) => { state.live.filter = v; render(); } },
926
+ errorsOnly: !!lv.errorsOnly,
927
+ onErrorsOnly: (on) => { state.live.errorsOnly = on; render(); },
928
+ selectable: true,
929
+ selected,
930
+ onToggleSelect: (s) => {
931
+ const set = (state.live.selected instanceof Set) ? state.live.selected : new Set();
932
+ if (set.has(s.sid)) set.delete(s.sid); else set.add(s.sid);
933
+ state.live.selected = set; render();
934
+ },
935
+ onStopSelected: (sids) => { stopAllActive(sids.map(sid => ({ sid }))); state.live.selected = new Set(); },
808
936
  emptyText: 'No live sessions - start a chat or run a local agent.',
809
937
  onStop: (s) => stopActiveChat(s.sid),
810
938
  onStopAll: (all) => stopAllActive(all),
811
939
  onOpen: (s) => { resumeInChat({ sid: s.sid }); },
812
- onResume: (s) => { resumeInChat({ sid: s.sid }); },
813
940
  onView: (s) => { navTo('history'); loadSession(s.sid); },
814
941
  }),
815
942
  ].filter(Boolean);
@@ -977,6 +1104,16 @@ function chatMain() {
977
1104
  'Find and explain the main entry point',
978
1105
  ],
979
1106
  onSuggestionClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
1107
+ // W14: a stable per-agent product mark (a line-SVG, not a per-agent letter)
1108
+ // and the active target shown inline above the composer.
1109
+ avatar: state.selectedAgent ? h('span', { class: 'agentchat-avatar-mark', 'aria-hidden': 'true' }, Icon('forum', { size: 16 })) : undefined,
1110
+ composerContext: state.selectedAgent ? {
1111
+ bits: [agentName, state.selectedModel || null, state.chatCwd ? state.chatCwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : 'server default'].filter(Boolean),
1112
+ onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); },
1113
+ } : undefined,
1114
+ // W15: contextual follow-up chips after a settled assistant turn.
1115
+ followups: state.chat.messages.length ? ['Explain that in more detail', 'Show me the diff', 'Run the tests'] : [],
1116
+ onFollowupClick: (t) => { if (!canSend()) return; state.chat.draft = t; sendChat(); },
980
1117
  // Per-message actions: copy any message; retry the last assistant turn;
981
1118
  // edit-and-resend a user message (drops everything after it, refills draft).
982
1119
  onCopyMessage: (m) => copyMessageText(m),
@@ -1229,8 +1366,8 @@ function historyMain() {
1229
1366
  reconnectAlert(),
1230
1367
  PageHeader({
1231
1368
  compact: true,
1232
- title: 'history',
1233
- lede: 'pick a session from the sidebar - events stream live from ccsniff /v1/history.',
1369
+ title: 'History',
1370
+ lede: 'Pick a session from the sidebar - events stream live from ccsniff /v1/history.',
1234
1371
  }),
1235
1372
  h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
1236
1373
  h('p', { key: 'gt', class: 'history-empty-title' },
@@ -1592,8 +1729,8 @@ function settingsMain() {
1592
1729
  return [
1593
1730
  PageHeader({
1594
1731
  compact: true,
1595
- title: 'settings',
1596
- lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=... or the field below persists via localStorage.',
1732
+ title: 'Settings',
1733
+ lede: 'Point agentgui at any backend. Blank = same-origin (ccsniff in-process). ?backend=... or the field below persists via localStorage.',
1597
1734
  }),
1598
1735
  h('div', { key: 'settings-grid', class: 'settings-grid' }, [
1599
1736
  Panel({
@@ -59,7 +59,12 @@ export async function listDir(base, dirPath) {
59
59
  const seg = dirPath ? '/' + encodeURIComponent(dirPath) : '';
60
60
  const r = await authedFetch(base + '/api/list' + seg);
61
61
  const j = await r.json().catch(() => ({}));
62
- if (!r.ok) throw new Error(j.error || ('list: ' + r.status));
62
+ if (!r.ok) {
63
+ // Carry the HTTP status so the app can map 403/404 to plain copy (W9).
64
+ const e = new Error(j.error || ('list: ' + r.status));
65
+ e.status = r.status;
66
+ throw e;
67
+ }
63
68
  return j;
64
69
  }
65
70
 
@@ -82,6 +87,12 @@ export function imageUrl(base, filePath) {
82
87
  return withToken(base + '/api/image/' + encodeURIComponent(filePath));
83
88
  }
84
89
 
90
+ // Same-origin download URL (served by /api/download, attachment disposition,
91
+ // confined + token-threaded). Used by the Files row download action.
92
+ export function downloadUrl(base, filePath) {
93
+ return withToken(base + '/api/download/' + encodeURIComponent(filePath));
94
+ }
95
+
85
96
  // ---------- History (HTTP, served by ccsniff) ----------
86
97
 
87
98
  export async function listSessions(base) {