anentrypoint-design 0.0.196 → 0.0.198

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.196",
3
+ "version": "0.0.198",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -13,14 +13,44 @@
13
13
  import * as webjsx from '../../vendor/webjsx/index.js';
14
14
  import { ChatComposer, ChatMessage, makeThreadAutoScroll } from './chat.js';
15
15
  import { Select } from './content.js';
16
- import { Btn } from './shell.js';
16
+ import { Btn, Icon } from './shell.js';
17
17
 
18
18
  const h = webjsx.createElement;
19
19
 
20
20
  // Auto-scroll behaviour is the shared chat helper; bind it to this thread's
21
21
  // live message count. (`makeThreadAutoScroll` takes a getter so the observer
22
22
  // always compares against current state, not a value captured at mount.)
23
- const threadRef = (msgCount) => makeThreadAutoScroll(() => msgCount);
23
+ const baseAutoScroll = (msgCount) => makeThreadAutoScroll(() => msgCount);
24
+
25
+ // Compose the auto-scroll ref with a scroll listener that reveals the
26
+ // jump-to-latest button when the user has scrolled away from the bottom. This
27
+ // is the scroll-anchoring fix: auto-scroll only pins when the user is already at
28
+ // the bottom (the IntersectionObserver gate), so reading back-history is no
29
+ // longer fought; the button is the explicit way back to the live edge.
30
+ const NEAR_BOTTOM_PX = 80;
31
+ const threadRef = (msgCount) => {
32
+ const auto = baseAutoScroll(msgCount);
33
+ return (el) => {
34
+ if (!el) return;
35
+ const disposeAuto = auto(el);
36
+ const jumpBtn = () => el.parentElement && el.parentElement.querySelector('.agentchat-jump');
37
+ const update = () => {
38
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
39
+ const btn = jumpBtn();
40
+ if (btn) btn.classList.toggle('show', !atBottom);
41
+ };
42
+ el.addEventListener('scroll', update, { passive: true });
43
+ requestAnimationFrame(update);
44
+ return () => { el.removeEventListener('scroll', update); if (typeof disposeAuto === 'function') disposeAuto(); };
45
+ };
46
+ };
47
+
48
+ // Scroll a thread to its live edge — used by the jump-to-latest button.
49
+ function scrollThreadToBottom(btn) {
50
+ const wrap = btn.closest('.agentchat-thread-wrap');
51
+ const thread = wrap && wrap.querySelector('.agentchat-thread');
52
+ if (thread) thread.scrollTop = thread.scrollHeight;
53
+ }
24
54
 
25
55
  // The agent picker: agent-then-model, not a flat model list. Unavailable agents
26
56
  // are disabled (unless installable via npx). Ordering is the host's concern.
@@ -93,6 +123,7 @@ export function AgentChat(props = {}) {
93
123
  onCwdEdit, onCwdSave, onCwdCancel, onCwdClear, onCwdDraft,
94
124
  canSend = true,
95
125
  suggestions = [], onSuggestionClick,
126
+ onCopyMessage, onRetryMessage, onEditMessage,
96
127
  } = props;
97
128
 
98
129
  const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
@@ -142,6 +173,19 @@ export function AgentChat(props = {}) {
142
173
  // carry prose, so a parts-driven turn isn't double-rendered.
143
174
  const partsHaveProse = parts.some(p => p.kind === 'md' || p.kind === 'text');
144
175
  if (m.content && !partsHaveProse) parts.unshift({ kind: isAssistant ? 'md' : 'text', text: m.content });
176
+ // The streaming caret rides the live assistant turn once it has body (the
177
+ // empty-shell turn already shows the inline typing dots).
178
+ const streaming = isStreaming && msgHasBody(m);
179
+ // Per-message actions: the host supplies onCopyMessage / onRetryMessage; we
180
+ // build the action row only for SETTLED messages (no actions mid-stream).
181
+ let actions;
182
+ if (!isStreaming && msgHasBody(m)) {
183
+ const built = [];
184
+ if (onCopyMessage) built.push({ label: 'copy', icon: 'page', title: 'copy message', onClick: () => onCopyMessage(m) });
185
+ if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
186
+ if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend', onClick: () => onEditMessage(m) });
187
+ if (built.length) actions = built;
188
+ }
145
189
  return ChatMessage({
146
190
  key: m.id || String(i),
147
191
  who: isAssistant ? 'them' : 'you',
@@ -149,6 +193,8 @@ export function AgentChat(props = {}) {
149
193
  name: isAssistant ? name : 'you',
150
194
  time: m.time || '',
151
195
  typing: emptyStreaming,
196
+ streaming,
197
+ actions,
152
198
  parts: emptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
153
199
  });
154
200
  });
@@ -196,14 +242,21 @@ export function AgentChat(props = {}) {
196
242
  // reconnecting-while-streaming state reads one word everywhere instead of
197
243
  // the head saying "streaming…" while the controls say "reconnecting…".
198
244
  busy ? (status || 'streaming…') : (messages.length ? messages.length + (messages.length === 1 ? ' message' : ' messages') : ''))),
199
- h('div', { class: 'agentchat-thread', ref: threadRef(messages.length), role: 'log', 'aria-label': 'conversation' },
200
- emptyState,
201
- ...rows.filter(Boolean),
202
- showWorkingTail
203
- ? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
204
- h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
205
- h('span', { class: 'agentchat-working-text' }, 'working…'))
206
- : null),
245
+ h('div', { class: 'agentchat-thread-wrap' },
246
+ h('div', { class: 'agentchat-thread', ref: threadRef(messages.length), role: 'log', 'aria-label': 'conversation' },
247
+ emptyState,
248
+ ...rows.filter(Boolean),
249
+ showWorkingTail
250
+ ? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
251
+ h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
252
+ h('span', { class: 'agentchat-working-text' }, 'working…'))
253
+ : null),
254
+ // Jump-to-latest: hidden until the scroll listener adds .show (user scrolled
255
+ // up). Clicking returns to the live edge. Pure-DOM, like the kit's other
256
+ // stateless chrome, so the host needn't thread scroll state through state.
257
+ h('button', { class: 'agentchat-jump', type: 'button', 'aria-label': 'jump to latest', title: 'jump to latest',
258
+ onclick: (e) => scrollThreadToBottom(e.currentTarget) },
259
+ Icon('arrow-down', { size: 16 }), h('span', { class: 'agentchat-jump-label' }, 'latest'))),
207
260
  composer,
208
261
  );
209
262
  }
@@ -223,7 +223,7 @@ function renderPart(p, key) {
223
223
  return node;
224
224
  }
225
225
 
226
- export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name }) {
226
+ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions }) {
227
227
  _stats.messages += 1;
228
228
  // Support legacy 'who' prop, prefer 'role' with mapping:
229
229
  // 'user' -> 'you' (right-aligned, accent bubble)
@@ -247,6 +247,11 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
247
247
  if (typing) bodyNodes = [h('div', { class: 'chat-bubble', key: 'typb' }, h('span', { class: 'chat-typing' }, h('span'), h('span'), h('span')))];
248
248
  else if (parts && parts.length) bodyNodes = parts.map((p, i) => renderPart(p, i));
249
249
  else bodyNodes = [h('div', { class: 'chat-bubble', key: 't' }, ...renderInline(text || ''))];
250
+ // A blinking caret at the stream head: while an assistant turn is streaming
251
+ // AND already shows content (so the inline typing dots have stopped), append
252
+ // a thin caret so the live edge reads as "still writing", not "done". Drawn as
253
+ // a CSS element, not a glyph character.
254
+ if (streaming && !typing) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
250
255
  const reactionRow = reactions && reactions.length
251
256
  ? h('div', { class: 'chat-reactions' },
252
257
  ...reactions.map((r, i) => h('span', { class: 'rxn' + (r.you ? ' you' : ''), key: 'r' + i, 'aria-label': `${r.emoji} reaction (${String(r.count)} ${String(r.count) === '1' ? 'reaction' : 'reactions'})${r.you ? ' - you reacted' : ''}` },
@@ -260,7 +265,19 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
260
265
  if (time) metaItems.push(h('span', { class: 't', key: 'ti' }, time));
261
266
  if (tickNode) metaItems.push(tickNode);
262
267
  const meta = metaItems.length ? h('div', { class: 'chat-meta' }, ...metaItems) : null;
263
- const stack = h('div', { class: 'chat-stack' }, ...bodyNodes, reactionRow, meta);
268
+ // Per-message actions (copy / retry / edit) a hover-revealed control row
269
+ // below the bubble, the way Claude-Desktop surfaces message-level actions.
270
+ // Each action is { label, icon, onClick, title }. Kept icon-only with an
271
+ // accessible name; no decorative glyphs (the Icon set is line-SVG).
272
+ const actionRow = (actions && actions.length)
273
+ ? h('div', { class: 'chat-msg-actions', role: 'group', 'aria-label': 'message actions' },
274
+ ...actions.filter(Boolean).map((a, i) => h('button', {
275
+ key: 'ma' + i, type: 'button', class: 'chat-msg-action',
276
+ title: a.title || a.label, 'aria-label': a.label || a.title,
277
+ onclick: (e) => { e.preventDefault(); a.onClick && a.onClick(e); },
278
+ }, a.icon ? Icon(a.icon, { size: 14 }) : (a.label || ''))))
279
+ : null;
280
+ const stack = h('div', { class: 'chat-stack' }, ...bodyNodes, reactionRow, actionRow, meta);
264
281
  // Centered roles (system/tool/thinking) skip the avatar column entirely so
265
282
  // the bubble owns the full row — the chrome reads as out-of-band signal,
266
283
  // not a participant turn.
@@ -0,0 +1,77 @@
1
+ // ContextPane — a compact right-hand context panel for the chat surface.
2
+ //
3
+ // Surfaces the current conversation's agent, model, working directory, and a
4
+ // live count of running tool calls in the in-flight turn. Built from the kit's
5
+ // Panel + Row primitives so it inherits the design tokens and rail semantics.
6
+ //
7
+ // Usage (consumer wires its own state):
8
+ // ContextPane({ agent, model, cwd, toolCount, onSetCwd })
9
+ //
10
+ // Props:
11
+ // agent : display name of the active agent (string) or falsy for "none"
12
+ // model : model id/name (string) or falsy
13
+ // cwd : the chat working directory (string) or falsy for server default
14
+ // toolCount : number of tool calls running in the current live turn (>=0)
15
+ // usage : OPTIONAL last-turn usage { inputTokens, outputTokens, costUsd, turns, durationMs }
16
+ // session : OPTIONAL session metadata { id, messages, startedAt } shown as a block
17
+ // onSetCwd : optional callback for the "set working directory" affordance
18
+ //
19
+ // No decorative glyphs — words + the kit's Icon SVGs only.
20
+
21
+ import * as webjsx from '../../vendor/webjsx/index.js';
22
+ import { Panel, Row } from './content.js';
23
+ import { Btn } from './shell.js';
24
+
25
+ const h = webjsx.createElement;
26
+
27
+ function fmtTok(n) {
28
+ if (n == null) return null;
29
+ if (n < 1000) return String(n);
30
+ if (n < 1000000) return (n / 1000).toFixed(n < 10000 ? 1 : 0) + 'k';
31
+ return (n / 1000000).toFixed(1) + 'M';
32
+ }
33
+
34
+ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
35
+ const running = Number(toolCount) > 0;
36
+ // Each Panel's children array is all-unkeyed (no key prop on any sibling),
37
+ // so webjsx never sees a mixed keyed/unkeyed array here.
38
+ const panels = [
39
+ Panel({
40
+ title: 'context',
41
+ children: [
42
+ Row({ title: 'agent', meta: agent || 'none' }),
43
+ Row({ title: 'model', meta: model || '—' }),
44
+ Row({
45
+ title: 'working dir',
46
+ sub: cwd || 'server default',
47
+ // Use the rail tone consistently with the GUI-wide semantics:
48
+ // green = active/ok. A default cwd carries no rail (neutral).
49
+ rail: cwd ? 'green' : null,
50
+ }),
51
+ Row({
52
+ title: 'running tools',
53
+ meta: running ? String(toolCount) : 'idle',
54
+ rail: running ? 'purple' : null,
55
+ }),
56
+ ],
57
+ }),
58
+ ];
59
+ // Usage block: surface the last turn's token/cost/turn/duration so the
60
+ // result event is no longer silently dropped.
61
+ if (usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null)) {
62
+ const tokRows = [];
63
+ if (usage.inputTokens != null) tokRows.push(Row({ title: 'input', meta: fmtTok(usage.inputTokens) + ' tok' }));
64
+ if (usage.outputTokens != null) tokRows.push(Row({ title: 'output', meta: fmtTok(usage.outputTokens) + ' tok' }));
65
+ if (usage.costUsd != null) tokRows.push(Row({ title: 'cost', meta: '$' + usage.costUsd.toFixed(4) }));
66
+ if (usage.turns != null) tokRows.push(Row({ title: 'turns', meta: String(usage.turns) }));
67
+ if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: (usage.durationMs / 1000).toFixed(1) + 's' }));
68
+ panels.push(Panel({ title: 'last turn', children: tokRows }));
69
+ }
70
+ return h('div', { class: 'ds-context' },
71
+ ...panels,
72
+ onSetCwd
73
+ ? h('div', { class: 'ds-context-actions' },
74
+ Btn({ onClick: onSetCwd, children: 'set working dir' }))
75
+ : null,
76
+ );
77
+ }
@@ -77,7 +77,49 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
77
77
  );
78
78
  }
79
79
 
80
- export function FileGrid({ files = [], onOpen, onAction, emptyText = 'no files here yet', columns = 'auto' } = {}) {
80
+ // FileSkeleton placeholder shimmer rows shown while a directory loads, so the
81
+ // grid does not flash from a bare spinner to a full list (predictable perceived
82
+ // perf, the file-manager feel). `rows` controls how many ghost rows render.
83
+ export function FileSkeleton({ rows = 8 } = {}) {
84
+ return h('div', { class: 'ds-file-grid ds-file-skeleton', 'aria-hidden': 'true' },
85
+ ...Array.from({ length: Math.max(1, rows) }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-file-row ds-file-row-skeleton' },
86
+ h('span', { class: 'ds-skel ds-skel-icon' }),
87
+ h('span', { class: 'ds-skel ds-skel-title' }),
88
+ h('span', { class: 'ds-skel ds-skel-meta' })))
89
+ );
90
+ }
91
+
92
+ // Sort a file list by a key (name/size/modified/type), dirs-first always so the
93
+ // hierarchy reads top-down regardless of sort. `dir` is 'asc'|'desc'.
94
+ // `modifiedTs` (epoch ms) is used for the modified sort when present, since the
95
+ // `modified` field is a pre-formatted relative string the host passes for display.
96
+ export function sortFiles(files = [], sort = 'name', dir = 'asc') {
97
+ const mul = dir === 'desc' ? -1 : 1;
98
+ const cmp = (a, b) => {
99
+ // Directories always cluster before files; within a cluster, apply the sort.
100
+ const ad = a.type === 'dir' ? 0 : 1, bd = b.type === 'dir' ? 0 : 1;
101
+ if (ad !== bd) return ad - bd;
102
+ let r = 0;
103
+ if (sort === 'size') r = (a.size || 0) - (b.size || 0);
104
+ else if (sort === 'modified') r = (a.modifiedTs || 0) - (b.modifiedTs || 0);
105
+ else if (sort === 'type') r = String(a.type || '').localeCompare(String(b.type || ''));
106
+ else r = String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' });
107
+ return r * mul || String(a.name || '').localeCompare(String(b.name || ''));
108
+ };
109
+ return files.slice().sort(cmp);
110
+ }
111
+
112
+ // FileGrid — the directory listing. Optional in-grid sort + filter make it a
113
+ // real file manager rather than a static dump:
114
+ // sort : { key, dir, onSort(key) } - clickable column headers (name/size/modified)
115
+ // filter : { value, onInput, placeholder } - a quick in-dir name filter
116
+ // onOpen(f) opens a row; onAction(act,f) wires the per-row download/rename/delete.
117
+ // Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
118
+ // row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
119
+ // focus state; the grid tracks it on the DOM via roving tabindex.
120
+ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'no files here yet',
121
+ columns = 'auto', sort, filter, loading = false } = {}) {
122
+ if (loading) return FileSkeleton({});
81
123
  if (!files.length) return EmptyState({ text: emptyText });
82
124
  const gridAttrs = {};
83
125
  if (columns !== 'auto' && columns > 0) {
@@ -89,7 +131,27 @@ export function FileGrid({ files = [], onOpen, onAction, emptyText = 'no files h
89
131
  gap: 'var(--space-3)'
90
132
  };
91
133
  }
92
- return h('div', { class: 'ds-file-grid', ...gridAttrs },
134
+ // Keyboard: roving focus over the .ds-file-open buttons inside the grid.
135
+ const onKeyDown = (e) => {
136
+ const grid = e.currentTarget;
137
+ const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled])'));
138
+ if (!opens.length) return;
139
+ const cur = opens.indexOf(document.activeElement);
140
+ if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
141
+ else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
142
+ else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
143
+ else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
144
+ else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
145
+ };
146
+ const head = sort ? FileSortHeader(sort) : null;
147
+ const filterBar = filter ? h('div', { class: 'ds-file-filter' },
148
+ h('input', {
149
+ class: 'ds-file-filter-input', type: 'search',
150
+ value: filter.value || '', placeholder: filter.placeholder || 'Filter files',
151
+ 'aria-label': filter.placeholder || 'Filter files in this directory',
152
+ oninput: (e) => filter.onInput && filter.onInput(e.target.value),
153
+ })) : null;
154
+ const grid = h('div', { class: 'ds-file-grid', role: 'listbox', 'aria-label': 'files', tabindex: '0', onkeydown: onKeyDown, ...gridAttrs },
93
155
  ...files.map((f, i) => FileRow({
94
156
  key: f.path || f.name + i,
95
157
  name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
@@ -97,6 +159,23 @@ export function FileGrid({ files = [], onOpen, onAction, emptyText = 'no files h
97
159
  onAction: onAction ? (act) => onAction(act, f) : null
98
160
  }))
99
161
  );
162
+ return (head || filterBar)
163
+ ? h('div', { class: 'ds-file-listing' }, filterBar, head, grid)
164
+ : grid;
165
+ }
166
+
167
+ // Clickable column headers for FileGrid sort. Active column shows its direction
168
+ // as an ASCII caret word (asc/desc) - never a glyph arrow.
169
+ function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {
170
+ const cols = [['name', 'name'], ['size', 'size'], ['modified', 'modified']];
171
+ return h('div', { class: 'ds-file-sort', role: 'group', 'aria-label': 'sort files' },
172
+ ...cols.map(([k, label]) => h('button', {
173
+ key: k, type: 'button',
174
+ class: 'ds-file-sort-btn' + (active === k ? ' active' : ''),
175
+ 'aria-pressed': active === k ? 'true' : 'false',
176
+ 'aria-label': 'sort by ' + label + (active === k ? ' (' + (dir === 'asc' ? 'ascending' : 'descending') + ')' : ''),
177
+ onclick: () => onSort && onSort(k),
178
+ }, label + (active === k ? ' ' + (dir === 'asc' ? 'asc' : 'desc') : ''))));
100
179
  }
101
180
 
102
181
  export function FileToolbar({ left = [], right = [] } = {}) {
@@ -0,0 +1,147 @@
1
+ // Session surfaces — a persistent conversation list (left-rail "Chats") and a
2
+ // live multi-session dashboard. Pure factories: props in, webjsx vnode out, all
3
+ // interaction via host callbacks. Styling lives in chat.css (.ds-session*,
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
+ const h = webjsx.createElement;
9
+
10
+ // ConversationList — the Claude-Desktop "Chats" column. Sessions grouped by a
11
+ // caller-supplied group label, each row showing title/project, relative time,
12
+ // agent badge, and a running/new-event indicator. Selecting a row switches the
13
+ // active conversation.
14
+ //
15
+ // sessions : [{ sid, title, project, agent, time, running, unread, rail }]
16
+ // selected : the active sid
17
+ // groups : OPTIONAL [{ label, sids:[...] }] to bucket rows; else one flat list
18
+ // search : { value, onInput, placeholder } inline filter (optional)
19
+ // onSelect(session), onNew() : intents
20
+ // emptyText, loading, error : explicit states
21
+ export function ConversationList({ sessions = [], selected, groups, search,
22
+ onSelect, onNew, newLabel = 'New chat',
23
+ emptyText = 'No conversations yet', loading = false, error = null } = {}) {
24
+ const rowFor = (s, i) => h('button', {
25
+ // Stable key: prefer sid, else position - a missing/duplicate sid would make
26
+ // key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
27
+ key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
28
+ type: 'button',
29
+ class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : ''),
30
+ 'aria-current': s.sid === selected ? 'true' : null,
31
+ onclick: () => onSelect && onSelect(s),
32
+ },
33
+ // Positional children must NOT mix keyed VElements with null/strings (webjsx
34
+ // applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
35
+ // each h() call gets a clean, consistent child list.
36
+ h('span', { class: 'ds-session-main' }, [
37
+ h('span', { class: 'ds-session-title' }, s.title || s.project || s.sid || ''),
38
+ (s.project || s.time) ? h('span', { class: 'ds-session-sub' },
39
+ [s.project, s.time].filter(Boolean).join(' · ')) : null,
40
+ ].filter(Boolean)),
41
+ h('span', { class: 'ds-session-meta' }, [
42
+ s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
43
+ s.running
44
+ ? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
45
+ : (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
46
+ ].filter(Boolean)));
47
+
48
+ // The body is ALWAYS a single keyed wrapper element of the same tag, so webjsx
49
+ // diffs its children across state transitions (loading -> empty -> populated)
50
+ // instead of swapping the container type - the swap is what triggered the
51
+ // applyDiff "reading 'key'" crash on the first populated mount. Row children
52
+ // are uniformly keyed; non-row states render a single unkeyed status line.
53
+ let inner;
54
+ if (loading) {
55
+ inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, 'Loading conversations…')];
56
+ } else if (error) {
57
+ inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
58
+ } else if (!sessions.length) {
59
+ inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status' }, emptyText)];
60
+ } else if (groups && groups.length) {
61
+ const bySid = new Map(sessions.map((s) => [s.sid, s]));
62
+ inner = groups.map((g) => h('div', { key: 'g-' + g.label, class: 'ds-session-group', role: 'group', 'aria-label': g.label },
63
+ h('div', { key: 'gl', class: 'ds-session-group-label' }, g.label),
64
+ h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'list' }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).map(rowFor))));
65
+ } else {
66
+ inner = sessions.map(rowFor);
67
+ }
68
+ const body = h('div', { key: 'body', class: 'ds-session-list', role: 'list' }, ...inner);
69
+
70
+ return h('div', { class: 'ds-sessions' },
71
+ h('div', { key: 'head', class: 'ds-session-head' },
72
+ onNew ? h('button', { key: 'new', type: 'button', class: 'ds-session-new', onclick: onNew, 'aria-label': newLabel },
73
+ Icon('pencil'), h('span', { key: 'l' }, newLabel)) : null,
74
+ search ? h('input', {
75
+ key: 'search', type: 'search', class: 'ds-session-search',
76
+ value: search.value || '', placeholder: search.placeholder || 'Search conversations',
77
+ 'aria-label': search.placeholder || 'Search conversations',
78
+ oninput: (e) => search.onInput && search.onInput(e.target.value),
79
+ }) : null),
80
+ body);
81
+ }
82
+
83
+ // SessionCard — one running session in the live dashboard. Status dot, agent /
84
+ // model / cwd, elapsed, live counter, last activity, and per-session controls
85
+ // that each act on this session's id independently.
86
+ //
87
+ // session : { sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }
88
+ // actions : { onStop, onOpen, onResume, onView } (any subset)
89
+ // `counter` carries the live activity tally (e.g. "12 ev · 3 tools"); `lastActivity`
90
+ // the relative time of the most-recent event ("4s ago"); `currentTool` the tool
91
+ // name a still-running turn is executing - together they distinguish a busy
92
+ // session from a stuck one (a frozen elapsed alone reads identically for both).
93
+ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } = {}) {
94
+ const s = session;
95
+ const statusTone = s.status === 'error' ? 'flame' : 'live';
96
+ // The stat line composes elapsed + live counter; the activity line carries the
97
+ // last-activity time and the current tool so a card shows MOTION, not just a
98
+ // start offset. Both are middot-joined (kept product separator).
99
+ const statBits = [s.elapsed != null ? s.elapsed : null, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
100
+ const activityBits = [
101
+ s.currentTool ? 'running: ' + s.currentTool : null,
102
+ s.lastActivity ? 'last ' + s.lastActivity : null,
103
+ ].filter(Boolean);
104
+ return h('div', { class: 'ds-dash-card' + (s.status === 'error' ? ' is-error' : ''), role: 'group', 'aria-label': 'session ' + (s.agent || s.sid) },
105
+ h('div', { class: 'ds-dash-card-head' },
106
+ h('span', { class: 'status-dot-disc ' + (statusTone === 'live' ? 'status-dot-live' : 'status-dot-error'), 'aria-hidden': 'true' }),
107
+ // Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
108
+ // aria-hidden, so the visible/AT status word carries the state.
109
+ h('span', { class: 'ds-dash-status ' + (s.status === 'error' ? 'is-error' : 'is-running') }, s.status === 'error' ? 'error' : 'running'),
110
+ h('span', { class: 'ds-dash-agent' }, s.agent || 'agent'),
111
+ s.model ? h('span', { class: 'ds-dash-model' }, s.model) : null),
112
+ h('div', { class: 'ds-dash-meta' },
113
+ s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
114
+ statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
115
+ activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null),
116
+ h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' },
117
+ onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
118
+ onResume ? Btn({ key: 'resume', onClick: () => onResume(s), children: 'resume' }) : null,
119
+ onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
120
+ onStop ? Btn({ key: 'stop', danger: true, onClick: () => onStop(s), children: 'stop' }) : null));
121
+ }
122
+
123
+ // SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
124
+ // sessions : [{ sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }]
125
+ // actions : { onStop, onOpen, onResume, onView } passed to each card
126
+ // onStopAll : OPTIONAL bulk control - stop every running session at once
127
+ // emptyText, offline : explicit states
128
+ // The bulk header is the "manage many at once" affordance: a live count plus a
129
+ // stop-all button, so a user running several agents does not have to hunt each
130
+ // card's stop. Rendered only when there are sessions AND onStopAll is wired.
131
+ export function SessionDashboard({ sessions = [], onStop, onOpen, onResume, onView, onStopAll,
132
+ emptyText = 'No live sessions', offline = false } = {}) {
133
+ if (offline) {
134
+ return h('div', { class: 'ds-dash-state ds-dash-state-error', role: 'status' }, 'Backend offline — live sessions unavailable');
135
+ }
136
+ if (!sessions.length) {
137
+ return h('div', { class: 'ds-dash-state', role: 'status' }, emptyText);
138
+ }
139
+ const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
140
+ h('span', { class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' },
141
+ sessions.length + ' running'),
142
+ onStopAll ? Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions), children: 'stop all' }) : null);
143
+ const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
144
+ ...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
145
+ SessionCard({ session: s, onStop, onOpen, onResume, onView }))));
146
+ return h('div', { class: 'ds-dash' }, header, grid);
147
+ }