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.
Files changed (71) hide show
  1. package/dist/247420.css +33 -0
  2. package/dist/247420.js +53 -51
  3. package/package.json +1 -1
  4. package/src/components/agent-chat/controls.js +143 -0
  5. package/src/components/agent-chat/empty-state.js +63 -0
  6. package/src/components/agent-chat/message-rows.js +141 -0
  7. package/src/components/agent-chat/surface.js +206 -0
  8. package/src/components/agent-chat/thread-behaviour.js +50 -0
  9. package/src/components/agent-chat.js +6 -546
  10. package/src/components/chat/composer-affordances.js +109 -0
  11. package/src/components/chat/composer.js +256 -0
  12. package/src/components/chat/threads.js +105 -0
  13. package/src/components/chat-message-parts/agent-nodes.js +103 -0
  14. package/src/components/chat-message-parts/inline.js +106 -0
  15. package/src/components/chat-message-parts/prose-nodes.js +133 -0
  16. package/src/components/chat-message-parts/renderers.js +89 -0
  17. package/src/components/chat-message-parts.js +12 -408
  18. package/src/components/chat-minimap/minimap.js +167 -0
  19. package/src/components/chat-minimap/paint.js +73 -0
  20. package/src/components/chat-minimap/preview.js +41 -0
  21. package/src/components/chat-minimap.js +7 -263
  22. package/src/components/chat.js +20 -628
  23. package/src/components/community/chrome.js +63 -0
  24. package/src/components/community/navigation.js +167 -0
  25. package/src/components/community/presence.js +106 -0
  26. package/src/components/community/shell.js +17 -0
  27. package/src/components/community/views.js +131 -0
  28. package/src/components/community.js +18 -447
  29. package/src/components/files/chrome.js +140 -0
  30. package/src/components/files/entries.js +156 -0
  31. package/src/components/files/grid-controls.js +63 -0
  32. package/src/components/files/grid.js +177 -0
  33. package/src/components/files/types.js +69 -0
  34. package/src/components/files-modals/dialogs.js +118 -0
  35. package/src/components/files-modals/modal-shell.js +153 -0
  36. package/src/components/files-modals/preview-bodies.js +129 -0
  37. package/src/components/files-modals/preview-containers.js +96 -0
  38. package/src/components/files-modals.js +14 -475
  39. package/src/components/files.js +15 -564
  40. package/src/components/freddie/pages-config.js +3 -94
  41. package/src/components/freddie/pages-models.js +97 -0
  42. package/src/components/freddie.js +2 -1
  43. package/src/components/interaction-primitives/mobile.js +25 -0
  44. package/src/components/interaction-primitives/pointer.js +214 -0
  45. package/src/components/interaction-primitives/reorderable.js +47 -0
  46. package/src/components/interaction-primitives/shortcuts.js +119 -0
  47. package/src/components/interaction-primitives.js +16 -388
  48. package/src/components/sessions/conversation-list.js +230 -0
  49. package/src/components/sessions/dashboard.js +202 -0
  50. package/src/components/sessions/detail-bits.js +49 -0
  51. package/src/components/sessions/format.js +42 -0
  52. package/src/components/sessions/session-card.js +92 -0
  53. package/src/components/sessions.js +16 -579
  54. package/src/components/voice/audio-cue.js +39 -0
  55. package/src/components/voice/capture.js +90 -0
  56. package/src/components/voice/playback.js +75 -0
  57. package/src/components/voice/settings-modal.js +104 -0
  58. package/src/components/voice.js +16 -293
  59. package/src/css/app-shell/base.css +23 -0
  60. package/src/css/app-shell/states-interactions.css +10 -0
  61. package/src/kits/os/freddie/chat-protocol.js +32 -0
  62. package/src/kits/os/freddie/chat-transport.js +178 -0
  63. package/src/kits/os/freddie/pages-chat.js +10 -180
  64. package/src/kits/os/shell-chrome.js +163 -0
  65. package/src/kits/os/shell-geometry.js +59 -0
  66. package/src/kits/os/shell.js +178 -335
  67. package/src/page-html/client-script.js +151 -0
  68. package/src/page-html/head-tags.js +59 -0
  69. package/src/page-html/markdown.js +68 -0
  70. package/src/page-html/page-styles.js +44 -0
  71. 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
- // session : { sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }
309
- // actions : { onStop, onOpen, onResume, onView } (any subset)
310
- // `counter` carries the live activity tally (e.g. "12 ev · 3 tools"); `lastActivity`
311
- // the relative time of the most-recent event ("4s ago"); `currentTool` the tool
312
- // name a still-running turn is executing - together they distinguish a busy
313
- // session from a stuck one (a frozen elapsed alone reads identically for both).
314
- // `status` is one of: 'error' | 'stale' | 'running'. A 'stale' session is one
315
- // the host has determined is alive but not making progress (no recent activity,
316
- // no current tool) it reads as `idle` with a NON-pulsing disc so a stuck agent
317
- // is visually distinct from a busy one (a frozen elapsed alone reads identically
318
- // for both, which is the high-severity oversight gap this closes).
319
- // `session.stopping` is the in-flight cancel state: the stop button disables
320
- // with label 'stopping…' and the status word flips to 'stopping', so the click
321
- // visibly took and cannot re-fire while the host waits for the active poll.
322
- // `session.external` marks a session we observe (ccsniff stream) but do not own
323
- // (no process to kill): the stop button is suppressed, an 'external' tag renders
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
- }