anentrypoint-design 0.0.228 → 0.0.230
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 +18 -0
- package/chat.css +3 -0
- package/dist/247420.css +21 -0
- package/dist/247420.js +13 -13
- package/package.json +1 -1
- package/src/components/agent-chat.js +14 -3
- package/src/components/chat.js +31 -14
- package/src/components/context-pane.js +23 -8
- package/src/components/files-modals.js +21 -10
- package/src/components/files.js +3 -6
- package/src/components/overlay-primitives.js +47 -9
- package/src/components/sessions.js +15 -6
- package/src/components/shell.js +38 -16
- package/src/kits/os/theme.css +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.230",
|
|
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",
|
|
@@ -234,6 +234,7 @@ export function AgentChat(props = {}) {
|
|
|
234
234
|
if (part.text && part.text.indexOf('```') !== -1) parts.push({ kind: 'text', text: part.text, mdShell: true, preShell: true });
|
|
235
235
|
else parts.push({ kind: 'text', text: part.text, mdShell: true });
|
|
236
236
|
}
|
|
237
|
+
else if (!isStreaming && part.kind === 'thinking') parts.push({ kind: 'thinking', settled: true, text: part.text });
|
|
237
238
|
else parts.push(part);
|
|
238
239
|
}
|
|
239
240
|
}
|
|
@@ -245,6 +246,15 @@ export function AgentChat(props = {}) {
|
|
|
245
246
|
// The streaming caret rides the live assistant turn once it has body (the
|
|
246
247
|
// empty-shell turn already shows the inline typing dots).
|
|
247
248
|
const streaming = isStreaming && msgHasBody(m);
|
|
249
|
+
// Place the caret inline inside the last text/md part rather than as a
|
|
250
|
+
// sibling span (which renders as a block below the last bubble). Tag the
|
|
251
|
+
// last text part so PART_RENDERERS.text can append it as an inline child.
|
|
252
|
+
if (streaming && parts.length) {
|
|
253
|
+
const lastPart = parts[parts.length - 1];
|
|
254
|
+
if (lastPart && (lastPart.kind === 'text' || lastPart.kind === 'md')) {
|
|
255
|
+
parts[parts.length - 1] = { ...lastPart, streamingCaret: true };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
248
258
|
// Per-message actions: the host supplies onCopyMessage / onRetryMessage; we
|
|
249
259
|
// build the action row only for SETTLED messages (no actions mid-stream).
|
|
250
260
|
let actions;
|
|
@@ -252,6 +262,7 @@ export function AgentChat(props = {}) {
|
|
|
252
262
|
const built = [];
|
|
253
263
|
if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
|
|
254
264
|
if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
|
|
265
|
+
if (!isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry', onClick: () => onRetryMessage(m) });
|
|
255
266
|
// With confirmEdit the host arms its own confirm affordance (onArmEdit)
|
|
256
267
|
// instead of resending immediately; the kit stays stateless either way.
|
|
257
268
|
if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend',
|
|
@@ -317,7 +328,7 @@ export function AgentChat(props = {}) {
|
|
|
317
328
|
// code / cowork surface these after a turn, not only on an empty thread). Shown
|
|
318
329
|
// only when not busy and the last message is an assistant turn with body.
|
|
319
330
|
const followupRow = (!busy && followups && followups.length && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg))
|
|
320
|
-
? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups' },
|
|
331
|
+
? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups', 'aria-hidden': 'true' },
|
|
321
332
|
...followups.map((s, i) => h('button', {
|
|
322
333
|
key: 'fu' + i, type: 'button', class: 'agentchat-empty-suggestion agentchat-followup',
|
|
323
334
|
onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onFollowupClick) onFollowupClick(t); else if (onSuggestionClick) onSuggestionClick(t); },
|
|
@@ -376,8 +387,8 @@ export function AgentChat(props = {}) {
|
|
|
376
387
|
onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft }),
|
|
377
388
|
...(banners || []).filter(Boolean),
|
|
378
389
|
h('div', { class: 'agentchat-head' },
|
|
379
|
-
h('
|
|
380
|
-
h('span', { class: 'agentchat-sub', 'aria-
|
|
390
|
+
h('h1', { class: 'agentchat-title' }, name + (selectedModel ? ' · ' + selectedModel : '')),
|
|
391
|
+
h('span', { class: 'agentchat-sub', 'aria-hidden': busy ? 'true' : null },
|
|
381
392
|
// Derive the busy label from the same status prop the controls use, so a
|
|
382
393
|
// reconnecting-while-streaming state reads one word everywhere instead of
|
|
383
394
|
// the head saying "streaming…" while the controls say "reconnecting…".
|
package/src/components/chat.js
CHANGED
|
@@ -256,6 +256,12 @@ function ToolCallNode(p) {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
function ThinkingNode(p) {
|
|
259
|
+
if (p.settled) {
|
|
260
|
+
return h('details', { class: 'chat-bubble chat-thinking-settled' },
|
|
261
|
+
h('summary', {}, 'View thinking'),
|
|
262
|
+
h('div', { class: 'chat-thinking-body' }, p.text)
|
|
263
|
+
);
|
|
264
|
+
}
|
|
259
265
|
return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
|
|
260
266
|
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
261
267
|
h('span', { class: 'chat-thinking-text' }, p.text || 'thinking…')
|
|
@@ -271,8 +277,11 @@ const PART_RENDERERS = {
|
|
|
271
277
|
// the tail-window path ('streaming · N KB so far').
|
|
272
278
|
? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
|
|
273
279
|
...[p.streamHead ? h('div', { key: 'sh', class: 'chat-stream-head', role: 'status', 'aria-live': 'polite' }, p.streamHead) : null,
|
|
274
|
-
h('pre', { key: 'pre' }, h('code', {}, p.text || ''))
|
|
275
|
-
|
|
280
|
+
h('pre', { key: 'pre' }, h('code', {}, p.text || '')),
|
|
281
|
+
p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null].filter(Boolean))
|
|
282
|
+
: h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') },
|
|
283
|
+
...renderInline(p.text || ''),
|
|
284
|
+
p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null),
|
|
276
285
|
md: (p) => MdNode(p),
|
|
277
286
|
code: (p) => CodeNode(p),
|
|
278
287
|
tool: (p) => ToolCallNode(p),
|
|
@@ -354,7 +363,10 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
354
363
|
// AND already shows content (so the inline typing dots have stopped), append
|
|
355
364
|
// a thin caret so the live edge reads as "still writing", not "done". Drawn as
|
|
356
365
|
// a CSS element, not a glyph character.
|
|
357
|
-
|
|
366
|
+
// Only append the caret as a sibling if the last part did not already embed
|
|
367
|
+
// it inline (streamingCaret flag on the last text/md part in parts array).
|
|
368
|
+
const lastPartHasCaret = parts && parts.length && parts[parts.length - 1] && parts[parts.length - 1].streamingCaret;
|
|
369
|
+
if (streaming && !typing && !lastPartHasCaret) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
|
|
358
370
|
// Out-of-band turn notices, plain copy in a NEUTRAL tone (not error red):
|
|
359
371
|
// stopped — the turn was cancelled (locally or remotely); truncated
|
|
360
372
|
// output must not read as a finished answer.
|
|
@@ -424,7 +436,7 @@ function flashComposerNote(composerEl, text) {
|
|
|
424
436
|
note._dsNoteTimer = setTimeout(() => { note.remove(); }, 2600);
|
|
425
437
|
}
|
|
426
438
|
|
|
427
|
-
export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, context, onPasteFiles, onDropFiles }) {
|
|
439
|
+
export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, disabledReason, label, context, onPasteFiles, onDropFiles }) {
|
|
428
440
|
// Keep a handle to the live textarea so send() reads the actual DOM value
|
|
429
441
|
// (not the possibly-lagging `value` prop) and so we can sync the DOM value
|
|
430
442
|
// only when it genuinely differs — re-applying `value` on every parent
|
|
@@ -495,7 +507,7 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
495
507
|
}, b.text));
|
|
496
508
|
else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, b.text));
|
|
497
509
|
});
|
|
498
|
-
contextLine = h('div', { class: 'chat-composer-context' }, ...kids);
|
|
510
|
+
contextLine = h('div', { class: 'chat-composer-context', role: 'group', 'aria-label': 'active session: ' + ctxBits.map((b) => b.text).join(', ') }, ...kids);
|
|
499
511
|
} else if (ctxBits.length) {
|
|
500
512
|
const joined = ctxBits.map((b) => b.text).join(' · ');
|
|
501
513
|
contextLine = h(context.onClick ? 'button' : 'div', {
|
|
@@ -506,7 +518,7 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
506
518
|
}
|
|
507
519
|
const hasDraft = !!(value && value.trim());
|
|
508
520
|
return h('div', {
|
|
509
|
-
class: 'chat-composer' + (hasDraft ? ' has-draft' : ''),
|
|
521
|
+
class: 'chat-composer' + (hasDraft ? ' has-draft' : '') + (disabled ? ' is-disabled' : ''),
|
|
510
522
|
// A drop on the composer must NEVER navigate the browser away from the
|
|
511
523
|
// live session: preventDefault on both dragover and drop, route files to
|
|
512
524
|
// the optional onDropFiles handler, ring via .dragover.
|
|
@@ -523,23 +535,26 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
523
535
|
},
|
|
524
536
|
},
|
|
525
537
|
contextLine,
|
|
526
|
-
h('textarea', { ref: taRef, placeholder, rows: 1,
|
|
538
|
+
h('textarea', { ref: taRef, placeholder, rows: 1,
|
|
539
|
+
'aria-label': label || (disabled && disabledReason ? 'message input — ' + disabledReason : 'message input'),
|
|
527
540
|
disabled: !!disabled, 'aria-disabled': disabled ? 'true' : null,
|
|
528
541
|
oninput: autoGrow,
|
|
529
542
|
onpaste: (e) => {
|
|
530
543
|
const cd = e.clipboardData;
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
if (cd && cd.files && cd.files.length
|
|
544
|
+
// If the clipboard contains files, always route them — even when
|
|
545
|
+
// text is also present (some apps attach a filename as text).
|
|
546
|
+
if (cd && cd.files && cd.files.length) {
|
|
534
547
|
e.preventDefault();
|
|
535
548
|
if (onPasteFiles) onPasteFiles(cd.files);
|
|
536
549
|
else flashComposerNote(e.currentTarget.closest('.chat-composer'), 'images are not supported yet');
|
|
537
550
|
}
|
|
538
551
|
},
|
|
539
552
|
onkeydown: (e) => {
|
|
540
|
-
// Escape
|
|
541
|
-
|
|
542
|
-
|
|
553
|
+
// Escape blurs the textarea when idle; stops generation when busy.
|
|
554
|
+
if (e.key === 'Escape') {
|
|
555
|
+
if (!busy) { e.currentTarget.blur(); return; }
|
|
556
|
+
if (onCancel) { e.preventDefault(); onCancel(e); return; }
|
|
557
|
+
}
|
|
543
558
|
// IME guard: the Enter that commits a CJK composition must never
|
|
544
559
|
// send (isComposing; keyCode 229 covers older engines).
|
|
545
560
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); send(); }
|
|
@@ -555,7 +570,9 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
555
570
|
onMenu ? h('button', { type: 'button', class: 'composer-btn', onclick: (e) => { e.preventDefault(); onMenu(e); }, 'aria-label': 'composer menu', title: 'more options' }, Icon('more-horizontal')) : null,
|
|
556
571
|
busy && onCancel
|
|
557
572
|
? h('button', { type: 'button', class: 'send cancel', onclick: (e) => { e.preventDefault(); onCancel(e); }, 'aria-label': 'stop generating', title: 'stop generating (Esc)' }, Icon('square'))
|
|
558
|
-
: h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send,
|
|
573
|
+
: h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send,
|
|
574
|
+
'aria-label': disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message',
|
|
575
|
+
title: disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message (Enter)' }, Icon('arrow-up'))
|
|
559
576
|
)
|
|
560
577
|
);
|
|
561
578
|
}
|
|
@@ -8,13 +8,17 @@
|
|
|
8
8
|
// ContextPane({ agent, model, cwd, toolCount, onSetCwd })
|
|
9
9
|
//
|
|
10
10
|
// Props:
|
|
11
|
-
// agent
|
|
12
|
-
// model
|
|
13
|
-
// cwd
|
|
14
|
-
// toolCount
|
|
15
|
-
// usage
|
|
16
|
-
// session
|
|
17
|
-
//
|
|
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 whole-conversation totals { turns, cost } shown as a block
|
|
17
|
+
// recentFiles: OPTIONAL [{ path, time }] files touched by tool calls this
|
|
18
|
+
// session (most-recent first), rendered as a compact panel -
|
|
19
|
+
// Claude Desktop's context surfaces recently-touched files.
|
|
20
|
+
// onSetCwd : optional callback for the "set working directory" affordance
|
|
21
|
+
// onOpenFile : optional callback(path) for clicking a recent-files row
|
|
18
22
|
//
|
|
19
23
|
// No decorative glyphs — words + the kit's Icon SVGs only.
|
|
20
24
|
|
|
@@ -32,7 +36,7 @@ function fmtTok(n) {
|
|
|
32
36
|
return (n / 1000000).toFixed(1) + 'M';
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
|
|
39
|
+
export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, recentFiles, onSetCwd, onOpenFile } = {}) {
|
|
36
40
|
const running = Number(toolCount) > 0;
|
|
37
41
|
const hasUsage = usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null);
|
|
38
42
|
const hasSession = session && (session.turns != null || session.cost != null);
|
|
@@ -93,6 +97,17 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
93
97
|
if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: fmtDuration(usage.durationMs) }));
|
|
94
98
|
panels.push(Panel({ title: 'last turn', children: tokRows }));
|
|
95
99
|
}
|
|
100
|
+
// Recent files: files touched by tool calls this session, most-recent
|
|
101
|
+
// first, capped to 5 rows so the panel stays a glance not a log.
|
|
102
|
+
if (Array.isArray(recentFiles) && recentFiles.length) {
|
|
103
|
+
const fileRows = recentFiles.slice(0, 5).map((f) => Row({
|
|
104
|
+
title: f.path.split(/[/\\]/).filter(Boolean).pop() || f.path,
|
|
105
|
+
sub: f.path,
|
|
106
|
+
meta: f.time || undefined,
|
|
107
|
+
onClick: onOpenFile ? () => onOpenFile(f.path) : undefined,
|
|
108
|
+
}));
|
|
109
|
+
panels.push(Panel({ title: 'recent files', children: fileRows }));
|
|
110
|
+
}
|
|
96
111
|
// The cwd action lives on the working-dir row above; no floating footer button.
|
|
97
112
|
return h('div', { class: 'ds-context' }, ...panels);
|
|
98
113
|
}
|
|
@@ -6,7 +6,14 @@ import { fileGlyph, fmtFileSize } from './files.js';
|
|
|
6
6
|
import { highlightAllUnder } from '../highlight.js';
|
|
7
7
|
const h = webjsx.createElement;
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// Stable per-call-site id source for aria-labelledby: we want one fixed id per
|
|
10
|
+
// logical dialog instance (rename, confirm, prompt, preview), NOT a monotonic
|
|
11
|
+
// counter that advances on every render and leaves the old aria-labelledby
|
|
12
|
+
// reference dangling. A WeakMap keyed on the options object would not survive
|
|
13
|
+
// re-renders, so we use a short random suffix minted ONCE per Modal() call
|
|
14
|
+
// inside the function body — the closure keeps it stable for that render tree.
|
|
15
|
+
// _modalSeq is retained only for external callers that may import it; it is no
|
|
16
|
+
// longer used internally.
|
|
10
17
|
let _modalSeq = 0;
|
|
11
18
|
|
|
12
19
|
function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {}) {
|
|
@@ -19,13 +26,6 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
|
|
|
19
26
|
const modal = el.querySelector('.ds-modal');
|
|
20
27
|
if (!modal) return;
|
|
21
28
|
|
|
22
|
-
// Focus trap: handle Tab key to cycle focus within modal
|
|
23
|
-
const focusables = modal.querySelectorAll(
|
|
24
|
-
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
25
|
-
);
|
|
26
|
-
const firstFocusable = focusables[0];
|
|
27
|
-
const lastFocusable = focusables[focusables.length - 1];
|
|
28
|
-
|
|
29
29
|
const handleKeydown = (e) => {
|
|
30
30
|
// Escape closes the modal — unless a mutation is in flight (the live
|
|
31
31
|
// busy state is read off the data-busy attribute, which re-renders;
|
|
@@ -36,12 +36,19 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
|
|
|
36
36
|
if (onClose) onClose();
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
|
-
// Tab
|
|
39
|
+
// Focus trap: re-query focusables on each Tab press so that buttons
|
|
40
|
+
// disabled mid-flight (busy state) are excluded from the cycle and
|
|
41
|
+
// do not break tab navigation.
|
|
40
42
|
if (e.key === 'Tab') {
|
|
43
|
+
const focusables = modal.querySelectorAll(
|
|
44
|
+
'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
45
|
+
);
|
|
41
46
|
if (focusables.length === 0) {
|
|
42
47
|
e.preventDefault();
|
|
43
48
|
return;
|
|
44
49
|
}
|
|
50
|
+
const firstFocusable = focusables[0];
|
|
51
|
+
const lastFocusable = focusables[focusables.length - 1];
|
|
45
52
|
if (e.shiftKey) {
|
|
46
53
|
if (document.activeElement === firstFocusable) {
|
|
47
54
|
e.preventDefault();
|
|
@@ -127,7 +134,11 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
|
|
|
127
134
|
function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions, busy = false } = {}) {
|
|
128
135
|
// Give the head a stable id so the dialog can point aria-labelledby at it,
|
|
129
136
|
// exposing the title as the dialog's accessible name to screen readers.
|
|
130
|
-
|
|
137
|
+
// The id is minted once per Modal() call with a short random suffix so it
|
|
138
|
+
// stays constant across re-renders of the same dialog instance — an
|
|
139
|
+
// incrementing counter advances on every render, leaving the previous
|
|
140
|
+
// aria-labelledby reference pointing at a now-absent element.
|
|
141
|
+
const headId = head != null ? ('ds-modal-head-' + Math.random().toString(36).slice(2, 8)) : null;
|
|
131
142
|
return Backdrop({
|
|
132
143
|
onClose,
|
|
133
144
|
kind,
|
package/src/components/files.js
CHANGED
|
@@ -462,7 +462,7 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
|
|
|
462
462
|
return h('div', {
|
|
463
463
|
class: 'ds-dropzone' + (kids.length ? ' ds-dropzone--wrap' : '') + (dragover ? ' dragover' : ''),
|
|
464
464
|
ondragover: (e) => { e.preventDefault(); onDragOver && onDragOver(e); },
|
|
465
|
-
ondragleave: (e) => { onDragLeave && onDragLeave(e); },
|
|
465
|
+
ondragleave: (e) => { if (!e.currentTarget.contains(e.relatedTarget)) { onDragLeave && onDragLeave(e); } },
|
|
466
466
|
ondrop: (e) => { e.preventDefault(); onDrop && onDrop(e.dataTransfer.files); }
|
|
467
467
|
},
|
|
468
468
|
h('div', { class: 'ds-dropzone-inner' },
|
|
@@ -498,12 +498,9 @@ export function UploadProgress({ items = [], onDismiss } = {}) {
|
|
|
498
498
|
return h('div', {
|
|
499
499
|
key: it.name + i,
|
|
500
500
|
class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
|
|
501
|
-
role: '
|
|
502
|
-
'aria-valuenow': String(Math.max(0, Math.min(100, it.pct || 0))),
|
|
503
|
-
'aria-valuemin': '0',
|
|
504
|
-
'aria-valuemax': '100',
|
|
501
|
+
role: 'status',
|
|
505
502
|
'aria-label': `${it.name}: ${status}`,
|
|
506
|
-
'aria-
|
|
503
|
+
'aria-live': 'polite'
|
|
507
504
|
},
|
|
508
505
|
h('span', { class: 'ds-upload-name' }, it.name),
|
|
509
506
|
h('span', { class: 'ds-upload-bar' },
|
|
@@ -132,6 +132,7 @@ export function Popover({ open, anchorEl, onClose, placement = 'bottom-start', c
|
|
|
132
132
|
const el = document.createElement('div');
|
|
133
133
|
el.className = 'ds-popover';
|
|
134
134
|
el.setAttribute('role', 'dialog');
|
|
135
|
+
el.setAttribute('aria-modal', 'true');
|
|
135
136
|
if (ariaLabel) el.setAttribute('aria-label', ariaLabel);
|
|
136
137
|
el.tabIndex = -1;
|
|
137
138
|
document.body.appendChild(el);
|
|
@@ -246,7 +247,7 @@ function _clampToViewport(x, y, w, h, margin = CLAMP_MARGIN) {
|
|
|
246
247
|
|
|
247
248
|
// Tab focus trap for a dialog root — keeps Tab/Shift+Tab cycling inside `el`.
|
|
248
249
|
// Call from an onkeydown handler; returns true if it handled the event.
|
|
249
|
-
function
|
|
250
|
+
export function trapTab(el, e) {
|
|
250
251
|
if (e.key !== 'Tab') return false;
|
|
251
252
|
const nodes = el.querySelectorAll(FOCUSABLE_SEL);
|
|
252
253
|
if (!nodes.length) { e.preventDefault(); return true; }
|
|
@@ -299,6 +300,7 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
|
|
|
299
300
|
const hint = it.hint != null ? it.hint : (it.shortcut != null ? it.shortcut : null);
|
|
300
301
|
out.push(h('button', {
|
|
301
302
|
type: 'button', role: 'option',
|
|
303
|
+
id: 'ov-cmd-item-' + idx,
|
|
302
304
|
'data-idx': String(idx),
|
|
303
305
|
'aria-selected': idx === active ? 'true' : 'false',
|
|
304
306
|
class: 'ov-cmd-item' + (idx === active ? ' is-active' : ''),
|
|
@@ -330,6 +332,7 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
|
|
|
330
332
|
filtered.length ? rowsFor(filtered) : h('div', { class: 'ov-cmd-empty' }, 'No results')));
|
|
331
333
|
const sel = listEl.querySelector('.ov-cmd-item.is-active');
|
|
332
334
|
if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: 'nearest' });
|
|
335
|
+
if (inputEl) inputEl.setAttribute('aria-activedescendant', filtered.length ? 'ov-cmd-item-' + active : '');
|
|
333
336
|
};
|
|
334
337
|
|
|
335
338
|
const onKey = (e) => {
|
|
@@ -349,11 +352,15 @@ export function CommandPalette({ open, items = [], onSelect, onClose } = {}) {
|
|
|
349
352
|
});
|
|
350
353
|
},
|
|
351
354
|
},
|
|
352
|
-
h('div', { class: 'ov-cmd-panel', role: 'dialog', 'aria-label': 'Command palette', onkeydown: onKey },
|
|
355
|
+
h('div', { class: 'ov-cmd-panel', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Command palette', onkeydown: onKey },
|
|
353
356
|
h('input', {
|
|
354
357
|
type: 'text', class: 'ov-cmd-input', placeholder: 'Type a command…',
|
|
355
358
|
'aria-label': 'command search',
|
|
359
|
+
role: 'combobox',
|
|
360
|
+
'aria-autocomplete': 'list',
|
|
361
|
+
'aria-expanded': 'true',
|
|
356
362
|
'aria-controls': 'ov-cmd-list',
|
|
363
|
+
'aria-activedescendant': '',
|
|
357
364
|
oninput: (e) => { filterText = e.target.value; active = 0; renderInner(); },
|
|
358
365
|
ref: (el) => { if (!el || el._ovCmdIn) return; el._ovCmdIn = true; inputEl = el; queueMicrotask(() => el.focus()); },
|
|
359
366
|
}),
|
|
@@ -391,10 +398,22 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
|
|
|
391
398
|
}, ch))));
|
|
392
399
|
};
|
|
393
400
|
|
|
401
|
+
const tabNavKey = (e) => {
|
|
402
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
403
|
+
const tabs = rootEl ? [...rootEl.querySelectorAll('.ov-emoji-tab')] : [];
|
|
404
|
+
if (!tabs.length) return;
|
|
405
|
+
const idx = tabs.indexOf(document.activeElement);
|
|
406
|
+
if (idx < 0) return;
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
|
|
409
|
+
tabs[next].focus();
|
|
410
|
+
tabs[next].click();
|
|
411
|
+
};
|
|
412
|
+
|
|
394
413
|
return h('div', {
|
|
395
|
-
class: 'ov-emoji-root', role: 'dialog', 'aria-label': 'Emoji picker',
|
|
414
|
+
class: 'ov-emoji-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Emoji picker',
|
|
396
415
|
tabindex: '-1',
|
|
397
|
-
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl)
|
|
416
|
+
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } tabNavKey(e); if (rootEl) trapTab(rootEl, e); },
|
|
398
417
|
ref: (el) => {
|
|
399
418
|
if (!el) { if (rootEl && rootEl._ovEmojiCleanup) rootEl._ovEmojiCleanup(); return; }
|
|
400
419
|
if (el._ovEmoji) return; el._ovEmoji = true; rootEl = el;
|
|
@@ -405,6 +424,7 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
|
|
|
405
424
|
...EMOJI_CATEGORIES.map((c) => h('button', {
|
|
406
425
|
type: 'button', class: 'ov-emoji-tab', role: 'tab',
|
|
407
426
|
'aria-selected': c.id === cat ? 'true' : 'false',
|
|
427
|
+
'aria-controls': 'ov-emoji-panel',
|
|
408
428
|
onclick: (e) => {
|
|
409
429
|
cat = c.id;
|
|
410
430
|
const tabs = rootEl.querySelectorAll('.ov-emoji-tab');
|
|
@@ -413,7 +433,9 @@ export function EmojiPicker({ open, anchorX = 0, anchorY = 0, onSelect, onClose
|
|
|
413
433
|
renderGrid();
|
|
414
434
|
},
|
|
415
435
|
}, c.label))),
|
|
416
|
-
h('div', {
|
|
436
|
+
h('div', {
|
|
437
|
+
class: 'ov-emoji-grid', id: 'ov-emoji-panel', role: 'tabpanel',
|
|
438
|
+
'aria-label': EMOJI_CATEGORIES.find(c => c.id === cat)?.label || EMOJI_CATEGORIES[0].label,
|
|
417
439
|
ref: (el) => { if (!el) return; gridEl = el; queueMicrotask(renderGrid); } })
|
|
418
440
|
);
|
|
419
441
|
}
|
|
@@ -495,8 +517,8 @@ export function SettingsPopover({ title = 'Settings', open, anchorX = 0, anchorY
|
|
|
495
517
|
};
|
|
496
518
|
|
|
497
519
|
return h('div', {
|
|
498
|
-
class: 'ov-set-root', role: 'dialog', 'aria-label': String(title), tabindex: '-1',
|
|
499
|
-
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl)
|
|
520
|
+
class: 'ov-set-root', role: 'dialog', 'aria-modal': 'true', 'aria-label': String(title), tabindex: '-1',
|
|
521
|
+
onkeydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (rootEl) trapTab(rootEl, e); },
|
|
500
522
|
ref: (el) => {
|
|
501
523
|
if (!el) { if (rootEl && rootEl._ovSetCleanup) rootEl._ovSetCleanup(); return; }
|
|
502
524
|
if (el._ovSet) return; el._ovSet = true; rootEl = el;
|
|
@@ -570,15 +592,31 @@ export function AuthModal({ mode = 'extension', error = '', busy = false, open =
|
|
|
570
592
|
h('h2', { class: 'ov-auth-title' }, 'Sign in'),
|
|
571
593
|
h('button', { type: 'button', class: 'ov-auth-x', 'aria-label': 'close', onclick: close }, Icon('x'))
|
|
572
594
|
),
|
|
573
|
-
h('div', { class: 'ov-auth-tabs', role: 'tablist'
|
|
595
|
+
h('div', { class: 'ov-auth-tabs', role: 'tablist',
|
|
596
|
+
onkeydown: (e) => {
|
|
597
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
598
|
+
const panel = e.currentTarget.closest('.ov-auth-panel');
|
|
599
|
+
const tabs = panel ? [...panel.querySelectorAll('.ov-auth-tab')] : [];
|
|
600
|
+
if (!tabs.length) return;
|
|
601
|
+
const idx = tabs.indexOf(document.activeElement);
|
|
602
|
+
if (idx < 0) return;
|
|
603
|
+
e.preventDefault();
|
|
604
|
+
const next = e.key === 'ArrowRight' ? (idx + 1) % tabs.length : (idx - 1 + tabs.length) % tabs.length;
|
|
605
|
+
tabs[next].focus();
|
|
606
|
+
onModeChange && onModeChange(modes[next].id);
|
|
607
|
+
},
|
|
608
|
+
},
|
|
574
609
|
...modes.map(m => h('button', {
|
|
575
610
|
type: 'button', role: 'tab', key: 'am-' + m.id,
|
|
611
|
+
id: 'ov-auth-tab-' + m.id,
|
|
576
612
|
class: 'ov-auth-tab' + (m.id === mode ? ' is-active' : ''),
|
|
577
613
|
'aria-selected': m.id === mode ? 'true' : 'false',
|
|
614
|
+
'aria-controls': 'ov-auth-panel',
|
|
578
615
|
onclick: () => onModeChange && onModeChange(m.id),
|
|
579
616
|
}, m.label))
|
|
580
617
|
),
|
|
581
|
-
h('div', { class: 'ov-auth-body'
|
|
618
|
+
h('div', { class: 'ov-auth-body', id: 'ov-auth-panel', role: 'tabpanel',
|
|
619
|
+
'aria-labelledby': 'ov-auth-tab-' + mode }, ...body()),
|
|
582
620
|
error ? h('div', { class: 'ov-auth-error', role: 'alert' }, String(error)) : null
|
|
583
621
|
)
|
|
584
622
|
);
|
|
@@ -36,14 +36,16 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
36
36
|
onSelect, onNew, newLabel = 'New chat',
|
|
37
37
|
emptyText = 'No conversations yet', loading = false, error = null,
|
|
38
38
|
loadingText = 'Loading conversations…' } = {}) {
|
|
39
|
-
const rowFor = (s, i) => h('
|
|
39
|
+
const rowFor = (s, i) => h('div', {
|
|
40
40
|
// Stable key: prefer sid, else position - a missing/duplicate sid would make
|
|
41
41
|
// key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
|
|
42
42
|
key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
|
|
43
|
-
|
|
43
|
+
role: 'option',
|
|
44
|
+
tabindex: s.sid === selected ? '0' : '-1',
|
|
44
45
|
class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : ''),
|
|
45
|
-
'aria-
|
|
46
|
+
'aria-selected': s.sid === selected ? 'true' : 'false',
|
|
46
47
|
onclick: () => onSelect && onSelect(s),
|
|
48
|
+
onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect && onSelect(s); } },
|
|
47
49
|
},
|
|
48
50
|
// Positional children must NOT mix keyed VElements with null/strings (webjsx
|
|
49
51
|
// applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
|
|
@@ -57,7 +59,14 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
57
59
|
].filter(Boolean)),
|
|
58
60
|
h('span', { class: 'ds-session-meta' }, [
|
|
59
61
|
s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
|
|
60
|
-
|
|
62
|
+
// Optional richer status ('error'|'stale'|'running'|'stopping') mirrors the
|
|
63
|
+
// SessionCard STATUS_DISC mapping used on the Live dashboard, so a session
|
|
64
|
+
// pinned to a "Running" rail group reads the same stuck-vs-busy signal it
|
|
65
|
+
// does there rather than only a boolean live dot. Falls back to the plain
|
|
66
|
+
// running dot when no status is supplied (existing callers unaffected).
|
|
67
|
+
s.status
|
|
68
|
+
? h('span', { class: 'status-dot-disc ' + (STATUS_DISC[s.status] || 'status-dot-live'), 'aria-label': STATUS_WORD[s.status] || s.status, role: 'img' })
|
|
69
|
+
: s.running
|
|
61
70
|
? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
|
|
62
71
|
: (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
|
|
63
72
|
].filter(Boolean)));
|
|
@@ -84,11 +93,11 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
84
93
|
const bySid = new Map(sessions.map((s) => [s.sid, s]));
|
|
85
94
|
inner = groups.map((g) => h('div', { key: 'g-' + g.label, class: 'ds-session-group', role: 'group', 'aria-label': g.label },
|
|
86
95
|
h('div', { key: 'gl', class: 'ds-session-group-label' }, g.label),
|
|
87
|
-
h('div', { key: 'gr', class: 'ds-session-group-rows', role: '
|
|
96
|
+
h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'listbox', 'aria-label': g.label }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).map(rowFor))));
|
|
88
97
|
} else {
|
|
89
98
|
inner = sessions.map(rowFor);
|
|
90
99
|
}
|
|
91
|
-
const body = h('div', { key: 'body', class: 'ds-session-list', role: '
|
|
100
|
+
const body = h('div', { key: 'body', class: 'ds-session-list', role: 'listbox', 'aria-label': caption || 'Conversations' }, ...inner);
|
|
92
101
|
|
|
93
102
|
return h('div', { class: 'ds-sessions' },
|
|
94
103
|
h('div', { key: 'head', class: 'ds-session-head' },
|