anentrypoint-design 0.0.385 → 0.0.387
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/dist/247420.css +33 -0
- package/dist/247420.js +53 -51
- package/package.json +1 -1
- package/src/components/agent-chat/controls.js +143 -0
- package/src/components/agent-chat/empty-state.js +63 -0
- package/src/components/agent-chat/message-rows.js +141 -0
- package/src/components/agent-chat/surface.js +206 -0
- package/src/components/agent-chat/thread-behaviour.js +50 -0
- package/src/components/agent-chat.js +6 -546
- package/src/components/chat/composer-affordances.js +109 -0
- package/src/components/chat/composer.js +256 -0
- package/src/components/chat/threads.js +105 -0
- package/src/components/chat-message-parts/agent-nodes.js +103 -0
- package/src/components/chat-message-parts/inline.js +106 -0
- package/src/components/chat-message-parts/prose-nodes.js +133 -0
- package/src/components/chat-message-parts/renderers.js +89 -0
- package/src/components/chat-message-parts.js +12 -408
- package/src/components/chat-minimap/minimap.js +167 -0
- package/src/components/chat-minimap/paint.js +73 -0
- package/src/components/chat-minimap/preview.js +41 -0
- package/src/components/chat-minimap.js +7 -263
- package/src/components/chat.js +20 -628
- package/src/components/community/chrome.js +63 -0
- package/src/components/community/navigation.js +167 -0
- package/src/components/community/presence.js +106 -0
- package/src/components/community/shell.js +17 -0
- package/src/components/community/views.js +131 -0
- package/src/components/community.js +18 -447
- package/src/components/files/chrome.js +140 -0
- package/src/components/files/entries.js +156 -0
- package/src/components/files/grid-controls.js +63 -0
- package/src/components/files/grid.js +177 -0
- package/src/components/files/types.js +69 -0
- package/src/components/files-modals/dialogs.js +118 -0
- package/src/components/files-modals/modal-shell.js +153 -0
- package/src/components/files-modals/preview-bodies.js +129 -0
- package/src/components/files-modals/preview-containers.js +96 -0
- package/src/components/files-modals.js +14 -475
- package/src/components/files.js +15 -564
- package/src/components/freddie/pages-config.js +3 -94
- package/src/components/freddie/pages-models.js +97 -0
- package/src/components/freddie.js +2 -1
- package/src/components/interaction-primitives/mobile.js +25 -0
- package/src/components/interaction-primitives/pointer.js +214 -0
- package/src/components/interaction-primitives/reorderable.js +47 -0
- package/src/components/interaction-primitives/shortcuts.js +119 -0
- package/src/components/interaction-primitives.js +16 -388
- package/src/components/sessions/conversation-list.js +230 -0
- package/src/components/sessions/dashboard.js +202 -0
- package/src/components/sessions/detail-bits.js +49 -0
- package/src/components/sessions/format.js +42 -0
- package/src/components/sessions/session-card.js +92 -0
- package/src/components/sessions.js +16 -579
- package/src/components/voice/audio-cue.js +39 -0
- package/src/components/voice/capture.js +90 -0
- package/src/components/voice/playback.js +75 -0
- package/src/components/voice/settings-modal.js +104 -0
- package/src/components/voice.js +16 -293
- package/src/css/app-shell/base.css +23 -0
- package/src/css/app-shell/states-interactions.css +10 -0
- package/src/kits/os/freddie/chat-protocol.js +32 -0
- package/src/kits/os/freddie/chat-transport.js +178 -0
- package/src/kits/os/freddie/pages-chat.js +10 -180
- package/src/kits/os/shell-chrome.js +163 -0
- package/src/kits/os/shell-geometry.js +59 -0
- package/src/kits/os/shell.js +178 -335
- package/src/page-html/client-script.js +151 -0
- package/src/page-html/head-tags.js +59 -0
- package/src/page-html/markdown.js +68 -0
- package/src/page-html/page-styles.js +44 -0
- package/src/page-html.js +14 -291
|
@@ -2,584 +2,21 @@
|
|
|
2
2
|
// live multi-session dashboard. Pure factories: props in, webjsx vnode out, all
|
|
3
3
|
// interaction via host callbacks. Styling lives in chat.css (.ds-session*,
|
|
4
4
|
// .ds-dash*) using kit tokens; no transport, no decorative glyphs.
|
|
5
|
-
|
|
6
|
-
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
7
|
-
import { Btn, Icon } from './shell.js';
|
|
8
|
-
import { Select, SearchInput } from './content.js';
|
|
9
|
-
import { formatDateTime, formatNumber } from '../locale.js';
|
|
10
|
-
const h = webjsx.createElement;
|
|
11
|
-
|
|
12
|
-
// ONE duration format for every surface (live cards, running panel, session
|
|
13
|
-
// meta, context pane): <60s -> 'Ns', <1h -> 'Nm Ss', else 'Nh Nm'. Durations
|
|
14
|
-
// roll s -> m -> h instead of an hour-long run reading '3712s'.
|
|
15
|
-
// ONE absolute-time / relative-time formatter for every surface that shows a
|
|
16
|
-
// timestamp (freddie pages, chat transcripts). fmtTime -> localized
|
|
17
|
-
// date+time string; fmtAgo -> coarse relative ('Ns/Nm/Nh/Nd ago').
|
|
18
|
-
export function fmtTime(t) {
|
|
19
|
-
try { return formatDateTime(t); } catch { return String(t || ''); }
|
|
20
|
-
}
|
|
21
|
-
export function fmtAgo(t) {
|
|
22
|
-
if (!t) return '';
|
|
23
|
-
const s = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
|
|
24
|
-
if (s < 60) return s + 's ago';
|
|
25
|
-
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
26
|
-
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
27
|
-
return Math.floor(s / 86400) + 'd ago';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function fmtDuration(ms) {
|
|
31
|
-
if (ms == null || !isFinite(ms) || ms < 0) return '';
|
|
32
|
-
const s = Math.round(ms / 1000);
|
|
33
|
-
if (s < 60) return s + 's';
|
|
34
|
-
const m = Math.floor(s / 60);
|
|
35
|
-
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
|
36
|
-
const hrs = Math.floor(m / 60);
|
|
37
|
-
return hrs + 'h ' + (m % 60) + 'm';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The Claude-Desktop "Chats" column. Sessions grouped by a caller-supplied
|
|
42
|
-
* group label, each row showing title/project, relative time, agent badge,
|
|
43
|
-
* and a running/new-event indicator. Selecting a row switches the active
|
|
44
|
-
* conversation.
|
|
45
|
-
*
|
|
46
|
-
* @param {Object} [props]
|
|
47
|
-
* @param {Array<{sid:*, title?:string, project?:string, agent?:string, time?:string, running?:boolean, unread?:boolean, rail?:string, parentSid?:*}>} [props.sessions=[]]
|
|
48
|
-
* @param {*} [props.selected] - the active sid.
|
|
49
|
-
* @param {Array<{label:string, sids:Array<*>}>} [props.groups] - OPTIONAL buckets for the rows; else one flat list.
|
|
50
|
-
* @param {{value:string, onInput:Function, placeholder?:string}} [props.search] - inline filter (optional).
|
|
51
|
-
* @param {Function} [props.onSelect] - onSelect(session).
|
|
52
|
-
* @param {Function} [props.onNew] - onNew().
|
|
53
|
-
* @param {string} [props.emptyText='No conversations yet']
|
|
54
|
-
* @param {boolean} [props.loading=false]
|
|
55
|
-
* @param {*} [props.error=null]
|
|
56
|
-
* @param {boolean} [props.tree=false] - OPTIONAL: nest rows whose `parentSid` matches
|
|
57
|
-
* another row's `sid` under that row (fork/branch tree), with an indent guide,
|
|
58
|
-
* a branch glyph, and a per-node collapse toggle. Ignored when `groups` is set
|
|
59
|
-
* (grouping and tree-nesting are mutually exclusive row layouts). `expanded`/
|
|
60
|
-
* `onToggleExpand` are host-driven (kit stays stateless): a `sid` NOT present
|
|
61
|
-
* in the `expanded` Set renders collapsed once it has children.
|
|
62
|
-
* @param {Set<*>|Array<*>} [props.expanded] - sids whose children are shown, when `tree`.
|
|
63
|
-
* @param {Function} [props.onToggleExpand] - onToggleExpand(sid), when `tree`.
|
|
64
|
-
* @param {Function} [props.onRename] - onRename(session, newTitle). Presence enables the
|
|
65
|
-
* hover-revealed rename action (row becomes an inline text input while active).
|
|
66
|
-
* @param {*} [props.renaming] - sid of the row currently in rename-edit mode (host-driven).
|
|
67
|
-
* @param {Function} [props.onStartRename] - onStartRename(session) - fired by the rename
|
|
68
|
-
* button click, before `onRename` commits; host flips `renaming` to this sid.
|
|
69
|
-
* @param {Function} [props.onCancelRename] - onCancelRename() - Escape / blur-without-change.
|
|
70
|
-
* @param {Function} [props.onDelete] - onDelete(session). Presence enables the hover-revealed
|
|
71
|
-
* delete action; clicking it arms an inline two-button confirm row (same height, no modal),
|
|
72
|
-
* mirroring SessionDashboard's arm-then-confirm stop control.
|
|
73
|
-
* @param {*} [props.confirmingDelete] - sid currently showing the armed delete-confirm state.
|
|
74
|
-
* @param {Function} [props.onArmDelete] - onArmDelete(session) - first delete click.
|
|
75
|
-
* @param {Function} [props.onCancelDelete] - onCancelDelete() - confirm-row Cancel click.
|
|
76
|
-
* @returns {*} webjsx vnode
|
|
77
|
-
*/
|
|
78
|
-
export function ConversationList({ sessions = [], selected, groups, search, caption,
|
|
79
|
-
onSelect, onNew, newLabel = 'New chat',
|
|
80
|
-
emptyText = 'No conversations yet', loading = false, error = null,
|
|
81
|
-
loadingText = 'Loading conversations…',
|
|
82
|
-
// hasMore/onLoadMore: the rail's host truncates the underlying
|
|
83
|
-
// session list at some limit (a 200+ conversation user would
|
|
84
|
-
// otherwise never reach older sessions) - mirrors the History
|
|
85
|
-
// tab's existing "load N older" EventList pattern.
|
|
86
|
-
hasMore = false, onLoadMore, loadMoreLabel = 'load more conversations',
|
|
87
|
-
// resultCount: forwarded straight through to the inner SearchInput's
|
|
88
|
-
// aria-live region, so a real "N results" string (computed by the
|
|
89
|
-
// host from its filtered session list) reaches AT users instead of
|
|
90
|
-
// the region sitting permanently empty.
|
|
91
|
-
resultCount,
|
|
92
|
-
// Fork/branch tree nesting (parentSid-driven), inline rename,
|
|
93
|
-
// inline delete — all host-driven, kit stays stateless.
|
|
94
|
-
tree = false, expanded, onToggleExpand,
|
|
95
|
-
onRename, renaming, onStartRename, onCancelRename,
|
|
96
|
-
onDelete, confirmingDelete, onArmDelete, onCancelDelete } = {}) {
|
|
97
|
-
const expSet = expanded instanceof Set ? expanded : new Set(expanded || []);
|
|
98
|
-
// childrenBySid: only consulted when `tree` is on - a flat caller pays nothing.
|
|
99
|
-
const childrenBySid = new Map();
|
|
100
|
-
if (tree) {
|
|
101
|
-
for (const s of sessions) {
|
|
102
|
-
if (s.parentSid == null) continue;
|
|
103
|
-
if (!childrenBySid.has(s.parentSid)) childrenBySid.set(s.parentSid, []);
|
|
104
|
-
childrenBySid.get(s.parentSid).push(s);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
const isRenaming = (s) => renaming != null && s.sid === renaming;
|
|
108
|
-
const isConfirmingDelete = (s) => confirmingDelete != null && s.sid === confirmingDelete;
|
|
109
|
-
|
|
110
|
-
const rowFor = (s, i, depth = 0) => {
|
|
111
|
-
const hasKids = tree && childrenBySid.has(s.sid) && childrenBySid.get(s.sid).length > 0;
|
|
112
|
-
const kidsOpen = hasKids && expSet.has(s.sid);
|
|
113
|
-
const renamingThis = onRename && isRenaming(s);
|
|
114
|
-
const confirmingThis = onDelete && isConfirmingDelete(s);
|
|
115
|
-
let content;
|
|
116
|
-
if (confirmingThis) {
|
|
117
|
-
// Inline confirm: same row height, no modal - mirrors SessionDashboard's
|
|
118
|
-
// arm-then-confirm bulk-stop control so delete has one consistent shape
|
|
119
|
-
// across the kit.
|
|
120
|
-
content = [
|
|
121
|
-
h('span', { key: 'cd-msg', class: 'ds-session-confirm-msg' }, 'Delete "' + (s.title || s.project || s.sid || '') + '"?'),
|
|
122
|
-
h('span', { key: 'cd-acts', class: 'ds-session-confirm-actions' }, [
|
|
123
|
-
h('button', { key: 'cd-yes', type: 'button', class: 'ds-session-confirm-delete',
|
|
124
|
-
onclick: (e) => { e.stopPropagation(); onDelete(s); } }, 'delete'),
|
|
125
|
-
h('button', { key: 'cd-no', type: 'button', class: 'ds-session-confirm-cancel',
|
|
126
|
-
onclick: (e) => { e.stopPropagation(); onCancelDelete && onCancelDelete(); } }, 'cancel'),
|
|
127
|
-
]),
|
|
128
|
-
];
|
|
129
|
-
} else if (renamingThis) {
|
|
130
|
-
content = [
|
|
131
|
-
h('input', {
|
|
132
|
-
key: 'rn-input', class: 'ds-session-rename-input', type: 'text',
|
|
133
|
-
value: s.title || '', autofocus: true,
|
|
134
|
-
onclick: (e) => e.stopPropagation(),
|
|
135
|
-
onkeydown: (e) => {
|
|
136
|
-
if (e.key === 'Enter') { e.preventDefault(); onRename(s, e.target.value); }
|
|
137
|
-
if (e.key === 'Escape') { e.preventDefault(); onCancelRename && onCancelRename(); }
|
|
138
|
-
},
|
|
139
|
-
onblur: (e) => onRename(s, e.target.value),
|
|
140
|
-
}),
|
|
141
|
-
];
|
|
142
|
-
} else {
|
|
143
|
-
content = [
|
|
144
|
-
hasKids ? h('button', {
|
|
145
|
-
key: 'tog', type: 'button', class: 'ds-session-tree-toggle' + (kidsOpen ? ' open' : ''),
|
|
146
|
-
'aria-label': (kidsOpen ? 'collapse' : 'expand') + ' forks', 'aria-expanded': kidsOpen ? 'true' : 'false',
|
|
147
|
-
onclick: (e) => { e.stopPropagation(); onToggleExpand && onToggleExpand(s.sid); },
|
|
148
|
-
}, Icon('chevron-right', { size: 10 })) : null,
|
|
149
|
-
depth > 0 ? h('span', { key: 'fork', class: 'ds-session-fork-icon', 'aria-hidden': 'true' }, Icon('corner-up-left', { size: 10 })) : null,
|
|
150
|
-
h('span', { key: 'main', class: 'ds-session-main' }, [
|
|
151
|
-
// Two-sided truncation: the CSS ellipsis is paired with a title= carrying
|
|
152
|
-
// the full string, so a long title/project is recoverable on hover.
|
|
153
|
-
h('span', { class: 'ds-session-title', title: s.title || s.project || s.sid || null }, s.title || s.project || s.sid || ''),
|
|
154
|
-
(s.project || s.time) ? h('span', { class: 'ds-session-sub', title: s.project || null },
|
|
155
|
-
[s.project, s.time].filter(Boolean).join(' · ')) : null,
|
|
156
|
-
].filter(Boolean)),
|
|
157
|
-
h('span', { key: 'meta', class: 'ds-session-meta' }, [
|
|
158
|
-
s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
|
|
159
|
-
// Optional richer status ('error'|'stale'|'running'|'stopping') mirrors the
|
|
160
|
-
// SessionCard STATUS_DISC mapping used on the Live dashboard, so a session
|
|
161
|
-
// pinned to a "Running" rail group reads the same stuck-vs-busy signal it
|
|
162
|
-
// does there rather than only a boolean live dot. Falls back to the plain
|
|
163
|
-
// running dot when no status is supplied (existing callers unaffected).
|
|
164
|
-
s.status
|
|
165
|
-
? h('span', { class: 'status-dot-disc ' + (STATUS_DISC[s.status] || 'status-dot-live'), 'aria-label': STATUS_WORD[s.status] || s.status, role: 'img' })
|
|
166
|
-
: s.running
|
|
167
|
-
? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
|
|
168
|
-
: (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
|
|
169
|
-
].filter(Boolean)),
|
|
170
|
-
(onRename || onDelete) ? h('span', { key: 'row-actions', class: 'ds-session-row-actions' }, [
|
|
171
|
-
onRename ? h('button', { key: 'ra-rn', type: 'button', class: 'ds-session-row-action', 'aria-label': 'rename',
|
|
172
|
-
onclick: (e) => { e.stopPropagation(); onStartRename ? onStartRename(s) : onRename(s, s.title); } }, Icon('pencil', { size: 12 })) : null,
|
|
173
|
-
onDelete ? h('button', { key: 'ra-del', type: 'button', class: 'ds-session-row-action ds-session-row-action-danger', 'aria-label': 'delete',
|
|
174
|
-
onclick: (e) => { e.stopPropagation(); onArmDelete ? onArmDelete(s) : onDelete(s); } }, Icon('trash', { size: 12 })) : null,
|
|
175
|
-
].filter(Boolean)) : null,
|
|
176
|
-
].filter(Boolean);
|
|
177
|
-
}
|
|
178
|
-
const row = h('div', {
|
|
179
|
-
// Stable key: prefer sid, else position - a missing/duplicate sid would make
|
|
180
|
-
// key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
|
|
181
|
-
key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
|
|
182
|
-
role: 'option',
|
|
183
|
-
tabindex: s.sid === selected ? '0' : '-1',
|
|
184
|
-
class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : '') +
|
|
185
|
-
(depth > 0 ? ' ds-session-row-nested' : ''),
|
|
186
|
-
style: depth > 0 ? ('padding-left: calc(var(--space-2) + ' + (depth * 16) + 'px)') : null,
|
|
187
|
-
'aria-selected': s.sid === selected ? 'true' : 'false',
|
|
188
|
-
onclick: (renamingThis || confirmingThis) ? null : () => onSelect && onSelect(s),
|
|
189
|
-
onkeydown: (renamingThis || confirmingThis) ? null : (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect && onSelect(s); } },
|
|
190
|
-
}, ...content);
|
|
191
|
-
if (!hasKids || !kidsOpen) return [row];
|
|
192
|
-
// Depth-first flatten of open children keeps the caller-facing return shape
|
|
193
|
-
// (an array of rows) identical whether tree nesting is on or off.
|
|
194
|
-
const kidRows = childrenBySid.get(s.sid)
|
|
195
|
-
.slice().sort((a, b) => (b.time || '').localeCompare(a.time || ''))
|
|
196
|
-
.flatMap((k, ki) => rowFor(k, ki, depth + 1));
|
|
197
|
-
return [row, ...kidRows];
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
// The body is ALWAYS a single keyed wrapper element of the same tag, so webjsx
|
|
201
|
-
// diffs its children across state transitions (loading -> empty -> populated)
|
|
202
|
-
// instead of swapping the container type - the swap is what triggered the
|
|
203
|
-
// applyDiff "reading 'key'" crash on the first populated mount. Row children
|
|
204
|
-
// are uniformly keyed; non-row states render a single unkeyed status line.
|
|
205
|
-
let inner;
|
|
206
|
-
if (loading && !sessions.length) {
|
|
207
|
-
// Shape-matched skeleton rows during the cold ccsniff index walk (the rail
|
|
208
|
-
// showed a bare line before) - Claude-Desktop skeletons its sidebar on load.
|
|
209
|
-
inner = [
|
|
210
|
-
h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, loadingText),
|
|
211
|
-
...Array.from({ length: 5 }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-session-row-skeleton', 'aria-hidden': 'true' },
|
|
212
|
-
h('div', { class: 'ds-skel ds-skel-title' }), h('div', { class: 'ds-skel ds-skel-meta' }))),
|
|
213
|
-
];
|
|
214
|
-
} else if (error) {
|
|
215
|
-
inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
|
|
216
|
-
} else if (!sessions.length) {
|
|
217
|
-
inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status' }, emptyText)];
|
|
218
|
-
} else if (groups && groups.length) {
|
|
219
|
-
const bySid = new Map(sessions.map((s) => [s.sid, s]));
|
|
220
|
-
inner = groups.map((g) => h('div', { key: 'g-' + g.label, class: 'ds-session-group', role: 'group', 'aria-label': g.label },
|
|
221
|
-
h('div', { key: 'gl', class: 'ds-session-group-label' }, g.label),
|
|
222
|
-
h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'listbox', 'aria-label': g.label }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).flatMap((s, i) => rowFor(s, i)))));
|
|
223
|
-
} else if (tree) {
|
|
224
|
-
// Roots = rows with no parentSid, or whose parentSid isn't present in this
|
|
225
|
-
// list (an orphaned fork - the ancestor was deleted/filtered out) - each
|
|
226
|
-
// root's flatMap already walks its open descendants via rowFor's recursion.
|
|
227
|
-
const sidSet = new Set(sessions.map((s) => s.sid));
|
|
228
|
-
const roots = sessions.filter((s) => s.parentSid == null || !sidSet.has(s.parentSid));
|
|
229
|
-
inner = roots.flatMap((s, i) => rowFor(s, i, 0));
|
|
230
|
-
} else {
|
|
231
|
-
inner = sessions.flatMap((s, i) => rowFor(s, i));
|
|
232
|
-
}
|
|
233
|
-
// The load-more row sits INSIDE the scrollable list body (not the outer
|
|
234
|
-
// .ds-sessions shell) so it scrolls with the rows it extends, matching
|
|
235
|
-
// where a user's eye already is after scrolling to the bottom of the rail.
|
|
236
|
-
const loadMoreRow = (hasMore && onLoadMore && sessions.length)
|
|
237
|
-
? h('button', { key: 'loadmore', type: 'button', class: 'ds-session-loadmore', onclick: onLoadMore }, loadMoreLabel)
|
|
238
|
-
: null;
|
|
239
|
-
const body = h('div', { key: 'body', class: 'ds-session-list', role: 'listbox', 'aria-label': caption || 'Conversations' },
|
|
240
|
-
...inner, loadMoreRow);
|
|
241
|
-
|
|
242
|
-
return h('div', { class: 'ds-sessions' },
|
|
243
|
-
h('div', { key: 'head', class: 'ds-session-head' },
|
|
244
|
-
onNew ? h('button', { key: 'new', type: 'button', class: 'ds-session-new', onclick: onNew, 'aria-label': newLabel },
|
|
245
|
-
Icon('pencil'), h('span', { key: 'l' }, newLabel)) : null,
|
|
246
|
-
search ? SearchInput({
|
|
247
|
-
key: 'search', value: search.value || '',
|
|
248
|
-
label: search.placeholder || 'Search conversations',
|
|
249
|
-
placeholder: search.placeholder || 'Search conversations',
|
|
250
|
-
onInput: (v) => search.onInput && search.onInput(v),
|
|
251
|
-
resultCount,
|
|
252
|
-
}) : null),
|
|
253
|
-
// Per-tab caption telling the user what selecting a row does on this surface
|
|
254
|
-
// (chat = resume the conversation, history = browse its events) so visually
|
|
255
|
-
// identical rows are disambiguated.
|
|
256
|
-
caption ? h('div', { key: 'cap', class: 'ds-session-caption' }, caption) : null,
|
|
257
|
-
body);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// SessionMeta — a middot-separated metadata strip for a session detail surface.
|
|
261
|
-
// items : [{ label, value, title, onCopy }]
|
|
262
|
-
// Each item is a span (label dimmed, value mono) with an optional per-item copy
|
|
263
|
-
// button; the strip flex-wraps at narrow widths. Class is .ds-session-meta-strip
|
|
264
|
-
// (the bare .ds-session-meta is already taken by ConversationList row meta).
|
|
265
|
-
export function SessionMeta({ items = [] } = {}) {
|
|
266
|
-
if (!items.length) return null;
|
|
267
|
-
return h('div', { class: 'ds-session-meta-strip', role: 'group', 'aria-label': 'session metadata' },
|
|
268
|
-
...items.map((it, i) => h('span', {
|
|
269
|
-
key: 'sm-' + (it.label != null ? it.label : i),
|
|
270
|
-
class: 'ds-session-meta-item',
|
|
271
|
-
title: it.title || null,
|
|
272
|
-
},
|
|
273
|
-
[
|
|
274
|
-
it.label != null ? h('span', { key: 'l', class: 'ds-session-meta-label' }, it.label) : null,
|
|
275
|
-
h('span', { key: 'v', class: 'ds-session-meta-value' }, it.value != null ? String(it.value) : ''),
|
|
276
|
-
it.onCopy ? h('button', {
|
|
277
|
-
key: 'c', type: 'button', class: 'ds-session-meta-copy',
|
|
278
|
-
'aria-label': 'copy ' + (it.title || it.label || 'value'),
|
|
279
|
-
onclick: () => it.onCopy(it.value),
|
|
280
|
-
}, 'copy') : null,
|
|
281
|
-
// Generic secondary action (e.g. a directory row's "use as chat cwd")
|
|
282
|
-
// - kept distinct from onCopy since a fact can want a non-copy action,
|
|
283
|
-
// or (rare) both.
|
|
284
|
-
it.onAction ? h('button', {
|
|
285
|
-
key: 'a', type: 'button', class: 'ds-session-meta-action',
|
|
286
|
-
onclick: () => it.onAction(it.value),
|
|
287
|
-
}, it.actionLabel || 'use') : null,
|
|
288
|
-
].filter(Boolean))));
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// AgentListSkeleton — placeholder shimmer rows shown while the agent picker's
|
|
292
|
-
// list is loading, so it doesn't flash from a bare spinner to a full list
|
|
293
|
-
// (same predictable-perceived-perf pattern as FileSkeleton). `rows` controls
|
|
294
|
-
// how many ghost rows render; each mimics a Row's icon+title+meta footprint.
|
|
295
|
-
export function AgentListSkeleton({ rows = 5 } = {}) {
|
|
296
|
-
return h('div', { class: 'ds-agent-list-skeleton', 'aria-hidden': 'true' },
|
|
297
|
-
...Array.from({ length: Math.max(1, rows) }, (_, i) => h('div', { key: 'ags' + i, class: 'ds-agent-row-skeleton' },
|
|
298
|
-
h('span', { class: 'ds-skel ds-skel-icon' }),
|
|
299
|
-
h('span', { class: 'ds-skel ds-skel-title' }),
|
|
300
|
-
h('span', { class: 'ds-skel ds-skel-meta' }))),
|
|
301
|
-
h('span', { key: 'st', class: 'ds-agent-list-skeleton-status', role: 'status', 'aria-live': 'polite' }, 'loading agents…'));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// SessionCard — one running session in the live dashboard. Status dot, agent /
|
|
305
|
-
// model / cwd, elapsed, live counter, last activity, and per-session controls
|
|
306
|
-
// that each act on this session's id independently.
|
|
307
5
|
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
// in the head, and the host wires onView to open it in history instead.
|
|
325
|
-
// `session.title` is the SAME string the conversation rails use, rendered as
|
|
326
|
-
// the card heading so the rail row and its dashboard card share one identity.
|
|
327
|
-
// `session.elapsedMs` (raw ms) is formatted internally via fmtDuration; the
|
|
328
|
-
// pre-formatted `elapsed` string remains as a legacy fallback.
|
|
329
|
-
const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running', stopping: 'stopping' };
|
|
330
|
-
const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live', stopping: 'status-dot-connecting' };
|
|
331
|
-
|
|
332
|
-
export function SessionCard({ session = {}, onStop, onOpen, onView, active = false,
|
|
333
|
-
selectable = false, selected = false, onToggleSelect } = {}) {
|
|
334
|
-
const s = session;
|
|
335
|
-
const st = s.stopping ? 'stopping' : (s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running'));
|
|
336
|
-
// The stat line composes elapsed + live counter; the activity line carries the
|
|
337
|
-
// last-activity time and the current tool so a card shows MOTION, not just a
|
|
338
|
-
// start offset. Both are middot-joined (kept product separator).
|
|
339
|
-
const elapsedText = s.elapsedMs != null ? fmtDuration(s.elapsedMs) : (s.elapsed != null ? s.elapsed : null);
|
|
340
|
-
// At-a-glance cost/usage (the prompt's named command-center signal). Null-safe:
|
|
341
|
-
// sessions with no cost source (external tally rows) simply omit the segment.
|
|
342
|
-
const tokText = s.tokens != null ? (typeof s.tokens === 'number' ? formatNumber(s.tokens) : s.tokens) + ' tok' : null;
|
|
343
|
-
const costText = s.cost != null ? (typeof s.cost === 'number' ? '$' + s.cost.toFixed(4) : String(s.cost)) : null;
|
|
344
|
-
// Cost is rendered as its own emphasized segment (not buried in the mono run)
|
|
345
|
-
// so the command-center cost-at-a-glance signal is scannable.
|
|
346
|
-
const statBits = [elapsedText, s.counter != null ? s.counter : null, tokText].filter((x) => x != null && x !== '');
|
|
347
|
-
const activityBits = [
|
|
348
|
-
s.currentTool ? 'running: ' + s.currentTool : null,
|
|
349
|
-
s.lastActivity ? 'last ' + s.lastActivity : null,
|
|
350
|
-
].filter(Boolean);
|
|
351
|
-
const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + (s.external ? ' is-external' : '') + (s.isNew ? ' is-new' : '');
|
|
352
|
-
// EVERY children array is filter(Boolean)'d: webjsx applyDiff crashes
|
|
353
|
-
// (reading 'key') on a bare null among VElement siblings, so a null cwd /
|
|
354
|
-
// model / external flag must never reach a positional child slot.
|
|
355
|
-
const head = h('div', { class: 'ds-dash-card-head' }, ...[
|
|
356
|
-
selectable ? h('button', {
|
|
357
|
-
type: 'button', class: 'ds-dash-select', role: 'checkbox',
|
|
358
|
-
'aria-checked': selected ? 'true' : 'false',
|
|
359
|
-
'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.title || s.agent || s.sid),
|
|
360
|
-
onclick: () => onToggleSelect && onToggleSelect(s),
|
|
361
|
-
}, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null,
|
|
362
|
-
h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
|
|
363
|
-
h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
|
|
364
|
-
s.external ? h('span', { class: 'ds-dash-external' }, 'external') : null,
|
|
365
|
-
h('span', { class: 'ds-dash-agent', title: s.agent || null }, s.agent || 'agent'),
|
|
366
|
-
s.model ? h('span', { class: 'ds-dash-model', title: s.model }, s.model) : null,
|
|
367
|
-
].filter(Boolean));
|
|
368
|
-
const meta = h('div', { class: 'ds-dash-meta' }, ...[
|
|
369
|
-
s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
|
|
370
|
-
(statBits.length || costText) ? h('span', { class: 'ds-dash-stat' },
|
|
371
|
-
...[
|
|
372
|
-
statBits.length ? statBits.join(' · ') : null,
|
|
373
|
-
(statBits.length && costText) ? ' · ' : null,
|
|
374
|
-
costText ? h('span', { class: 'ds-dash-stat-cost' }, costText) : null,
|
|
375
|
-
].filter(Boolean)
|
|
376
|
-
) : null,
|
|
377
|
-
activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null,
|
|
378
|
-
].filter(Boolean));
|
|
379
|
-
const actions = h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' }, ...[
|
|
380
|
-
onOpen ? Btn({ key: 'open', variant: 'primary', 'aria-label': 'open session', onClick: () => onOpen(s),
|
|
381
|
-
children: [Icon('external-link', { size: 14 }), h('span', {}, 'open')] }) : null,
|
|
382
|
-
onView ? Btn({ key: 'view', 'aria-label': s.external ? 'open in history' : 'view events', onClick: () => onView(s),
|
|
383
|
-
children: [Icon('file-text', { size: 14 }), h('span', {}, s.external ? 'history' : 'events')] }) : null,
|
|
384
|
-
(onStop && !s.external) ? Btn({ key: 'stop', variant: 'danger', disabled: !!s.stopping, 'aria-label': 'stop session',
|
|
385
|
-
onClick: () => !s.stopping && onStop(s),
|
|
386
|
-
children: [Icon('square', { size: 14 }), h('span', {}, s.stopping ? 'stopping…' : 'stop')] }) : null,
|
|
387
|
-
].filter(Boolean));
|
|
388
|
-
return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.title || s.agent || s.sid), 'aria-current': active ? 'true' : null },
|
|
389
|
-
...[
|
|
390
|
-
s.title ? h('div', { class: 'ds-dash-title', title: s.title }, s.title) : null,
|
|
391
|
-
head, meta, actions,
|
|
392
|
-
].filter(Boolean));
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
|
|
396
|
-
// sessions : [{ sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }]
|
|
397
|
-
// actions : { onStop, onOpen, onResume, onView } passed to each card
|
|
398
|
-
// onStopAll : OPTIONAL bulk control - stop every running session at once
|
|
399
|
-
// emptyText, offline : explicit states
|
|
400
|
-
// The bulk header is the "manage many at once" affordance: a live count plus a
|
|
401
|
-
// stop-all button, so a user running several agents does not have to hunt each
|
|
402
|
-
// card's stop. Rendered only when there are sessions AND onStopAll is wired.
|
|
403
|
-
// Streamstate words: the live-stream health signal so "connected, zero running"
|
|
404
|
-
// still tells the user the dashboard is listening (vs a dropped stream).
|
|
405
|
-
// One connection vocabulary across the crumb, settings chip, and the dashboard
|
|
406
|
-
// stream line: connected / connecting / offline ('lost' kept as a legacy alias).
|
|
407
|
-
const STREAM_WORD = {
|
|
408
|
-
connected: 'listening for activity',
|
|
409
|
-
connecting: 'connecting to live stream…',
|
|
410
|
-
offline: 'live stream offline — retrying…',
|
|
411
|
-
lost: 'live stream offline — retrying…',
|
|
6
|
+
// This module is a barrel: every component lives in a single-responsibility
|
|
7
|
+
// submodule under ./sessions/, and the public export surface here is unchanged
|
|
8
|
+
// — no consumer import needs to move.
|
|
9
|
+
|
|
10
|
+
import { fmtTime, fmtAgo, fmtDuration } from './sessions/format.js';
|
|
11
|
+
import { ConversationList } from './sessions/conversation-list.js';
|
|
12
|
+
import { SessionMeta, AgentListSkeleton } from './sessions/detail-bits.js';
|
|
13
|
+
import { SessionCard } from './sessions/session-card.js';
|
|
14
|
+
import { SessionDashboard } from './sessions/dashboard.js';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
fmtTime, fmtAgo, fmtDuration,
|
|
18
|
+
ConversationList,
|
|
19
|
+
SessionMeta, AgentListSkeleton,
|
|
20
|
+
SessionCard,
|
|
21
|
+
SessionDashboard,
|
|
412
22
|
};
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* The live multi-session command center ("Live" dashboard).
|
|
416
|
-
*
|
|
417
|
-
* The stop-all / stop-selected danger buttons are two-step (host-driven, the
|
|
418
|
-
* kit is stateless): the first click fires onArmStop* so the host flips
|
|
419
|
-
* confirming* true and re-renders; the armed button reads 'stop N sessions -
|
|
420
|
-
* press again' and only THAT click fires the real onStopAll/onStopSelected.
|
|
421
|
-
* Hosts that wire no onArmStop* keep the old single-click behavior.
|
|
422
|
-
*
|
|
423
|
-
* @param {Object} [props]
|
|
424
|
-
* @param {Array<Object>} [props.sessions=[]] - session shape: `{ sid, realSid, title, agent, model, cwd, elapsedMs, counter, lastActivity, currentTool, status, stopping, external, isNew, cost, tokens }`.
|
|
425
|
-
* @param {Function} [props.onStop] - onStop(session).
|
|
426
|
-
* @param {Function} [props.onOpen] - onOpen(session).
|
|
427
|
-
* @param {Function} [props.onView] - onView(session).
|
|
428
|
-
* @param {Function} [props.onStopAll]
|
|
429
|
-
* @param {Function} [props.onStopSelected]
|
|
430
|
-
* @param {boolean} [props.confirmingStopAll=false]
|
|
431
|
-
* @param {boolean} [props.confirmingStopSelected=false]
|
|
432
|
-
* @param {'connected'|'connecting'|'lost'|'offline'} [props.streamState]
|
|
433
|
-
* @returns {*} webjsx vnode
|
|
434
|
-
*/
|
|
435
|
-
export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStopAll, onStopSelected,
|
|
436
|
-
confirmingStopAll = false, confirmingStopSelected = false,
|
|
437
|
-
onArmStopAll, onArmStopSelected,
|
|
438
|
-
sort, filter, errorsOnly = false, onErrorsOnly,
|
|
439
|
-
selectable = false, selected, onToggleSelect, onSelectAll, onClearSelection,
|
|
440
|
-
activeSid, streamState,
|
|
441
|
-
emptyText = 'No live sessions', emptyAction, offline = false } = {}) {
|
|
442
|
-
if (offline) {
|
|
443
|
-
return h('div', { class: 'ds-dash-state ds-dash-state-error', role: 'status' }, 'Backend offline — live sessions unavailable');
|
|
444
|
-
}
|
|
445
|
-
const selSet = selected instanceof Set ? selected : new Set(selected || []);
|
|
446
|
-
const selCount = selSet.size;
|
|
447
|
-
// While any session is mid-cancel the bulk control reads disabled
|
|
448
|
-
// 'stopping N…' so a bulk stop visibly takes instead of staying re-firable.
|
|
449
|
-
const stoppingCount = sessions.filter((s) => s.stopping).length;
|
|
450
|
-
// The stream-state line always renders (even with zero sessions) so a
|
|
451
|
-
// connected-but-idle dashboard reads differently from an offline one.
|
|
452
|
-
// The stream line leads with a status disc so a connected dashboard visibly
|
|
453
|
-
// PULSES that it is listening (the command-center heartbeat), connecting/offline
|
|
454
|
-
// show a static disc. The disc is aria-hidden; the word carries the state.
|
|
455
|
-
const streamDisc = streamState
|
|
456
|
-
? 'status-dot-disc ' + (streamState === 'connected' ? 'status-dot-live'
|
|
457
|
-
: streamState === 'connecting' ? 'status-dot-connecting' : 'status-dot-error')
|
|
458
|
-
: null;
|
|
459
|
-
const streamLine = streamState
|
|
460
|
-
? h('span', { key: 'stream', class: 'ds-dash-stream-disc' },
|
|
461
|
-
h('span', { class: streamDisc, 'aria-hidden': 'true' }),
|
|
462
|
-
h('span', { class: 'ds-dash-stream is-' + streamState, role: 'status', 'aria-live': 'polite' }, STREAM_WORD[streamState] || streamState))
|
|
463
|
-
: null;
|
|
464
|
-
// At-a-glance status breakdown for the command-center header.
|
|
465
|
-
const counts = sessions.reduce((a, s) => {
|
|
466
|
-
const k = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'idle' : 'running');
|
|
467
|
-
a[k] = (a[k] || 0) + 1; return a;
|
|
468
|
-
}, {});
|
|
469
|
-
const breakdownSegs = [
|
|
470
|
-
counts.running ? { k: 'running', t: counts.running + ' running' } : null,
|
|
471
|
-
counts.idle ? { k: 'idle', t: counts.idle + ' idle' } : null,
|
|
472
|
-
counts.error ? { k: 'error', t: counts.error + ' error' + (counts.error === 1 ? '' : 's') } : null,
|
|
473
|
-
].filter(Boolean);
|
|
474
|
-
const breakdown = breakdownSegs.length
|
|
475
|
-
? h('span', { key: 'bd', class: 'ds-dash-breakdown', role: 'status', 'aria-live': 'polite' },
|
|
476
|
-
...breakdownSegs.flatMap((seg, i) => [
|
|
477
|
-
i ? h('span', { key: 'bsep' + i, class: 'ds-dash-breakdown-sep', 'aria-hidden': 'true' }, ' · ') : null,
|
|
478
|
-
h('span', { key: 'bseg' + i, class: 'seg is-' + seg.k }, seg.t),
|
|
479
|
-
].filter(Boolean)))
|
|
480
|
-
: null;
|
|
481
|
-
const toolbar = (sort || filter || onErrorsOnly)
|
|
482
|
-
? h('div', { key: 'tb', class: 'ds-dash-toolbar', role: 'group', 'aria-label': 'sort and filter sessions' },
|
|
483
|
-
filter ? SearchInput({
|
|
484
|
-
key: 'filt', value: filter.value || '', label: filter.placeholder || 'Filter sessions', placeholder: filter.placeholder || 'Filter sessions',
|
|
485
|
-
onInput: (v) => filter.onInput && filter.onInput(v),
|
|
486
|
-
// `sessions` here is already the filtered/errors-only list, so its
|
|
487
|
-
// length IS the live result count - forward it to SearchInput's
|
|
488
|
-
// aria-live region whenever a filter is actually active, matching
|
|
489
|
-
// the same wiring ConversationList/history search already has.
|
|
490
|
-
resultCount: filter.value ? (sessions.length + ' result' + (sessions.length === 1 ? '' : 's')) : undefined,
|
|
491
|
-
}) : null,
|
|
492
|
-
sort ? Select({ key: 'sort', value: sort.value || 'status', title: 'Sort sessions',
|
|
493
|
-
options: [
|
|
494
|
-
{ value: 'status', label: 'sort: status' },
|
|
495
|
-
{ value: 'elapsed', label: 'sort: elapsed' },
|
|
496
|
-
{ value: 'activity', label: 'sort: last activity' },
|
|
497
|
-
{ value: 'errors', label: 'sort: errors first' },
|
|
498
|
-
], onChange: (v) => sort.onChange && sort.onChange(v) }) : null,
|
|
499
|
-
onErrorsOnly ? h('button', { key: 'eo', type: 'button', class: 'ds-dash-errors-toggle' + (errorsOnly ? ' active' : ''),
|
|
500
|
-
'aria-pressed': errorsOnly ? 'true' : 'false', onclick: () => onErrorsOnly(!errorsOnly) }, 'errors only') : null)
|
|
501
|
-
: null;
|
|
502
|
-
// NOTE: no separate empty-branch return. The empty state renders as a KEYED
|
|
503
|
-
// child of the same stable body wrapper the populated states use - swapping
|
|
504
|
-
// an unkeyed .ds-dash-state for keyed group children used to crash webjsx
|
|
505
|
-
// applyDiff (reading 'key') the moment the first session appeared, leaving a
|
|
506
|
-
// half-applied DOM ('1 running' header over 'No live sessions' body).
|
|
507
|
-
// Tri-state select-all over the selectable (non-external) sessions.
|
|
508
|
-
const selectableSids = sessions.filter((s) => !s.external).map((s) => s.sid);
|
|
509
|
-
const selOfVisible = selectableSids.filter((sid) => selSet.has(sid)).length;
|
|
510
|
-
const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableSids.length ? 'true' : 'mixed');
|
|
511
|
-
const selectAllCtl = (selectable && onSelectAll && selectableSids.length)
|
|
512
|
-
? h('button', { key: 'selall', type: 'button', class: 'ds-dash-selectall', role: 'checkbox',
|
|
513
|
-
'aria-checked': allState, 'aria-label': allState === 'true' ? 'clear selection' : 'select all sessions',
|
|
514
|
-
onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableSids) },
|
|
515
|
-
h('span', { class: 'ds-check-box', 'aria-hidden': 'true' }),
|
|
516
|
-
h('span', {}, 'all'))
|
|
517
|
-
: null;
|
|
518
|
-
const clearCtl = (selectable && selCount && onClearSelection)
|
|
519
|
-
? h('button', { key: 'selclr', type: 'button', class: 'ds-dash-clear', onclick: () => onClearSelection() }, 'clear')
|
|
520
|
-
: null;
|
|
521
|
-
const stopBtn = stoppingCount > 0 && (onStopSelected || onStopAll)
|
|
522
|
-
? Btn({ key: 'stopbusy', variant: 'danger', disabled: true, children: 'stopping ' + stoppingCount + '…' })
|
|
523
|
-
: (selectable && selCount && onStopSelected
|
|
524
|
-
? (onArmStopSelected && !confirmingStopSelected
|
|
525
|
-
? Btn({ key: 'stopsel', variant: 'danger', onClick: () => onArmStopSelected([...selSet]), children: 'stop selected' })
|
|
526
|
-
: Btn({ key: 'stopsel', variant: 'danger', class: confirmingStopSelected ? 'is-armed' : null, onClick: () => onStopSelected([...selSet]),
|
|
527
|
-
children: confirmingStopSelected ? 'stop ' + selCount + ' sessions - press again' : 'stop selected' }))
|
|
528
|
-
: (onStopAll
|
|
529
|
-
? (onArmStopAll && !confirmingStopAll
|
|
530
|
-
? Btn({ key: 'stopall', variant: 'danger', onClick: () => onArmStopAll(sessions), children: 'stop all' })
|
|
531
|
-
: Btn({ key: 'stopall', variant: 'danger', class: confirmingStopAll ? 'is-armed' : null, onClick: () => onStopAll(sessions),
|
|
532
|
-
children: confirmingStopAll ? 'stop ' + sessions.length + ' sessions - press again' : 'stop all' }))
|
|
533
|
-
: null));
|
|
534
|
-
// Build header children as a filtered array: webjsx applyDiff crashes
|
|
535
|
-
// (reading 'key') when a bare null sits among keyed siblings, so never pass
|
|
536
|
-
// a conditional child positionally - filter it out first.
|
|
537
|
-
const headerKids = [
|
|
538
|
-
selectable && selCount
|
|
539
|
-
? h('span', { key: 'cnt', class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' }, selCount + ' selected')
|
|
540
|
-
: (breakdown || h('span', { key: 'cnt', class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' },
|
|
541
|
-
sessions.length ? sessions.length + ' running' : '0 running')),
|
|
542
|
-
selectAllCtl, clearCtl, streamLine,
|
|
543
|
-
h('span', { key: 'spread', class: 'spread' }),
|
|
544
|
-
// No stop control without a session to stop; the empty dashboard keeps
|
|
545
|
-
// only the count, heartbeat, and (when wired) filter/sort chrome.
|
|
546
|
-
sessions.length ? stopBtn : null,
|
|
547
|
-
toolbar,
|
|
548
|
-
].filter(Boolean);
|
|
549
|
-
const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' }, ...headerKids);
|
|
550
|
-
// Status-bucketed command center: when sorting by status (the default), the
|
|
551
|
-
// grid renders labelled sections (Errored / Running / Idle / External) so a
|
|
552
|
-
// pile of sessions reads as scannable groups. Other sorts collapse to one
|
|
553
|
-
// flat grid (the sort already orders them).
|
|
554
|
-
const grouped = !sort || !sort.value || sort.value === 'status';
|
|
555
|
-
const cardOf = (s) => h('div', { key: s.sid, role: 'listitem' },
|
|
556
|
-
SessionCard({ session: s, onStop, onOpen, onView, active: s.sid === activeSid,
|
|
557
|
-
selectable, selected: selSet.has(s.sid), onToggleSelect }));
|
|
558
|
-
// ONE stable body wrapper across every state (empty / grouped / flat), with
|
|
559
|
-
// KEYED children - the ConversationList stable-keyed-body rule. Diffing
|
|
560
|
-
// happens on the children, never by swapping the container's shape.
|
|
561
|
-
let bodyKids;
|
|
562
|
-
if (!sessions.length) {
|
|
563
|
-
bodyKids = [h('div', { key: 'empty', class: 'ds-dash-state', role: 'status' },
|
|
564
|
-
...[
|
|
565
|
-
h('span', { key: 'et' }, emptyText),
|
|
566
|
-
(emptyAction && emptyAction.onClick)
|
|
567
|
-
? Btn({ key: 'ea', onClick: emptyAction.onClick, children: emptyAction.label || 'start a chat' })
|
|
568
|
-
: null,
|
|
569
|
-
].filter(Boolean))];
|
|
570
|
-
} else if (grouped) {
|
|
571
|
-
const buckets = [
|
|
572
|
-
{ key: 'error', label: 'Errored', rows: sessions.filter((s) => !s.external && s.status === 'error') },
|
|
573
|
-
{ key: 'running', label: 'Running', rows: sessions.filter((s) => !s.external && s.status !== 'error' && s.status !== 'stale') },
|
|
574
|
-
{ key: 'idle', label: 'Idle', rows: sessions.filter((s) => !s.external && s.status === 'stale') },
|
|
575
|
-
{ key: 'external', label: 'External', rows: sessions.filter((s) => s.external) },
|
|
576
|
-
].filter((b) => b.rows.length);
|
|
577
|
-
bodyKids = buckets.map((b) => h('div', { key: 'grp' + b.key, class: 'ds-dash-group', role: 'group', 'aria-label': b.label + ' sessions' },
|
|
578
|
-
h('div', { key: 'gl', class: 'ds-dash-group-label' }, b.label + ' · ' + b.rows.length),
|
|
579
|
-
h('div', { key: 'gg', class: 'ds-dash-grid', role: 'list', 'aria-label': b.label + ' sessions' }, ...b.rows.map(cardOf))));
|
|
580
|
-
} else {
|
|
581
|
-
bodyKids = [h('div', { key: 'flat', class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' }, ...sessions.map(cardOf))];
|
|
582
|
-
}
|
|
583
|
-
const body = h('div', { key: 'body', class: 'ds-dash-groups' }, ...bodyKids);
|
|
584
|
-
return h('div', { class: 'ds-dash' }, header, body);
|
|
585
|
-
}
|