anentrypoint-design 0.0.195 → 0.0.197

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.195",
3
+ "version": "0.0.197",
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';
@@ -129,7 +160,11 @@ export function AgentChat(props = {}) {
129
160
  // accumulated source and swaps the entire bubble innerHTML on every frame
130
161
  // (O(n^2) over the turn, with a visible reflow). Downgrade md -> text
131
162
  // mid-stream; the settled turn below renders real markdown once.
132
- if (isStreaming && part.kind === 'md') parts.push({ kind: 'text', text: part.text });
163
+ // Carry a `mdShell` flag so the streaming-text bubble uses the same
164
+ // container shape (.chat-md padding/spacing) the settled markdown will
165
+ // use — only the inner content swaps on settle, so the bubble box does
166
+ // not reflow/jump when the turn finishes and renders real markdown.
167
+ if (isStreaming && part.kind === 'md') parts.push({ kind: 'text', text: part.text, mdShell: true });
133
168
  else parts.push(part);
134
169
  }
135
170
  }
@@ -138,6 +173,19 @@ export function AgentChat(props = {}) {
138
173
  // carry prose, so a parts-driven turn isn't double-rendered.
139
174
  const partsHaveProse = parts.some(p => p.kind === 'md' || p.kind === 'text');
140
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
+ }
141
189
  return ChatMessage({
142
190
  key: m.id || String(i),
143
191
  who: isAssistant ? 'them' : 'you',
@@ -145,6 +193,8 @@ export function AgentChat(props = {}) {
145
193
  name: isAssistant ? name : 'you',
146
194
  time: m.time || '',
147
195
  typing: emptyStreaming,
196
+ streaming,
197
+ actions,
148
198
  parts: emptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
149
199
  });
150
200
  });
@@ -192,14 +242,21 @@ export function AgentChat(props = {}) {
192
242
  // reconnecting-while-streaming state reads one word everywhere instead of
193
243
  // the head saying "streaming…" while the controls say "reconnecting…".
194
244
  busy ? (status || 'streaming…') : (messages.length ? messages.length + (messages.length === 1 ? ' message' : ' messages') : ''))),
195
- h('div', { class: 'agentchat-thread', ref: threadRef(messages.length), role: 'log', 'aria-label': 'conversation' },
196
- emptyState,
197
- ...rows.filter(Boolean),
198
- showWorkingTail
199
- ? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
200
- h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
201
- h('span', { class: 'agentchat-working-text' }, 'working…'))
202
- : 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'))),
203
260
  composer,
204
261
  );
205
262
  }
@@ -19,6 +19,21 @@ export function fmtBytes(n) {
19
19
  return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
20
20
  }
21
21
 
22
+ // Reject dangerous URL schemes (javascript:, data:, vbscript:, file:) so an
23
+ // inline markdown link or an image src built from untrusted text can't smuggle a
24
+ // script-executing or data-exfiltrating URL past the inline renderer (which does
25
+ // NOT pass through DOMPurify the way the full md path does). http(s), mailto,
26
+ // protocol-relative, root/relative, and anchor links are allowed.
27
+ export function safeUrl(url) {
28
+ const s = String(url == null ? '' : url).trim();
29
+ if (!s) return null;
30
+ // Allow relative / anchor / protocol-relative without a scheme.
31
+ if (/^(\/|\.|#|\?)/.test(s) || s.startsWith('//')) return s;
32
+ const scheme = (s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1];
33
+ if (!scheme) return s; // schemeless relative
34
+ return /^(https?|mailto|tel)$/i.test(scheme) ? s : null;
35
+ }
36
+
22
37
  // Inline-only markdown subset; safe for chat bubbles.
23
38
  export function renderInline(text) {
24
39
  if (text == null) return [];
@@ -31,7 +46,13 @@ export function renderInline(text) {
31
46
  if (m[2] != null) push(h('strong', { key: 's' + i }, m[2]));
32
47
  else if (m[3] != null) push(h('em', { key: 's' + i }, m[3]));
33
48
  else if (m[4] != null) push(h('code', { key: 's' + i, class: 'chat-tick' }, m[4]));
34
- else if (m[5] != null) push(h('a', { key: 's' + i, href: m[6], target: '_blank', rel: 'noopener' }, m[5]));
49
+ else if (m[5] != null) {
50
+ const safe = safeUrl(m[6]);
51
+ // A link with a rejected (unsafe) scheme degrades to its plain label
52
+ // text rather than a clickable, scheme-smuggling anchor.
53
+ if (safe) push(h('a', { key: 's' + i, href: safe, target: '_blank', rel: 'noopener noreferrer' }, m[5]));
54
+ else push(h('span', { key: 's' + i }, m[5]));
55
+ }
35
56
  last = m.index + m[0].length; i += 1;
36
57
  }
37
58
  if (last < text.length) push(h('span', { key: 's' + i + 'a' }, text.slice(last)));
@@ -133,7 +154,13 @@ function ToolCallNode(p) {
133
154
  h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))),
134
155
  resultText ? h('div', { class: 'chat-tool-section' },
135
156
  h('div', { class: 'chat-tool-section-label' }, p.error ? 'error' : 'result'),
136
- h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText))) : null
157
+ h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText)))
158
+ // A finished tool with no output would otherwise render no result
159
+ // section, reading identically to a still-running tool. Show an
160
+ // explicit placeholder so "done, empty" is distinguishable.
161
+ : (status === 'done' ? h('div', { class: 'chat-tool-section' },
162
+ h('div', { class: 'chat-tool-section-label' }, 'result'),
163
+ h('pre', { class: 'chat-tool-pre chat-tool-empty' }, h('code', {}, '(no output)'))) : null)
137
164
  )
138
165
  );
139
166
  }
@@ -146,16 +173,24 @@ function ThinkingNode(p) {
146
173
  }
147
174
 
148
175
  const PART_RENDERERS = {
149
- text: (p) => h('div', { class: 'chat-bubble' }, ...renderInline(p.text || '')),
176
+ text: (p) => h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') }, ...renderInline(p.text || '')),
150
177
  md: (p) => MdNode(p),
151
178
  code: (p) => CodeNode(p),
152
179
  tool: (p) => ToolCallNode(p),
153
180
  tool_call: (p) => ToolCallNode(p),
154
181
  tool_result: (p) => ToolCallNode({ ...p, name: p.name || 'tool_result', result: p.text != null ? p.text : p.result }),
155
182
  thinking: (p) => ThinkingNode(p),
156
- image: (p) => h('a', { class: 'chat-image', href: p.href || p.src, target: '_blank', rel: 'noopener', 'aria-label': p.alt || `embedded image: ${p.src}` },
157
- h('img', { src: p.src, alt: p.alt || `embedded image from ${p.src}`, loading: 'lazy' }),
158
- p.caption ? h('span', { class: 'cap' }, p.caption) : null),
183
+ image: (p) => {
184
+ // Guard both the wrapping link and the img src against unsafe schemes
185
+ // (e.g. a data:text/html src) so an embedded-image part from untrusted
186
+ // markdown can't smuggle an active payload.
187
+ const imgSrc = safeUrl(p.src);
188
+ const linkHref = safeUrl(p.href || p.src);
189
+ if (!imgSrc) return h('span', { class: 'chat-image-blocked' }, p.alt || 'image blocked (unsafe url)');
190
+ return h('a', { class: 'chat-image', href: linkHref || imgSrc, target: '_blank', rel: 'noopener noreferrer', 'aria-label': p.alt || `embedded image: ${imgSrc}` },
191
+ h('img', { src: imgSrc, alt: p.alt || `embedded image from ${imgSrc}`, loading: 'lazy' }),
192
+ p.caption ? h('span', { class: 'cap' }, p.caption) : null);
193
+ },
159
194
  pdf: (p) => h('div', { class: 'chat-pdf' },
160
195
  h('div', { class: 'chat-pdf-head' },
161
196
  h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('file-pdf', { size: 18 })),
@@ -171,7 +206,7 @@ const PART_RENDERERS = {
171
206
  h('span', { class: 'size' }, [p.kindLabel || (p.name || '').split('.').pop().toUpperCase(), p.size != null ? fmtBytes(p.size) : null].filter(Boolean).join(' · '))
172
207
  ),
173
208
  h('span', { class: 'go', 'aria-hidden': 'true' }, Icon('arrow-down'))),
174
- link: (p) => h('a', { class: 'chat-link', href: p.href, target: '_blank', rel: 'noopener', 'aria-label': `link: ${p.title || p.href}` },
209
+ link: (p) => h('a', { class: 'chat-link', href: safeUrl(p.href) || '#', target: '_blank', rel: 'noopener noreferrer', 'aria-label': `link: ${p.title || p.href}` },
175
210
  p.thumb ? h('img', { class: 'thumb', src: p.thumb, alt: `preview for ${p.title || p.href}` }) : null,
176
211
  h('span', { class: 'meta' },
177
212
  h('span', { class: 'host' }, p.host || (() => { try { return new URL(p.href).host; } catch { return ''; } })()),
@@ -188,7 +223,7 @@ function renderPart(p, key) {
188
223
  return node;
189
224
  }
190
225
 
191
- 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 }) {
192
227
  _stats.messages += 1;
193
228
  // Support legacy 'who' prop, prefer 'role' with mapping:
194
229
  // 'user' -> 'you' (right-aligned, accent bubble)
@@ -212,6 +247,11 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
212
247
  if (typing) bodyNodes = [h('div', { class: 'chat-bubble', key: 'typb' }, h('span', { class: 'chat-typing' }, h('span'), h('span'), h('span')))];
213
248
  else if (parts && parts.length) bodyNodes = parts.map((p, i) => renderPart(p, i));
214
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' })];
215
255
  const reactionRow = reactions && reactions.length
216
256
  ? h('div', { class: 'chat-reactions' },
217
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' : ''}` },
@@ -225,7 +265,19 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
225
265
  if (time) metaItems.push(h('span', { class: 't', key: 'ti' }, time));
226
266
  if (tickNode) metaItems.push(tickNode);
227
267
  const meta = metaItems.length ? h('div', { class: 'chat-meta' }, ...metaItems) : null;
228
- 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);
229
281
  // Centered roles (system/tool/thinking) skip the avatar column entirely so
230
282
  // the bubble owns the full row — the chrome reads as out-of-band signal,
231
283
  // not a participant turn.
@@ -293,7 +345,6 @@ export function Chat({ title = 'chat', sub, messages = [], composer, header, sug
293
345
  const msgCount = messages.length;
294
346
  return h('div', { class: 'chat' },
295
347
  header || h('div', { class: 'chat-head', role: 'banner' },
296
- h('span', { class: 'dot', 'aria-hidden': 'true' }),
297
348
  h('h2', { class: 'ds-chat-title' }, title),
298
349
  sub ? h('span', { class: 'sub', 'aria-label': `subtitle: ${sub}` }, ' · ' + sub) : null,
299
350
  h('span', { class: 'spread' }),
@@ -253,8 +253,11 @@ export function ProjectView({ project = {}, copied, onCopy } = {}) {
253
253
  ].filter(Boolean).flat();
254
254
  }
255
255
 
256
- export function PageHeader({ title, lede, eyebrow, right }) {
257
- return h('section', { class: 'ds-section' },
256
+ export function PageHeader({ title, lede, eyebrow, right, compact }) {
257
+ // `compact` drops the large leading/trailing section margins so a PageHeader
258
+ // 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' : '') },
258
261
  eyebrow ? h('span', { class: 'eyebrow' }, eyebrow) : null,
259
262
  title != null ? h('h1', {}, title) : null,
260
263
  lede != null ? h('p', { class: 'lede' }, lede) : null,
@@ -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
+ }