anentrypoint-design 0.0.199 → 0.0.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app-shell.css +53 -0
- package/chat.css +220 -5
- package/dist/247420.css +273 -5
- package/dist/247420.js +12 -12
- package/package.json +1 -1
- package/src/components/agent-chat.js +77 -6
- package/src/components/chat.js +65 -5
- package/src/components/content.js +69 -8
- package/src/components/context-pane.js +21 -2
- package/src/components/files-modals.js +66 -14
- package/src/components/files.js +93 -16
- package/src/components/sessions.js +112 -14
- package/src/components/shell.js +14 -4
- package/src/components.js +4 -4
package/src/components/files.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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' },
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
package/src/components/shell.js
CHANGED
|
@@ -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 {
|