anentrypoint-design 0.0.206 → 0.0.207

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.206",
3
+ "version": "0.0.207",
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",
@@ -252,7 +252,11 @@ export function AgentChat(props = {}) {
252
252
  return ChatMessage({
253
253
  key: m.id || String(i),
254
254
  who: isAssistant ? 'them' : 'you',
255
- aicat: isAssistant,
255
+ // Claude-Code-web layout: flat full-width turns (no avatar disc, no colored
256
+ // bubble), distinguished by a role label + a faint assistant background.
257
+ // aicat is left OFF so the mascot tint never reaches the agent surface.
258
+ flat: true,
259
+ aicat: false,
256
260
  // A stable per-agent product mark (host passes a small line-SVG via
257
261
  // `avatar`) instead of a per-agent letter initial that shifts identity.
258
262
  avatar: isAssistant ? (m.avatar != null ? m.avatar : avatar) : undefined,
@@ -128,6 +128,18 @@ export function injectCodeCopy(container) {
128
128
  shell.className = 'chat-code-block';
129
129
  pre.parentNode.insertBefore(shell, pre);
130
130
  shell.appendChild(pre);
131
+ // Surface the fenced language as a small header tab (claude.ai/code
132
+ // shows the language on every block, not just the structured CodeNode).
133
+ // The highlighter sets language-xx / lang-xx on the inner <code>.
134
+ const codeEl = pre.querySelector('code');
135
+ const langCls = codeEl && (codeEl.className || '').match(/(?:language|lang)-([a-z0-9+#]+)/i);
136
+ if (langCls && langCls[1]) {
137
+ const lang = document.createElement('span');
138
+ lang.className = 'chat-code-lang';
139
+ lang.setAttribute('aria-hidden', 'true');
140
+ lang.textContent = langCls[1];
141
+ shell.appendChild(lang);
142
+ }
131
143
  const btn = document.createElement('button');
132
144
  btn.type = 'button';
133
145
  btn.className = 'chat-code-copy';
@@ -289,7 +301,7 @@ function renderPart(p, key) {
289
301
  return node;
290
302
  }
291
303
 
292
- export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped }) {
304
+ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped, flat }) {
293
305
  _stats.messages += 1;
294
306
  // Support legacy 'who' prop, prefer 'role' with mapping:
295
307
  // 'user' -> 'you' (right-aligned, accent bubble)
@@ -304,7 +316,11 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
304
316
  : role)
305
317
  : who;
306
318
  const isCentered = resolvedWho === 'system' || resolvedWho === 'tool' || resolvedWho === 'thinking';
307
- const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '') + (isCentered ? ' centered' : '');
319
+ // Flat layout (Claude-Code-web): full-width, avatar-less turns with a role
320
+ // label above the content and a faint assistant background, instead of the
321
+ // messenger avatar-disc + colored-bubble layout (kept for the chat demo).
322
+ const isFlat = flat && !isCentered;
323
+ const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '') + (isCentered ? ' centered' : '') + (isFlat ? ' chat-msg-flat' : '');
308
324
  const fallbackAvatar = avatar != null
309
325
  ? avatar
310
326
  : (resolvedWho === 'you' ? 'u' : (name ? String(name).trim().charAt(0).toUpperCase() || '?' : '?'));
@@ -355,11 +371,18 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
355
371
  }, a.icon ? Icon(a.icon, { size: 14 }) : null,
356
372
  a.label ? h('span', { class: 'chat-msg-action-label' }, a.label) : null)))
357
373
  : null;
358
- const stack = h('div', { class: 'chat-stack' }, ...bodyNodes, reactionRow, actionRow, meta);
374
+ // Flat layout leads the turn with a small role label (You / agent name)
375
+ // above the content, the way claude.ai/code titles each turn.
376
+ const roleLabel = isFlat
377
+ ? h('div', { class: 'chat-role', key: '_role' }, resolvedWho === 'you' ? 'You' : (name || 'Assistant'))
378
+ : null;
379
+ const stack = h('div', { class: 'chat-stack' }, roleLabel, ...bodyNodes, reactionRow, actionRow, meta);
359
380
  // Centered roles (system/tool/thinking) skip the avatar column entirely so
360
381
  // the bubble owns the full row — the chrome reads as out-of-band signal,
361
382
  // not a participant turn.
362
383
  if (isCentered) return h('div', { key, class: cls }, stack);
384
+ // Flat turns drop the avatar column entirely (full-width content).
385
+ if (isFlat) return h('div', { key, class: cls }, stack);
363
386
  return h('div', { key, class: cls }, resolvedWho === 'you' ? stack : av, resolvedWho === 'you' ? av : stack);
364
387
  }
365
388
 
@@ -3,6 +3,7 @@
3
3
  import * as webjsx from '../../vendor/webjsx/index.js';
4
4
  import { Btn, Icon } from './shell.js';
5
5
  import { fileGlyph, fmtFileSize } from './files.js';
6
+ import { highlightAllUnder } from '../highlight.js';
6
7
  const h = webjsx.createElement;
7
8
 
8
9
  // Monotonic id source for aria-labelledby wiring between a modal and its head.
@@ -205,7 +206,29 @@ export function PromptDialog({ title = 'name', value = '', placeholder = '', con
205
206
  }
206
207
 
207
208
  export function FilePreviewMedia({ src, type = 'other', name } = {}) {
208
- if (type === 'image') return h('img', { class: 'ds-preview-media', src, alt: name || '' });
209
+ if (type === 'image') {
210
+ // Fit-to-pane (default) vs actual-size (1:1) toggle + a checkerboard so
211
+ // transparency reads. The toggle flips a class on the img in-place and
212
+ // reports the natural pixel dimensions into its own caption on load.
213
+ const onToggle = (e) => {
214
+ const wrap = e.currentTarget.closest('.ds-preview-media-wrap');
215
+ const img = wrap && wrap.querySelector('.ds-preview-media');
216
+ if (!img) return;
217
+ const actual = img.classList.toggle('is-actual');
218
+ e.currentTarget.textContent = actual ? 'fit to pane' : 'actual size';
219
+ };
220
+ const onLoad = (e) => {
221
+ const img = e.currentTarget;
222
+ const cap = img.closest('.ds-preview-media-wrap');
223
+ const dim = cap && cap.querySelector('.ds-preview-media-dim');
224
+ if (dim && img.naturalWidth) dim.textContent = img.naturalWidth + ' x ' + img.naturalHeight + ' px';
225
+ };
226
+ return h('div', { class: 'ds-preview-media-wrap' },
227
+ h('img', { class: 'ds-preview-media ds-preview-media-alpha', src, alt: name || '', onload: onLoad }),
228
+ h('div', { class: 'ds-preview-media-controls' },
229
+ h('span', { class: 'ds-preview-media-dim', 'aria-live': 'polite' }, ''),
230
+ h('button', { type: 'button', class: 'chat-code-copy', onclick: onToggle }, 'actual size')));
231
+ }
209
232
  if (type === 'video') return h('video', { class: 'ds-preview-media', src, controls: true });
210
233
  if (type === 'audio') return h('audio', { class: 'ds-preview-audio', src, controls: true });
211
234
  return h('div', { class: 'ds-preview-fallback' },
@@ -230,11 +253,30 @@ export function FilePreviewCode({ content = '', lang, filename } = {}) {
230
253
  filename ? h('span', { class: 'name' }, filename) : null,
231
254
  h('span', { class: 'spread' }),
232
255
  h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')),
233
- h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') },
234
- h('code', { class: lang ? 'language-' + lang : '' }, content))
256
+ codeBody({ content, lang })
235
257
  );
236
258
  }
237
259
 
260
+ // The code body: a non-selectable line-number gutter + the highlighted code.
261
+ // A ref triggers Prism over the <code> after mount (the bundle only auto-runs
262
+ // Prism in the chat path), so the file preview is token-colored like Claude
263
+ // Code's file pane. lineNumbers defaults on for code, off for plaintext.
264
+ function codeBody({ content = '', lang } = {}) {
265
+ const wantGutter = !!lang;
266
+ const lineCount = content ? content.split('\n').length : 1;
267
+ const gutter = wantGutter
268
+ ? h('div', { class: 'ds-preview-gutter', 'aria-hidden': 'true' },
269
+ Array.from({ length: lineCount }, (_, i) => String(i + 1)).join('\n'))
270
+ : null;
271
+ const highlightRef = (el) => {
272
+ if (!el) return;
273
+ try { highlightAllUnder(el); } catch {}
274
+ };
275
+ return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') + (wantGutter ? ' has-gutter' : ''), ref: highlightRef },
276
+ gutter,
277
+ h('code', { class: lang ? 'language-' + lang : '' }, content));
278
+ }
279
+
238
280
  export function FilePreviewText({ content = '', truncated } = {}) {
239
281
  return h('pre', { class: 'ds-preview-text' },
240
282
  h('code', {}, content),
@@ -10,8 +10,8 @@ const FILE_GRID_MIN_COL = '240px';
10
10
 
11
11
  const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
12
12
  const TYPE_ICON = {
13
- dir: 'file', image: 'file', video: 'file-video', audio: 'file-audio', code: 'file-code',
14
- text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'file', other: 'file'
13
+ dir: 'folder', image: 'file-image', video: 'file-video', audio: 'file-audio', code: 'file-code',
14
+ text: 'file-text', archive: 'file-zip', document: 'file-text', symlink: 'link', other: 'file'
15
15
  };
16
16
 
17
17
  const TYPE_LABELS = {
@@ -69,7 +69,13 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
69
69
  // are uniformly keyed; non-row states render a single unkeyed status line.
70
70
  let inner;
71
71
  if (loading) {
72
- inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, loadingText)];
72
+ // Shape-matched skeleton rows during the cold ccsniff index walk (the rail
73
+ // showed a bare line before) - Claude-Desktop skeletons its sidebar on load.
74
+ inner = [
75
+ h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, loadingText),
76
+ ...Array.from({ length: 5 }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-session-row-skeleton', 'aria-hidden': 'true' },
77
+ h('div', { class: 'ds-skel ds-skel-title' }), h('div', { class: 'ds-skel ds-skel-meta' }))),
78
+ ];
73
79
  } else if (error) {
74
80
  inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
75
81
  } else if (!sessions.length) {
@@ -161,41 +167,51 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
161
167
  // last-activity time and the current tool so a card shows MOTION, not just a
162
168
  // start offset. Both are middot-joined (kept product separator).
163
169
  const elapsedText = s.elapsedMs != null ? fmtDuration(s.elapsedMs) : (s.elapsed != null ? s.elapsed : null);
164
- const statBits = [elapsedText, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
170
+ // At-a-glance cost/usage (the prompt's named command-center signal). Null-safe:
171
+ // sessions with no cost source (external tally rows) simply omit the segment.
172
+ const tokText = s.tokens != null ? (typeof s.tokens === 'number' ? s.tokens.toLocaleString() : s.tokens) + ' tok' : null;
173
+ const costText = s.cost != null ? (typeof s.cost === 'number' ? '$' + s.cost.toFixed(4) : String(s.cost)) : null;
174
+ const statBits = [elapsedText, s.counter != null ? s.counter : null, tokText, costText].filter((x) => x != null && x !== '');
165
175
  const activityBits = [
166
176
  s.currentTool ? 'running: ' + s.currentTool : null,
167
177
  s.lastActivity ? 'last ' + s.lastActivity : null,
168
178
  ].filter(Boolean);
169
- const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + (s.external ? ' is-external' : '');
179
+ const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + (s.external ? ' is-external' : '') + (s.isNew ? ' is-new' : '');
180
+ // EVERY children array is filter(Boolean)'d: webjsx applyDiff crashes
181
+ // (reading 'key') on a bare null among VElement siblings, so a null cwd /
182
+ // model / external flag must never reach a positional child slot.
183
+ const head = h('div', { class: 'ds-dash-card-head' }, ...[
184
+ selectable ? h('button', {
185
+ type: 'button', class: 'ds-dash-select', role: 'checkbox',
186
+ 'aria-checked': selected ? 'true' : 'false',
187
+ 'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.title || s.agent || s.sid),
188
+ onclick: () => onToggleSelect && onToggleSelect(s),
189
+ }, selected ? '[x]' : '[ ]') : null,
190
+ h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
191
+ h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
192
+ s.external ? h('span', { class: 'ds-dash-external' }, 'external') : null,
193
+ h('span', { class: 'ds-dash-agent', title: s.agent || null }, s.agent || 'agent'),
194
+ s.model ? h('span', { class: 'ds-dash-model', title: s.model }, s.model) : null,
195
+ ].filter(Boolean));
196
+ const meta = h('div', { class: 'ds-dash-meta' }, ...[
197
+ s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
198
+ statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
199
+ activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null,
200
+ ].filter(Boolean));
201
+ const actions = h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' }, ...[
202
+ onOpen ? Btn({ key: 'open', primary: true, 'aria-label': 'open session', onClick: () => onOpen(s),
203
+ children: [Icon('external-link', { size: 14 }), h('span', {}, 'open')] }) : null,
204
+ onView ? Btn({ key: 'view', 'aria-label': s.external ? 'open in history' : 'view events', onClick: () => onView(s),
205
+ children: [Icon('file-text', { size: 14 }), h('span', {}, s.external ? 'history' : 'events')] }) : null,
206
+ (onStop && !s.external) ? Btn({ key: 'stop', danger: true, disabled: !!s.stopping, 'aria-label': 'stop session',
207
+ onClick: () => !s.stopping && onStop(s),
208
+ children: [Icon('square', { size: 14 }), h('span', {}, s.stopping ? 'stopping…' : 'stop')] }) : null,
209
+ ].filter(Boolean));
170
210
  return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.title || s.agent || s.sid), 'aria-current': active ? 'true' : null },
171
- // Shared session identity: the same title the conversation rails show.
172
- s.title ? h('div', { class: 'ds-dash-title', title: s.title }, s.title) : null,
173
- h('div', { class: 'ds-dash-card-head' },
174
- selectable ? h('button', {
175
- type: 'button', class: 'ds-dash-select', role: 'checkbox',
176
- 'aria-checked': selected ? 'true' : 'false',
177
- 'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.title || s.agent || s.sid),
178
- onclick: () => onToggleSelect && onToggleSelect(s),
179
- }, selected ? '[x]' : '[ ]') : null,
180
- h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
181
- // Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
182
- // aria-hidden, so the visible/AT status word carries the state.
183
- h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
184
- s.external ? h('span', { class: 'ds-dash-external' }, 'external') : null,
185
- h('span', { class: 'ds-dash-agent', title: s.agent || null }, s.agent || 'agent'),
186
- s.model ? h('span', { class: 'ds-dash-model', title: s.model }, s.model) : null),
187
- h('div', { class: 'ds-dash-meta' },
188
- s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
189
- statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
190
- activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null),
191
- h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' },
192
- // open and resume collapsed into one 'open' action (they both just reopen
193
- // the session in chat); 'events' kept for the read-only event view.
194
- onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
195
- onView ? Btn({ key: 'view', onClick: () => onView(s), children: s.external ? 'open in history' : 'events' }) : null,
196
- // External sessions get no stop control: we own no process to kill.
197
- (onStop && !s.external) ? Btn({ key: 'stop', danger: true, disabled: !!s.stopping,
198
- onClick: () => !s.stopping && onStop(s), children: s.stopping ? 'stopping…' : 'stop' }) : null));
211
+ ...[
212
+ s.title ? h('div', { class: 'ds-dash-title', title: s.title }, s.title) : null,
213
+ head, meta, actions,
214
+ ].filter(Boolean));
199
215
  }
200
216
 
201
217
  // SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
@@ -226,7 +242,7 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
226
242
  confirmingStopAll = false, confirmingStopSelected = false,
227
243
  onArmStopAll, onArmStopSelected,
228
244
  sort, filter, errorsOnly = false, onErrorsOnly,
229
- selectable = false, selected, onToggleSelect,
245
+ selectable = false, selected, onToggleSelect, onSelectAll, onClearSelection,
230
246
  activeSid, streamState,
231
247
  emptyText = 'No live sessions', offline = false } = {}) {
232
248
  if (offline) {
@@ -239,11 +255,37 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
239
255
  const stoppingCount = sessions.filter((s) => s.stopping).length;
240
256
  // The stream-state line always renders (even with zero sessions) so a
241
257
  // connected-but-idle dashboard reads differently from an offline one.
258
+ // The stream line leads with a status disc so a connected dashboard visibly
259
+ // PULSES that it is listening (the command-center heartbeat), connecting/offline
260
+ // show a static disc. The disc is aria-hidden; the word carries the state.
261
+ const streamDisc = streamState
262
+ ? 'status-dot-disc ' + (streamState === 'connected' ? 'status-dot-live'
263
+ : streamState === 'connecting' ? 'status-dot-connecting' : 'status-dot-error')
264
+ : null;
242
265
  const streamLine = streamState
243
- ? h('span', { class: 'ds-dash-stream is-' + streamState, role: 'status', 'aria-live': 'polite' }, STREAM_WORD[streamState] || streamState)
266
+ ? h('span', { key: 'stream', class: 'ds-dash-stream-disc' },
267
+ h('span', { class: streamDisc, 'aria-hidden': 'true' }),
268
+ h('span', { class: 'ds-dash-stream is-' + streamState, role: 'status', 'aria-live': 'polite' }, STREAM_WORD[streamState] || streamState))
269
+ : null;
270
+ // At-a-glance status breakdown for the command-center header.
271
+ const counts = sessions.reduce((a, s) => {
272
+ const k = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'idle' : 'running');
273
+ a[k] = (a[k] || 0) + 1; return a;
274
+ }, {});
275
+ const breakdownSegs = [
276
+ counts.running ? { k: 'running', t: counts.running + ' running' } : null,
277
+ counts.idle ? { k: 'idle', t: counts.idle + ' idle' } : null,
278
+ counts.error ? { k: 'error', t: counts.error + ' error' + (counts.error === 1 ? '' : 's') } : null,
279
+ ].filter(Boolean);
280
+ const breakdown = breakdownSegs.length
281
+ ? h('span', { key: 'bd', class: 'ds-dash-breakdown', role: 'status', 'aria-live': 'polite' },
282
+ ...breakdownSegs.flatMap((seg, i) => [
283
+ i ? h('span', { key: 'bsep' + i, class: 'ds-dash-breakdown-sep', 'aria-hidden': 'true' }, ' · ') : null,
284
+ h('span', { key: 'bseg' + i, class: 'seg is-' + seg.k }, seg.t),
285
+ ].filter(Boolean)))
244
286
  : null;
245
287
  const toolbar = (sort || filter || onErrorsOnly)
246
- ? h('div', { class: 'ds-dash-toolbar', role: 'group', 'aria-label': 'sort and filter sessions' },
288
+ ? h('div', { key: 'tb', class: 'ds-dash-toolbar', role: 'group', 'aria-label': 'sort and filter sessions' },
247
289
  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,
248
290
  sort ? Select({ key: 'sort', value: sort.value || 'status', title: 'Sort sessions',
249
291
  options: [
@@ -258,31 +300,70 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
258
300
  if (!sessions.length) {
259
301
  return h('div', { class: 'ds-dash' },
260
302
  h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
261
- h('span', { class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' }, '0 running'), streamLine),
303
+ ...[h('span', { key: 'cnt', class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' }, '0 running'), streamLine].filter(Boolean)),
262
304
  h('div', { class: 'ds-dash-state', role: 'status' }, emptyText));
263
305
  }
264
- const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
265
- h('span', { class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' },
266
- selectable && selCount ? selCount + ' selected' : sessions.length + ' running'),
267
- streamLine,
268
- h('span', { class: 'spread' }),
269
- stoppingCount > 0 && (onStopSelected || onStopAll)
306
+ // Tri-state select-all over the selectable (non-external) sessions.
307
+ const selectableSids = sessions.filter((s) => !s.external).map((s) => s.sid);
308
+ const selOfVisible = selectableSids.filter((sid) => selSet.has(sid)).length;
309
+ const allState = selOfVisible === 0 ? 'false' : (selOfVisible === selectableSids.length ? 'true' : 'mixed');
310
+ const selectAllCtl = (selectable && onSelectAll && selectableSids.length)
311
+ ? h('button', { key: 'selall', type: 'button', class: 'ds-dash-selectall', role: 'checkbox',
312
+ 'aria-checked': allState, 'aria-label': allState === 'true' ? 'clear selection' : 'select all sessions',
313
+ onclick: () => (allState === 'true' && onClearSelection) ? onClearSelection() : onSelectAll(selectableSids) },
314
+ h('span', { 'aria-hidden': 'true' }, allState === 'true' ? '[x]' : allState === 'mixed' ? '[-]' : '[ ]'),
315
+ h('span', {}, 'all'))
316
+ : null;
317
+ const clearCtl = (selectable && selCount && onClearSelection)
318
+ ? h('button', { key: 'selclr', type: 'button', class: 'ds-dash-clear', onclick: () => onClearSelection() }, 'clear')
319
+ : null;
320
+ const stopBtn = stoppingCount > 0 && (onStopSelected || onStopAll)
270
321
  ? Btn({ key: 'stopbusy', danger: true, disabled: true, children: 'stopping ' + stoppingCount + '…' })
271
322
  : (selectable && selCount && onStopSelected
272
323
  ? (onArmStopSelected && !confirmingStopSelected
273
324
  ? Btn({ key: 'stopsel', danger: true, onClick: () => onArmStopSelected([...selSet]), children: 'stop selected' })
274
- : Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]),
325
+ : Btn({ key: 'stopsel', danger: true, className: confirmingStopSelected ? 'is-armed' : null, onClick: () => onStopSelected([...selSet]),
275
326
  children: confirmingStopSelected ? 'stop ' + selCount + ' sessions - press again' : 'stop selected' }))
276
327
  : (onStopAll
277
328
  ? (onArmStopAll && !confirmingStopAll
278
329
  ? Btn({ key: 'stopall', danger: true, onClick: () => onArmStopAll(sessions), children: 'stop all' })
279
- : Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions),
330
+ : Btn({ key: 'stopall', danger: true, className: confirmingStopAll ? 'is-armed' : null, onClick: () => onStopAll(sessions),
280
331
  children: confirmingStopAll ? 'stop ' + sessions.length + ' sessions - press again' : 'stop all' }))
281
- : null)),
282
- toolbar);
283
- const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
284
- ...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
285
- SessionCard({ session: s, onStop, onOpen, onView, active: s.sid === activeSid,
286
- selectable, selected: selSet.has(s.sid), onToggleSelect }))));
287
- return h('div', { class: 'ds-dash' }, header, grid);
332
+ : null));
333
+ // Build header children as a filtered array: webjsx applyDiff crashes
334
+ // (reading 'key') when a bare null sits among keyed siblings, so never pass
335
+ // a conditional child positionally - filter it out first.
336
+ const headerKids = [
337
+ selectable && selCount
338
+ ? h('span', { key: 'cnt', class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' }, selCount + ' selected')
339
+ : (breakdown || h('span', { key: 'cnt', class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' }, sessions.length + ' running')),
340
+ selectAllCtl, clearCtl, streamLine,
341
+ h('span', { key: 'spread', class: 'spread' }),
342
+ stopBtn, toolbar,
343
+ ].filter(Boolean);
344
+ const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' }, ...headerKids);
345
+ // Status-bucketed command center: when sorting by status (the default), the
346
+ // grid renders labelled sections (Errored / Running / Idle / External) so a
347
+ // pile of sessions reads as scannable groups. Other sorts collapse to one
348
+ // flat grid (the sort already orders them).
349
+ const grouped = !sort || !sort.value || sort.value === 'status';
350
+ const cardOf = (s) => h('div', { key: s.sid, role: 'listitem' },
351
+ SessionCard({ session: s, onStop, onOpen, onView, active: s.sid === activeSid,
352
+ selectable, selected: selSet.has(s.sid), onToggleSelect }));
353
+ let body;
354
+ if (grouped) {
355
+ const buckets = [
356
+ { key: 'error', label: 'Errored', rows: sessions.filter((s) => !s.external && s.status === 'error') },
357
+ { key: 'running', label: 'Running', rows: sessions.filter((s) => !s.external && s.status !== 'error' && s.status !== 'stale') },
358
+ { key: 'idle', label: 'Idle', rows: sessions.filter((s) => !s.external && s.status === 'stale') },
359
+ { key: 'external', label: 'External', rows: sessions.filter((s) => s.external) },
360
+ ].filter((b) => b.rows.length);
361
+ body = h('div', { class: 'ds-dash-groups' },
362
+ ...buckets.map((b) => h('div', { key: 'grp' + b.key, class: 'ds-dash-group', role: 'group', 'aria-label': b.label + ' sessions' },
363
+ h('div', { class: 'ds-dash-group-label' }, b.label + ' · ' + b.rows.length),
364
+ h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': b.label + ' sessions' }, ...b.rows.map(cardOf)))));
365
+ } else {
366
+ body = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' }, ...sessions.map(cardOf));
367
+ }
368
+ return h('div', { class: 'ds-dash' }, header, body);
288
369
  }
@@ -15,11 +15,12 @@ export function Chip({ tone = '', children }) {
15
15
  return h('span', { class: 'chip' + (tone ? ' tone-' + tone : '') }, children);
16
16
  }
17
17
 
18
- export function Btn({ href, variant = 'default', children, onClick, 'aria-label': ariaLabel, primary, ghost, danger, disabled }) {
18
+ export function Btn({ href, variant = 'default', children, onClick, 'aria-label': ariaLabel, primary, ghost, danger, disabled, className }) {
19
19
  // Support legacy primary/ghost props for backward compatibility, but prefer variant
20
20
  const resolvedVariant = variant !== 'default' ? variant : (primary ? 'primary' : (ghost ? 'ghost' : (danger ? 'danger' : 'default')));
21
21
  const cls = (resolvedVariant === 'primary' ? 'btn-primary' : (resolvedVariant === 'ghost' ? 'btn-ghost' : (resolvedVariant === 'danger' ? 'btn-primary danger' : 'btn')))
22
- + (disabled ? ' is-disabled' : '');
22
+ + (disabled ? ' is-disabled' : '')
23
+ + (className ? ' ' + className : '');
23
24
  const onclick = (e) => {
24
25
  if (disabled) { e.preventDefault(); return; }
25
26
  if (onClick) onClick(e);
@@ -29,6 +30,10 @@ export function Btn({ href, variant = 'default', children, onClick, 'aria-label'
29
30
  // A real navigational href renders an anchor; everything else is an action
30
31
  // button and renders a native <button> (correct semantics + keyboard
31
32
  // activation for free, no role=button / href="#" scroll-jump hack).
33
+ // children may be a string OR an array of vnodes (e.g. icon + label); spread
34
+ // arrays so each vnode is a real child - passing the array as a single child
35
+ // produces a nested array webjsx applyDiff cannot key-diff (reading 'key').
36
+ const kids = Array.isArray(children) ? children : [children];
32
37
  const isLink = href != null && href !== '' && href !== '#';
33
38
  if (isLink) {
34
39
  return h('a', {
@@ -37,14 +42,14 @@ export function Btn({ href, variant = 'default', children, onClick, 'aria-label'
37
42
  'aria-disabled': disabled ? 'true' : null,
38
43
  tabindex: disabled ? '-1' : null,
39
44
  onclick
40
- }, children);
45
+ }, ...kids);
41
46
  }
42
47
  return h('button', {
43
48
  type: 'button', class: cls,
44
49
  disabled: disabled ? true : null,
45
50
  'aria-label': ariaName,
46
51
  onclick
47
- }, children);
52
+ }, ...kids);
48
53
  }
49
54
 
50
55
  export function IconButton({ icon, onClick, title, size = 'base', variant = 'ghost', disabled = false }) {
@@ -138,6 +143,9 @@ const ICON_PATHS = {
138
143
  contrast: '<circle cx="12" cy="12" r="9"/><path d="M12 3v18a9 9 0 0 0 0-18z" fill="currentColor"/>',
139
144
  // file-browser icons (replace folder/file emoji + arrow glyphs in fs apps)
140
145
  folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
146
+ 'folder-open': '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2H5l-2 9z"/><path d="M3 18l2-9h17l-2 9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
147
+ 'file-image': '<path d="M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5"/><circle cx="9.5" cy="12.5" r="1.5"/><path d="M18 19l-4-4-3 3-2-2-3 3"/>',
148
+ link: '<path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/>',
141
149
  upload: '<path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/>',
142
150
  download: '<path d="M12 4v12M7 11l5 5 5-5"/><path d="M5 20h14"/>',
143
151
  'corner-up-left': '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v6"/>',
@@ -363,7 +371,7 @@ function wsCollapsed(which, fallback) {
363
371
  export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narrow,
364
372
  railCollapsed = false, paneCollapsed = false,
365
373
  railLabel = 'workspace navigation',
366
- paneLabel = 'context', stableFrame = false } = {}) {
374
+ paneLabel = 'context', stableFrame = false, mainFlush = false } = {}) {
367
375
  const hasSessions = Boolean(sessions);
368
376
  const hasPane = Boolean(pane);
369
377
  // Stable frame: keep the pane grid TRACK present even when this tab has no
@@ -423,7 +431,7 @@ export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narr
423
431
  onclick: () => toggleWsDrawer('pane'),
424
432
  }, Icon('page')) : null)
425
433
  : null,
426
- h('main', { class: 'ws-main' + (narrow ? ' narrow' : ''), id: 'ws-main', tabindex: '-1' },
434
+ h('main', { class: 'ws-main' + (narrow ? ' narrow' : '') + (mainFlush ? ' ws-main--flush' : ''), id: 'ws-main', tabindex: '-1' },
427
435
  ...(Array.isArray(main) ? main : [main])),
428
436
  status || null),
429
437
  // Optional right context pane with its own collapse toggle.