anentrypoint-design 0.0.200 → 0.0.202
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 +67 -2
- package/chat.css +159 -0
- package/dist/247420.css +226 -2
- package/dist/247420.js +13 -13
- package/package.json +1 -1
- package/src/components/agent-chat.js +110 -9
- package/src/components/chat.js +129 -24
- package/src/components/content.js +71 -9
- package/src/components/context-pane.js +14 -3
- package/src/components/files-modals.js +83 -24
- package/src/components/files.js +58 -10
- package/src/components/sessions.js +102 -19
- package/src/components.js +4 -4
- package/src/markdown-cache.js +15 -11
- package/src/markdown.js +15 -0
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// cwd : the chat working directory (string) or falsy for server default
|
|
14
14
|
// toolCount : number of tool calls running in the current live turn (>=0)
|
|
15
15
|
// usage : OPTIONAL last-turn usage { inputTokens, outputTokens, costUsd, turns, durationMs }
|
|
16
|
-
// session : OPTIONAL
|
|
16
|
+
// session : OPTIONAL whole-conversation totals { turns, cost } shown as a block
|
|
17
17
|
// onSetCwd : optional callback for the "set working directory" affordance
|
|
18
18
|
//
|
|
19
19
|
// No decorative glyphs — words + the kit's Icon SVGs only.
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
22
22
|
import { Panel, Row } from './content.js';
|
|
23
23
|
import { Btn } from './shell.js';
|
|
24
|
+
import { fmtDuration } from './sessions.js';
|
|
24
25
|
|
|
25
26
|
const h = webjsx.createElement;
|
|
26
27
|
|
|
@@ -34,10 +35,11 @@ function fmtTok(n) {
|
|
|
34
35
|
export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
|
|
35
36
|
const running = Number(toolCount) > 0;
|
|
36
37
|
const hasUsage = usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null);
|
|
38
|
+
const hasSession = session && (session.turns != null || session.cost != null);
|
|
37
39
|
// Empty state: before an agent is picked AND with no usage/session, four
|
|
38
40
|
// placeholder rows (agent: none / model: dash / ...) read as a dead panel.
|
|
39
41
|
// Show one honest line instead.
|
|
40
|
-
if (!agent && !hasUsage && !
|
|
42
|
+
if (!agent && !hasUsage && !hasSession && !cwd) {
|
|
41
43
|
return h('div', { class: 'ds-context' },
|
|
42
44
|
h('div', { class: 'ds-context-empty', role: 'status' },
|
|
43
45
|
'No active conversation — start a chat to see context here'),
|
|
@@ -66,6 +68,14 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
66
68
|
],
|
|
67
69
|
}),
|
|
68
70
|
];
|
|
71
|
+
// Conversation block: whole-session totals (turn count + accumulated cost)
|
|
72
|
+
// between the context panel and the per-turn usage panel.
|
|
73
|
+
if (hasSession) {
|
|
74
|
+
const sesRows = [];
|
|
75
|
+
if (session.turns != null) sesRows.push(Row({ title: 'turns', meta: String(session.turns) }));
|
|
76
|
+
if (session.cost != null) sesRows.push(Row({ title: 'total cost', meta: '$' + Number(session.cost).toFixed(4) }));
|
|
77
|
+
panels.push(Panel({ title: 'conversation', children: sesRows }));
|
|
78
|
+
}
|
|
69
79
|
// Usage block: surface the last turn's token/cost/turn/duration so the
|
|
70
80
|
// result event is no longer silently dropped.
|
|
71
81
|
if (hasUsage) {
|
|
@@ -74,7 +84,8 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
74
84
|
if (usage.outputTokens != null) tokRows.push(Row({ title: 'output', meta: fmtTok(usage.outputTokens) + ' tok' }));
|
|
75
85
|
if (usage.costUsd != null) tokRows.push(Row({ title: 'cost', meta: '$' + usage.costUsd.toFixed(4) }));
|
|
76
86
|
if (usage.turns != null) tokRows.push(Row({ title: 'turns', meta: String(usage.turns) }));
|
|
77
|
-
|
|
87
|
+
// One duration vocabulary kit-wide: shared fmtDuration (s -> m -> h).
|
|
88
|
+
if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: fmtDuration(usage.durationMs) }));
|
|
78
89
|
panels.push(Panel({ title: 'last turn', children: tokRows }));
|
|
79
90
|
}
|
|
80
91
|
return h('div', { class: 'ds-context' },
|
|
@@ -8,7 +8,7 @@ const h = webjsx.createElement;
|
|
|
8
8
|
// Monotonic id source for aria-labelledby wiring between a modal and its head.
|
|
9
9
|
let _modalSeq = 0;
|
|
10
10
|
|
|
11
|
-
function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
|
|
11
|
+
function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {}) {
|
|
12
12
|
// webjsx invokes a ref callback with the element on mount and with null on
|
|
13
13
|
// unmount. We stash the per-element keydown teardown on the node itself so
|
|
14
14
|
// the null branch can run it — otherwise the document/element listener leaks
|
|
@@ -26,9 +26,12 @@ function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
|
|
|
26
26
|
const lastFocusable = focusables[focusables.length - 1];
|
|
27
27
|
|
|
28
28
|
const handleKeydown = (e) => {
|
|
29
|
-
// Escape closes the modal
|
|
29
|
+
// Escape closes the modal — unless a mutation is in flight (the live
|
|
30
|
+
// busy state is read off the data-busy attribute, which re-renders;
|
|
31
|
+
// this handler's closure is bound once at mount).
|
|
30
32
|
if (e.key === 'Escape') {
|
|
31
33
|
e.preventDefault();
|
|
34
|
+
if (el.dataset.busy === '1') return;
|
|
32
35
|
if (onClose) onClose();
|
|
33
36
|
return;
|
|
34
37
|
}
|
|
@@ -52,22 +55,61 @@ function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
|
|
|
52
55
|
}
|
|
53
56
|
};
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// Escape must close the modal no matter where focus sits (re-renders
|
|
59
|
+
// can bounce focus out of the dialog), so listen at document level
|
|
60
|
+
// for the modal's lifetime.
|
|
61
|
+
document.addEventListener('keydown', handleKeydown, true);
|
|
62
|
+
// Record the invoker BEFORE the modal steals focus, so close (confirm,
|
|
63
|
+
// cancel, Escape, backdrop click) restores keyboard/AT focus to where
|
|
64
|
+
// the user was (e.g. the FileGrid row button) instead of <body>.
|
|
65
|
+
// Re-mounts mid-lifetime (every app render re-runs this ref) keep the
|
|
66
|
+
// ORIGINAL invoker and never re-steal focus from the user.
|
|
67
|
+
const invoker = el.contains(document.activeElement) ? (Backdrop._invoker || document.activeElement) : document.activeElement;
|
|
68
|
+
if (!Backdrop._invoker) Backdrop._invoker = invoker;
|
|
69
|
+
el._dsModalTeardown = (removed) => {
|
|
70
|
+
document.removeEventListener('keydown', handleKeydown, true);
|
|
71
|
+
// Only restore focus when the modal is genuinely going away (not a
|
|
72
|
+
// re-render remount) and focus is not already somewhere useful.
|
|
73
|
+
if (removed && Backdrop._invoker && Backdrop._invoker.focus && Backdrop._invoker.isConnected) {
|
|
74
|
+
try { Backdrop._invoker.focus(); } catch {}
|
|
75
|
+
}
|
|
76
|
+
if (removed) Backdrop._invoker = null;
|
|
77
|
+
};
|
|
78
|
+
// Auto-focus on open - only when focus is not already inside the modal
|
|
79
|
+
// (re-renders must not yank the caret around).
|
|
80
|
+
if (!el.contains(document.activeElement)) {
|
|
81
|
+
const preferred = modal.querySelector('[autofocus]') || firstFocusable;
|
|
82
|
+
if (preferred) preferred.focus();
|
|
83
|
+
}
|
|
61
84
|
};
|
|
62
85
|
|
|
63
86
|
return h('div', {
|
|
64
87
|
class: 'ds-modal-backdrop',
|
|
88
|
+
// Live busy flag read by the mount-bound Escape handler + backdrop click.
|
|
89
|
+
'data-busy': busy ? '1' : '0',
|
|
65
90
|
ref: (el) => {
|
|
66
|
-
if (el)
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
if (el) {
|
|
92
|
+
// A remount in the same tick (render churn) is not a close:
|
|
93
|
+
// cancel the pending removal teardown before re-binding.
|
|
94
|
+
Backdrop._pendingRemoval = false;
|
|
95
|
+
backdropRef(el);
|
|
96
|
+
Backdrop._last = el;
|
|
97
|
+
} else if (Backdrop._last && Backdrop._last._dsModalTeardown) {
|
|
98
|
+
const t = Backdrop._last._dsModalTeardown;
|
|
99
|
+
Backdrop._last = null;
|
|
100
|
+
Backdrop._pendingRemoval = true;
|
|
101
|
+
t(false); // always unhook the document listener now
|
|
102
|
+
queueMicrotask(() => {
|
|
103
|
+
// Still gone next microtask -> genuine close: restore focus.
|
|
104
|
+
if (Backdrop._pendingRemoval) { t(true); Backdrop._pendingRemoval = false; }
|
|
105
|
+
});
|
|
106
|
+
}
|
|
69
107
|
},
|
|
70
|
-
onclick: (e) => {
|
|
108
|
+
onclick: (e) => {
|
|
109
|
+
if (e.target !== e.currentTarget) return;
|
|
110
|
+
if (e.currentTarget.dataset.busy === '1') return; // no mid-flight close
|
|
111
|
+
if (onClose) onClose();
|
|
112
|
+
}
|
|
71
113
|
},
|
|
72
114
|
h('div', {
|
|
73
115
|
class: 'ds-modal' + (kind ? ' ds-modal-' + kind : ''),
|
|
@@ -81,13 +123,14 @@ function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
|
|
|
81
123
|
// FileViewer all funnel through this so the ds-modal markup is authored once.
|
|
82
124
|
// `actions` is an array of vnodes (already using the Btn primitive). Any of the
|
|
83
125
|
// slots may be omitted.
|
|
84
|
-
function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions } = {}) {
|
|
126
|
+
function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions, busy = false } = {}) {
|
|
85
127
|
// Give the head a stable id so the dialog can point aria-labelledby at it,
|
|
86
128
|
// exposing the title as the dialog's accessible name to screen readers.
|
|
87
129
|
const headId = head != null ? ('ds-modal-head-' + (++_modalSeq)) : null;
|
|
88
130
|
return Backdrop({
|
|
89
131
|
onClose,
|
|
90
132
|
kind,
|
|
133
|
+
busy,
|
|
91
134
|
labelledBy: headId,
|
|
92
135
|
children: [
|
|
93
136
|
head != null ? h('div', { id: headId, class: ('ds-modal-head' + (headClass ? ' ' + headClass : '')), ...headAttrs }, ...(Array.isArray(head) ? head : [head])) : null,
|
|
@@ -97,40 +140,56 @@ function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body,
|
|
|
97
140
|
});
|
|
98
141
|
}
|
|
99
142
|
|
|
100
|
-
|
|
143
|
+
// A role=alert error line rendered INSIDE the modal body (so a 409/403 from a
|
|
144
|
+
// mutation is visible at the point of action, inside the focus trap — not a
|
|
145
|
+
// sibling stuck in page flow behind the fixed backdrop).
|
|
146
|
+
function modalError(error) {
|
|
147
|
+
return error ? h('p', { class: 'ds-modal-error field-error', role: 'alert' }, String(error)) : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `error` renders inside .ds-modal-body (role=alert, error tone). `busy`
|
|
151
|
+
// disables both action buttons AND the Escape/backdrop close paths; the confirm
|
|
152
|
+
// label flips to `busyLabel` (default 'working…') so the in-flight state reads.
|
|
153
|
+
export function ConfirmDialog({ title = 'confirm', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel, error, busy = false, busyLabel = 'working…' } = {}) {
|
|
101
154
|
return Modal({
|
|
102
155
|
onClose: onCancel,
|
|
103
156
|
kind: 'small',
|
|
157
|
+
busy,
|
|
104
158
|
head: title,
|
|
105
|
-
body: message || '',
|
|
159
|
+
body: [message || '', modalError(error)].filter(Boolean),
|
|
106
160
|
actions: [
|
|
107
|
-
Btn({ onClick: onCancel, children: cancelLabel }),
|
|
108
|
-
Btn({ primary: true, danger: !!destructive, onClick: onConfirm, children: confirmLabel })
|
|
161
|
+
Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
|
|
162
|
+
Btn({ primary: true, danger: !!destructive, disabled: busy, onClick: onConfirm, children: busy ? busyLabel : confirmLabel })
|
|
109
163
|
]
|
|
110
164
|
});
|
|
111
165
|
}
|
|
112
166
|
|
|
113
|
-
export function PromptDialog({ title = 'name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput } = {}) {
|
|
167
|
+
export function PromptDialog({ title = 'name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput, error, busy = false, busyLabel = 'working…' } = {}) {
|
|
114
168
|
return Modal({
|
|
115
169
|
onClose: onCancel,
|
|
116
170
|
kind: 'small',
|
|
171
|
+
busy,
|
|
117
172
|
head: title,
|
|
118
|
-
body: h('input', {
|
|
173
|
+
body: [h('input', {
|
|
119
174
|
class: 'input ds-modal-input',
|
|
120
175
|
type: 'text',
|
|
121
176
|
value,
|
|
122
177
|
placeholder,
|
|
123
178
|
autofocus: true,
|
|
179
|
+
disabled: busy ? true : null,
|
|
180
|
+
'aria-invalid': error ? 'true' : null,
|
|
124
181
|
oninput: (e) => onInput && onInput(e.target.value),
|
|
125
182
|
onkeydown: (e) => {
|
|
126
|
-
|
|
127
|
-
if (e.key === '
|
|
183
|
+
// IME guard: the Enter that commits a CJK composition must not confirm.
|
|
184
|
+
if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); if (!busy) onConfirm && onConfirm(e.target.value); }
|
|
185
|
+
if (e.key === 'Escape') { e.preventDefault(); if (!busy) onCancel && onCancel(); }
|
|
128
186
|
}
|
|
129
|
-
}),
|
|
187
|
+
}), modalError(error)].filter(Boolean),
|
|
130
188
|
actions: [
|
|
131
|
-
Btn({ onClick: onCancel, children: cancelLabel }),
|
|
189
|
+
Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
|
|
132
190
|
Btn({
|
|
133
191
|
primary: true,
|
|
192
|
+
disabled: busy,
|
|
134
193
|
// Read the live input value, not the closed-over `value` prop:
|
|
135
194
|
// consumers update their state in oninput without re-rendering
|
|
136
195
|
// (to avoid caret jump), so the prop is stale at click time.
|
|
@@ -139,7 +198,7 @@ export function PromptDialog({ title = 'name', value = '', placeholder = '', con
|
|
|
139
198
|
const inp = e.currentTarget.closest('.ds-modal')?.querySelector('.ds-modal-input');
|
|
140
199
|
onConfirm(inp ? inp.value : value);
|
|
141
200
|
},
|
|
142
|
-
children: confirmLabel
|
|
201
|
+
children: busy ? busyLabel : confirmLabel
|
|
143
202
|
})
|
|
144
203
|
]
|
|
145
204
|
});
|
package/src/components/files.js
CHANGED
|
@@ -31,8 +31,11 @@ export function fileGlyph(type) {
|
|
|
31
31
|
return TYPE_ICON[type] || TYPE_ICON.other;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
|
|
35
|
+
// format everywhere: '0 B' for zero; the em-dash means unknown/null ONLY.
|
|
34
36
|
export function fmtFileSize(bytes) {
|
|
35
|
-
if (bytes == null
|
|
37
|
+
if (bytes == null) return '—';
|
|
38
|
+
if (bytes === 0) return '0 B';
|
|
36
39
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
37
40
|
let i = 0, n = bytes;
|
|
38
41
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
@@ -43,7 +46,12 @@ export function FileIcon({ type = 'other' } = {}) {
|
|
|
43
46
|
return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
|
|
44
47
|
}
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
// Default action set for FileRow. A host without mutation endpoints passes a
|
|
50
|
+
// narrower `actions` list (e.g. ['download']) so the row renders no dead controls.
|
|
51
|
+
const FILE_ROW_ACTIONS = ['download', 'rename', 'delete'];
|
|
52
|
+
|
|
53
|
+
export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
|
|
54
|
+
actions = FILE_ROW_ACTIONS, busy = false } = {}) {
|
|
47
55
|
// permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
|
|
48
56
|
// be listed (the dir stat saw it) but not opened — show an ASCII tag and
|
|
49
57
|
// disable the open button so the row reads honestly instead of silently
|
|
@@ -54,7 +62,29 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
54
62
|
const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null, permTag].filter(Boolean).join(' · ');
|
|
55
63
|
const typeLabel = TYPE_LABELS[type] || 'file';
|
|
56
64
|
const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}`;
|
|
57
|
-
const canOpen = onOpen && !noAccess;
|
|
65
|
+
const canOpen = onOpen && !noAccess && !busy;
|
|
66
|
+
// Mutation actions on a read-only/no-access row render disabled (with a
|
|
67
|
+
// 'read-only' title) instead of vanishing, so the affordance reads honestly.
|
|
68
|
+
// `busy` (in-flight mutation) disables every control on the row.
|
|
69
|
+
const mutateDisabled = busy || readOnly || noAccess;
|
|
70
|
+
const actBtn = (act, title, ariaLabel, icon, warn) => h('button', {
|
|
71
|
+
key: 'act-' + act,
|
|
72
|
+
type: 'button',
|
|
73
|
+
class: 'ds-file-act' + (warn ? ' ds-file-act-warn' : ''),
|
|
74
|
+
title: mutateDisabled && act !== 'download' ? 'read-only' : title,
|
|
75
|
+
'aria-label': ariaLabel,
|
|
76
|
+
disabled: (act === 'download' ? busy : mutateDisabled) ? true : null,
|
|
77
|
+
'aria-disabled': (act === 'download' ? busy : mutateDisabled) ? 'true' : null,
|
|
78
|
+
onclick: () => onAction(act),
|
|
79
|
+
}, Icon(icon));
|
|
80
|
+
const actionBtns = onAction ? [
|
|
81
|
+
actions.indexOf('download') !== -1 && type !== 'dir'
|
|
82
|
+
? actBtn('download', 'download', `download ${name}`, 'arrow-down', false) : null,
|
|
83
|
+
actions.indexOf('rename') !== -1
|
|
84
|
+
? actBtn('rename', 'rename', `rename ${name}`, 'pencil', false) : null,
|
|
85
|
+
actions.indexOf('delete') !== -1
|
|
86
|
+
? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
|
|
87
|
+
].filter(Boolean) : [];
|
|
58
88
|
// A role=button row containing real <button> action controls is invalid
|
|
59
89
|
// HTML (interactive nesting). Instead the row is a plain container and the
|
|
60
90
|
// primary "open" affordance is itself a real <button> (native keyboard +
|
|
@@ -63,6 +93,7 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
63
93
|
key,
|
|
64
94
|
class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
|
|
65
95
|
'data-file-type': type,
|
|
96
|
+
'aria-busy': busy ? 'true' : null,
|
|
66
97
|
},
|
|
67
98
|
h('button', {
|
|
68
99
|
type: 'button',
|
|
@@ -77,10 +108,8 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
77
108
|
h('span', { class: 'title' }, name),
|
|
78
109
|
h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
|
|
79
110
|
),
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, Icon('pencil')),
|
|
83
|
-
h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, Icon('x'))
|
|
111
|
+
actionBtns.length ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
|
|
112
|
+
...actionBtns
|
|
84
113
|
) : null
|
|
85
114
|
);
|
|
86
115
|
}
|
|
@@ -133,7 +162,7 @@ const FILE_GRID_CAP = 200;
|
|
|
133
162
|
|
|
134
163
|
export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
|
|
135
164
|
columns = 'auto', sort, filter, loading = false,
|
|
136
|
-
shown, onShowMore } = {}) {
|
|
165
|
+
shown, onShowMore, actions, busy } = {}) {
|
|
137
166
|
if (loading) return FileSkeleton({});
|
|
138
167
|
if (!files.length) return EmptyState({ text: emptyText });
|
|
139
168
|
// Cap the rendered rows. `shown` (host-controlled) overrides the default cap
|
|
@@ -179,6 +208,8 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
|
|
|
179
208
|
key: f.path || f.name + i,
|
|
180
209
|
name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
|
|
181
210
|
permissions: f.permissions, locked: f.locked,
|
|
211
|
+
actions: actions != null ? actions : undefined,
|
|
212
|
+
busy: busy != null ? !!busy : !!f.busy,
|
|
182
213
|
onOpen: onOpen ? () => onOpen(f) : null,
|
|
183
214
|
onAction: onAction ? (act) => onAction(act, f) : null
|
|
184
215
|
}))
|
|
@@ -250,11 +281,27 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
|
|
|
250
281
|
);
|
|
251
282
|
}
|
|
252
283
|
|
|
253
|
-
|
|
284
|
+
// UploadProgress — per-file upload rows. Error rows are recoverable, not dead
|
|
285
|
+
// ends: each item may carry `actions` ([{ label, onClick }], e.g. 'replace' on
|
|
286
|
+
// a 409 collision) and the host may wire `onDismiss(item, index)` so error rows
|
|
287
|
+
// can be cleared without waiting for the next successful batch.
|
|
288
|
+
export function UploadProgress({ items = [], onDismiss } = {}) {
|
|
254
289
|
if (!items.length) return null;
|
|
255
290
|
return h('div', { class: 'ds-upload-progress' },
|
|
256
291
|
...items.map((it, i) => {
|
|
257
292
|
const status = it.error ? 'error' : (it.done ? 'complete' : `uploading ${it.pct || 0}%`);
|
|
293
|
+
const rowActions = [
|
|
294
|
+
...((it.actions || []).map((a, ai) => h('button', {
|
|
295
|
+
key: 'ua' + ai, type: 'button', class: 'ds-upload-act',
|
|
296
|
+
'aria-label': `${a.label} ${it.name}`,
|
|
297
|
+
onclick: () => a.onClick && a.onClick(it, i),
|
|
298
|
+
}, a.label))),
|
|
299
|
+
(it.error && onDismiss) ? h('button', {
|
|
300
|
+
key: 'ud', type: 'button', class: 'ds-upload-act',
|
|
301
|
+
'aria-label': `dismiss ${it.name}`,
|
|
302
|
+
onclick: () => onDismiss(it, i),
|
|
303
|
+
}, 'dismiss') : null,
|
|
304
|
+
].filter(Boolean);
|
|
258
305
|
return h('div', {
|
|
259
306
|
key: it.name + i,
|
|
260
307
|
class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
|
|
@@ -269,7 +316,8 @@ export function UploadProgress({ items = [] } = {}) {
|
|
|
269
316
|
h('span', { class: 'ds-upload-bar' },
|
|
270
317
|
h('span', { class: 'ds-upload-fill', 'data-pct': String(Math.max(0, Math.min(100, it.pct || 0))), 'aria-hidden': 'true' })
|
|
271
318
|
),
|
|
272
|
-
h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (it.pct || 0) + '%')))
|
|
319
|
+
h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (it.pct || 0) + '%'))),
|
|
320
|
+
rowActions.length ? h('span', { class: 'ds-upload-actions', role: 'group', 'aria-label': `actions for ${it.name}` }, ...rowActions) : null
|
|
273
321
|
);
|
|
274
322
|
})
|
|
275
323
|
);
|
|
@@ -8,6 +8,19 @@ import { Btn, Icon } from './shell.js';
|
|
|
8
8
|
import { Select, SearchInput } from './content.js';
|
|
9
9
|
const h = webjsx.createElement;
|
|
10
10
|
|
|
11
|
+
// ONE duration format for every surface (live cards, running panel, session
|
|
12
|
+
// meta, context pane): <60s -> 'Ns', <1h -> 'Nm Ss', else 'Nh Nm'. Durations
|
|
13
|
+
// roll s -> m -> h instead of an hour-long run reading '3712s'.
|
|
14
|
+
export function fmtDuration(ms) {
|
|
15
|
+
if (ms == null || !isFinite(ms) || ms < 0) return '';
|
|
16
|
+
const s = Math.round(ms / 1000);
|
|
17
|
+
if (s < 60) return s + 's';
|
|
18
|
+
const m = Math.floor(s / 60);
|
|
19
|
+
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
|
20
|
+
const hrs = Math.floor(m / 60);
|
|
21
|
+
return hrs + 'h ' + (m % 60) + 'm';
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
// ConversationList — the Claude-Desktop "Chats" column. Sessions grouped by a
|
|
12
25
|
// caller-supplied group label, each row showing title/project, relative time,
|
|
13
26
|
// agent badge, and a running/new-event indicator. Selecting a row switches the
|
|
@@ -21,7 +34,8 @@ const h = webjsx.createElement;
|
|
|
21
34
|
// emptyText, loading, error : explicit states
|
|
22
35
|
export function ConversationList({ sessions = [], selected, groups, search, caption,
|
|
23
36
|
onSelect, onNew, newLabel = 'New chat',
|
|
24
|
-
emptyText = 'No conversations yet', loading = false, error = null
|
|
37
|
+
emptyText = 'No conversations yet', loading = false, error = null,
|
|
38
|
+
loadingText = 'Loading conversations…' } = {}) {
|
|
25
39
|
const rowFor = (s, i) => h('button', {
|
|
26
40
|
// Stable key: prefer sid, else position - a missing/duplicate sid would make
|
|
27
41
|
// key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
|
|
@@ -35,8 +49,10 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
35
49
|
// applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
|
|
36
50
|
// each h() call gets a clean, consistent child list.
|
|
37
51
|
h('span', { class: 'ds-session-main' }, [
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
// Two-sided truncation: the CSS ellipsis is paired with a title= carrying
|
|
53
|
+
// the full string, so a long title/project is recoverable on hover.
|
|
54
|
+
h('span', { class: 'ds-session-title', title: s.title || s.project || s.sid || null }, s.title || s.project || s.sid || ''),
|
|
55
|
+
(s.project || s.time) ? h('span', { class: 'ds-session-sub', title: s.project || null },
|
|
40
56
|
[s.project, s.time].filter(Boolean).join(' · ')) : null,
|
|
41
57
|
].filter(Boolean)),
|
|
42
58
|
h('span', { class: 'ds-session-meta' }, [
|
|
@@ -53,7 +69,7 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
53
69
|
// are uniformly keyed; non-row states render a single unkeyed status line.
|
|
54
70
|
let inner;
|
|
55
71
|
if (loading) {
|
|
56
|
-
inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' },
|
|
72
|
+
inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, loadingText)];
|
|
57
73
|
} else if (error) {
|
|
58
74
|
inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
|
|
59
75
|
} else if (!sessions.length) {
|
|
@@ -85,6 +101,30 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
85
101
|
body);
|
|
86
102
|
}
|
|
87
103
|
|
|
104
|
+
// SessionMeta — a middot-separated metadata strip for a session detail surface.
|
|
105
|
+
// items : [{ label, value, title, onCopy }]
|
|
106
|
+
// Each item is a span (label dimmed, value mono) with an optional per-item copy
|
|
107
|
+
// button; the strip flex-wraps at narrow widths. Class is .ds-session-meta-strip
|
|
108
|
+
// (the bare .ds-session-meta is already taken by ConversationList row meta).
|
|
109
|
+
export function SessionMeta({ items = [] } = {}) {
|
|
110
|
+
if (!items.length) return null;
|
|
111
|
+
return h('div', { class: 'ds-session-meta-strip', role: 'group', 'aria-label': 'session metadata' },
|
|
112
|
+
...items.map((it, i) => h('span', {
|
|
113
|
+
key: 'sm-' + (it.label != null ? it.label : i),
|
|
114
|
+
class: 'ds-session-meta-item',
|
|
115
|
+
title: it.title || null,
|
|
116
|
+
},
|
|
117
|
+
[
|
|
118
|
+
it.label != null ? h('span', { key: 'l', class: 'ds-session-meta-label' }, it.label) : null,
|
|
119
|
+
h('span', { key: 'v', class: 'ds-session-meta-value' }, it.value != null ? String(it.value) : ''),
|
|
120
|
+
it.onCopy ? h('button', {
|
|
121
|
+
key: 'c', type: 'button', class: 'ds-session-meta-copy',
|
|
122
|
+
'aria-label': 'copy ' + (it.title || it.label || 'value'),
|
|
123
|
+
onclick: () => it.onCopy(it.value),
|
|
124
|
+
}, 'copy') : null,
|
|
125
|
+
].filter(Boolean))));
|
|
126
|
+
}
|
|
127
|
+
|
|
88
128
|
// SessionCard — one running session in the live dashboard. Status dot, agent /
|
|
89
129
|
// model / cwd, elapsed, live counter, last activity, and per-session controls
|
|
90
130
|
// that each act on this session's id independently.
|
|
@@ -100,36 +140,50 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
|
|
|
100
140
|
// no current tool) — it reads as `idle` with a NON-pulsing disc so a stuck agent
|
|
101
141
|
// is visually distinct from a busy one (a frozen elapsed alone reads identically
|
|
102
142
|
// for both, which is the high-severity oversight gap this closes).
|
|
103
|
-
|
|
104
|
-
|
|
143
|
+
// `session.stopping` is the in-flight cancel state: the stop button disables
|
|
144
|
+
// with label 'stopping…' and the status word flips to 'stopping', so the click
|
|
145
|
+
// visibly took and cannot re-fire while the host waits for the active poll.
|
|
146
|
+
// `session.external` marks a session we observe (ccsniff stream) but do not own
|
|
147
|
+
// (no process to kill): the stop button is suppressed, an 'external' tag renders
|
|
148
|
+
// in the head, and the host wires onView to open it in history instead.
|
|
149
|
+
// `session.title` is the SAME string the conversation rails use, rendered as
|
|
150
|
+
// the card heading so the rail row and its dashboard card share one identity.
|
|
151
|
+
// `session.elapsedMs` (raw ms) is formatted internally via fmtDuration; the
|
|
152
|
+
// pre-formatted `elapsed` string remains as a legacy fallback.
|
|
153
|
+
const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running', stopping: 'stopping' };
|
|
154
|
+
const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live', stopping: 'status-dot-connecting' };
|
|
105
155
|
|
|
106
156
|
export function SessionCard({ session = {}, onStop, onOpen, onView, active = false,
|
|
107
157
|
selectable = false, selected = false, onToggleSelect } = {}) {
|
|
108
158
|
const s = session;
|
|
109
|
-
const st = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running');
|
|
159
|
+
const st = s.stopping ? 'stopping' : (s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running'));
|
|
110
160
|
// The stat line composes elapsed + live counter; the activity line carries the
|
|
111
161
|
// last-activity time and the current tool so a card shows MOTION, not just a
|
|
112
162
|
// start offset. Both are middot-joined (kept product separator).
|
|
113
|
-
const
|
|
163
|
+
const elapsedText = s.elapsedMs != null ? fmtDuration(s.elapsedMs) : (s.elapsed != null ? s.elapsed : null);
|
|
164
|
+
const statBits = [elapsedText, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
|
|
114
165
|
const activityBits = [
|
|
115
166
|
s.currentTool ? 'running: ' + s.currentTool : null,
|
|
116
167
|
s.lastActivity ? 'last ' + s.lastActivity : null,
|
|
117
168
|
].filter(Boolean);
|
|
118
|
-
const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '');
|
|
119
|
-
return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.agent || s.sid), 'aria-current': active ? 'true' : null },
|
|
169
|
+
const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + (s.external ? ' is-external' : '');
|
|
170
|
+
return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.title || s.agent || s.sid), 'aria-current': active ? 'true' : null },
|
|
171
|
+
// Shared session identity: the same title the conversation rails show.
|
|
172
|
+
s.title ? h('div', { class: 'ds-dash-title', title: s.title }, s.title) : null,
|
|
120
173
|
h('div', { class: 'ds-dash-card-head' },
|
|
121
174
|
selectable ? h('button', {
|
|
122
175
|
type: 'button', class: 'ds-dash-select', role: 'checkbox',
|
|
123
176
|
'aria-checked': selected ? 'true' : 'false',
|
|
124
|
-
'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.agent || s.sid),
|
|
177
|
+
'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.title || s.agent || s.sid),
|
|
125
178
|
onclick: () => onToggleSelect && onToggleSelect(s),
|
|
126
179
|
}, selected ? '[x]' : '[ ]') : null,
|
|
127
180
|
h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
|
|
128
181
|
// Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
|
|
129
182
|
// aria-hidden, so the visible/AT status word carries the state.
|
|
130
183
|
h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
|
|
131
|
-
h('span', { class: 'ds-dash-
|
|
132
|
-
|
|
184
|
+
s.external ? h('span', { class: 'ds-dash-external' }, 'external') : null,
|
|
185
|
+
h('span', { class: 'ds-dash-agent', title: s.agent || null }, s.agent || 'agent'),
|
|
186
|
+
s.model ? h('span', { class: 'ds-dash-model', title: s.model }, s.model) : null),
|
|
133
187
|
h('div', { class: 'ds-dash-meta' },
|
|
134
188
|
s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
|
|
135
189
|
statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
|
|
@@ -138,8 +192,10 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
|
|
|
138
192
|
// open and resume collapsed into one 'open' action (they both just reopen
|
|
139
193
|
// the session in chat); 'events' kept for the read-only event view.
|
|
140
194
|
onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
|
|
141
|
-
onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
|
|
142
|
-
|
|
195
|
+
onView ? Btn({ key: 'view', onClick: () => onView(s), children: s.external ? 'open in history' : 'events' }) : null,
|
|
196
|
+
// External sessions get no stop control: we own no process to kill.
|
|
197
|
+
(onStop && !s.external) ? Btn({ key: 'stop', danger: true, disabled: !!s.stopping,
|
|
198
|
+
onClick: () => !s.stopping && onStop(s), children: s.stopping ? 'stopping…' : 'stop' }) : null));
|
|
143
199
|
}
|
|
144
200
|
|
|
145
201
|
// SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
|
|
@@ -152,9 +208,23 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
|
|
|
152
208
|
// card's stop. Rendered only when there are sessions AND onStopAll is wired.
|
|
153
209
|
// Streamstate words: the live-stream health signal so "connected, zero running"
|
|
154
210
|
// still tells the user the dashboard is listening (vs a dropped stream).
|
|
155
|
-
|
|
211
|
+
// One connection vocabulary across the crumb, settings chip, and the dashboard
|
|
212
|
+
// stream line: connected / connecting / offline ('lost' kept as a legacy alias).
|
|
213
|
+
const STREAM_WORD = {
|
|
214
|
+
connected: 'listening for activity',
|
|
215
|
+
connecting: 'connecting to live stream…',
|
|
216
|
+
offline: 'live stream offline — retrying…',
|
|
217
|
+
lost: 'live stream offline — retrying…',
|
|
218
|
+
};
|
|
156
219
|
|
|
220
|
+
// The stop-all / stop-selected danger buttons are two-step (host-driven, the kit
|
|
221
|
+
// is stateless): the first click fires onArmStop* so the host flips confirming*
|
|
222
|
+
// true and re-renders; the armed button reads 'stop N sessions - press again'
|
|
223
|
+
// and only THAT click fires the real onStopAll/onStopSelected. Hosts that wire
|
|
224
|
+
// no onArmStop* keep the old single-click behavior.
|
|
157
225
|
export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStopAll, onStopSelected,
|
|
226
|
+
confirmingStopAll = false, confirmingStopSelected = false,
|
|
227
|
+
onArmStopAll, onArmStopSelected,
|
|
158
228
|
sort, filter, errorsOnly = false, onErrorsOnly,
|
|
159
229
|
selectable = false, selected, onToggleSelect,
|
|
160
230
|
activeSid, streamState,
|
|
@@ -164,6 +234,9 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
|
|
|
164
234
|
}
|
|
165
235
|
const selSet = selected instanceof Set ? selected : new Set(selected || []);
|
|
166
236
|
const selCount = selSet.size;
|
|
237
|
+
// While any session is mid-cancel the bulk control reads disabled
|
|
238
|
+
// 'stopping N…' so a bulk stop visibly takes instead of staying re-firable.
|
|
239
|
+
const stoppingCount = sessions.filter((s) => s.stopping).length;
|
|
167
240
|
// The stream-state line always renders (even with zero sessions) so a
|
|
168
241
|
// connected-but-idle dashboard reads differently from an offline one.
|
|
169
242
|
const streamLine = streamState
|
|
@@ -193,9 +266,19 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
|
|
|
193
266
|
selectable && selCount ? selCount + ' selected' : sessions.length + ' running'),
|
|
194
267
|
streamLine,
|
|
195
268
|
h('span', { class: 'spread' }),
|
|
196
|
-
|
|
197
|
-
? Btn({ key: '
|
|
198
|
-
: (
|
|
269
|
+
stoppingCount > 0 && (onStopSelected || onStopAll)
|
|
270
|
+
? Btn({ key: 'stopbusy', danger: true, disabled: true, children: 'stopping ' + stoppingCount + '…' })
|
|
271
|
+
: (selectable && selCount && onStopSelected
|
|
272
|
+
? (onArmStopSelected && !confirmingStopSelected
|
|
273
|
+
? Btn({ key: 'stopsel', danger: true, onClick: () => onArmStopSelected([...selSet]), children: 'stop selected' })
|
|
274
|
+
: Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]),
|
|
275
|
+
children: confirmingStopSelected ? 'stop ' + selCount + ' sessions - press again' : 'stop selected' }))
|
|
276
|
+
: (onStopAll
|
|
277
|
+
? (onArmStopAll && !confirmingStopAll
|
|
278
|
+
? Btn({ key: 'stopall', danger: true, onClick: () => onArmStopAll(sessions), children: 'stop all' })
|
|
279
|
+
: Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions),
|
|
280
|
+
children: confirmingStopAll ? 'stop ' + sessions.length + ' sessions - press again' : 'stop all' }))
|
|
281
|
+
: null)),
|
|
199
282
|
toolbar);
|
|
200
283
|
const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
|
|
201
284
|
...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
|
package/src/components.js
CHANGED
|
@@ -16,19 +16,19 @@ 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 {
|
|
23
|
-
fmtBytes, renderInline,
|
|
23
|
+
fmtBytes, renderInline, hasSelectionInside,
|
|
24
24
|
ChatMessage, ChatComposer, Chat,
|
|
25
25
|
AICAT_FACE, AICatPortrait, AICat
|
|
26
26
|
} from './components/chat.js';
|
|
27
27
|
|
|
28
|
-
export { AgentChat } from './components/agent-chat.js';
|
|
28
|
+
export { AgentChat, MESSAGE_CAP } from './components/agent-chat.js';
|
|
29
29
|
|
|
30
30
|
export {
|
|
31
|
-
ConversationList, SessionCard, SessionDashboard
|
|
31
|
+
ConversationList, SessionCard, SessionDashboard, SessionMeta, fmtDuration
|
|
32
32
|
} from './components/sessions.js';
|
|
33
33
|
|
|
34
34
|
export { ContextPane } from './components/context-pane.js';
|