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
|
@@ -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
|
+
}
|