anentrypoint-design 0.0.198 → 0.0.200

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.198",
3
+ "version": "0.0.200",
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",
@@ -124,6 +124,8 @@ export function AgentChat(props = {}) {
124
124
  canSend = true,
125
125
  suggestions = [], onSuggestionClick,
126
126
  onCopyMessage, onRetryMessage, onEditMessage,
127
+ avatar, composerContext,
128
+ followups = [], onFollowupClick,
127
129
  } = props;
128
130
 
129
131
  const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
@@ -164,7 +166,16 @@ export function AgentChat(props = {}) {
164
166
  // container shape (.chat-md padding/spacing) the settled markdown will
165
167
  // use — only the inner content swaps on settle, so the bubble box does
166
168
  // 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 });
169
+ if (isStreaming && part.kind === 'md') {
170
+ // If the streaming prose contains a code fence, the inline renderer
171
+ // (which has no triple-backtick handling) would show it as run-on text
172
+ // with literal ``` and no monospace, then snap into a styled <pre> on
173
+ // settle (a visible reflow during the most-watched moment). Detect a
174
+ // fence and render the bubble as a cheap monospaced <pre> shell instead
175
+ // (no Prism mid-stream, so no O(n^2)) so it does not reflow on settle.
176
+ if (part.text && part.text.indexOf('```') !== -1) parts.push({ kind: 'text', text: part.text, mdShell: true, preShell: true });
177
+ else parts.push({ kind: 'text', text: part.text, mdShell: true });
178
+ }
168
179
  else parts.push(part);
169
180
  }
170
181
  }
@@ -181,7 +192,7 @@ export function AgentChat(props = {}) {
181
192
  let actions;
182
193
  if (!isStreaming && msgHasBody(m)) {
183
194
  const built = [];
184
- if (onCopyMessage) built.push({ label: 'copy', icon: 'page', title: 'copy message', onClick: () => onCopyMessage(m) });
195
+ if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
185
196
  if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
186
197
  if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend', onClick: () => onEditMessage(m) });
187
198
  if (built.length) actions = built;
@@ -190,6 +201,9 @@ export function AgentChat(props = {}) {
190
201
  key: m.id || String(i),
191
202
  who: isAssistant ? 'them' : 'you',
192
203
  aicat: isAssistant,
204
+ // A stable per-agent product mark (host passes a small line-SVG via
205
+ // `avatar`) instead of a per-agent letter initial that shifts identity.
206
+ avatar: isAssistant ? (m.avatar != null ? m.avatar : avatar) : undefined,
193
207
  name: isAssistant ? name : 'you',
194
208
  time: m.time || '',
195
209
  typing: emptyStreaming,
@@ -210,8 +224,21 @@ export function AgentChat(props = {}) {
210
224
  onInput: (v) => onInput && onInput(v),
211
225
  onSend: (v) => onSend && onSend(v),
212
226
  onCancel: busy && onStop ? () => onStop() : undefined,
227
+ // The active target (agent / model / cwd-basename) at the point of typing.
228
+ context: composerContext,
213
229
  });
214
230
 
231
+ // Contextual follow-up chips below the last SETTLED assistant turn (claude.ai/
232
+ // code / cowork surface these after a turn, not only on an empty thread). Shown
233
+ // only when not busy and the last message is an assistant turn with body.
234
+ const followupRow = (!busy && followups && followups.length && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg))
235
+ ? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups' },
236
+ ...followups.map((s, i) => h('button', {
237
+ key: 'fu' + i, type: 'button', class: 'agentchat-empty-suggestion agentchat-followup',
238
+ onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onFollowupClick) onFollowupClick(t); else if (onSuggestionClick) onSuggestionClick(t); },
239
+ }, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
240
+ : null;
241
+
215
242
  // Empty state: a fresh thread is a void without this. Mirrors the kit's Chat
216
243
  // empty surface (title, sub, optional starter prompts) so AgentChat opens to
217
244
  // an invitation, not a blank panel.
@@ -250,7 +277,8 @@ export function AgentChat(props = {}) {
250
277
  ? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
251
278
  h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
252
279
  h('span', { class: 'agentchat-working-text' }, 'working…'))
253
- : null),
280
+ : null,
281
+ followupRow),
254
282
  // Jump-to-latest: hidden until the scroll listener adds .show (user scrolled
255
283
  // up). Clicking returns to the live edge. Pure-DOM, like the kit's other
256
284
  // 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) => {
@@ -33,6 +33,16 @@ 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
+ // Empty state: before an agent is picked AND with no usage/session, four
38
+ // placeholder rows (agent: none / model: dash / ...) read as a dead panel.
39
+ // Show one honest line instead.
40
+ if (!agent && !hasUsage && !session && !cwd) {
41
+ return h('div', { class: 'ds-context' },
42
+ h('div', { class: 'ds-context-empty', role: 'status' },
43
+ 'No active conversation — start a chat to see context here'),
44
+ onSetCwd ? h('div', { class: 'ds-context-actions' }, Btn({ onClick: onSetCwd, children: 'set working dir' })) : null);
45
+ }
36
46
  // Each Panel's children array is all-unkeyed (no key prop on any sibling),
37
47
  // so webjsx never sees a mixed keyed/unkeyed array here.
38
48
  const panels = [
@@ -58,7 +68,7 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
58
68
  ];
59
69
  // Usage block: surface the last turn's token/cost/turn/duration so the
60
70
  // result event is no longer silently dropped.
61
- if (usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null)) {
71
+ if (hasUsage) {
62
72
  const tokRows = [];
63
73
  if (usage.inputTokens != null) tokRows.push(Row({ title: 'input', meta: fmtTok(usage.inputTokens) + ' tok' }));
64
74
  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
+ }
@@ -43,26 +43,34 @@ 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 } = {}) {
47
- const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null].filter(Boolean).join(' · ');
46
+ export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked } = {}) {
47
+ // permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
48
+ // be listed (the dir stat saw it) but not opened — show an ASCII tag and
49
+ // disable the open button so the row reads honestly instead of silently
50
+ // failing on click.
51
+ const noAccess = locked || permissions === 'EACCES' || (Array.isArray(permissions) && permissions.length === 0);
52
+ const readOnly = !noAccess && Array.isArray(permissions) && permissions.indexOf('write') === -1 && permissions.indexOf('read') !== -1;
53
+ const permTag = noAccess ? 'no access' : (readOnly ? 'read-only' : null);
54
+ const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null, permTag].filter(Boolean).join(' · ');
48
55
  const typeLabel = TYPE_LABELS[type] || 'file';
49
56
  const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}`;
57
+ const canOpen = onOpen && !noAccess;
50
58
  // A role=button row containing real <button> action controls is invalid
51
59
  // HTML (interactive nesting). Instead the row is a plain container and the
52
60
  // primary "open" affordance is itself a real <button> (native keyboard +
53
61
  // semantics); the per-file action buttons sit alongside it as siblings.
54
62
  return h('div', {
55
63
  key,
56
- class: 'ds-file-row row' + (active ? ' active' : ''),
64
+ class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
57
65
  'data-file-type': type,
58
66
  },
59
67
  h('button', {
60
68
  type: 'button',
61
69
  class: 'ds-file-open',
62
- onclick: onOpen || null,
63
- 'aria-label': accessibleLabel,
70
+ onclick: canOpen ? onOpen : null,
71
+ 'aria-label': accessibleLabel + (noAccess ? ' (no access)' : ''),
64
72
  'aria-pressed': active ? 'true' : 'false',
65
- disabled: onOpen ? null : true,
73
+ disabled: canOpen ? null : true,
66
74
  },
67
75
  code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
68
76
  FileIcon({ type }),
@@ -117,10 +125,22 @@ export function sortFiles(files = [], sort = 'name', dir = 'asc') {
117
125
  // Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
118
126
  // row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
119
127
  // 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 } = {}) {
128
+ // How many rows to render before the "show more" cap kicks in. A node_modules-
129
+ // scale directory would otherwise flood the DOM with thousands of rows (and make
130
+ // the roving-tabindex querySelectorAll scan O(n) per keypress). Render the first
131
+ // CAP and a "show N more" row, mirroring the History tab's "load N older".
132
+ const FILE_GRID_CAP = 200;
133
+
134
+ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
135
+ columns = 'auto', sort, filter, loading = false,
136
+ shown, onShowMore } = {}) {
122
137
  if (loading) return FileSkeleton({});
123
138
  if (!files.length) return EmptyState({ text: emptyText });
139
+ // Cap the rendered rows. `shown` (host-controlled) overrides the default cap
140
+ // so "show more" can grow it; otherwise default to FILE_GRID_CAP.
141
+ const limit = shown != null ? shown : FILE_GRID_CAP;
142
+ const capped = files.length > limit;
143
+ const visible = capped ? files.slice(0, limit) : files;
124
144
  const gridAttrs = {};
125
145
  if (columns !== 'auto' && columns > 0) {
126
146
  const col = Math.max(1, Math.min(4, Math.floor(columns)));
@@ -151,16 +171,30 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'no f
151
171
  'aria-label': filter.placeholder || 'Filter files in this directory',
152
172
  oninput: (e) => filter.onInput && filter.onInput(e.target.value),
153
173
  })) : null;
154
- const grid = h('div', { class: 'ds-file-grid', role: 'listbox', 'aria-label': 'files', tabindex: '0', onkeydown: onKeyDown, ...gridAttrs },
155
- ...files.map((f, i) => FileRow({
174
+ // role=group not listbox: the rows contain real <button> action controls, so
175
+ // listbox/option semantics are invalid (an option can't host interactive
176
+ // children). Keyboard nav still works via roving focus over the open buttons.
177
+ const grid = h('div', { class: 'ds-file-grid', role: 'group', 'aria-label': 'files', tabindex: '0', onkeydown: onKeyDown, ...gridAttrs },
178
+ ...visible.map((f, i) => FileRow({
156
179
  key: f.path || f.name + i,
157
180
  name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
181
+ permissions: f.permissions, locked: f.locked,
158
182
  onOpen: onOpen ? () => onOpen(f) : null,
159
183
  onAction: onAction ? (act) => onAction(act, f) : null
160
184
  }))
161
185
  );
162
- return (head || filterBar)
163
- ? h('div', { class: 'ds-file-listing' }, filterBar, head, grid)
186
+ // A count + "show more" affordance so a capped large dir reads as "more
187
+ // exist", not "this is everything". aria-live announces the shown/total.
188
+ const more = capped
189
+ ? h('div', { class: 'ds-file-more' },
190
+ h('span', { class: 'ds-file-more-count', role: 'status', 'aria-live': 'polite' },
191
+ 'showing ' + visible.length + ' of ' + files.length),
192
+ onShowMore ? h('button', { type: 'button', class: 'ds-file-more-btn',
193
+ onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
194
+ 'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
195
+ : null;
196
+ return (head || filterBar || more)
197
+ ? h('div', { class: 'ds-file-listing' }, filterBar, head, grid, more)
164
198
  : grid;
165
199
  }
166
200
 
@@ -185,6 +219,21 @@ export function FileToolbar({ left = [], right = [] } = {}) {
185
219
  );
186
220
  }
187
221
 
222
+ // RootsPicker — a segmented control for choosing among multiple allowed FS roots
223
+ // (so the app stops borrowing the history-tab .pill markup). Each root is
224
+ // { id, label }; `selected` is the active id. role=tablist for AT navigation.
225
+ export function RootsPicker({ roots = [], selected, onSelect, label = 'roots' } = {}) {
226
+ if (!roots.length) return null;
227
+ return h('div', { class: 'ds-roots-picker', role: 'tablist', 'aria-label': label },
228
+ ...roots.map((r) => h('button', {
229
+ key: 'root-' + (r.id != null ? r.id : r.label),
230
+ type: 'button', role: 'tab',
231
+ class: 'ds-roots-tab' + ((r.id != null ? r.id : r.label) === selected ? ' active' : ''),
232
+ 'aria-selected': (r.id != null ? r.id : r.label) === selected ? 'true' : 'false',
233
+ onclick: () => onSelect && onSelect(r.id != null ? r.id : r.label),
234
+ }, r.label || r.id)));
235
+ }
236
+
188
237
  export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave, label = 'drop files here', onPick } = {}) {
189
238
  return h('div', {
190
239
  class: 'ds-dropzone' + (dragover ? ' dragover' : ''),
@@ -330,7 +330,10 @@ export function ShortcutHelpDialog({ open = false, onClose, registry } = {}) {
330
330
  ...Object.entries(groups).map(([scope, rows]) =>
331
331
  h('section', { class: 'ds-kbd-group' },
332
332
  h('h3', null, scope),
333
- h('ul', null, ...rows.map(r => h('li', null, h(ShortcutHint, { combo: r.combo }))))
333
+ h('ul', null, ...rows.map(r => h('li', { class: 'ds-kbd-row' },
334
+ h(ShortcutHint, { combo: r.combo }),
335
+ (r.label || r.description) ? h('span', { class: 'ds-kbd-label' }, r.label || r.description) : null
336
+ )))
334
337
  )
335
338
  )
336
339
  )
@@ -5,6 +5,7 @@
5
5
 
6
6
  import * as webjsx from '../../vendor/webjsx/index.js';
7
7
  import { Btn, Icon } from './shell.js';
8
+ import { Select, SearchInput } from './content.js';
8
9
  const h = webjsx.createElement;
9
10
 
10
11
  // ConversationList — the Claude-Desktop "Chats" column. Sessions grouped by a
@@ -18,7 +19,7 @@ const h = webjsx.createElement;
18
19
  // search : { value, onInput, placeholder } inline filter (optional)
19
20
  // onSelect(session), onNew() : intents
20
21
  // emptyText, loading, error : explicit states
21
- export function ConversationList({ sessions = [], selected, groups, search,
22
+ export function ConversationList({ sessions = [], selected, groups, search, caption,
22
23
  onSelect, onNew, newLabel = 'New chat',
23
24
  emptyText = 'No conversations yet', loading = false, error = null } = {}) {
24
25
  const rowFor = (s, i) => h('button', {
@@ -77,6 +78,10 @@ export function ConversationList({ sessions = [], selected, groups, search,
77
78
  'aria-label': search.placeholder || 'Search conversations',
78
79
  oninput: (e) => search.onInput && search.onInput(e.target.value),
79
80
  }) : null),
81
+ // Per-tab caption telling the user what selecting a row does on this surface
82
+ // (chat = resume the conversation, history = browse its events) so visually
83
+ // identical rows are disambiguated.
84
+ caption ? h('div', { key: 'cap', class: 'ds-session-caption' }, caption) : null,
80
85
  body);
81
86
  }
82
87
 
@@ -90,9 +95,18 @@ export function ConversationList({ sessions = [], selected, groups, search,
90
95
  // the relative time of the most-recent event ("4s ago"); `currentTool` the tool
91
96
  // name a still-running turn is executing - together they distinguish a busy
92
97
  // session from a stuck one (a frozen elapsed alone reads identically for both).
93
- export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } = {}) {
98
+ // `status` is one of: 'error' | 'stale' | 'running'. A 'stale' session is one
99
+ // the host has determined is alive but not making progress (no recent activity,
100
+ // no current tool) — it reads as `idle` with a NON-pulsing disc so a stuck agent
101
+ // is visually distinct from a busy one (a frozen elapsed alone reads identically
102
+ // for both, which is the high-severity oversight gap this closes).
103
+ const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running' };
104
+ const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live' };
105
+
106
+ export function SessionCard({ session = {}, onStop, onOpen, onView, active = false,
107
+ selectable = false, selected = false, onToggleSelect } = {}) {
94
108
  const s = session;
95
- const statusTone = s.status === 'error' ? 'flame' : 'live';
109
+ const st = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running');
96
110
  // The stat line composes elapsed + live counter; the activity line carries the
97
111
  // last-activity time and the current tool so a card shows MOTION, not just a
98
112
  // start offset. Both are middot-joined (kept product separator).
@@ -101,12 +115,19 @@ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } =
101
115
  s.currentTool ? 'running: ' + s.currentTool : null,
102
116
  s.lastActivity ? 'last ' + s.lastActivity : null,
103
117
  ].filter(Boolean);
104
- return h('div', { class: 'ds-dash-card' + (s.status === 'error' ? ' is-error' : ''), role: 'group', 'aria-label': 'session ' + (s.agent || s.sid) },
118
+ const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '');
119
+ return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.agent || s.sid), 'aria-current': active ? 'true' : null },
105
120
  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' }),
121
+ selectable ? h('button', {
122
+ type: 'button', class: 'ds-dash-select', role: 'checkbox',
123
+ 'aria-checked': selected ? 'true' : 'false',
124
+ 'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.agent || s.sid),
125
+ onclick: () => onToggleSelect && onToggleSelect(s),
126
+ }, selected ? '[x]' : '[ ]') : null,
127
+ h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
107
128
  // Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
108
129
  // 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'),
130
+ h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
110
131
  h('span', { class: 'ds-dash-agent' }, s.agent || 'agent'),
111
132
  s.model ? h('span', { class: 'ds-dash-model' }, s.model) : null),
112
133
  h('div', { class: 'ds-dash-meta' },
@@ -114,8 +135,9 @@ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } =
114
135
  statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
115
136
  activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null),
116
137
  h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' },
138
+ // open and resume collapsed into one 'open' action (they both just reopen
139
+ // the session in chat); 'events' kept for the read-only event view.
117
140
  onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
118
- onResume ? Btn({ key: 'resume', onClick: () => onResume(s), children: 'resume' }) : null,
119
141
  onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
120
142
  onStop ? Btn({ key: 'stop', danger: true, onClick: () => onStop(s), children: 'stop' }) : null));
121
143
  }
@@ -128,20 +150,56 @@ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } =
128
150
  // The bulk header is the "manage many at once" affordance: a live count plus a
129
151
  // stop-all button, so a user running several agents does not have to hunt each
130
152
  // card's stop. Rendered only when there are sessions AND onStopAll is wired.
131
- export function SessionDashboard({ sessions = [], onStop, onOpen, onResume, onView, onStopAll,
153
+ // Streamstate words: the live-stream health signal so "connected, zero running"
154
+ // still tells the user the dashboard is listening (vs a dropped stream).
155
+ const STREAM_WORD = { connected: 'listening for activity', connecting: 'connecting to live stream…', lost: 'live stream lost — retrying…' };
156
+
157
+ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStopAll, onStopSelected,
158
+ sort, filter, errorsOnly = false, onErrorsOnly,
159
+ selectable = false, selected, onToggleSelect,
160
+ activeSid, streamState,
132
161
  emptyText = 'No live sessions', offline = false } = {}) {
133
162
  if (offline) {
134
163
  return h('div', { class: 'ds-dash-state ds-dash-state-error', role: 'status' }, 'Backend offline — live sessions unavailable');
135
164
  }
165
+ const selSet = selected instanceof Set ? selected : new Set(selected || []);
166
+ const selCount = selSet.size;
167
+ // The stream-state line always renders (even with zero sessions) so a
168
+ // connected-but-idle dashboard reads differently from an offline one.
169
+ const streamLine = streamState
170
+ ? h('span', { class: 'ds-dash-stream is-' + streamState, role: 'status', 'aria-live': 'polite' }, STREAM_WORD[streamState] || streamState)
171
+ : null;
172
+ const toolbar = (sort || filter || onErrorsOnly)
173
+ ? h('div', { class: 'ds-dash-toolbar', role: 'group', 'aria-label': 'sort and filter sessions' },
174
+ filter ? SearchInput({ key: 'filt', value: filter.value || '', label: filter.placeholder || 'Filter sessions', placeholder: filter.placeholder || 'Filter sessions', onInput: (v) => filter.onInput && filter.onInput(v) }) : null,
175
+ sort ? Select({ key: 'sort', value: sort.value || 'status', title: 'Sort sessions',
176
+ options: [
177
+ { value: 'status', label: 'sort: status' },
178
+ { value: 'elapsed', label: 'sort: elapsed' },
179
+ { value: 'activity', label: 'sort: last activity' },
180
+ { value: 'errors', label: 'sort: errors first' },
181
+ ], onChange: (v) => sort.onChange && sort.onChange(v) }) : null,
182
+ onErrorsOnly ? h('button', { key: 'eo', type: 'button', class: 'ds-dash-errors-toggle' + (errorsOnly ? ' active' : ''),
183
+ 'aria-pressed': errorsOnly ? 'true' : 'false', onclick: () => onErrorsOnly(!errorsOnly) }, 'errors only') : null)
184
+ : null;
136
185
  if (!sessions.length) {
137
- return h('div', { class: 'ds-dash-state', role: 'status' }, emptyText);
186
+ return h('div', { class: 'ds-dash' },
187
+ h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
188
+ h('span', { class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' }, '0 running'), streamLine),
189
+ h('div', { class: 'ds-dash-state', role: 'status' }, emptyText));
138
190
  }
139
191
  const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
140
192
  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);
193
+ selectable && selCount ? selCount + ' selected' : sessions.length + ' running'),
194
+ streamLine,
195
+ h('span', { class: 'spread' }),
196
+ 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),
199
+ toolbar);
143
200
  const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
144
201
  ...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
145
- SessionCard({ session: s, onStop, onOpen, onResume, onView }))));
202
+ SessionCard({ session: s, onStop, onOpen, onView, active: s.sid === activeSid,
203
+ selectable, selected: selSet.has(s.sid), onToggleSelect }))));
146
204
  return h('div', { class: 'ds-dash' }, header, grid);
147
205
  }