anentrypoint-design 0.0.199 → 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.199",
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,6 +134,10 @@ export function AgentChat(props = {}) {
124
134
  canSend = true,
125
135
  suggestions = [], onSuggestionClick,
126
136
  onCopyMessage, onRetryMessage, onEditMessage,
137
+ confirmEdit = false, onArmEdit,
138
+ avatar, composerContext,
139
+ followups = [], onFollowupClick,
140
+ installHint, exportActions = [],
127
141
  } = props;
128
142
 
129
143
  const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
@@ -164,7 +178,16 @@ export function AgentChat(props = {}) {
164
178
  // container shape (.chat-md padding/spacing) the settled markdown will
165
179
  // use — only the inner content swaps on settle, so the bubble box does
166
180
  // 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 });
181
+ if (isStreaming && part.kind === 'md') {
182
+ // If the streaming prose contains a code fence, the inline renderer
183
+ // (which has no triple-backtick handling) would show it as run-on text
184
+ // with literal ``` and no monospace, then snap into a styled <pre> on
185
+ // settle (a visible reflow during the most-watched moment). Detect a
186
+ // fence and render the bubble as a cheap monospaced <pre> shell instead
187
+ // (no Prism mid-stream, so no O(n^2)) so it does not reflow on settle.
188
+ if (part.text && part.text.indexOf('```') !== -1) parts.push({ kind: 'text', text: part.text, mdShell: true, preShell: true });
189
+ else parts.push({ kind: 'text', text: part.text, mdShell: true });
190
+ }
168
191
  else parts.push(part);
169
192
  }
170
193
  }
@@ -181,15 +204,21 @@ export function AgentChat(props = {}) {
181
204
  let actions;
182
205
  if (!isStreaming && msgHasBody(m)) {
183
206
  const built = [];
184
- if (onCopyMessage) built.push({ label: 'copy', icon: 'page', title: 'copy message', onClick: () => onCopyMessage(m) });
207
+ if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
185
208
  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) });
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) });
187
213
  if (built.length) actions = built;
188
214
  }
189
215
  return ChatMessage({
190
216
  key: m.id || String(i),
191
217
  who: isAssistant ? 'them' : 'you',
192
218
  aicat: isAssistant,
219
+ // A stable per-agent product mark (host passes a small line-SVG via
220
+ // `avatar`) instead of a per-agent letter initial that shifts identity.
221
+ avatar: isAssistant ? (m.avatar != null ? m.avatar : avatar) : undefined,
193
222
  name: isAssistant ? name : 'you',
194
223
  time: m.time || '',
195
224
  typing: emptyStreaming,
@@ -210,8 +239,21 @@ export function AgentChat(props = {}) {
210
239
  onInput: (v) => onInput && onInput(v),
211
240
  onSend: (v) => onSend && onSend(v),
212
241
  onCancel: busy && onStop ? () => onStop() : undefined,
242
+ // The active target (agent / model / cwd-basename) at the point of typing.
243
+ context: composerContext,
213
244
  });
214
245
 
246
+ // Contextual follow-up chips below the last SETTLED assistant turn (claude.ai/
247
+ // code / cowork surface these after a turn, not only on an empty thread). Shown
248
+ // only when not busy and the last message is an assistant turn with body.
249
+ const followupRow = (!busy && followups && followups.length && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg))
250
+ ? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups' },
251
+ ...followups.map((s, i) => h('button', {
252
+ key: 'fu' + i, type: 'button', class: 'agentchat-empty-suggestion agentchat-followup',
253
+ onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onFollowupClick) onFollowupClick(t); else if (onSuggestionClick) onSuggestionClick(t); },
254
+ }, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
255
+ : null;
256
+
215
257
  // Empty state: a fresh thread is a void without this. Mirrors the kit's Chat
216
258
  // empty surface (title, sub, optional starter prompts) so AgentChat opens to
217
259
  // an invitation, not a blank panel.
@@ -226,12 +268,40 @@ export function AgentChat(props = {}) {
226
268
  key: 'sug' + i, type: 'button', class: 'agentchat-empty-suggestion',
227
269
  onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onSuggestionClick) onSuggestionClick(t); },
228
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)
229
299
  : null)
230
300
  : null;
231
301
 
232
302
  return h('div', { class: 'agentchat' },
233
303
  AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading,
234
- onSelectAgent, onSelectModel, onNewChat, onStop }),
304
+ onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }),
235
305
  CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft,
236
306
  onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft }),
237
307
  ...(banners || []).filter(Boolean),
@@ -250,7 +320,8 @@ export function AgentChat(props = {}) {
250
320
  ? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
251
321
  h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
252
322
  h('span', { class: 'agentchat-working-text' }, 'working…'))
253
- : null),
323
+ : null,
324
+ followupRow),
254
325
  // Jump-to-latest: hidden until the scroll listener adds .show (user scrolled
255
326
  // up). Clicking returns to the live edge. Pure-DOM, like the kit's other
256
327
  // stateless chrome, so the host needn't thread scroll state through state.
@@ -100,12 +100,44 @@ export function makeThreadAutoScroll(getCount) {
100
100
  };
101
101
  }
102
102
 
103
+ // Inject a per-block copy button into every <pre> inside a rendered-markdown
104
+ // container. claude.ai/code and Claude Desktop give EVERY fenced block a hover
105
+ // copy affordance; the chat surface had only a whole-message copy. Idempotent:
106
+ // marks each <pre> with data-copy-wired so re-renders don't stack buttons. The
107
+ // button reveals on .chat-code-block:hover/:focus-within (CSS) and flips its
108
+ // label copy -> copied for ~1.6s. Drawn with a real icon + word, no glyph.
109
+ export function injectCodeCopy(container) {
110
+ if (!container) return;
111
+ container.querySelectorAll('pre').forEach((pre) => {
112
+ if (pre.dataset.copyWired === '1') return;
113
+ pre.dataset.copyWired = '1';
114
+ // Wrap the <pre> in a position:relative shell so the button can sit
115
+ // top-right without disturbing code layout.
116
+ const shell = document.createElement('div');
117
+ shell.className = 'chat-code-block';
118
+ pre.parentNode.insertBefore(shell, pre);
119
+ shell.appendChild(pre);
120
+ const btn = document.createElement('button');
121
+ btn.type = 'button';
122
+ btn.className = 'chat-code-copy';
123
+ btn.setAttribute('aria-label', 'copy code');
124
+ btn.textContent = 'copy';
125
+ btn.addEventListener('click', () => {
126
+ const code = pre.innerText;
127
+ const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
128
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(done).catch(() => {});
129
+ else { try { const t = document.createElement('textarea'); t.value = code; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
130
+ });
131
+ shell.appendChild(btn);
132
+ });
133
+ }
134
+
103
135
  function MdNode(p) {
104
136
  const refSink = (el) => {
105
137
  if (!el) return;
106
138
  if (el.dataset.mdSrc === p.text) return;
107
139
  el.dataset.mdSrc = p.text || '';
108
- renderMarkdownCached(p.text || '').then((html) => { el.innerHTML = html; });
140
+ renderMarkdownCached(p.text || '').then((html) => { el.innerHTML = html; injectCodeCopy(el); });
109
141
  };
110
142
  return h('div', { class: 'chat-bubble chat-md', ref: refSink });
111
143
  }
@@ -121,10 +153,20 @@ function CodeNode(p) {
121
153
  el.dataset.codeKey = codeKey;
122
154
  highlightCodeBlockCached(el);
123
155
  };
156
+ // Copy the raw code (not the highlighted DOM) for the structured CodeNode.
157
+ const onCopy = (e) => {
158
+ const btn = e.currentTarget;
159
+ const code = p.code || '';
160
+ const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
161
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(done).catch(() => {});
162
+ else { try { const t = document.createElement('textarea'); t.value = code; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
163
+ };
124
164
  return h('div', { class: 'chat-bubble chat-code', ref: refSink },
125
165
  h('div', { class: 'chat-code-head' },
126
166
  h('span', { class: 'lang' }, p.lang || 'code'),
127
- p.filename ? h('span', { class: 'name' }, p.filename) : null
167
+ p.filename ? h('span', { class: 'name' }, p.filename) : null,
168
+ h('span', { class: 'spread' }),
169
+ h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')
128
170
  ),
129
171
  h('pre', {}, h('code', { class: p.lang ? 'lang-' + p.lang + ' language-' + p.lang : '' }, p.code || ''))
130
172
  );
@@ -173,7 +215,12 @@ function ThinkingNode(p) {
173
215
  }
174
216
 
175
217
  const PART_RENDERERS = {
176
- text: (p) => h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') }, ...renderInline(p.text || '')),
218
+ text: (p) => p.preShell
219
+ // Streaming prose that already contains a code fence renders as a plain
220
+ // monospaced <pre> so it does not reflow from prose to a styled block on
221
+ // settle (no Prism mid-stream). The settled turn renders real markdown.
222
+ ? h('div', { class: 'chat-bubble chat-md chat-stream-pre' }, h('pre', {}, h('code', {}, p.text || '')))
223
+ : h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') }, ...renderInline(p.text || '')),
177
224
  md: (p) => MdNode(p),
178
225
  code: (p) => CodeNode(p),
179
226
  tool: (p) => ToolCallNode(p),
@@ -275,7 +322,8 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
275
322
  key: 'ma' + i, type: 'button', class: 'chat-msg-action',
276
323
  title: a.title || a.label, 'aria-label': a.label || a.title,
277
324
  onclick: (e) => { e.preventDefault(); a.onClick && a.onClick(e); },
278
- }, a.icon ? Icon(a.icon, { size: 14 }) : (a.label || ''))))
325
+ }, a.icon ? Icon(a.icon, { size: 14 }) : null,
326
+ a.label ? h('span', { class: 'chat-msg-action-label' }, a.label) : null)))
279
327
  : null;
280
328
  const stack = h('div', { class: 'chat-stack' }, ...bodyNodes, reactionRow, actionRow, meta);
281
329
  // Centered roles (system/tool/thinking) skip the avatar column entirely so
@@ -285,7 +333,7 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
285
333
  return h('div', { key, class: cls }, resolvedWho === 'you' ? stack : av, resolvedWho === 'you' ? av : stack);
286
334
  }
287
335
 
288
- export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled }) {
336
+ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, context }) {
289
337
  // Keep a handle to the live textarea so send() reads the actual DOM value
290
338
  // (not the possibly-lagging `value` prop) and so we can sync the DOM value
291
339
  // only when it genuinely differs — re-applying `value` on every parent
@@ -320,7 +368,19 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
320
368
  el.style.height = 'auto';
321
369
  el.style.height = Math.min(el.scrollHeight, 200) + 'px';
322
370
  };
371
+ // Optional context line shown above the textarea: agent / model / cwd at the
372
+ // point of typing (the way Claude-Desktop surfaces the active target inline).
373
+ // `context` is { bits:[...strings], onClick? }; bits are middot-joined (kept
374
+ // product separator). Clickable when onClick is wired (opens the picker).
375
+ const contextLine = (context && context.bits && context.bits.length)
376
+ ? h(context.onClick ? 'button' : 'div', {
377
+ class: 'chat-composer-context', type: context.onClick ? 'button' : null,
378
+ 'aria-label': context.onClick ? ('change target: ' + context.bits.join(' · ')) : null,
379
+ onclick: context.onClick ? (e) => { e.preventDefault(); context.onClick(e); } : null,
380
+ }, context.bits.filter(Boolean).join(' · '))
381
+ : null;
323
382
  return h('div', { class: 'chat-composer' },
383
+ contextLine,
324
384
  h('textarea', { ref: taRef, placeholder, rows: 1, 'aria-label': 'message input',
325
385
  oninput: autoGrow,
326
386
  onkeydown: (e) => {
@@ -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.
@@ -33,6 +33,17 @@ function fmtTok(n) {
33
33
 
34
34
  export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
35
35
  const running = Number(toolCount) > 0;
36
+ const hasUsage = usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null);
37
+ const hasSession = session && (session.turns != null || session.cost != null);
38
+ // Empty state: before an agent is picked AND with no usage/session, four
39
+ // placeholder rows (agent: none / model: dash / ...) read as a dead panel.
40
+ // Show one honest line instead.
41
+ if (!agent && !hasUsage && !hasSession && !cwd) {
42
+ return h('div', { class: 'ds-context' },
43
+ h('div', { class: 'ds-context-empty', role: 'status' },
44
+ 'No active conversation — start a chat to see context here'),
45
+ onSetCwd ? h('div', { class: 'ds-context-actions' }, Btn({ onClick: onSetCwd, children: 'set working dir' })) : null);
46
+ }
36
47
  // Each Panel's children array is all-unkeyed (no key prop on any sibling),
37
48
  // so webjsx never sees a mixed keyed/unkeyed array here.
38
49
  const panels = [
@@ -56,9 +67,17 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
56
67
  ],
57
68
  }),
58
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
+ }
59
78
  // Usage block: surface the last turn's token/cost/turn/duration so the
60
79
  // result event is no longer silently dropped.
61
- if (usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null)) {
80
+ if (hasUsage) {
62
81
  const tokRows = [];
63
82
  if (usage.inputTokens != null) tokRows.push(Row({ title: 'input', meta: fmtTok(usage.inputTokens) + ' tok' }));
64
83
  if (usage.outputTokens != null) tokRows.push(Row({ title: 'output', meta: fmtTok(usage.outputTokens) + ' tok' }));
@@ -155,9 +155,24 @@ export function FilePreviewMedia({ src, type = 'other', name } = {}) {
155
155
  );
156
156
  }
157
157
 
158
- export function FilePreviewCode({ content = '', lang } = {}) {
159
- return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') },
160
- h('code', { class: lang ? 'language-' + lang : '' }, content)
158
+ export function FilePreviewCode({ content = '', lang, filename } = {}) {
159
+ // A filename/lang header matching the chat CodeNode's .chat-code-head, plus
160
+ // the same copy control (chat A1/A2 ship this run, so preview matches for
161
+ // full cross-surface consistency).
162
+ const onCopy = (e) => {
163
+ const btn = e.currentTarget;
164
+ const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
165
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(content).then(done).catch(() => {});
166
+ else { try { const t = document.createElement('textarea'); t.value = content; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
167
+ };
168
+ return h('div', { class: 'ds-preview-code-wrap' },
169
+ h('div', { class: 'chat-code-head ds-preview-code-head' },
170
+ h('span', { class: 'lang' }, lang || 'text'),
171
+ filename ? h('span', { class: 'name' }, filename) : null,
172
+ h('span', { class: 'spread' }),
173
+ h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')),
174
+ h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') },
175
+ h('code', { class: lang ? 'language-' + lang : '' }, content))
161
176
  );
162
177
  }
163
178
 
@@ -168,25 +183,62 @@ export function FilePreviewText({ content = '', truncated } = {}) {
168
183
  );
169
184
  }
170
185
 
171
- export function FileViewer({ file, body, onClose, onAction } = {}) {
172
- if (!file) return null;
186
+ // Shared preview-head children for both the modal FileViewer and the inline
187
+ // FilePreviewPane: name + meta + prev/next stepper + download + close. ASCII
188
+ // prev/next words (no glyph arrows). onPrev/onNext are omitted when there is no
189
+ // previewable neighbour in that direction.
190
+ function previewHead({ file, onClose, onAction, onPrev, onNext } = {}) {
173
191
  const meta = [file.type, file.size != null ? fmtFileSize(file.size) : null, file.modified || null]
174
192
  .filter(Boolean).join(' · ');
193
+ return [
194
+ h('span', { class: 'ds-preview-name', title: file.path || file.name || '' }, file.name || ''),
195
+ h('span', { class: 'ds-preview-meta' }, meta),
196
+ h('span', { class: 'ds-preview-actions' },
197
+ (onPrev || onNext) ? h('span', { class: 'ds-preview-step', role: 'group', 'aria-label': 'step files' },
198
+ h('button', { class: 'ds-file-act', title: 'previous file', 'aria-label': 'previous file', disabled: onPrev ? null : true, onclick: () => onPrev && onPrev() }, 'prev'),
199
+ h('button', { class: 'ds-file-act', title: 'next file', 'aria-label': 'next file', disabled: onNext ? null : true, onclick: () => onNext && onNext() }, 'next')) : null,
200
+ onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
201
+ onClose ? h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x')) : null
202
+ )
203
+ ];
204
+ }
205
+
206
+ // ArrowLeft/Right step the preview when focus is inside it (both pane + modal).
207
+ function previewKeyNav(onPrev, onNext) {
208
+ return (e) => {
209
+ if (e.key === 'ArrowLeft' && onPrev) { e.preventDefault(); onPrev(); }
210
+ else if (e.key === 'ArrowRight' && onNext) { e.preventDefault(); onNext(); }
211
+ };
212
+ }
213
+
214
+ export function FileViewer({ file, body, onClose, onAction, onPrev, onNext } = {}) {
215
+ if (!file) return null;
175
216
  return Modal({
176
217
  onClose,
177
218
  kind: 'preview',
178
219
  headClass: 'ds-preview-head',
179
- headAttrs: { 'data-file-type': file.type || 'other' },
180
- head: [
181
- h('span', { class: 'ds-preview-name' }, file.name || ''),
182
- h('span', { class: 'ds-preview-meta' }, meta),
183
- h('span', { class: 'ds-preview-actions' },
184
- onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
185
- h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x'))
186
- )
187
- ],
220
+ headAttrs: { 'data-file-type': file.type || 'other', onkeydown: previewKeyNav(onPrev, onNext) },
221
+ head: previewHead({ file, onClose, onAction, onPrev, onNext }),
188
222
  bodyClass: 'ds-preview-body',
189
223
  bodyAttrs: { 'data-file-type': file.type || 'other' },
190
224
  body: Array.isArray(body) ? body : [body],
191
225
  });
192
226
  }
227
+
228
+ // FilePreviewPane — the SAME preview, but as a persistent, non-modal side pane
229
+ // for the WorkspaceShell's pane slot (the split-view, claude-Desktop file-pane
230
+ // feel). Distinct from the overlay FileViewer (kept as the <900px fallback).
231
+ // Not focus-trapped (it is not modal); ArrowLeft/Right step files when focused.
232
+ export function FilePreviewPane({ file, body, onClose, onAction, onPrev, onNext } = {}) {
233
+ if (!file) {
234
+ return h('div', { class: 'ds-preview-pane ds-preview-pane-empty', role: 'status' },
235
+ h('span', {}, 'Select a file to preview'));
236
+ }
237
+ return h('div', { class: 'ds-preview-pane', role: 'region', 'aria-label': 'file preview: ' + (file.name || ''),
238
+ tabindex: '0', onkeydown: previewKeyNav(onPrev, onNext) },
239
+ h('div', { class: 'ds-preview-head', 'data-file-type': file.type || 'other' },
240
+ ...previewHead({ file, onClose, onAction, onPrev, onNext })),
241
+ h('div', { class: 'ds-preview-body', 'data-file-type': file.type || 'other' },
242
+ ...(Array.isArray(body) ? body : [body]))
243
+ );
244
+ }