anentrypoint-design 0.0.200 → 0.0.201

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.200",
3
+ "version": "0.0.201",
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",
@@ -55,7 +55,7 @@ function scrollThreadToBottom(btn) {
55
55
  // The agent picker: agent-then-model, not a flat model list. Unavailable agents
56
56
  // are disabled (unless installable via npx). Ordering is the host's concern.
57
57
  function AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading,
58
- onSelectAgent, onSelectModel, onNewChat, onStop }) {
58
+ onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }) {
59
59
  const agentOptions = (agents || []).map((a) => ({
60
60
  value: a.id,
61
61
  label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
@@ -85,6 +85,16 @@ function AgentControls({ agents, selectedAgent, models, selectedModel, busy, sta
85
85
  h('span', { key: 'st', class: 'agentchat-status', role: 'status', 'aria-live': 'polite' },
86
86
  h('span', { class: 'status-dot-disc ' + (busy ? 'status-dot-live' : ''), 'aria-hidden': 'true' }),
87
87
  h('span', {}, status || (busy ? 'streaming…' : 'ready'))),
88
+ // Host-supplied transcript actions (copy-all / export-md / export-json):
89
+ // small text-labeled buttons riding the same controls row. All siblings in
90
+ // this h(...) call are keyed VElements or null — never bare strings.
91
+ ...(exportActions && exportActions.length
92
+ ? exportActions.map((a, i) => h('button', {
93
+ key: 'exp' + i, type: 'button', class: 'agentchat-export-act',
94
+ title: a.title || a.label,
95
+ onclick: () => a.onClick && a.onClick(),
96
+ }, a.label))
97
+ : []),
88
98
  );
89
99
  }
90
100
 
@@ -124,8 +134,10 @@ export function AgentChat(props = {}) {
124
134
  canSend = true,
125
135
  suggestions = [], onSuggestionClick,
126
136
  onCopyMessage, onRetryMessage, onEditMessage,
137
+ confirmEdit = false, onArmEdit,
127
138
  avatar, composerContext,
128
139
  followups = [], onFollowupClick,
140
+ installHint, exportActions = [],
129
141
  } = props;
130
142
 
131
143
  const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
@@ -194,7 +206,10 @@ export function AgentChat(props = {}) {
194
206
  const built = [];
195
207
  if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
196
208
  if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
197
- if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend', onClick: () => onEditMessage(m) });
209
+ // With confirmEdit the host arms its own confirm affordance (onArmEdit)
210
+ // instead of resending immediately; the kit stays stateless either way.
211
+ if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend',
212
+ onClick: () => (confirmEdit && onArmEdit) ? onArmEdit(m) : onEditMessage(m) });
198
213
  if (built.length) actions = built;
199
214
  }
200
215
  return ChatMessage({
@@ -253,12 +268,40 @@ export function AgentChat(props = {}) {
253
268
  key: 'sug' + i, type: 'button', class: 'agentchat-empty-suggestion',
254
269
  onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onSuggestionClick) onSuggestionClick(t); },
255
270
  }, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
271
+ : null,
272
+ // Guided install path for a brand-new user with zero installed agents:
273
+ // a plain copy line, a monospaced command per row (each with its own
274
+ // copy button, pure-DOM label flip like the code-block copy), and a
275
+ // recheck button so the user needn't reload after installing.
276
+ installHint
277
+ ? h('div', { class: 'agentchat-install', role: 'group', 'aria-label': 'install an agent' },
278
+ installHint.text ? h('p', { class: 'agentchat-install-text' }, installHint.text) : null,
279
+ (installHint.commands && installHint.commands.length)
280
+ ? h('ul', { class: 'agentchat-install-list' },
281
+ ...installHint.commands.map((c, i) => h('li', { key: 'inst' + i, class: 'agentchat-install-row' },
282
+ h('span', { class: 'agentchat-install-agent' }, c.agent),
283
+ h('code', { class: 'agentchat-install-cmd' }, c.command),
284
+ h('button', {
285
+ type: 'button', class: 'agentchat-install-copy',
286
+ 'aria-label': 'copy install command for ' + c.agent, title: 'copy command',
287
+ onclick: (e) => {
288
+ const btn = e.currentTarget;
289
+ navigator.clipboard && navigator.clipboard.writeText(c.command);
290
+ btn.textContent = 'copied';
291
+ setTimeout(() => { btn.textContent = 'copy'; }, 1200);
292
+ },
293
+ }, 'copy'))))
294
+ : null,
295
+ installHint.onRecheck
296
+ ? h('div', { class: 'agentchat-install-actions' },
297
+ Btn({ onClick: () => installHint.onRecheck(), children: 'recheck agents', title: 'Re-check installed agents' }))
298
+ : null)
256
299
  : null)
257
300
  : null;
258
301
 
259
302
  return h('div', { class: 'agentchat' },
260
303
  AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading,
261
- onSelectAgent, onSelectModel, onNewChat, onStop }),
304
+ onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }),
262
305
  CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft,
263
306
  onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft }),
264
307
  ...(banners || []).filter(Boolean),
@@ -6,9 +6,9 @@ import * as webjsx from '../../vendor/webjsx/index.js';
6
6
  import { Btn, Heading, Lede, Dot, Icon } from './shell.js';
7
7
  const h = webjsx.createElement;
8
8
 
9
- export function Panel({ title, count, right, style = '', children, kind }) {
9
+ export function Panel({ title, count, right, style = '', children, kind, id }) {
10
10
  const cls = 'panel' + (kind ? ' panel-' + kind : '');
11
- return h('div', { class: cls, style },
11
+ return h('div', { class: cls, style, id: id || null },
12
12
  title != null ? h('div', { class: 'panel-head' },
13
13
  h('span', {}, title),
14
14
  right != null ? right : (count != null ? h('span', {}, String(count)) : null)
@@ -20,7 +20,30 @@ export function Panel({ title, count, right, style = '', children, kind }) {
20
20
  // Card — semantic alias of Panel; behaves identically.
21
21
  export const Card = Panel;
22
22
 
23
- export function Row({ code, rank, title, sub, meta, active, state = 'default', onClick, key, style, href, kind, cols, leading, trailing, target, selected, rail, expanded }) {
23
+ // Split a title string around case-insensitive matches of `highlight`, wrapping
24
+ // hits in <mark class="ds-hl">. Every segment is a keyed span so the children
25
+ // array never mixes keyed VElements with bare strings (webjsx applyDiff crashes
26
+ // on mixed keying).
27
+ function highlightTitle(title, highlight) {
28
+ const text = String(title);
29
+ const needle = String(highlight).toLowerCase();
30
+ if (!needle) return text;
31
+ const lower = text.toLowerCase();
32
+ const segs = [];
33
+ let pos = 0, n = 0;
34
+ while (pos <= text.length) {
35
+ const hit = lower.indexOf(needle, pos);
36
+ if (hit === -1) break;
37
+ if (hit > pos) segs.push(h('span', { key: 'hs' + n++ }, text.slice(pos, hit)));
38
+ segs.push(h('mark', { key: 'hs' + n++, class: 'ds-hl' }, text.slice(hit, hit + needle.length)));
39
+ pos = hit + needle.length;
40
+ }
41
+ if (!segs.length) return text;
42
+ if (pos < text.length) segs.push(h('span', { key: 'hs' + n++ }, text.slice(pos)));
43
+ return segs;
44
+ }
45
+
46
+ export function Row({ code, rank, title, sub, meta, active, state = 'default', onClick, key, style, href, kind, cols, leading, trailing, target, selected, rail, expanded, highlight, actions }) {
24
47
  // `rank` is an alias for `code` (the leading monospace index); callers use
25
48
  // either name. `rail` renders a thin colour bar at the row's leading edge as
26
49
  // a status indicator (tone: green | purple | flame | <any token>).
@@ -52,10 +75,32 @@ export function Row({ code, rank, title, sub, meta, active, state = 'default', o
52
75
  }
53
76
  if (isDisabled) props['aria-disabled'] = 'true';
54
77
  if (isActive && (isLink || isButton)) props['aria-current'] = isActive ? 'page' : null;
78
+ // `highlight` wraps case-insensitive matches in the title in <mark class="ds-hl">.
79
+ // The segments live inside a single wrapper span so the title's child list
80
+ // never mixes keyed and unkeyed siblings.
81
+ const titleNode = (highlight && typeof title === 'string')
82
+ ? h('span', {}, ...[].concat(highlightTitle(title, highlight)))
83
+ : title;
84
+ // `actions` render ONLY when the row is expanded, as a sibling action strip
85
+ // inside the row container; each button stops propagation so it never fires
86
+ // the row onClick.
87
+ const actionRow = (expanded === true && Array.isArray(actions) && actions.length)
88
+ ? h('span', { class: 'row-actions', role: 'group', 'aria-label': 'row actions' },
89
+ ...actions.map((a, i) => h('button', {
90
+ key: 'ract' + i,
91
+ type: 'button',
92
+ class: 'row-act',
93
+ title: a.title || a.label,
94
+ 'aria-label': a.title || a.label,
95
+ onclick: (e) => { e.stopPropagation(); a.onClick && a.onClick(e); },
96
+ onkeydown: (e) => { e.stopPropagation(); },
97
+ }, a.label)))
98
+ : null;
55
99
  return h(isLink ? 'a' : 'div', props,
56
100
  leading != null ? leading : (codeVal != null ? h('span', { class: 'code' }, codeVal) : null),
57
- h('span', { class: 'title' }, title, sub ? h('span', { class: 'sub' }, sub) : null),
58
- trailing != null ? trailing : (meta != null ? h('span', { class: 'meta' }, meta) : null));
101
+ h('span', { class: 'title' }, titleNode, sub ? h('span', { class: 'sub' }, sub) : null),
102
+ trailing != null ? trailing : (meta != null ? h('span', { class: 'meta' }, meta) : null),
103
+ actionRow);
59
104
  }
60
105
 
61
106
  export function RowLink({ code, title, sub, meta, href = '#', key, target }) {
@@ -253,11 +298,12 @@ export function ProjectView({ project = {}, copied, onCopy } = {}) {
253
298
  ].filter(Boolean).flat();
254
299
  }
255
300
 
256
- export function PageHeader({ title, lede, eyebrow, right, compact }) {
301
+ export function PageHeader({ title, lede, eyebrow, right, compact, id }) {
257
302
  // `compact` drops the large leading/trailing section margins so a PageHeader
258
303
  // used as a page's first element top-aligns cleanly without the consumer
259
- // having to !important-override the .ds-section margin.
260
- return h('section', { class: 'ds-section' + (compact ? ' ds-section-compact' : '') },
304
+ // having to !important-override the .ds-section margin. `id` lands on the
305
+ // outermost section so the header can serve as a deep-link anchor.
306
+ return h('section', { class: 'ds-section' + (compact ? ' ds-section-compact' : ''), id: id || null },
261
307
  eyebrow ? h('span', { class: 'eyebrow' }, eyebrow) : null,
262
308
  title != null ? h('h1', {}, title) : null,
263
309
  lede != null ? h('p', { class: 'lede' }, lede) : null,
@@ -409,6 +455,21 @@ export function Skeleton({ height = '1em', width = '100%', count = 1, label = 'l
409
455
  );
410
456
  }
411
457
 
458
+ // FilterPills — a role=group of pill toggle buttons for quick category filters.
459
+ // `options` is [{ id, label }]; `selected` the active id; clicking a pill calls
460
+ // onSelect(id). Pressed state is announced via aria-pressed.
461
+ export function FilterPills({ options = [], selected, onSelect, label = 'filters' } = {}) {
462
+ if (!options.length) return null;
463
+ return h('div', { class: 'ds-filter-pills', role: 'group', 'aria-label': label },
464
+ ...options.map((o) => h('button', {
465
+ key: 'fp-' + o.id,
466
+ type: 'button',
467
+ class: 'ds-filter-pill' + (o.id === selected ? ' active' : ''),
468
+ 'aria-pressed': o.id === selected ? 'true' : 'false',
469
+ onclick: () => onSelect && onSelect(o.id),
470
+ }, o.label != null ? o.label : o.id)));
471
+ }
472
+
412
473
  export function Alert({ kind = 'info', children, onDismiss, title, key } = {}) {
413
474
  const icons = { info: 'info', success: 'check', warn: 'warn', error: 'x' };
414
475
  const cls = 'ds-alert ds-alert-' + kind;
@@ -13,7 +13,7 @@
13
13
  // cwd : the chat working directory (string) or falsy for server default
14
14
  // toolCount : number of tool calls running in the current live turn (>=0)
15
15
  // usage : OPTIONAL last-turn usage { inputTokens, outputTokens, costUsd, turns, durationMs }
16
- // session : OPTIONAL session metadata { id, messages, startedAt } shown as a block
16
+ // session : OPTIONAL whole-conversation totals { turns, cost } shown as a block
17
17
  // onSetCwd : optional callback for the "set working directory" affordance
18
18
  //
19
19
  // No decorative glyphs — words + the kit's Icon SVGs only.
@@ -34,10 +34,11 @@ function fmtTok(n) {
34
34
  export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
35
35
  const running = Number(toolCount) > 0;
36
36
  const hasUsage = usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null);
37
+ const hasSession = session && (session.turns != null || session.cost != null);
37
38
  // Empty state: before an agent is picked AND with no usage/session, four
38
39
  // placeholder rows (agent: none / model: dash / ...) read as a dead panel.
39
40
  // Show one honest line instead.
40
- if (!agent && !hasUsage && !session && !cwd) {
41
+ if (!agent && !hasUsage && !hasSession && !cwd) {
41
42
  return h('div', { class: 'ds-context' },
42
43
  h('div', { class: 'ds-context-empty', role: 'status' },
43
44
  'No active conversation — start a chat to see context here'),
@@ -66,6 +67,14 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
66
67
  ],
67
68
  }),
68
69
  ];
70
+ // Conversation block: whole-session totals (turn count + accumulated cost)
71
+ // between the context panel and the per-turn usage panel.
72
+ if (hasSession) {
73
+ const sesRows = [];
74
+ if (session.turns != null) sesRows.push(Row({ title: 'turns', meta: String(session.turns) }));
75
+ if (session.cost != null) sesRows.push(Row({ title: 'total cost', meta: '$' + Number(session.cost).toFixed(4) }));
76
+ panels.push(Panel({ title: 'conversation', children: sesRows }));
77
+ }
69
78
  // Usage block: surface the last turn's token/cost/turn/duration so the
70
79
  // result event is no longer silently dropped.
71
80
  if (hasUsage) {
@@ -43,7 +43,12 @@ export function FileIcon({ type = 'other' } = {}) {
43
43
  return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
44
44
  }
45
45
 
46
- export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked } = {}) {
46
+ // Default action set for FileRow. A host without mutation endpoints passes a
47
+ // narrower `actions` list (e.g. ['download']) so the row renders no dead controls.
48
+ const FILE_ROW_ACTIONS = ['download', 'rename', 'delete'];
49
+
50
+ export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
51
+ actions = FILE_ROW_ACTIONS, busy = false } = {}) {
47
52
  // permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
48
53
  // be listed (the dir stat saw it) but not opened — show an ASCII tag and
49
54
  // disable the open button so the row reads honestly instead of silently
@@ -54,7 +59,29 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
54
59
  const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null, permTag].filter(Boolean).join(' · ');
55
60
  const typeLabel = TYPE_LABELS[type] || 'file';
56
61
  const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}`;
57
- const canOpen = onOpen && !noAccess;
62
+ const canOpen = onOpen && !noAccess && !busy;
63
+ // Mutation actions on a read-only/no-access row render disabled (with a
64
+ // 'read-only' title) instead of vanishing, so the affordance reads honestly.
65
+ // `busy` (in-flight mutation) disables every control on the row.
66
+ const mutateDisabled = busy || readOnly || noAccess;
67
+ const actBtn = (act, title, ariaLabel, icon, warn) => h('button', {
68
+ key: 'act-' + act,
69
+ type: 'button',
70
+ class: 'ds-file-act' + (warn ? ' ds-file-act-warn' : ''),
71
+ title: mutateDisabled && act !== 'download' ? 'read-only' : title,
72
+ 'aria-label': ariaLabel,
73
+ disabled: (act === 'download' ? busy : mutateDisabled) ? true : null,
74
+ 'aria-disabled': (act === 'download' ? busy : mutateDisabled) ? 'true' : null,
75
+ onclick: () => onAction(act),
76
+ }, Icon(icon));
77
+ const actionBtns = onAction ? [
78
+ actions.indexOf('download') !== -1 && type !== 'dir'
79
+ ? actBtn('download', 'download', `download ${name}`, 'arrow-down', false) : null,
80
+ actions.indexOf('rename') !== -1
81
+ ? actBtn('rename', 'rename', `rename ${name}`, 'pencil', false) : null,
82
+ actions.indexOf('delete') !== -1
83
+ ? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
84
+ ].filter(Boolean) : [];
58
85
  // A role=button row containing real <button> action controls is invalid
59
86
  // HTML (interactive nesting). Instead the row is a plain container and the
60
87
  // primary "open" affordance is itself a real <button> (native keyboard +
@@ -63,6 +90,7 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
63
90
  key,
64
91
  class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
65
92
  'data-file-type': type,
93
+ 'aria-busy': busy ? 'true' : null,
66
94
  },
67
95
  h('button', {
68
96
  type: 'button',
@@ -77,10 +105,8 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
77
105
  h('span', { class: 'title' }, name),
78
106
  h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
79
107
  ),
80
- onAction ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
81
- h('button', { class: 'ds-file-act', title: 'download', 'aria-label': `download ${name}`, onclick: () => onAction('download') }, Icon('arrow-down')),
82
- h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, Icon('pencil')),
83
- h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, Icon('x'))
108
+ actionBtns.length ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
109
+ ...actionBtns
84
110
  ) : null
85
111
  );
86
112
  }
@@ -133,7 +159,7 @@ const FILE_GRID_CAP = 200;
133
159
 
134
160
  export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
135
161
  columns = 'auto', sort, filter, loading = false,
136
- shown, onShowMore } = {}) {
162
+ shown, onShowMore, actions, busy } = {}) {
137
163
  if (loading) return FileSkeleton({});
138
164
  if (!files.length) return EmptyState({ text: emptyText });
139
165
  // Cap the rendered rows. `shown` (host-controlled) overrides the default cap
@@ -179,6 +205,8 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
179
205
  key: f.path || f.name + i,
180
206
  name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
181
207
  permissions: f.permissions, locked: f.locked,
208
+ actions: actions != null ? actions : undefined,
209
+ busy: busy != null ? !!busy : !!f.busy,
182
210
  onOpen: onOpen ? () => onOpen(f) : null,
183
211
  onAction: onAction ? (act) => onAction(act, f) : null
184
212
  }))
@@ -21,7 +21,8 @@ const h = webjsx.createElement;
21
21
  // emptyText, loading, error : explicit states
22
22
  export function ConversationList({ sessions = [], selected, groups, search, caption,
23
23
  onSelect, onNew, newLabel = 'New chat',
24
- emptyText = 'No conversations yet', loading = false, error = null } = {}) {
24
+ emptyText = 'No conversations yet', loading = false, error = null,
25
+ loadingText = 'Loading conversations…' } = {}) {
25
26
  const rowFor = (s, i) => h('button', {
26
27
  // Stable key: prefer sid, else position - a missing/duplicate sid would make
27
28
  // key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
@@ -53,7 +54,7 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
53
54
  // are uniformly keyed; non-row states render a single unkeyed status line.
54
55
  let inner;
55
56
  if (loading) {
56
- inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, 'Loading conversations…')];
57
+ inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, loadingText)];
57
58
  } else if (error) {
58
59
  inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
59
60
  } else if (!sessions.length) {
@@ -85,6 +86,30 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
85
86
  body);
86
87
  }
87
88
 
89
+ // SessionMeta — a middot-separated metadata strip for a session detail surface.
90
+ // items : [{ label, value, title, onCopy }]
91
+ // Each item is a span (label dimmed, value mono) with an optional per-item copy
92
+ // button; the strip flex-wraps at narrow widths. Class is .ds-session-meta-strip
93
+ // (the bare .ds-session-meta is already taken by ConversationList row meta).
94
+ export function SessionMeta({ items = [] } = {}) {
95
+ if (!items.length) return null;
96
+ return h('div', { class: 'ds-session-meta-strip', role: 'group', 'aria-label': 'session metadata' },
97
+ ...items.map((it, i) => h('span', {
98
+ key: 'sm-' + (it.label != null ? it.label : i),
99
+ class: 'ds-session-meta-item',
100
+ title: it.title || null,
101
+ },
102
+ [
103
+ it.label != null ? h('span', { key: 'l', class: 'ds-session-meta-label' }, it.label) : null,
104
+ h('span', { key: 'v', class: 'ds-session-meta-value' }, it.value != null ? String(it.value) : ''),
105
+ it.onCopy ? h('button', {
106
+ key: 'c', type: 'button', class: 'ds-session-meta-copy',
107
+ 'aria-label': 'copy ' + (it.title || it.label || 'value'),
108
+ onclick: () => it.onCopy(it.value),
109
+ }, 'copy') : null,
110
+ ].filter(Boolean))));
111
+ }
112
+
88
113
  // SessionCard — one running session in the live dashboard. Status dot, agent /
89
114
  // model / cwd, elapsed, live counter, last activity, and per-session controls
90
115
  // that each act on this session's id independently.
@@ -154,7 +179,14 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
154
179
  // still tells the user the dashboard is listening (vs a dropped stream).
155
180
  const STREAM_WORD = { connected: 'listening for activity', connecting: 'connecting to live stream…', lost: 'live stream lost — retrying…' };
156
181
 
182
+ // The stop-all / stop-selected danger buttons are two-step (host-driven, the kit
183
+ // is stateless): the first click fires onArmStop* so the host flips confirming*
184
+ // true and re-renders; the armed button reads 'stop N sessions - press again'
185
+ // and only THAT click fires the real onStopAll/onStopSelected. Hosts that wire
186
+ // no onArmStop* keep the old single-click behavior.
157
187
  export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStopAll, onStopSelected,
188
+ confirmingStopAll = false, confirmingStopSelected = false,
189
+ onArmStopAll, onArmStopSelected,
158
190
  sort, filter, errorsOnly = false, onErrorsOnly,
159
191
  selectable = false, selected, onToggleSelect,
160
192
  activeSid, streamState,
@@ -194,8 +226,16 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
194
226
  streamLine,
195
227
  h('span', { class: 'spread' }),
196
228
  selectable && selCount && onStopSelected
197
- ? Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]), children: 'stop selected' })
198
- : (onStopAll ? Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions), children: 'stop all' }) : null),
229
+ ? (onArmStopSelected && !confirmingStopSelected
230
+ ? Btn({ key: 'stopsel', danger: true, onClick: () => onArmStopSelected([...selSet]), children: 'stop selected' })
231
+ : Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]),
232
+ children: confirmingStopSelected ? 'stop ' + selCount + ' sessions - press again' : 'stop selected' }))
233
+ : (onStopAll
234
+ ? (onArmStopAll && !confirmingStopAll
235
+ ? Btn({ key: 'stopall', danger: true, onClick: () => onArmStopAll(sessions), children: 'stop all' })
236
+ : Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions),
237
+ children: confirmingStopAll ? 'stop ' + sessions.length + ' sessions - press again' : 'stop all' }))
238
+ : null),
199
239
  toolbar);
200
240
  const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
201
241
  ...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
package/src/components.js CHANGED
@@ -16,7 +16,7 @@ export {
16
16
  WorksList, WritingList, Manifesto, Section, PageHeader,
17
17
  Kpi, Table, SearchInput, TextField, Select, EventList,
18
18
  HomeView, ProjectView, Form,
19
- Spinner, Skeleton, Alert
19
+ Spinner, Skeleton, Alert, FilterPills
20
20
  } from './components/content.js';
21
21
 
22
22
  export {
@@ -28,7 +28,7 @@ export {
28
28
  export { AgentChat } from './components/agent-chat.js';
29
29
 
30
30
  export {
31
- ConversationList, SessionCard, SessionDashboard
31
+ ConversationList, SessionCard, SessionDashboard, SessionMeta
32
32
  } from './components/sessions.js';
33
33
 
34
34
  export { ContextPane } from './components/context-pane.js';