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
@@ -0,0 +1,202 @@
1
+ // SessionDashboard — the live multi-session command center: stream-health
2
+ // heartbeat, status breakdown, sort/filter/errors-only toolbar, tri-state
3
+ // select-all with arm-then-confirm bulk stop, and a status-bucketed grid of
4
+ // SessionCards.
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ import { Btn } from '../shell.js';
8
+ import { Select, SearchInput } from '../content.js';
9
+ import { SessionCard } from './session-card.js';
10
+ const h = webjsx.createElement;
11
+
12
+ // SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
13
+ // sessions : [{ sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }]
14
+ // actions : { onStop, onOpen, onResume, onView } passed to each card
15
+ // onStopAll : OPTIONAL bulk control - stop every running session at once
16
+ // emptyText, offline : explicit states
17
+ // The bulk header is the "manage many at once" affordance: a live count plus a
18
+ // stop-all button, so a user running several agents does not have to hunt each
19
+ // card's stop. Rendered only when there are sessions AND onStopAll is wired.
20
+ // Streamstate words: the live-stream health signal so "connected, zero running"
21
+ // still tells the user the dashboard is listening (vs a dropped stream).
22
+ // One connection vocabulary across the crumb, settings chip, and the dashboard
23
+ // stream line: connected / connecting / offline ('lost' kept as a legacy alias).
24
+ const STREAM_WORD = {
25
+ connected: 'listening for activity',
26
+ connecting: 'connecting to live stream…',
27
+ offline: 'live stream offline — retrying…',
28
+ lost: 'live stream offline — retrying…',
29
+ };
30
+
31
+ /**
32
+ * The live multi-session command center ("Live" dashboard).
33
+ *
34
+ * The stop-all / stop-selected danger buttons are two-step (host-driven, the
35
+ * kit is stateless): the first click fires onArmStop* so the host flips
36
+ * confirming* true and re-renders; the armed button reads 'stop N sessions -
37
+ * press again' and only THAT click fires the real onStopAll/onStopSelected.
38
+ * Hosts that wire no onArmStop* keep the old single-click behavior.
39
+ *
40
+ * @param {Object} [props]
41
+ * @param {Array<Object>} [props.sessions=[]] - session shape: `{ sid, realSid, title, agent, model, cwd, elapsedMs, counter, lastActivity, currentTool, status, stopping, external, isNew, cost, tokens }`.
42
+ * @param {Function} [props.onStop] - onStop(session).
43
+ * @param {Function} [props.onOpen] - onOpen(session).
44
+ * @param {Function} [props.onView] - onView(session).
45
+ * @param {Function} [props.onStopAll]
46
+ * @param {Function} [props.onStopSelected]
47
+ * @param {boolean} [props.confirmingStopAll=false]
48
+ * @param {boolean} [props.confirmingStopSelected=false]
49
+ * @param {'connected'|'connecting'|'lost'|'offline'} [props.streamState]
50
+ * @returns {*} webjsx vnode
51
+ */
52
+ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStopAll, onStopSelected,
53
+ confirmingStopAll = false, confirmingStopSelected = false,
54
+ onArmStopAll, onArmStopSelected,
55
+ sort, filter, errorsOnly = false, onErrorsOnly,
56
+ selectable = false, selected, onToggleSelect, onSelectAll, onClearSelection,
57
+ activeSid, streamState,
58
+ emptyText = 'No live sessions', emptyAction, offline = false } = {}) {
59
+ if (offline) {
60
+ return h('div', { class: 'ds-dash-state ds-dash-state-error', role: 'status' }, 'Backend offline — live sessions unavailable');
61
+ }
62
+ const selSet = selected instanceof Set ? selected : new Set(selected || []);
63
+ const selCount = selSet.size;
64
+ // While any session is mid-cancel the bulk control reads disabled
65
+ // 'stopping N…' so a bulk stop visibly takes instead of staying re-firable.
66
+ const stoppingCount = sessions.filter((s) => s.stopping).length;
67
+ // The stream-state line always renders (even with zero sessions) so a
68
+ // connected-but-idle dashboard reads differently from an offline one.
69
+ // The stream line leads with a status disc so a connected dashboard visibly
70
+ // PULSES that it is listening (the command-center heartbeat), connecting/offline
71
+ // show a static disc. The disc is aria-hidden; the word carries the state.
72
+ const streamDisc = streamState
73
+ ? 'status-dot-disc ' + (streamState === 'connected' ? 'status-dot-live'
74
+ : streamState === 'connecting' ? 'status-dot-connecting' : 'status-dot-error')
75
+ : null;
76
+ const streamLine = streamState
77
+ ? h('span', { key: 'stream', class: 'ds-dash-stream-disc' },
78
+ h('span', { class: streamDisc, 'aria-hidden': 'true' }),
79
+ h('span', { class: 'ds-dash-stream is-' + streamState, role: 'status', 'aria-live': 'polite' }, STREAM_WORD[streamState] || streamState))
80
+ : null;
81
+ // At-a-glance status breakdown for the command-center header.
82
+ const counts = sessions.reduce((a, s) => {
83
+ const k = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'idle' : 'running');
84
+ a[k] = (a[k] || 0) + 1; return a;
85
+ }, {});
86
+ const breakdownSegs = [
87
+ counts.running ? { k: 'running', t: counts.running + ' running' } : null,
88
+ counts.idle ? { k: 'idle', t: counts.idle + ' idle' } : null,
89
+ counts.error ? { k: 'error', t: counts.error + ' error' + (counts.error === 1 ? '' : 's') } : null,
90
+ ].filter(Boolean);
91
+ const breakdown = breakdownSegs.length
92
+ ? h('span', { key: 'bd', class: 'ds-dash-breakdown', role: 'status', 'aria-live': 'polite' },
93
+ ...breakdownSegs.flatMap((seg, i) => [
94
+ i ? h('span', { key: 'bsep' + i, class: 'ds-dash-breakdown-sep', 'aria-hidden': 'true' }, ' · ') : null,
95
+ h('span', { key: 'bseg' + i, class: 'seg is-' + seg.k }, seg.t),
96
+ ].filter(Boolean)))
97
+ : null;
98
+ const toolbar = (sort || filter || onErrorsOnly)
99
+ ? h('div', { key: 'tb', class: 'ds-dash-toolbar', role: 'group', 'aria-label': 'sort and filter sessions' },
100
+ filter ? SearchInput({
101
+ key: 'filt', value: filter.value || '', label: filter.placeholder || 'Filter sessions', placeholder: filter.placeholder || 'Filter sessions',
102
+ onInput: (v) => filter.onInput && filter.onInput(v),
103
+ // `sessions` here is already the filtered/errors-only list, so its
104
+ // length IS the live result count - forward it to SearchInput's
105
+ // aria-live region whenever a filter is actually active, matching
106
+ // the same wiring ConversationList/history search already has.
107
+ resultCount: filter.value ? (sessions.length + ' result' + (sessions.length === 1 ? '' : 's')) : undefined,
108
+ }) : null,
109
+ sort ? Select({ key: 'sort', value: sort.value || 'status', title: 'Sort sessions',
110
+ options: [
111
+ { value: 'status', label: 'sort: status' },
112
+ { value: 'elapsed', label: 'sort: elapsed' },
113
+ { value: 'activity', label: 'sort: last activity' },
114
+ { value: 'errors', label: 'sort: errors first' },
115
+ ], onChange: (v) => sort.onChange && sort.onChange(v) }) : null,
116
+ onErrorsOnly ? h('button', { key: 'eo', type: 'button', class: 'ds-dash-errors-toggle' + (errorsOnly ? ' active' : ''),
117
+ 'aria-pressed': errorsOnly ? 'true' : 'false', onclick: () => onErrorsOnly(!errorsOnly) }, 'errors only') : null)
118
+ : null;
119
+ // NOTE: no separate empty-branch return. The empty state renders as a KEYED
120
+ // child of the same stable body wrapper the populated states use - swapping
121
+ // an unkeyed .ds-dash-state for keyed group children used to crash webjsx
122
+ // applyDiff (reading 'key') the moment the first session appeared, leaving a
123
+ // half-applied DOM ('1 running' header over 'No live sessions' body).
124
+ // Tri-state select-all over the selectable (non-external) sessions.
125
+ const selectableSids = sessions.filter((s) => !s.external).map((s) => s.sid);
126
+ const selOfVisible = selectableSids.filter((sid) => selSet.has(sid)).length;
127
+ const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableSids.length ? 'true' : 'mixed');
128
+ const selectAllCtl = (selectable && onSelectAll && selectableSids.length)
129
+ ? h('button', { key: 'selall', type: 'button', class: 'ds-dash-selectall', role: 'checkbox',
130
+ 'aria-checked': allState, 'aria-label': allState === 'true' ? 'clear selection' : 'select all sessions',
131
+ onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableSids) },
132
+ h('span', { class: 'ds-check-box', 'aria-hidden': 'true' }),
133
+ h('span', {}, 'all'))
134
+ : null;
135
+ const clearCtl = (selectable && selCount && onClearSelection)
136
+ ? h('button', { key: 'selclr', type: 'button', class: 'ds-dash-clear', onclick: () => onClearSelection() }, 'clear')
137
+ : null;
138
+ const stopBtn = stoppingCount > 0 && (onStopSelected || onStopAll)
139
+ ? Btn({ key: 'stopbusy', variant: 'danger', disabled: true, children: 'stopping ' + stoppingCount + '…' })
140
+ : (selectable && selCount && onStopSelected
141
+ ? (onArmStopSelected && !confirmingStopSelected
142
+ ? Btn({ key: 'stopsel', variant: 'danger', onClick: () => onArmStopSelected([...selSet]), children: 'stop selected' })
143
+ : Btn({ key: 'stopsel', variant: 'danger', class: confirmingStopSelected ? 'is-armed' : null, onClick: () => onStopSelected([...selSet]),
144
+ children: confirmingStopSelected ? 'stop ' + selCount + ' sessions - press again' : 'stop selected' }))
145
+ : (onStopAll
146
+ ? (onArmStopAll && !confirmingStopAll
147
+ ? Btn({ key: 'stopall', variant: 'danger', onClick: () => onArmStopAll(sessions), children: 'stop all' })
148
+ : Btn({ key: 'stopall', variant: 'danger', class: confirmingStopAll ? 'is-armed' : null, onClick: () => onStopAll(sessions),
149
+ children: confirmingStopAll ? 'stop ' + sessions.length + ' sessions - press again' : 'stop all' }))
150
+ : null));
151
+ // Build header children as a filtered array: webjsx applyDiff crashes
152
+ // (reading 'key') when a bare null sits among keyed siblings, so never pass
153
+ // a conditional child positionally - filter it out first.
154
+ const headerKids = [
155
+ selectable && selCount
156
+ ? h('span', { key: 'cnt', class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' }, selCount + ' selected')
157
+ : (breakdown || h('span', { key: 'cnt', class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' },
158
+ sessions.length ? sessions.length + ' running' : '0 running')),
159
+ selectAllCtl, clearCtl, streamLine,
160
+ h('span', { key: 'spread', class: 'spread' }),
161
+ // No stop control without a session to stop; the empty dashboard keeps
162
+ // only the count, heartbeat, and (when wired) filter/sort chrome.
163
+ sessions.length ? stopBtn : null,
164
+ toolbar,
165
+ ].filter(Boolean);
166
+ const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' }, ...headerKids);
167
+ // Status-bucketed command center: when sorting by status (the default), the
168
+ // grid renders labelled sections (Errored / Running / Idle / External) so a
169
+ // pile of sessions reads as scannable groups. Other sorts collapse to one
170
+ // flat grid (the sort already orders them).
171
+ const grouped = !sort || !sort.value || sort.value === 'status';
172
+ const cardOf = (s) => h('div', { key: s.sid, role: 'listitem' },
173
+ SessionCard({ session: s, onStop, onOpen, onView, active: s.sid === activeSid,
174
+ selectable, selected: selSet.has(s.sid), onToggleSelect }));
175
+ // ONE stable body wrapper across every state (empty / grouped / flat), with
176
+ // KEYED children - the ConversationList stable-keyed-body rule. Diffing
177
+ // happens on the children, never by swapping the container's shape.
178
+ let bodyKids;
179
+ if (!sessions.length) {
180
+ bodyKids = [h('div', { key: 'empty', class: 'ds-dash-state', role: 'status' },
181
+ ...[
182
+ h('span', { key: 'et' }, emptyText),
183
+ (emptyAction && emptyAction.onClick)
184
+ ? Btn({ key: 'ea', onClick: emptyAction.onClick, children: emptyAction.label || 'start a chat' })
185
+ : null,
186
+ ].filter(Boolean))];
187
+ } else if (grouped) {
188
+ const buckets = [
189
+ { key: 'error', label: 'Errored', rows: sessions.filter((s) => !s.external && s.status === 'error') },
190
+ { key: 'running', label: 'Running', rows: sessions.filter((s) => !s.external && s.status !== 'error' && s.status !== 'stale') },
191
+ { key: 'idle', label: 'Idle', rows: sessions.filter((s) => !s.external && s.status === 'stale') },
192
+ { key: 'external', label: 'External', rows: sessions.filter((s) => s.external) },
193
+ ].filter((b) => b.rows.length);
194
+ bodyKids = buckets.map((b) => h('div', { key: 'grp' + b.key, class: 'ds-dash-group', role: 'group', 'aria-label': b.label + ' sessions' },
195
+ h('div', { key: 'gl', class: 'ds-dash-group-label' }, b.label + ' · ' + b.rows.length),
196
+ h('div', { key: 'gg', class: 'ds-dash-grid', role: 'list', 'aria-label': b.label + ' sessions' }, ...b.rows.map(cardOf))));
197
+ } else {
198
+ bodyKids = [h('div', { key: 'flat', class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' }, ...sessions.map(cardOf))];
199
+ }
200
+ const body = h('div', { key: 'body', class: 'ds-dash-groups' }, ...bodyKids);
201
+ return h('div', { class: 'ds-dash' }, header, body);
202
+ }
@@ -0,0 +1,49 @@
1
+ // Small session-detail chrome: the middot-separated metadata strip shown on a
2
+ // session detail surface, and the agent-picker loading skeleton.
3
+
4
+ import * as webjsx from '../../../vendor/webjsx/index.js';
5
+ const h = webjsx.createElement;
6
+
7
+ // SessionMeta — a middot-separated metadata strip for a session detail surface.
8
+ // items : [{ label, value, title, onCopy }]
9
+ // Each item is a span (label dimmed, value mono) with an optional per-item copy
10
+ // button; the strip flex-wraps at narrow widths. Class is .ds-session-meta-strip
11
+ // (the bare .ds-session-meta is already taken by ConversationList row meta).
12
+ export function SessionMeta({ items = [] } = {}) {
13
+ if (!items.length) return null;
14
+ return h('div', { class: 'ds-session-meta-strip', role: 'group', 'aria-label': 'session metadata' },
15
+ ...items.map((it, i) => h('span', {
16
+ key: 'sm-' + (it.label != null ? it.label : i),
17
+ class: 'ds-session-meta-item',
18
+ title: it.title || null,
19
+ },
20
+ [
21
+ it.label != null ? h('span', { key: 'l', class: 'ds-session-meta-label' }, it.label) : null,
22
+ h('span', { key: 'v', class: 'ds-session-meta-value' }, it.value != null ? String(it.value) : ''),
23
+ it.onCopy ? h('button', {
24
+ key: 'c', type: 'button', class: 'ds-session-meta-copy',
25
+ 'aria-label': 'copy ' + (it.title || it.label || 'value'),
26
+ onclick: () => it.onCopy(it.value),
27
+ }, 'copy') : null,
28
+ // Generic secondary action (e.g. a directory row's "use as chat cwd")
29
+ // - kept distinct from onCopy since a fact can want a non-copy action,
30
+ // or (rare) both.
31
+ it.onAction ? h('button', {
32
+ key: 'a', type: 'button', class: 'ds-session-meta-action',
33
+ onclick: () => it.onAction(it.value),
34
+ }, it.actionLabel || 'use') : null,
35
+ ].filter(Boolean))));
36
+ }
37
+
38
+ // AgentListSkeleton — placeholder shimmer rows shown while the agent picker's
39
+ // list is loading, so it doesn't flash from a bare spinner to a full list
40
+ // (same predictable-perceived-perf pattern as FileSkeleton). `rows` controls
41
+ // how many ghost rows render; each mimics a Row's icon+title+meta footprint.
42
+ export function AgentListSkeleton({ rows = 5 } = {}) {
43
+ return h('div', { class: 'ds-agent-list-skeleton', 'aria-hidden': 'true' },
44
+ ...Array.from({ length: Math.max(1, rows) }, (_, i) => h('div', { key: 'ags' + i, class: 'ds-agent-row-skeleton' },
45
+ h('span', { class: 'ds-skel ds-skel-icon' }),
46
+ h('span', { class: 'ds-skel ds-skel-title' }),
47
+ h('span', { class: 'ds-skel ds-skel-meta' }))),
48
+ h('span', { key: 'st', class: 'ds-agent-list-skeleton-status', role: 'status', 'aria-live': 'polite' }, 'loading agents…'));
49
+ }
@@ -0,0 +1,42 @@
1
+ // ONE time/duration vocabulary for every session surface, plus the shared
2
+ // status word/disc maps that keep a session's "running / idle / error" reading
3
+ // identical on a rail row and on its Live-dashboard card.
4
+
5
+ import { formatDateTime } from '../../locale.js';
6
+
7
+ // ONE duration format for every surface (live cards, running panel, session
8
+ // meta, context pane): <60s -> 'Ns', <1h -> 'Nm Ss', else 'Nh Nm'. Durations
9
+ // roll s -> m -> h instead of an hour-long run reading '3712s'.
10
+ // ONE absolute-time / relative-time formatter for every surface that shows a
11
+ // timestamp (freddie pages, chat transcripts). fmtTime -> localized
12
+ // date+time string; fmtAgo -> coarse relative ('Ns/Nm/Nh/Nd ago').
13
+ export function fmtTime(t) {
14
+ try { return formatDateTime(t); } catch { return String(t || ''); }
15
+ }
16
+ export function fmtAgo(t) {
17
+ if (!t) return '';
18
+ const s = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
19
+ if (s < 60) return s + 's ago';
20
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
21
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
22
+ return Math.floor(s / 86400) + 'd ago';
23
+ }
24
+
25
+ export function fmtDuration(ms) {
26
+ if (ms == null || !isFinite(ms) || ms < 0) return '';
27
+ const s = Math.round(ms / 1000);
28
+ if (s < 60) return s + 's';
29
+ const m = Math.floor(s / 60);
30
+ if (m < 60) return m + 'm ' + (s % 60) + 's';
31
+ const hrs = Math.floor(m / 60);
32
+ return hrs + 'h ' + (m % 60) + 'm';
33
+ }
34
+
35
+ // `status` is one of: 'error' | 'stale' | 'running' | 'stopping'. A 'stale'
36
+ // session is one the host has determined is alive but not making progress (no
37
+ // recent activity, no current tool) — it reads as `idle` with a NON-pulsing
38
+ // disc so a stuck agent is visually distinct from a busy one (a frozen elapsed
39
+ // alone reads identically for both, which is the high-severity oversight gap
40
+ // this closes).
41
+ export const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running', stopping: 'stopping' };
42
+ export const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live', stopping: 'status-dot-connecting' };
@@ -0,0 +1,92 @@
1
+ // SessionCard — one running session on the live dashboard: status disc, agent /
2
+ // model / cwd, elapsed + live counters, cost/tokens, last activity, and the
3
+ // per-session controls that each act on this session's id independently.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Btn, Icon } from '../shell.js';
7
+ import { formatNumber } from '../../locale.js';
8
+ import { fmtDuration, STATUS_WORD, STATUS_DISC } from './format.js';
9
+ const h = webjsx.createElement;
10
+
11
+ // SessionCard — one running session in the live dashboard. Status dot, agent /
12
+ // model / cwd, elapsed, live counter, last activity, and per-session controls
13
+ // that each act on this session's id independently.
14
+ //
15
+ // session : { sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }
16
+ // actions : { onStop, onOpen, onResume, onView } (any subset)
17
+ // `counter` carries the live activity tally (e.g. "12 ev · 3 tools"); `lastActivity`
18
+ // the relative time of the most-recent event ("4s ago"); `currentTool` the tool
19
+ // name a still-running turn is executing - together they distinguish a busy
20
+ // session from a stuck one (a frozen elapsed alone reads identically for both).
21
+ // `session.stopping` is the in-flight cancel state: the stop button disables
22
+ // with label 'stopping…' and the status word flips to 'stopping', so the click
23
+ // visibly took and cannot re-fire while the host waits for the active poll.
24
+ // `session.external` marks a session we observe (ccsniff stream) but do not own
25
+ // (no process to kill): the stop button is suppressed, an 'external' tag renders
26
+ // in the head, and the host wires onView to open it in history instead.
27
+ // `session.title` is the SAME string the conversation rails use, rendered as
28
+ // the card heading so the rail row and its dashboard card share one identity.
29
+ // `session.elapsedMs` (raw ms) is formatted internally via fmtDuration; the
30
+ // pre-formatted `elapsed` string remains as a legacy fallback.
31
+ export function SessionCard({ session = {}, onStop, onOpen, onView, active = false,
32
+ selectable = false, selected = false, onToggleSelect } = {}) {
33
+ const s = session;
34
+ const st = s.stopping ? 'stopping' : (s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running'));
35
+ // The stat line composes elapsed + live counter; the activity line carries the
36
+ // last-activity time and the current tool so a card shows MOTION, not just a
37
+ // start offset. Both are middot-joined (kept product separator).
38
+ const elapsedText = s.elapsedMs != null ? fmtDuration(s.elapsedMs) : (s.elapsed != null ? s.elapsed : null);
39
+ // At-a-glance cost/usage (the prompt's named command-center signal). Null-safe:
40
+ // sessions with no cost source (external tally rows) simply omit the segment.
41
+ const tokText = s.tokens != null ? (typeof s.tokens === 'number' ? formatNumber(s.tokens) : s.tokens) + ' tok' : null;
42
+ const costText = s.cost != null ? (typeof s.cost === 'number' ? '$' + s.cost.toFixed(4) : String(s.cost)) : null;
43
+ // Cost is rendered as its own emphasized segment (not buried in the mono run)
44
+ // so the command-center cost-at-a-glance signal is scannable.
45
+ const statBits = [elapsedText, s.counter != null ? s.counter : null, tokText].filter((x) => x != null && x !== '');
46
+ const activityBits = [
47
+ s.currentTool ? 'running: ' + s.currentTool : null,
48
+ s.lastActivity ? 'last ' + s.lastActivity : null,
49
+ ].filter(Boolean);
50
+ const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + (s.external ? ' is-external' : '') + (s.isNew ? ' is-new' : '');
51
+ // EVERY children array is filter(Boolean)'d: webjsx applyDiff crashes
52
+ // (reading 'key') on a bare null among VElement siblings, so a null cwd /
53
+ // model / external flag must never reach a positional child slot.
54
+ const head = h('div', { class: 'ds-dash-card-head' }, ...[
55
+ selectable ? h('button', {
56
+ type: 'button', class: 'ds-dash-select', role: 'checkbox',
57
+ 'aria-checked': selected ? 'true' : 'false',
58
+ 'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.title || s.agent || s.sid),
59
+ onclick: () => onToggleSelect && onToggleSelect(s),
60
+ }, h('span', { class: 'ds-check-box', 'aria-hidden': 'true' })) : null,
61
+ h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
62
+ h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
63
+ s.external ? h('span', { class: 'ds-dash-external' }, 'external') : null,
64
+ h('span', { class: 'ds-dash-agent', title: s.agent || null }, s.agent || 'agent'),
65
+ s.model ? h('span', { class: 'ds-dash-model', title: s.model }, s.model) : null,
66
+ ].filter(Boolean));
67
+ const meta = h('div', { class: 'ds-dash-meta' }, ...[
68
+ s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
69
+ (statBits.length || costText) ? h('span', { class: 'ds-dash-stat' },
70
+ ...[
71
+ statBits.length ? statBits.join(' · ') : null,
72
+ (statBits.length && costText) ? ' · ' : null,
73
+ costText ? h('span', { class: 'ds-dash-stat-cost' }, costText) : null,
74
+ ].filter(Boolean)
75
+ ) : null,
76
+ activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null,
77
+ ].filter(Boolean));
78
+ const actions = h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' }, ...[
79
+ onOpen ? Btn({ key: 'open', variant: 'primary', 'aria-label': 'open session', onClick: () => onOpen(s),
80
+ children: [Icon('external-link', { size: 14 }), h('span', {}, 'open')] }) : null,
81
+ onView ? Btn({ key: 'view', 'aria-label': s.external ? 'open in history' : 'view events', onClick: () => onView(s),
82
+ children: [Icon('file-text', { size: 14 }), h('span', {}, s.external ? 'history' : 'events')] }) : null,
83
+ (onStop && !s.external) ? Btn({ key: 'stop', variant: 'danger', disabled: !!s.stopping, 'aria-label': 'stop session',
84
+ onClick: () => !s.stopping && onStop(s),
85
+ children: [Icon('square', { size: 14 }), h('span', {}, s.stopping ? 'stopping…' : 'stop')] }) : null,
86
+ ].filter(Boolean));
87
+ return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.title || s.agent || s.sid), 'aria-current': active ? 'true' : null },
88
+ ...[
89
+ s.title ? h('div', { class: 'ds-dash-title', title: s.title }, s.title) : null,
90
+ head, meta, actions,
91
+ ].filter(Boolean));
92
+ }