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.
- package/.claude/workflows/gui-cohesion.js +95 -0
- package/PUNCHLIST-COHESION.md +367 -0
- package/lib/http-handler.js +49 -3
- package/package.json +1 -1
- package/site/app/index.html +4 -22
- package/site/app/js/app.js +228 -91
- package/site/app/js/backend.js +12 -1
- package/site/app/vendor/anentrypoint-design/247420.css +170 -7
- package/site/app/vendor/anentrypoint-design/247420.js +11 -11
package/site/app/index.html
CHANGED
|
@@ -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
|
-
/*
|
|
80
|
-
|
|
81
|
-
|
|
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 {
|
package/site/app/js/app.js
CHANGED
|
@@ -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
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
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
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
632
|
-
//
|
|
633
|
-
|
|
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
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
//
|
|
733
|
-
// breadcrumb-walking down from the first).
|
|
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
|
-
?
|
|
738
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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: '
|
|
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
|
-
//
|
|
754
|
-
//
|
|
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
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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 (
|
|
777
|
-
|
|
778
|
-
|
|
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((
|
|
893
|
+
elapsed: r.startedAt ? Math.round((now - r.startedAt) / 1000) + 's' : '',
|
|
796
894
|
counter: counterBits.length ? counterBits.join(' · ') : null,
|
|
797
|
-
lastActivity:
|
|
895
|
+
lastActivity: lastTs ? fmtRelTime(lastTs) : '',
|
|
798
896
|
currentTool,
|
|
799
|
-
status
|
|
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: '
|
|
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: '
|
|
1233
|
-
lede: '
|
|
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: '
|
|
1596
|
-
lede: '
|
|
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({
|
package/site/app/js/backend.js
CHANGED
|
@@ -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)
|
|
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) {
|