anentrypoint-design 0.0.195 → 0.0.197
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 +198 -2
- package/chat.css +134 -0
- package/dist/247420.css +332 -2
- package/dist/247420.js +15 -15
- package/package.json +1 -1
- package/src/components/agent-chat.js +68 -11
- package/src/components/chat.js +61 -10
- package/src/components/content.js +5 -2
- package/src/components/context-pane.js +77 -0
- package/src/components/files.js +81 -2
- package/src/components/sessions.js +147 -0
- package/src/components/shell.js +189 -0
- package/src/components.js +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.197",
|
|
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",
|
|
@@ -13,14 +13,44 @@
|
|
|
13
13
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
14
14
|
import { ChatComposer, ChatMessage, makeThreadAutoScroll } from './chat.js';
|
|
15
15
|
import { Select } from './content.js';
|
|
16
|
-
import { Btn } from './shell.js';
|
|
16
|
+
import { Btn, Icon } from './shell.js';
|
|
17
17
|
|
|
18
18
|
const h = webjsx.createElement;
|
|
19
19
|
|
|
20
20
|
// Auto-scroll behaviour is the shared chat helper; bind it to this thread's
|
|
21
21
|
// live message count. (`makeThreadAutoScroll` takes a getter so the observer
|
|
22
22
|
// always compares against current state, not a value captured at mount.)
|
|
23
|
-
const
|
|
23
|
+
const baseAutoScroll = (msgCount) => makeThreadAutoScroll(() => msgCount);
|
|
24
|
+
|
|
25
|
+
// Compose the auto-scroll ref with a scroll listener that reveals the
|
|
26
|
+
// jump-to-latest button when the user has scrolled away from the bottom. This
|
|
27
|
+
// is the scroll-anchoring fix: auto-scroll only pins when the user is already at
|
|
28
|
+
// the bottom (the IntersectionObserver gate), so reading back-history is no
|
|
29
|
+
// longer fought; the button is the explicit way back to the live edge.
|
|
30
|
+
const NEAR_BOTTOM_PX = 80;
|
|
31
|
+
const threadRef = (msgCount) => {
|
|
32
|
+
const auto = baseAutoScroll(msgCount);
|
|
33
|
+
return (el) => {
|
|
34
|
+
if (!el) return;
|
|
35
|
+
const disposeAuto = auto(el);
|
|
36
|
+
const jumpBtn = () => el.parentElement && el.parentElement.querySelector('.agentchat-jump');
|
|
37
|
+
const update = () => {
|
|
38
|
+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
|
|
39
|
+
const btn = jumpBtn();
|
|
40
|
+
if (btn) btn.classList.toggle('show', !atBottom);
|
|
41
|
+
};
|
|
42
|
+
el.addEventListener('scroll', update, { passive: true });
|
|
43
|
+
requestAnimationFrame(update);
|
|
44
|
+
return () => { el.removeEventListener('scroll', update); if (typeof disposeAuto === 'function') disposeAuto(); };
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Scroll a thread to its live edge — used by the jump-to-latest button.
|
|
49
|
+
function scrollThreadToBottom(btn) {
|
|
50
|
+
const wrap = btn.closest('.agentchat-thread-wrap');
|
|
51
|
+
const thread = wrap && wrap.querySelector('.agentchat-thread');
|
|
52
|
+
if (thread) thread.scrollTop = thread.scrollHeight;
|
|
53
|
+
}
|
|
24
54
|
|
|
25
55
|
// The agent picker: agent-then-model, not a flat model list. Unavailable agents
|
|
26
56
|
// are disabled (unless installable via npx). Ordering is the host's concern.
|
|
@@ -93,6 +123,7 @@ export function AgentChat(props = {}) {
|
|
|
93
123
|
onCwdEdit, onCwdSave, onCwdCancel, onCwdClear, onCwdDraft,
|
|
94
124
|
canSend = true,
|
|
95
125
|
suggestions = [], onSuggestionClick,
|
|
126
|
+
onCopyMessage, onRetryMessage, onEditMessage,
|
|
96
127
|
} = props;
|
|
97
128
|
|
|
98
129
|
const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
|
|
@@ -129,7 +160,11 @@ export function AgentChat(props = {}) {
|
|
|
129
160
|
// accumulated source and swaps the entire bubble innerHTML on every frame
|
|
130
161
|
// (O(n^2) over the turn, with a visible reflow). Downgrade md -> text
|
|
131
162
|
// mid-stream; the settled turn below renders real markdown once.
|
|
132
|
-
|
|
163
|
+
// Carry a `mdShell` flag so the streaming-text bubble uses the same
|
|
164
|
+
// container shape (.chat-md padding/spacing) the settled markdown will
|
|
165
|
+
// use — only the inner content swaps on settle, so the bubble box does
|
|
166
|
+
// 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 });
|
|
133
168
|
else parts.push(part);
|
|
134
169
|
}
|
|
135
170
|
}
|
|
@@ -138,6 +173,19 @@ export function AgentChat(props = {}) {
|
|
|
138
173
|
// carry prose, so a parts-driven turn isn't double-rendered.
|
|
139
174
|
const partsHaveProse = parts.some(p => p.kind === 'md' || p.kind === 'text');
|
|
140
175
|
if (m.content && !partsHaveProse) parts.unshift({ kind: isAssistant ? 'md' : 'text', text: m.content });
|
|
176
|
+
// The streaming caret rides the live assistant turn once it has body (the
|
|
177
|
+
// empty-shell turn already shows the inline typing dots).
|
|
178
|
+
const streaming = isStreaming && msgHasBody(m);
|
|
179
|
+
// Per-message actions: the host supplies onCopyMessage / onRetryMessage; we
|
|
180
|
+
// build the action row only for SETTLED messages (no actions mid-stream).
|
|
181
|
+
let actions;
|
|
182
|
+
if (!isStreaming && msgHasBody(m)) {
|
|
183
|
+
const built = [];
|
|
184
|
+
if (onCopyMessage) built.push({ label: 'copy', icon: 'page', title: 'copy message', onClick: () => onCopyMessage(m) });
|
|
185
|
+
if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
|
|
186
|
+
if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend', onClick: () => onEditMessage(m) });
|
|
187
|
+
if (built.length) actions = built;
|
|
188
|
+
}
|
|
141
189
|
return ChatMessage({
|
|
142
190
|
key: m.id || String(i),
|
|
143
191
|
who: isAssistant ? 'them' : 'you',
|
|
@@ -145,6 +193,8 @@ export function AgentChat(props = {}) {
|
|
|
145
193
|
name: isAssistant ? name : 'you',
|
|
146
194
|
time: m.time || '',
|
|
147
195
|
typing: emptyStreaming,
|
|
196
|
+
streaming,
|
|
197
|
+
actions,
|
|
148
198
|
parts: emptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
|
|
149
199
|
});
|
|
150
200
|
});
|
|
@@ -192,14 +242,21 @@ export function AgentChat(props = {}) {
|
|
|
192
242
|
// reconnecting-while-streaming state reads one word everywhere instead of
|
|
193
243
|
// the head saying "streaming…" while the controls say "reconnecting…".
|
|
194
244
|
busy ? (status || 'streaming…') : (messages.length ? messages.length + (messages.length === 1 ? ' message' : ' messages') : ''))),
|
|
195
|
-
h('div', { class: 'agentchat-thread
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
245
|
+
h('div', { class: 'agentchat-thread-wrap' },
|
|
246
|
+
h('div', { class: 'agentchat-thread', ref: threadRef(messages.length), role: 'log', 'aria-label': 'conversation' },
|
|
247
|
+
emptyState,
|
|
248
|
+
...rows.filter(Boolean),
|
|
249
|
+
showWorkingTail
|
|
250
|
+
? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
|
|
251
|
+
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
252
|
+
h('span', { class: 'agentchat-working-text' }, 'working…'))
|
|
253
|
+
: null),
|
|
254
|
+
// Jump-to-latest: hidden until the scroll listener adds .show (user scrolled
|
|
255
|
+
// up). Clicking returns to the live edge. Pure-DOM, like the kit's other
|
|
256
|
+
// stateless chrome, so the host needn't thread scroll state through state.
|
|
257
|
+
h('button', { class: 'agentchat-jump', type: 'button', 'aria-label': 'jump to latest', title: 'jump to latest',
|
|
258
|
+
onclick: (e) => scrollThreadToBottom(e.currentTarget) },
|
|
259
|
+
Icon('arrow-down', { size: 16 }), h('span', { class: 'agentchat-jump-label' }, 'latest'))),
|
|
203
260
|
composer,
|
|
204
261
|
);
|
|
205
262
|
}
|
package/src/components/chat.js
CHANGED
|
@@ -19,6 +19,21 @@ export function fmtBytes(n) {
|
|
|
19
19
|
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Reject dangerous URL schemes (javascript:, data:, vbscript:, file:) so an
|
|
23
|
+
// inline markdown link or an image src built from untrusted text can't smuggle a
|
|
24
|
+
// script-executing or data-exfiltrating URL past the inline renderer (which does
|
|
25
|
+
// NOT pass through DOMPurify the way the full md path does). http(s), mailto,
|
|
26
|
+
// protocol-relative, root/relative, and anchor links are allowed.
|
|
27
|
+
export function safeUrl(url) {
|
|
28
|
+
const s = String(url == null ? '' : url).trim();
|
|
29
|
+
if (!s) return null;
|
|
30
|
+
// Allow relative / anchor / protocol-relative without a scheme.
|
|
31
|
+
if (/^(\/|\.|#|\?)/.test(s) || s.startsWith('//')) return s;
|
|
32
|
+
const scheme = (s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1];
|
|
33
|
+
if (!scheme) return s; // schemeless relative
|
|
34
|
+
return /^(https?|mailto|tel)$/i.test(scheme) ? s : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
// Inline-only markdown subset; safe for chat bubbles.
|
|
23
38
|
export function renderInline(text) {
|
|
24
39
|
if (text == null) return [];
|
|
@@ -31,7 +46,13 @@ export function renderInline(text) {
|
|
|
31
46
|
if (m[2] != null) push(h('strong', { key: 's' + i }, m[2]));
|
|
32
47
|
else if (m[3] != null) push(h('em', { key: 's' + i }, m[3]));
|
|
33
48
|
else if (m[4] != null) push(h('code', { key: 's' + i, class: 'chat-tick' }, m[4]));
|
|
34
|
-
else if (m[5] != null)
|
|
49
|
+
else if (m[5] != null) {
|
|
50
|
+
const safe = safeUrl(m[6]);
|
|
51
|
+
// A link with a rejected (unsafe) scheme degrades to its plain label
|
|
52
|
+
// text rather than a clickable, scheme-smuggling anchor.
|
|
53
|
+
if (safe) push(h('a', { key: 's' + i, href: safe, target: '_blank', rel: 'noopener noreferrer' }, m[5]));
|
|
54
|
+
else push(h('span', { key: 's' + i }, m[5]));
|
|
55
|
+
}
|
|
35
56
|
last = m.index + m[0].length; i += 1;
|
|
36
57
|
}
|
|
37
58
|
if (last < text.length) push(h('span', { key: 's' + i + 'a' }, text.slice(last)));
|
|
@@ -133,7 +154,13 @@ function ToolCallNode(p) {
|
|
|
133
154
|
h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))),
|
|
134
155
|
resultText ? h('div', { class: 'chat-tool-section' },
|
|
135
156
|
h('div', { class: 'chat-tool-section-label' }, p.error ? 'error' : 'result'),
|
|
136
|
-
h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText)))
|
|
157
|
+
h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText)))
|
|
158
|
+
// A finished tool with no output would otherwise render no result
|
|
159
|
+
// section, reading identically to a still-running tool. Show an
|
|
160
|
+
// explicit placeholder so "done, empty" is distinguishable.
|
|
161
|
+
: (status === 'done' ? h('div', { class: 'chat-tool-section' },
|
|
162
|
+
h('div', { class: 'chat-tool-section-label' }, 'result'),
|
|
163
|
+
h('pre', { class: 'chat-tool-pre chat-tool-empty' }, h('code', {}, '(no output)'))) : null)
|
|
137
164
|
)
|
|
138
165
|
);
|
|
139
166
|
}
|
|
@@ -146,16 +173,24 @@ function ThinkingNode(p) {
|
|
|
146
173
|
}
|
|
147
174
|
|
|
148
175
|
const PART_RENDERERS = {
|
|
149
|
-
text: (p) => h('div', { class: 'chat-bubble' }, ...renderInline(p.text || '')),
|
|
176
|
+
text: (p) => h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') }, ...renderInline(p.text || '')),
|
|
150
177
|
md: (p) => MdNode(p),
|
|
151
178
|
code: (p) => CodeNode(p),
|
|
152
179
|
tool: (p) => ToolCallNode(p),
|
|
153
180
|
tool_call: (p) => ToolCallNode(p),
|
|
154
181
|
tool_result: (p) => ToolCallNode({ ...p, name: p.name || 'tool_result', result: p.text != null ? p.text : p.result }),
|
|
155
182
|
thinking: (p) => ThinkingNode(p),
|
|
156
|
-
image: (p) =>
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
image: (p) => {
|
|
184
|
+
// Guard both the wrapping link and the img src against unsafe schemes
|
|
185
|
+
// (e.g. a data:text/html src) so an embedded-image part from untrusted
|
|
186
|
+
// markdown can't smuggle an active payload.
|
|
187
|
+
const imgSrc = safeUrl(p.src);
|
|
188
|
+
const linkHref = safeUrl(p.href || p.src);
|
|
189
|
+
if (!imgSrc) return h('span', { class: 'chat-image-blocked' }, p.alt || 'image blocked (unsafe url)');
|
|
190
|
+
return h('a', { class: 'chat-image', href: linkHref || imgSrc, target: '_blank', rel: 'noopener noreferrer', 'aria-label': p.alt || `embedded image: ${imgSrc}` },
|
|
191
|
+
h('img', { src: imgSrc, alt: p.alt || `embedded image from ${imgSrc}`, loading: 'lazy' }),
|
|
192
|
+
p.caption ? h('span', { class: 'cap' }, p.caption) : null);
|
|
193
|
+
},
|
|
159
194
|
pdf: (p) => h('div', { class: 'chat-pdf' },
|
|
160
195
|
h('div', { class: 'chat-pdf-head' },
|
|
161
196
|
h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('file-pdf', { size: 18 })),
|
|
@@ -171,7 +206,7 @@ const PART_RENDERERS = {
|
|
|
171
206
|
h('span', { class: 'size' }, [p.kindLabel || (p.name || '').split('.').pop().toUpperCase(), p.size != null ? fmtBytes(p.size) : null].filter(Boolean).join(' · '))
|
|
172
207
|
),
|
|
173
208
|
h('span', { class: 'go', 'aria-hidden': 'true' }, Icon('arrow-down'))),
|
|
174
|
-
link: (p) => h('a', { class: 'chat-link', href: p.href, target: '_blank', rel: 'noopener', 'aria-label': `link: ${p.title || p.href}` },
|
|
209
|
+
link: (p) => h('a', { class: 'chat-link', href: safeUrl(p.href) || '#', target: '_blank', rel: 'noopener noreferrer', 'aria-label': `link: ${p.title || p.href}` },
|
|
175
210
|
p.thumb ? h('img', { class: 'thumb', src: p.thumb, alt: `preview for ${p.title || p.href}` }) : null,
|
|
176
211
|
h('span', { class: 'meta' },
|
|
177
212
|
h('span', { class: 'host' }, p.host || (() => { try { return new URL(p.href).host; } catch { return ''; } })()),
|
|
@@ -188,7 +223,7 @@ function renderPart(p, key) {
|
|
|
188
223
|
return node;
|
|
189
224
|
}
|
|
190
225
|
|
|
191
|
-
export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name }) {
|
|
226
|
+
export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions }) {
|
|
192
227
|
_stats.messages += 1;
|
|
193
228
|
// Support legacy 'who' prop, prefer 'role' with mapping:
|
|
194
229
|
// 'user' -> 'you' (right-aligned, accent bubble)
|
|
@@ -212,6 +247,11 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
212
247
|
if (typing) bodyNodes = [h('div', { class: 'chat-bubble', key: 'typb' }, h('span', { class: 'chat-typing' }, h('span'), h('span'), h('span')))];
|
|
213
248
|
else if (parts && parts.length) bodyNodes = parts.map((p, i) => renderPart(p, i));
|
|
214
249
|
else bodyNodes = [h('div', { class: 'chat-bubble', key: 't' }, ...renderInline(text || ''))];
|
|
250
|
+
// A blinking caret at the stream head: while an assistant turn is streaming
|
|
251
|
+
// AND already shows content (so the inline typing dots have stopped), append
|
|
252
|
+
// a thin caret so the live edge reads as "still writing", not "done". Drawn as
|
|
253
|
+
// a CSS element, not a glyph character.
|
|
254
|
+
if (streaming && !typing) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
|
|
215
255
|
const reactionRow = reactions && reactions.length
|
|
216
256
|
? h('div', { class: 'chat-reactions' },
|
|
217
257
|
...reactions.map((r, i) => h('span', { class: 'rxn' + (r.you ? ' you' : ''), key: 'r' + i, 'aria-label': `${r.emoji} reaction (${String(r.count)} ${String(r.count) === '1' ? 'reaction' : 'reactions'})${r.you ? ' - you reacted' : ''}` },
|
|
@@ -225,7 +265,19 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
225
265
|
if (time) metaItems.push(h('span', { class: 't', key: 'ti' }, time));
|
|
226
266
|
if (tickNode) metaItems.push(tickNode);
|
|
227
267
|
const meta = metaItems.length ? h('div', { class: 'chat-meta' }, ...metaItems) : null;
|
|
228
|
-
|
|
268
|
+
// Per-message actions (copy / retry / edit) — a hover-revealed control row
|
|
269
|
+
// below the bubble, the way Claude-Desktop surfaces message-level actions.
|
|
270
|
+
// Each action is { label, icon, onClick, title }. Kept icon-only with an
|
|
271
|
+
// accessible name; no decorative glyphs (the Icon set is line-SVG).
|
|
272
|
+
const actionRow = (actions && actions.length)
|
|
273
|
+
? h('div', { class: 'chat-msg-actions', role: 'group', 'aria-label': 'message actions' },
|
|
274
|
+
...actions.filter(Boolean).map((a, i) => h('button', {
|
|
275
|
+
key: 'ma' + i, type: 'button', class: 'chat-msg-action',
|
|
276
|
+
title: a.title || a.label, 'aria-label': a.label || a.title,
|
|
277
|
+
onclick: (e) => { e.preventDefault(); a.onClick && a.onClick(e); },
|
|
278
|
+
}, a.icon ? Icon(a.icon, { size: 14 }) : (a.label || ''))))
|
|
279
|
+
: null;
|
|
280
|
+
const stack = h('div', { class: 'chat-stack' }, ...bodyNodes, reactionRow, actionRow, meta);
|
|
229
281
|
// Centered roles (system/tool/thinking) skip the avatar column entirely so
|
|
230
282
|
// the bubble owns the full row — the chrome reads as out-of-band signal,
|
|
231
283
|
// not a participant turn.
|
|
@@ -293,7 +345,6 @@ export function Chat({ title = 'chat', sub, messages = [], composer, header, sug
|
|
|
293
345
|
const msgCount = messages.length;
|
|
294
346
|
return h('div', { class: 'chat' },
|
|
295
347
|
header || h('div', { class: 'chat-head', role: 'banner' },
|
|
296
|
-
h('span', { class: 'dot', 'aria-hidden': 'true' }),
|
|
297
348
|
h('h2', { class: 'ds-chat-title' }, title),
|
|
298
349
|
sub ? h('span', { class: 'sub', 'aria-label': `subtitle: ${sub}` }, ' · ' + sub) : null,
|
|
299
350
|
h('span', { class: 'spread' }),
|
|
@@ -253,8 +253,11 @@ export function ProjectView({ project = {}, copied, onCopy } = {}) {
|
|
|
253
253
|
].filter(Boolean).flat();
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
export function PageHeader({ title, lede, eyebrow, right }) {
|
|
257
|
-
|
|
256
|
+
export function PageHeader({ title, lede, eyebrow, right, compact }) {
|
|
257
|
+
// `compact` drops the large leading/trailing section margins so a PageHeader
|
|
258
|
+
// used as a page's first element top-aligns cleanly without the consumer
|
|
259
|
+
// having to !important-override the .ds-section margin.
|
|
260
|
+
return h('section', { class: 'ds-section' + (compact ? ' ds-section-compact' : '') },
|
|
258
261
|
eyebrow ? h('span', { class: 'eyebrow' }, eyebrow) : null,
|
|
259
262
|
title != null ? h('h1', {}, title) : null,
|
|
260
263
|
lede != null ? h('p', { class: 'lede' }, lede) : null,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ContextPane — a compact right-hand context panel for the chat surface.
|
|
2
|
+
//
|
|
3
|
+
// Surfaces the current conversation's agent, model, working directory, and a
|
|
4
|
+
// live count of running tool calls in the in-flight turn. Built from the kit's
|
|
5
|
+
// Panel + Row primitives so it inherits the design tokens and rail semantics.
|
|
6
|
+
//
|
|
7
|
+
// Usage (consumer wires its own state):
|
|
8
|
+
// ContextPane({ agent, model, cwd, toolCount, onSetCwd })
|
|
9
|
+
//
|
|
10
|
+
// Props:
|
|
11
|
+
// agent : display name of the active agent (string) or falsy for "none"
|
|
12
|
+
// model : model id/name (string) or falsy
|
|
13
|
+
// cwd : the chat working directory (string) or falsy for server default
|
|
14
|
+
// toolCount : number of tool calls running in the current live turn (>=0)
|
|
15
|
+
// usage : OPTIONAL last-turn usage { inputTokens, outputTokens, costUsd, turns, durationMs }
|
|
16
|
+
// session : OPTIONAL session metadata { id, messages, startedAt } shown as a block
|
|
17
|
+
// onSetCwd : optional callback for the "set working directory" affordance
|
|
18
|
+
//
|
|
19
|
+
// No decorative glyphs — words + the kit's Icon SVGs only.
|
|
20
|
+
|
|
21
|
+
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
22
|
+
import { Panel, Row } from './content.js';
|
|
23
|
+
import { Btn } from './shell.js';
|
|
24
|
+
|
|
25
|
+
const h = webjsx.createElement;
|
|
26
|
+
|
|
27
|
+
function fmtTok(n) {
|
|
28
|
+
if (n == null) return null;
|
|
29
|
+
if (n < 1000) return String(n);
|
|
30
|
+
if (n < 1000000) return (n / 1000).toFixed(n < 10000 ? 1 : 0) + 'k';
|
|
31
|
+
return (n / 1000000).toFixed(1) + 'M';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
|
|
35
|
+
const running = Number(toolCount) > 0;
|
|
36
|
+
// Each Panel's children array is all-unkeyed (no key prop on any sibling),
|
|
37
|
+
// so webjsx never sees a mixed keyed/unkeyed array here.
|
|
38
|
+
const panels = [
|
|
39
|
+
Panel({
|
|
40
|
+
title: 'context',
|
|
41
|
+
children: [
|
|
42
|
+
Row({ title: 'agent', meta: agent || 'none' }),
|
|
43
|
+
Row({ title: 'model', meta: model || '—' }),
|
|
44
|
+
Row({
|
|
45
|
+
title: 'working dir',
|
|
46
|
+
sub: cwd || 'server default',
|
|
47
|
+
// Use the rail tone consistently with the GUI-wide semantics:
|
|
48
|
+
// green = active/ok. A default cwd carries no rail (neutral).
|
|
49
|
+
rail: cwd ? 'green' : null,
|
|
50
|
+
}),
|
|
51
|
+
Row({
|
|
52
|
+
title: 'running tools',
|
|
53
|
+
meta: running ? String(toolCount) : 'idle',
|
|
54
|
+
rail: running ? 'purple' : null,
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
];
|
|
59
|
+
// Usage block: surface the last turn's token/cost/turn/duration so the
|
|
60
|
+
// result event is no longer silently dropped.
|
|
61
|
+
if (usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null)) {
|
|
62
|
+
const tokRows = [];
|
|
63
|
+
if (usage.inputTokens != null) tokRows.push(Row({ title: 'input', meta: fmtTok(usage.inputTokens) + ' tok' }));
|
|
64
|
+
if (usage.outputTokens != null) tokRows.push(Row({ title: 'output', meta: fmtTok(usage.outputTokens) + ' tok' }));
|
|
65
|
+
if (usage.costUsd != null) tokRows.push(Row({ title: 'cost', meta: '$' + usage.costUsd.toFixed(4) }));
|
|
66
|
+
if (usage.turns != null) tokRows.push(Row({ title: 'turns', meta: String(usage.turns) }));
|
|
67
|
+
if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: (usage.durationMs / 1000).toFixed(1) + 's' }));
|
|
68
|
+
panels.push(Panel({ title: 'last turn', children: tokRows }));
|
|
69
|
+
}
|
|
70
|
+
return h('div', { class: 'ds-context' },
|
|
71
|
+
...panels,
|
|
72
|
+
onSetCwd
|
|
73
|
+
? h('div', { class: 'ds-context-actions' },
|
|
74
|
+
Btn({ onClick: onSetCwd, children: 'set working dir' }))
|
|
75
|
+
: null,
|
|
76
|
+
);
|
|
77
|
+
}
|
package/src/components/files.js
CHANGED
|
@@ -77,7 +77,49 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
77
77
|
);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
// FileSkeleton — placeholder shimmer rows shown while a directory loads, so the
|
|
81
|
+
// grid does not flash from a bare spinner to a full list (predictable perceived
|
|
82
|
+
// perf, the file-manager feel). `rows` controls how many ghost rows render.
|
|
83
|
+
export function FileSkeleton({ rows = 8 } = {}) {
|
|
84
|
+
return h('div', { class: 'ds-file-grid ds-file-skeleton', 'aria-hidden': 'true' },
|
|
85
|
+
...Array.from({ length: Math.max(1, rows) }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-file-row ds-file-row-skeleton' },
|
|
86
|
+
h('span', { class: 'ds-skel ds-skel-icon' }),
|
|
87
|
+
h('span', { class: 'ds-skel ds-skel-title' }),
|
|
88
|
+
h('span', { class: 'ds-skel ds-skel-meta' })))
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Sort a file list by a key (name/size/modified/type), dirs-first always so the
|
|
93
|
+
// hierarchy reads top-down regardless of sort. `dir` is 'asc'|'desc'.
|
|
94
|
+
// `modifiedTs` (epoch ms) is used for the modified sort when present, since the
|
|
95
|
+
// `modified` field is a pre-formatted relative string the host passes for display.
|
|
96
|
+
export function sortFiles(files = [], sort = 'name', dir = 'asc') {
|
|
97
|
+
const mul = dir === 'desc' ? -1 : 1;
|
|
98
|
+
const cmp = (a, b) => {
|
|
99
|
+
// Directories always cluster before files; within a cluster, apply the sort.
|
|
100
|
+
const ad = a.type === 'dir' ? 0 : 1, bd = b.type === 'dir' ? 0 : 1;
|
|
101
|
+
if (ad !== bd) return ad - bd;
|
|
102
|
+
let r = 0;
|
|
103
|
+
if (sort === 'size') r = (a.size || 0) - (b.size || 0);
|
|
104
|
+
else if (sort === 'modified') r = (a.modifiedTs || 0) - (b.modifiedTs || 0);
|
|
105
|
+
else if (sort === 'type') r = String(a.type || '').localeCompare(String(b.type || ''));
|
|
106
|
+
else r = String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' });
|
|
107
|
+
return r * mul || String(a.name || '').localeCompare(String(b.name || ''));
|
|
108
|
+
};
|
|
109
|
+
return files.slice().sort(cmp);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// FileGrid — the directory listing. Optional in-grid sort + filter make it a
|
|
113
|
+
// real file manager rather than a static dump:
|
|
114
|
+
// sort : { key, dir, onSort(key) } - clickable column headers (name/size/modified)
|
|
115
|
+
// filter : { value, onInput, placeholder } - a quick in-dir name filter
|
|
116
|
+
// onOpen(f) opens a row; onAction(act,f) wires the per-row download/rename/delete.
|
|
117
|
+
// Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
|
|
118
|
+
// row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
|
|
119
|
+
// 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 } = {}) {
|
|
122
|
+
if (loading) return FileSkeleton({});
|
|
81
123
|
if (!files.length) return EmptyState({ text: emptyText });
|
|
82
124
|
const gridAttrs = {};
|
|
83
125
|
if (columns !== 'auto' && columns > 0) {
|
|
@@ -89,7 +131,27 @@ export function FileGrid({ files = [], onOpen, onAction, emptyText = 'no files h
|
|
|
89
131
|
gap: 'var(--space-3)'
|
|
90
132
|
};
|
|
91
133
|
}
|
|
92
|
-
|
|
134
|
+
// Keyboard: roving focus over the .ds-file-open buttons inside the grid.
|
|
135
|
+
const onKeyDown = (e) => {
|
|
136
|
+
const grid = e.currentTarget;
|
|
137
|
+
const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled])'));
|
|
138
|
+
if (!opens.length) return;
|
|
139
|
+
const cur = opens.indexOf(document.activeElement);
|
|
140
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
|
|
141
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
|
|
142
|
+
else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
|
|
143
|
+
else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
|
|
144
|
+
else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
|
|
145
|
+
};
|
|
146
|
+
const head = sort ? FileSortHeader(sort) : null;
|
|
147
|
+
const filterBar = filter ? h('div', { class: 'ds-file-filter' },
|
|
148
|
+
h('input', {
|
|
149
|
+
class: 'ds-file-filter-input', type: 'search',
|
|
150
|
+
value: filter.value || '', placeholder: filter.placeholder || 'Filter files',
|
|
151
|
+
'aria-label': filter.placeholder || 'Filter files in this directory',
|
|
152
|
+
oninput: (e) => filter.onInput && filter.onInput(e.target.value),
|
|
153
|
+
})) : null;
|
|
154
|
+
const grid = h('div', { class: 'ds-file-grid', role: 'listbox', 'aria-label': 'files', tabindex: '0', onkeydown: onKeyDown, ...gridAttrs },
|
|
93
155
|
...files.map((f, i) => FileRow({
|
|
94
156
|
key: f.path || f.name + i,
|
|
95
157
|
name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
|
|
@@ -97,6 +159,23 @@ export function FileGrid({ files = [], onOpen, onAction, emptyText = 'no files h
|
|
|
97
159
|
onAction: onAction ? (act) => onAction(act, f) : null
|
|
98
160
|
}))
|
|
99
161
|
);
|
|
162
|
+
return (head || filterBar)
|
|
163
|
+
? h('div', { class: 'ds-file-listing' }, filterBar, head, grid)
|
|
164
|
+
: grid;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Clickable column headers for FileGrid sort. Active column shows its direction
|
|
168
|
+
// as an ASCII caret word (asc/desc) - never a glyph arrow.
|
|
169
|
+
function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {
|
|
170
|
+
const cols = [['name', 'name'], ['size', 'size'], ['modified', 'modified']];
|
|
171
|
+
return h('div', { class: 'ds-file-sort', role: 'group', 'aria-label': 'sort files' },
|
|
172
|
+
...cols.map(([k, label]) => h('button', {
|
|
173
|
+
key: k, type: 'button',
|
|
174
|
+
class: 'ds-file-sort-btn' + (active === k ? ' active' : ''),
|
|
175
|
+
'aria-pressed': active === k ? 'true' : 'false',
|
|
176
|
+
'aria-label': 'sort by ' + label + (active === k ? ' (' + (dir === 'asc' ? 'ascending' : 'descending') + ')' : ''),
|
|
177
|
+
onclick: () => onSort && onSort(k),
|
|
178
|
+
}, label + (active === k ? ' ' + (dir === 'asc' ? 'asc' : 'desc') : ''))));
|
|
100
179
|
}
|
|
101
180
|
|
|
102
181
|
export function FileToolbar({ left = [], right = [] } = {}) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Session surfaces — a persistent conversation list (left-rail "Chats") and a
|
|
2
|
+
// live multi-session dashboard. Pure factories: props in, webjsx vnode out, all
|
|
3
|
+
// interaction via host callbacks. Styling lives in chat.css (.ds-session*,
|
|
4
|
+
// .ds-dash*) using kit tokens; no transport, no decorative glyphs.
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
7
|
+
import { Btn, Icon } from './shell.js';
|
|
8
|
+
const h = webjsx.createElement;
|
|
9
|
+
|
|
10
|
+
// ConversationList — the Claude-Desktop "Chats" column. Sessions grouped by a
|
|
11
|
+
// caller-supplied group label, each row showing title/project, relative time,
|
|
12
|
+
// agent badge, and a running/new-event indicator. Selecting a row switches the
|
|
13
|
+
// active conversation.
|
|
14
|
+
//
|
|
15
|
+
// sessions : [{ sid, title, project, agent, time, running, unread, rail }]
|
|
16
|
+
// selected : the active sid
|
|
17
|
+
// groups : OPTIONAL [{ label, sids:[...] }] to bucket rows; else one flat list
|
|
18
|
+
// search : { value, onInput, placeholder } inline filter (optional)
|
|
19
|
+
// onSelect(session), onNew() : intents
|
|
20
|
+
// emptyText, loading, error : explicit states
|
|
21
|
+
export function ConversationList({ sessions = [], selected, groups, search,
|
|
22
|
+
onSelect, onNew, newLabel = 'New chat',
|
|
23
|
+
emptyText = 'No conversations yet', loading = false, error = null } = {}) {
|
|
24
|
+
const rowFor = (s, i) => h('button', {
|
|
25
|
+
// Stable key: prefer sid, else position - a missing/duplicate sid would make
|
|
26
|
+
// key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
|
|
27
|
+
key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
|
|
28
|
+
type: 'button',
|
|
29
|
+
class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : ''),
|
|
30
|
+
'aria-current': s.sid === selected ? 'true' : null,
|
|
31
|
+
onclick: () => onSelect && onSelect(s),
|
|
32
|
+
},
|
|
33
|
+
// Positional children must NOT mix keyed VElements with null/strings (webjsx
|
|
34
|
+
// applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
|
|
35
|
+
// each h() call gets a clean, consistent child list.
|
|
36
|
+
h('span', { class: 'ds-session-main' }, [
|
|
37
|
+
h('span', { class: 'ds-session-title' }, s.title || s.project || s.sid || ''),
|
|
38
|
+
(s.project || s.time) ? h('span', { class: 'ds-session-sub' },
|
|
39
|
+
[s.project, s.time].filter(Boolean).join(' · ')) : null,
|
|
40
|
+
].filter(Boolean)),
|
|
41
|
+
h('span', { class: 'ds-session-meta' }, [
|
|
42
|
+
s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
|
|
43
|
+
s.running
|
|
44
|
+
? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
|
|
45
|
+
: (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
|
|
46
|
+
].filter(Boolean)));
|
|
47
|
+
|
|
48
|
+
// The body is ALWAYS a single keyed wrapper element of the same tag, so webjsx
|
|
49
|
+
// diffs its children across state transitions (loading -> empty -> populated)
|
|
50
|
+
// instead of swapping the container type - the swap is what triggered the
|
|
51
|
+
// applyDiff "reading 'key'" crash on the first populated mount. Row children
|
|
52
|
+
// are uniformly keyed; non-row states render a single unkeyed status line.
|
|
53
|
+
let inner;
|
|
54
|
+
if (loading) {
|
|
55
|
+
inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, 'Loading conversations…')];
|
|
56
|
+
} else if (error) {
|
|
57
|
+
inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
|
|
58
|
+
} else if (!sessions.length) {
|
|
59
|
+
inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status' }, emptyText)];
|
|
60
|
+
} else if (groups && groups.length) {
|
|
61
|
+
const bySid = new Map(sessions.map((s) => [s.sid, s]));
|
|
62
|
+
inner = groups.map((g) => h('div', { key: 'g-' + g.label, class: 'ds-session-group', role: 'group', 'aria-label': g.label },
|
|
63
|
+
h('div', { key: 'gl', class: 'ds-session-group-label' }, g.label),
|
|
64
|
+
h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'list' }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).map(rowFor))));
|
|
65
|
+
} else {
|
|
66
|
+
inner = sessions.map(rowFor);
|
|
67
|
+
}
|
|
68
|
+
const body = h('div', { key: 'body', class: 'ds-session-list', role: 'list' }, ...inner);
|
|
69
|
+
|
|
70
|
+
return h('div', { class: 'ds-sessions' },
|
|
71
|
+
h('div', { key: 'head', class: 'ds-session-head' },
|
|
72
|
+
onNew ? h('button', { key: 'new', type: 'button', class: 'ds-session-new', onclick: onNew, 'aria-label': newLabel },
|
|
73
|
+
Icon('pencil'), h('span', { key: 'l' }, newLabel)) : null,
|
|
74
|
+
search ? h('input', {
|
|
75
|
+
key: 'search', type: 'search', class: 'ds-session-search',
|
|
76
|
+
value: search.value || '', placeholder: search.placeholder || 'Search conversations',
|
|
77
|
+
'aria-label': search.placeholder || 'Search conversations',
|
|
78
|
+
oninput: (e) => search.onInput && search.onInput(e.target.value),
|
|
79
|
+
}) : null),
|
|
80
|
+
body);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// SessionCard — one running session in the live dashboard. Status dot, agent /
|
|
84
|
+
// model / cwd, elapsed, live counter, last activity, and per-session controls
|
|
85
|
+
// that each act on this session's id independently.
|
|
86
|
+
//
|
|
87
|
+
// session : { sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }
|
|
88
|
+
// actions : { onStop, onOpen, onResume, onView } (any subset)
|
|
89
|
+
// `counter` carries the live activity tally (e.g. "12 ev · 3 tools"); `lastActivity`
|
|
90
|
+
// the relative time of the most-recent event ("4s ago"); `currentTool` the tool
|
|
91
|
+
// name a still-running turn is executing - together they distinguish a busy
|
|
92
|
+
// session from a stuck one (a frozen elapsed alone reads identically for both).
|
|
93
|
+
export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } = {}) {
|
|
94
|
+
const s = session;
|
|
95
|
+
const statusTone = s.status === 'error' ? 'flame' : 'live';
|
|
96
|
+
// The stat line composes elapsed + live counter; the activity line carries the
|
|
97
|
+
// last-activity time and the current tool so a card shows MOTION, not just a
|
|
98
|
+
// start offset. Both are middot-joined (kept product separator).
|
|
99
|
+
const statBits = [s.elapsed != null ? s.elapsed : null, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
|
|
100
|
+
const activityBits = [
|
|
101
|
+
s.currentTool ? 'running: ' + s.currentTool : null,
|
|
102
|
+
s.lastActivity ? 'last ' + s.lastActivity : null,
|
|
103
|
+
].filter(Boolean);
|
|
104
|
+
return h('div', { class: 'ds-dash-card' + (s.status === 'error' ? ' is-error' : ''), role: 'group', 'aria-label': 'session ' + (s.agent || s.sid) },
|
|
105
|
+
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' }),
|
|
107
|
+
// Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
|
|
108
|
+
// 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'),
|
|
110
|
+
h('span', { class: 'ds-dash-agent' }, s.agent || 'agent'),
|
|
111
|
+
s.model ? h('span', { class: 'ds-dash-model' }, s.model) : null),
|
|
112
|
+
h('div', { class: 'ds-dash-meta' },
|
|
113
|
+
s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
|
|
114
|
+
statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
|
|
115
|
+
activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null),
|
|
116
|
+
h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' },
|
|
117
|
+
onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
|
|
118
|
+
onResume ? Btn({ key: 'resume', onClick: () => onResume(s), children: 'resume' }) : null,
|
|
119
|
+
onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
|
|
120
|
+
onStop ? Btn({ key: 'stop', danger: true, onClick: () => onStop(s), children: 'stop' }) : null));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
|
|
124
|
+
// sessions : [{ sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }]
|
|
125
|
+
// actions : { onStop, onOpen, onResume, onView } passed to each card
|
|
126
|
+
// onStopAll : OPTIONAL bulk control - stop every running session at once
|
|
127
|
+
// emptyText, offline : explicit states
|
|
128
|
+
// The bulk header is the "manage many at once" affordance: a live count plus a
|
|
129
|
+
// stop-all button, so a user running several agents does not have to hunt each
|
|
130
|
+
// card's stop. Rendered only when there are sessions AND onStopAll is wired.
|
|
131
|
+
export function SessionDashboard({ sessions = [], onStop, onOpen, onResume, onView, onStopAll,
|
|
132
|
+
emptyText = 'No live sessions', offline = false } = {}) {
|
|
133
|
+
if (offline) {
|
|
134
|
+
return h('div', { class: 'ds-dash-state ds-dash-state-error', role: 'status' }, 'Backend offline — live sessions unavailable');
|
|
135
|
+
}
|
|
136
|
+
if (!sessions.length) {
|
|
137
|
+
return h('div', { class: 'ds-dash-state', role: 'status' }, emptyText);
|
|
138
|
+
}
|
|
139
|
+
const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
|
|
140
|
+
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);
|
|
143
|
+
const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
|
|
144
|
+
...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
|
|
145
|
+
SessionCard({ session: s, onStop, onOpen, onResume, onView }))));
|
|
146
|
+
return h('div', { class: 'ds-dash' }, header, grid);
|
|
147
|
+
}
|