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.
@@ -43,36 +43,70 @@ 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
+ // Default action set for FileRow. A host without mutation endpoints passes a
47
+ // narrower `actions` list (e.g. ['download']) so the row renders no dead controls.
48
+ const FILE_ROW_ACTIONS = ['download', 'rename', 'delete'];
49
+
50
+ export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
51
+ actions = FILE_ROW_ACTIONS, busy = false } = {}) {
52
+ // permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
53
+ // be listed (the dir stat saw it) but not opened — show an ASCII tag and
54
+ // disable the open button so the row reads honestly instead of silently
55
+ // failing on click.
56
+ const noAccess = locked || permissions === 'EACCES' || (Array.isArray(permissions) && permissions.length === 0);
57
+ const readOnly = !noAccess && Array.isArray(permissions) && permissions.indexOf('write') === -1 && permissions.indexOf('read') !== -1;
58
+ const permTag = noAccess ? 'no access' : (readOnly ? 'read-only' : null);
59
+ const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null, permTag].filter(Boolean).join(' · ');
48
60
  const typeLabel = TYPE_LABELS[type] || 'file';
49
61
  const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}`;
62
+ const canOpen = onOpen && !noAccess && !busy;
63
+ // Mutation actions on a read-only/no-access row render disabled (with a
64
+ // 'read-only' title) instead of vanishing, so the affordance reads honestly.
65
+ // `busy` (in-flight mutation) disables every control on the row.
66
+ const mutateDisabled = busy || readOnly || noAccess;
67
+ const actBtn = (act, title, ariaLabel, icon, warn) => h('button', {
68
+ key: 'act-' + act,
69
+ type: 'button',
70
+ class: 'ds-file-act' + (warn ? ' ds-file-act-warn' : ''),
71
+ title: mutateDisabled && act !== 'download' ? 'read-only' : title,
72
+ 'aria-label': ariaLabel,
73
+ disabled: (act === 'download' ? busy : mutateDisabled) ? true : null,
74
+ 'aria-disabled': (act === 'download' ? busy : mutateDisabled) ? 'true' : null,
75
+ onclick: () => onAction(act),
76
+ }, Icon(icon));
77
+ const actionBtns = onAction ? [
78
+ actions.indexOf('download') !== -1 && type !== 'dir'
79
+ ? actBtn('download', 'download', `download ${name}`, 'arrow-down', false) : null,
80
+ actions.indexOf('rename') !== -1
81
+ ? actBtn('rename', 'rename', `rename ${name}`, 'pencil', false) : null,
82
+ actions.indexOf('delete') !== -1
83
+ ? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
84
+ ].filter(Boolean) : [];
50
85
  // A role=button row containing real <button> action controls is invalid
51
86
  // HTML (interactive nesting). Instead the row is a plain container and the
52
87
  // primary "open" affordance is itself a real <button> (native keyboard +
53
88
  // semantics); the per-file action buttons sit alongside it as siblings.
54
89
  return h('div', {
55
90
  key,
56
- class: 'ds-file-row row' + (active ? ' active' : ''),
91
+ class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
57
92
  'data-file-type': type,
93
+ 'aria-busy': busy ? 'true' : null,
58
94
  },
59
95
  h('button', {
60
96
  type: 'button',
61
97
  class: 'ds-file-open',
62
- onclick: onOpen || null,
63
- 'aria-label': accessibleLabel,
98
+ onclick: canOpen ? onOpen : null,
99
+ 'aria-label': accessibleLabel + (noAccess ? ' (no access)' : ''),
64
100
  'aria-pressed': active ? 'true' : 'false',
65
- disabled: onOpen ? null : true,
101
+ disabled: canOpen ? null : true,
66
102
  },
67
103
  code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
68
104
  FileIcon({ type }),
69
105
  h('span', { class: 'title' }, name),
70
106
  h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
71
107
  ),
72
- onAction ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
73
- h('button', { class: 'ds-file-act', title: 'download', 'aria-label': `download ${name}`, onclick: () => onAction('download') }, Icon('arrow-down')),
74
- h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, Icon('pencil')),
75
- h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, Icon('x'))
108
+ actionBtns.length ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
109
+ ...actionBtns
76
110
  ) : null
77
111
  );
78
112
  }
@@ -117,10 +151,22 @@ export function sortFiles(files = [], sort = 'name', dir = 'asc') {
117
151
  // Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
118
152
  // row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
119
153
  // 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 } = {}) {
154
+ // How many rows to render before the "show more" cap kicks in. A node_modules-
155
+ // scale directory would otherwise flood the DOM with thousands of rows (and make
156
+ // the roving-tabindex querySelectorAll scan O(n) per keypress). Render the first
157
+ // CAP and a "show N more" row, mirroring the History tab's "load N older".
158
+ const FILE_GRID_CAP = 200;
159
+
160
+ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
161
+ columns = 'auto', sort, filter, loading = false,
162
+ shown, onShowMore, actions, busy } = {}) {
122
163
  if (loading) return FileSkeleton({});
123
164
  if (!files.length) return EmptyState({ text: emptyText });
165
+ // Cap the rendered rows. `shown` (host-controlled) overrides the default cap
166
+ // so "show more" can grow it; otherwise default to FILE_GRID_CAP.
167
+ const limit = shown != null ? shown : FILE_GRID_CAP;
168
+ const capped = files.length > limit;
169
+ const visible = capped ? files.slice(0, limit) : files;
124
170
  const gridAttrs = {};
125
171
  if (columns !== 'auto' && columns > 0) {
126
172
  const col = Math.max(1, Math.min(4, Math.floor(columns)));
@@ -151,16 +197,32 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'no f
151
197
  'aria-label': filter.placeholder || 'Filter files in this directory',
152
198
  oninput: (e) => filter.onInput && filter.onInput(e.target.value),
153
199
  })) : 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({
200
+ // role=group not listbox: the rows contain real <button> action controls, so
201
+ // listbox/option semantics are invalid (an option can't host interactive
202
+ // children). Keyboard nav still works via roving focus over the open buttons.
203
+ const grid = h('div', { class: 'ds-file-grid', role: 'group', 'aria-label': 'files', tabindex: '0', onkeydown: onKeyDown, ...gridAttrs },
204
+ ...visible.map((f, i) => FileRow({
156
205
  key: f.path || f.name + i,
157
206
  name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
207
+ permissions: f.permissions, locked: f.locked,
208
+ actions: actions != null ? actions : undefined,
209
+ busy: busy != null ? !!busy : !!f.busy,
158
210
  onOpen: onOpen ? () => onOpen(f) : null,
159
211
  onAction: onAction ? (act) => onAction(act, f) : null
160
212
  }))
161
213
  );
162
- return (head || filterBar)
163
- ? h('div', { class: 'ds-file-listing' }, filterBar, head, grid)
214
+ // A count + "show more" affordance so a capped large dir reads as "more
215
+ // exist", not "this is everything". aria-live announces the shown/total.
216
+ const more = capped
217
+ ? h('div', { class: 'ds-file-more' },
218
+ h('span', { class: 'ds-file-more-count', role: 'status', 'aria-live': 'polite' },
219
+ 'showing ' + visible.length + ' of ' + files.length),
220
+ onShowMore ? h('button', { type: 'button', class: 'ds-file-more-btn',
221
+ onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
222
+ 'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
223
+ : null;
224
+ return (head || filterBar || more)
225
+ ? h('div', { class: 'ds-file-listing' }, filterBar, head, grid, more)
164
226
  : grid;
165
227
  }
166
228
 
@@ -185,6 +247,21 @@ export function FileToolbar({ left = [], right = [] } = {}) {
185
247
  );
186
248
  }
187
249
 
250
+ // RootsPicker — a segmented control for choosing among multiple allowed FS roots
251
+ // (so the app stops borrowing the history-tab .pill markup). Each root is
252
+ // { id, label }; `selected` is the active id. role=tablist for AT navigation.
253
+ export function RootsPicker({ roots = [], selected, onSelect, label = 'roots' } = {}) {
254
+ if (!roots.length) return null;
255
+ return h('div', { class: 'ds-roots-picker', role: 'tablist', 'aria-label': label },
256
+ ...roots.map((r) => h('button', {
257
+ key: 'root-' + (r.id != null ? r.id : r.label),
258
+ type: 'button', role: 'tab',
259
+ class: 'ds-roots-tab' + ((r.id != null ? r.id : r.label) === selected ? ' active' : ''),
260
+ 'aria-selected': (r.id != null ? r.id : r.label) === selected ? 'true' : 'false',
261
+ onclick: () => onSelect && onSelect(r.id != null ? r.id : r.label),
262
+ }, r.label || r.id)));
263
+ }
264
+
188
265
  export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave, label = 'drop files here', onPick } = {}) {
189
266
  return h('div', {
190
267
  class: 'ds-dropzone' + (dragover ? ' dragover' : ''),
@@ -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,9 +19,10 @@ 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
- emptyText = 'No conversations yet', loading = false, error = null } = {}) {
24
+ emptyText = 'No conversations yet', loading = false, error = null,
25
+ loadingText = 'Loading conversations…' } = {}) {
24
26
  const rowFor = (s, i) => h('button', {
25
27
  // Stable key: prefer sid, else position - a missing/duplicate sid would make
26
28
  // key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
@@ -52,7 +54,7 @@ export function ConversationList({ sessions = [], selected, groups, search,
52
54
  // are uniformly keyed; non-row states render a single unkeyed status line.
53
55
  let inner;
54
56
  if (loading) {
55
- inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, 'Loading conversations…')];
57
+ inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, loadingText)];
56
58
  } else if (error) {
57
59
  inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
58
60
  } else if (!sessions.length) {
@@ -77,9 +79,37 @@ export function ConversationList({ sessions = [], selected, groups, search,
77
79
  'aria-label': search.placeholder || 'Search conversations',
78
80
  oninput: (e) => search.onInput && search.onInput(e.target.value),
79
81
  }) : null),
82
+ // Per-tab caption telling the user what selecting a row does on this surface
83
+ // (chat = resume the conversation, history = browse its events) so visually
84
+ // identical rows are disambiguated.
85
+ caption ? h('div', { key: 'cap', class: 'ds-session-caption' }, caption) : null,
80
86
  body);
81
87
  }
82
88
 
89
+ // SessionMeta — a middot-separated metadata strip for a session detail surface.
90
+ // items : [{ label, value, title, onCopy }]
91
+ // Each item is a span (label dimmed, value mono) with an optional per-item copy
92
+ // button; the strip flex-wraps at narrow widths. Class is .ds-session-meta-strip
93
+ // (the bare .ds-session-meta is already taken by ConversationList row meta).
94
+ export function SessionMeta({ items = [] } = {}) {
95
+ if (!items.length) return null;
96
+ return h('div', { class: 'ds-session-meta-strip', role: 'group', 'aria-label': 'session metadata' },
97
+ ...items.map((it, i) => h('span', {
98
+ key: 'sm-' + (it.label != null ? it.label : i),
99
+ class: 'ds-session-meta-item',
100
+ title: it.title || null,
101
+ },
102
+ [
103
+ it.label != null ? h('span', { key: 'l', class: 'ds-session-meta-label' }, it.label) : null,
104
+ h('span', { key: 'v', class: 'ds-session-meta-value' }, it.value != null ? String(it.value) : ''),
105
+ it.onCopy ? h('button', {
106
+ key: 'c', type: 'button', class: 'ds-session-meta-copy',
107
+ 'aria-label': 'copy ' + (it.title || it.label || 'value'),
108
+ onclick: () => it.onCopy(it.value),
109
+ }, 'copy') : null,
110
+ ].filter(Boolean))));
111
+ }
112
+
83
113
  // SessionCard — one running session in the live dashboard. Status dot, agent /
84
114
  // model / cwd, elapsed, live counter, last activity, and per-session controls
85
115
  // that each act on this session's id independently.
@@ -90,9 +120,18 @@ export function ConversationList({ sessions = [], selected, groups, search,
90
120
  // the relative time of the most-recent event ("4s ago"); `currentTool` the tool
91
121
  // name a still-running turn is executing - together they distinguish a busy
92
122
  // session from a stuck one (a frozen elapsed alone reads identically for both).
93
- export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } = {}) {
123
+ // `status` is one of: 'error' | 'stale' | 'running'. A 'stale' session is one
124
+ // the host has determined is alive but not making progress (no recent activity,
125
+ // no current tool) — it reads as `idle` with a NON-pulsing disc so a stuck agent
126
+ // is visually distinct from a busy one (a frozen elapsed alone reads identically
127
+ // for both, which is the high-severity oversight gap this closes).
128
+ const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running' };
129
+ const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live' };
130
+
131
+ export function SessionCard({ session = {}, onStop, onOpen, onView, active = false,
132
+ selectable = false, selected = false, onToggleSelect } = {}) {
94
133
  const s = session;
95
- const statusTone = s.status === 'error' ? 'flame' : 'live';
134
+ const st = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running');
96
135
  // The stat line composes elapsed + live counter; the activity line carries the
97
136
  // last-activity time and the current tool so a card shows MOTION, not just a
98
137
  // start offset. Both are middot-joined (kept product separator).
@@ -101,12 +140,19 @@ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } =
101
140
  s.currentTool ? 'running: ' + s.currentTool : null,
102
141
  s.lastActivity ? 'last ' + s.lastActivity : null,
103
142
  ].filter(Boolean);
104
- return h('div', { class: 'ds-dash-card' + (s.status === 'error' ? ' is-error' : ''), role: 'group', 'aria-label': 'session ' + (s.agent || s.sid) },
143
+ const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '');
144
+ return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.agent || s.sid), 'aria-current': active ? 'true' : null },
105
145
  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' }),
146
+ selectable ? h('button', {
147
+ type: 'button', class: 'ds-dash-select', role: 'checkbox',
148
+ 'aria-checked': selected ? 'true' : 'false',
149
+ 'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.agent || s.sid),
150
+ onclick: () => onToggleSelect && onToggleSelect(s),
151
+ }, selected ? '[x]' : '[ ]') : null,
152
+ h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
107
153
  // Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
108
154
  // 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'),
155
+ h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
110
156
  h('span', { class: 'ds-dash-agent' }, s.agent || 'agent'),
111
157
  s.model ? h('span', { class: 'ds-dash-model' }, s.model) : null),
112
158
  h('div', { class: 'ds-dash-meta' },
@@ -114,8 +160,9 @@ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } =
114
160
  statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
115
161
  activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null),
116
162
  h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' },
163
+ // open and resume collapsed into one 'open' action (they both just reopen
164
+ // the session in chat); 'events' kept for the read-only event view.
117
165
  onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
118
- onResume ? Btn({ key: 'resume', onClick: () => onResume(s), children: 'resume' }) : null,
119
166
  onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
120
167
  onStop ? Btn({ key: 'stop', danger: true, onClick: () => onStop(s), children: 'stop' }) : null));
121
168
  }
@@ -128,20 +175,71 @@ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } =
128
175
  // The bulk header is the "manage many at once" affordance: a live count plus a
129
176
  // stop-all button, so a user running several agents does not have to hunt each
130
177
  // card's stop. Rendered only when there are sessions AND onStopAll is wired.
131
- export function SessionDashboard({ sessions = [], onStop, onOpen, onResume, onView, onStopAll,
178
+ // Streamstate words: the live-stream health signal so "connected, zero running"
179
+ // still tells the user the dashboard is listening (vs a dropped stream).
180
+ const STREAM_WORD = { connected: 'listening for activity', connecting: 'connecting to live stream…', lost: 'live stream lost — retrying…' };
181
+
182
+ // The stop-all / stop-selected danger buttons are two-step (host-driven, the kit
183
+ // is stateless): the first click fires onArmStop* so the host flips confirming*
184
+ // true and re-renders; the armed button reads 'stop N sessions - press again'
185
+ // and only THAT click fires the real onStopAll/onStopSelected. Hosts that wire
186
+ // no onArmStop* keep the old single-click behavior.
187
+ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStopAll, onStopSelected,
188
+ confirmingStopAll = false, confirmingStopSelected = false,
189
+ onArmStopAll, onArmStopSelected,
190
+ sort, filter, errorsOnly = false, onErrorsOnly,
191
+ selectable = false, selected, onToggleSelect,
192
+ activeSid, streamState,
132
193
  emptyText = 'No live sessions', offline = false } = {}) {
133
194
  if (offline) {
134
195
  return h('div', { class: 'ds-dash-state ds-dash-state-error', role: 'status' }, 'Backend offline — live sessions unavailable');
135
196
  }
197
+ const selSet = selected instanceof Set ? selected : new Set(selected || []);
198
+ const selCount = selSet.size;
199
+ // The stream-state line always renders (even with zero sessions) so a
200
+ // connected-but-idle dashboard reads differently from an offline one.
201
+ const streamLine = streamState
202
+ ? h('span', { class: 'ds-dash-stream is-' + streamState, role: 'status', 'aria-live': 'polite' }, STREAM_WORD[streamState] || streamState)
203
+ : null;
204
+ const toolbar = (sort || filter || onErrorsOnly)
205
+ ? h('div', { class: 'ds-dash-toolbar', role: 'group', 'aria-label': 'sort and filter sessions' },
206
+ 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,
207
+ sort ? Select({ key: 'sort', value: sort.value || 'status', title: 'Sort sessions',
208
+ options: [
209
+ { value: 'status', label: 'sort: status' },
210
+ { value: 'elapsed', label: 'sort: elapsed' },
211
+ { value: 'activity', label: 'sort: last activity' },
212
+ { value: 'errors', label: 'sort: errors first' },
213
+ ], onChange: (v) => sort.onChange && sort.onChange(v) }) : null,
214
+ onErrorsOnly ? h('button', { key: 'eo', type: 'button', class: 'ds-dash-errors-toggle' + (errorsOnly ? ' active' : ''),
215
+ 'aria-pressed': errorsOnly ? 'true' : 'false', onclick: () => onErrorsOnly(!errorsOnly) }, 'errors only') : null)
216
+ : null;
136
217
  if (!sessions.length) {
137
- return h('div', { class: 'ds-dash-state', role: 'status' }, emptyText);
218
+ return h('div', { class: 'ds-dash' },
219
+ h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
220
+ h('span', { class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' }, '0 running'), streamLine),
221
+ h('div', { class: 'ds-dash-state', role: 'status' }, emptyText));
138
222
  }
139
223
  const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
140
224
  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);
225
+ selectable && selCount ? selCount + ' selected' : sessions.length + ' running'),
226
+ streamLine,
227
+ h('span', { class: 'spread' }),
228
+ selectable && selCount && onStopSelected
229
+ ? (onArmStopSelected && !confirmingStopSelected
230
+ ? Btn({ key: 'stopsel', danger: true, onClick: () => onArmStopSelected([...selSet]), children: 'stop selected' })
231
+ : Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]),
232
+ children: confirmingStopSelected ? 'stop ' + selCount + ' sessions - press again' : 'stop selected' }))
233
+ : (onStopAll
234
+ ? (onArmStopAll && !confirmingStopAll
235
+ ? Btn({ key: 'stopall', danger: true, onClick: () => onArmStopAll(sessions), children: 'stop all' })
236
+ : Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions),
237
+ children: confirmingStopAll ? 'stop ' + sessions.length + ' sessions - press again' : 'stop all' }))
238
+ : null),
239
+ toolbar);
143
240
  const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
144
241
  ...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
145
- SessionCard({ session: s, onStop, onOpen, onResume, onView }))));
242
+ SessionCard({ session: s, onStop, onOpen, onView, active: s.sid === activeSid,
243
+ selectable, selected: selSet.has(s.sid), onToggleSelect }))));
146
244
  return h('div', { class: 'ds-dash' }, header, grid);
147
245
  }
@@ -140,7 +140,11 @@ const ICON_PATHS = {
140
140
  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"/>',
141
141
  upload: '<path d="M12 16V4M7 9l5-5 5 5"/><path d="M5 20h14"/>',
142
142
  download: '<path d="M12 4v12M7 11l5 5 5-5"/><path d="M5 20h14"/>',
143
- 'corner-up-left': '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v6"/>'
143
+ 'corner-up-left': '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 5 5v6"/>',
144
+ // clipboard/copy — for the per-block code copy + message copy action, so the
145
+ // copy affordance reads as copy, not the lined-document `page` glyph.
146
+ copy: '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/>',
147
+ clipboard: '<rect x="8" y="4" width="8" height="4" rx="1"/><path d="M8 6H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-2"/>'
144
148
  };
145
149
  // Raw-DOM consumers (no webjsx render in scope) need the SVG as a markup string
146
150
  // rather than an h() vnode. Same path table, same viewBox/stroke contract as
@@ -355,15 +359,21 @@ function wsCollapsed(which, fallback) {
355
359
  export function WorkspaceShell({ rail, sessions, main, pane, crumb, status, narrow,
356
360
  railCollapsed = false, paneCollapsed = false,
357
361
  railLabel = 'workspace navigation',
358
- paneLabel = 'context' } = {}) {
362
+ paneLabel = 'context', stableFrame = false } = {}) {
359
363
  const hasSessions = Boolean(sessions);
360
364
  const hasPane = Boolean(pane);
365
+ // Stable frame: keep the pane grid TRACK present even when this tab has no
366
+ // pane, so the shell does not re-flow its column count (4/3/2) on every tab
367
+ // switch - the loudest "separate pages" tell. The track collapses to width 0
368
+ // (ws-pane-collapsed) instead of being removed (ws-no-pane), so chat/history/
369
+ // files/live/settings all keep the same column geometry.
370
+ const keepPaneTrack = stableFrame && !hasPane;
361
371
  const railIsCollapsed = wsCollapsed('rail', railCollapsed);
362
372
  const paneIsCollapsed = hasPane ? wsCollapsed('pane', paneCollapsed) : true;
363
373
  const shellCls = 'ws-shell'
364
374
  + (railIsCollapsed ? ' ws-rail-collapsed' : '')
365
- + (hasPane ? '' : ' ws-no-pane')
366
- + (hasPane && paneIsCollapsed ? ' ws-pane-collapsed' : '')
375
+ + ((hasPane || keepPaneTrack) ? '' : ' ws-no-pane')
376
+ + (((hasPane && paneIsCollapsed) || keepPaneTrack) ? ' ws-pane-collapsed' : '')
367
377
  + (hasSessions ? '' : ' ws-no-sessions')
368
378
  + (narrow ? ' narrow' : '');
369
379
  return h('div', { class: shellCls },
package/src/components.js CHANGED
@@ -16,7 +16,7 @@ export {
16
16
  WorksList, WritingList, Manifesto, Section, PageHeader,
17
17
  Kpi, Table, SearchInput, TextField, Select, EventList,
18
18
  HomeView, ProjectView, Form,
19
- Spinner, Skeleton, Alert
19
+ Spinner, Skeleton, Alert, FilterPills
20
20
  } from './components/content.js';
21
21
 
22
22
  export {
@@ -28,20 +28,20 @@ export {
28
28
  export { AgentChat } from './components/agent-chat.js';
29
29
 
30
30
  export {
31
- ConversationList, SessionCard, SessionDashboard
31
+ ConversationList, SessionCard, SessionDashboard, SessionMeta
32
32
  } from './components/sessions.js';
33
33
 
34
34
  export { ContextPane } from './components/context-pane.js';
35
35
 
36
36
  export {
37
37
  fileGlyph, fmtFileSize,
38
- FileIcon, FileRow, FileGrid, FileSkeleton, sortFiles, FileToolbar,
38
+ FileIcon, FileRow, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker,
39
39
  DropZone, UploadProgress, EmptyState, BreadcrumbPath
40
40
  } from './components/files.js';
41
41
 
42
42
  export {
43
43
  ConfirmDialog, PromptDialog,
44
- FilePreviewMedia, FilePreviewCode, FilePreviewText, FileViewer
44
+ FilePreviewMedia, FilePreviewCode, FilePreviewText, FileViewer, FilePreviewPane
45
45
  } from './components/files-modals.js';
46
46
 
47
47
  export {