anentrypoint-design 0.0.199 → 0.0.200
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 +11 -0
- package/chat.css +152 -5
- package/dist/247420.css +163 -5
- package/dist/247420.js +12 -12
- package/package.json +1 -1
- package/src/components/agent-chat.js +31 -3
- package/src/components/chat.js +65 -5
- package/src/components/context-pane.js +11 -1
- package/src/components/files-modals.js +66 -14
- package/src/components/files.js +61 -12
- package/src/components/sessions.js +70 -12
- package/src/components/shell.js +14 -4
- package/src/components.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.200",
|
|
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",
|
|
@@ -124,6 +124,8 @@ export function AgentChat(props = {}) {
|
|
|
124
124
|
canSend = true,
|
|
125
125
|
suggestions = [], onSuggestionClick,
|
|
126
126
|
onCopyMessage, onRetryMessage, onEditMessage,
|
|
127
|
+
avatar, composerContext,
|
|
128
|
+
followups = [], onFollowupClick,
|
|
127
129
|
} = props;
|
|
128
130
|
|
|
129
131
|
const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
|
|
@@ -164,7 +166,16 @@ export function AgentChat(props = {}) {
|
|
|
164
166
|
// container shape (.chat-md padding/spacing) the settled markdown will
|
|
165
167
|
// use — only the inner content swaps on settle, so the bubble box does
|
|
166
168
|
// not reflow/jump when the turn finishes and renders real markdown.
|
|
167
|
-
if (isStreaming && part.kind === 'md')
|
|
169
|
+
if (isStreaming && part.kind === 'md') {
|
|
170
|
+
// If the streaming prose contains a code fence, the inline renderer
|
|
171
|
+
// (which has no triple-backtick handling) would show it as run-on text
|
|
172
|
+
// with literal ``` and no monospace, then snap into a styled <pre> on
|
|
173
|
+
// settle (a visible reflow during the most-watched moment). Detect a
|
|
174
|
+
// fence and render the bubble as a cheap monospaced <pre> shell instead
|
|
175
|
+
// (no Prism mid-stream, so no O(n^2)) so it does not reflow on settle.
|
|
176
|
+
if (part.text && part.text.indexOf('```') !== -1) parts.push({ kind: 'text', text: part.text, mdShell: true, preShell: true });
|
|
177
|
+
else parts.push({ kind: 'text', text: part.text, mdShell: true });
|
|
178
|
+
}
|
|
168
179
|
else parts.push(part);
|
|
169
180
|
}
|
|
170
181
|
}
|
|
@@ -181,7 +192,7 @@ export function AgentChat(props = {}) {
|
|
|
181
192
|
let actions;
|
|
182
193
|
if (!isStreaming && msgHasBody(m)) {
|
|
183
194
|
const built = [];
|
|
184
|
-
if (onCopyMessage) built.push({ label: 'copy', icon: '
|
|
195
|
+
if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
|
|
185
196
|
if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
|
|
186
197
|
if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend', onClick: () => onEditMessage(m) });
|
|
187
198
|
if (built.length) actions = built;
|
|
@@ -190,6 +201,9 @@ export function AgentChat(props = {}) {
|
|
|
190
201
|
key: m.id || String(i),
|
|
191
202
|
who: isAssistant ? 'them' : 'you',
|
|
192
203
|
aicat: isAssistant,
|
|
204
|
+
// A stable per-agent product mark (host passes a small line-SVG via
|
|
205
|
+
// `avatar`) instead of a per-agent letter initial that shifts identity.
|
|
206
|
+
avatar: isAssistant ? (m.avatar != null ? m.avatar : avatar) : undefined,
|
|
193
207
|
name: isAssistant ? name : 'you',
|
|
194
208
|
time: m.time || '',
|
|
195
209
|
typing: emptyStreaming,
|
|
@@ -210,8 +224,21 @@ export function AgentChat(props = {}) {
|
|
|
210
224
|
onInput: (v) => onInput && onInput(v),
|
|
211
225
|
onSend: (v) => onSend && onSend(v),
|
|
212
226
|
onCancel: busy && onStop ? () => onStop() : undefined,
|
|
227
|
+
// The active target (agent / model / cwd-basename) at the point of typing.
|
|
228
|
+
context: composerContext,
|
|
213
229
|
});
|
|
214
230
|
|
|
231
|
+
// Contextual follow-up chips below the last SETTLED assistant turn (claude.ai/
|
|
232
|
+
// code / cowork surface these after a turn, not only on an empty thread). Shown
|
|
233
|
+
// only when not busy and the last message is an assistant turn with body.
|
|
234
|
+
const followupRow = (!busy && followups && followups.length && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg))
|
|
235
|
+
? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups' },
|
|
236
|
+
...followups.map((s, i) => h('button', {
|
|
237
|
+
key: 'fu' + i, type: 'button', class: 'agentchat-empty-suggestion agentchat-followup',
|
|
238
|
+
onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onFollowupClick) onFollowupClick(t); else if (onSuggestionClick) onSuggestionClick(t); },
|
|
239
|
+
}, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
|
|
240
|
+
: null;
|
|
241
|
+
|
|
215
242
|
// Empty state: a fresh thread is a void without this. Mirrors the kit's Chat
|
|
216
243
|
// empty surface (title, sub, optional starter prompts) so AgentChat opens to
|
|
217
244
|
// an invitation, not a blank panel.
|
|
@@ -250,7 +277,8 @@ export function AgentChat(props = {}) {
|
|
|
250
277
|
? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
|
|
251
278
|
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
252
279
|
h('span', { class: 'agentchat-working-text' }, 'working…'))
|
|
253
|
-
: null
|
|
280
|
+
: null,
|
|
281
|
+
followupRow),
|
|
254
282
|
// Jump-to-latest: hidden until the scroll listener adds .show (user scrolled
|
|
255
283
|
// up). Clicking returns to the live edge. Pure-DOM, like the kit's other
|
|
256
284
|
// stateless chrome, so the host needn't thread scroll state through state.
|
package/src/components/chat.js
CHANGED
|
@@ -100,12 +100,44 @@ export function makeThreadAutoScroll(getCount) {
|
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// Inject a per-block copy button into every <pre> inside a rendered-markdown
|
|
104
|
+
// container. claude.ai/code and Claude Desktop give EVERY fenced block a hover
|
|
105
|
+
// copy affordance; the chat surface had only a whole-message copy. Idempotent:
|
|
106
|
+
// marks each <pre> with data-copy-wired so re-renders don't stack buttons. The
|
|
107
|
+
// button reveals on .chat-code-block:hover/:focus-within (CSS) and flips its
|
|
108
|
+
// label copy -> copied for ~1.6s. Drawn with a real icon + word, no glyph.
|
|
109
|
+
export function injectCodeCopy(container) {
|
|
110
|
+
if (!container) return;
|
|
111
|
+
container.querySelectorAll('pre').forEach((pre) => {
|
|
112
|
+
if (pre.dataset.copyWired === '1') return;
|
|
113
|
+
pre.dataset.copyWired = '1';
|
|
114
|
+
// Wrap the <pre> in a position:relative shell so the button can sit
|
|
115
|
+
// top-right without disturbing code layout.
|
|
116
|
+
const shell = document.createElement('div');
|
|
117
|
+
shell.className = 'chat-code-block';
|
|
118
|
+
pre.parentNode.insertBefore(shell, pre);
|
|
119
|
+
shell.appendChild(pre);
|
|
120
|
+
const btn = document.createElement('button');
|
|
121
|
+
btn.type = 'button';
|
|
122
|
+
btn.className = 'chat-code-copy';
|
|
123
|
+
btn.setAttribute('aria-label', 'copy code');
|
|
124
|
+
btn.textContent = 'copy';
|
|
125
|
+
btn.addEventListener('click', () => {
|
|
126
|
+
const code = pre.innerText;
|
|
127
|
+
const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
|
|
128
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(done).catch(() => {});
|
|
129
|
+
else { try { const t = document.createElement('textarea'); t.value = code; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
|
|
130
|
+
});
|
|
131
|
+
shell.appendChild(btn);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
103
135
|
function MdNode(p) {
|
|
104
136
|
const refSink = (el) => {
|
|
105
137
|
if (!el) return;
|
|
106
138
|
if (el.dataset.mdSrc === p.text) return;
|
|
107
139
|
el.dataset.mdSrc = p.text || '';
|
|
108
|
-
renderMarkdownCached(p.text || '').then((html) => { el.innerHTML = html; });
|
|
140
|
+
renderMarkdownCached(p.text || '').then((html) => { el.innerHTML = html; injectCodeCopy(el); });
|
|
109
141
|
};
|
|
110
142
|
return h('div', { class: 'chat-bubble chat-md', ref: refSink });
|
|
111
143
|
}
|
|
@@ -121,10 +153,20 @@ function CodeNode(p) {
|
|
|
121
153
|
el.dataset.codeKey = codeKey;
|
|
122
154
|
highlightCodeBlockCached(el);
|
|
123
155
|
};
|
|
156
|
+
// Copy the raw code (not the highlighted DOM) for the structured CodeNode.
|
|
157
|
+
const onCopy = (e) => {
|
|
158
|
+
const btn = e.currentTarget;
|
|
159
|
+
const code = p.code || '';
|
|
160
|
+
const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
|
|
161
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(done).catch(() => {});
|
|
162
|
+
else { try { const t = document.createElement('textarea'); t.value = code; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
|
|
163
|
+
};
|
|
124
164
|
return h('div', { class: 'chat-bubble chat-code', ref: refSink },
|
|
125
165
|
h('div', { class: 'chat-code-head' },
|
|
126
166
|
h('span', { class: 'lang' }, p.lang || 'code'),
|
|
127
|
-
p.filename ? h('span', { class: 'name' }, p.filename) : null
|
|
167
|
+
p.filename ? h('span', { class: 'name' }, p.filename) : null,
|
|
168
|
+
h('span', { class: 'spread' }),
|
|
169
|
+
h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')
|
|
128
170
|
),
|
|
129
171
|
h('pre', {}, h('code', { class: p.lang ? 'lang-' + p.lang + ' language-' + p.lang : '' }, p.code || ''))
|
|
130
172
|
);
|
|
@@ -173,7 +215,12 @@ function ThinkingNode(p) {
|
|
|
173
215
|
}
|
|
174
216
|
|
|
175
217
|
const PART_RENDERERS = {
|
|
176
|
-
text: (p) =>
|
|
218
|
+
text: (p) => p.preShell
|
|
219
|
+
// Streaming prose that already contains a code fence renders as a plain
|
|
220
|
+
// monospaced <pre> so it does not reflow from prose to a styled block on
|
|
221
|
+
// settle (no Prism mid-stream). The settled turn renders real markdown.
|
|
222
|
+
? h('div', { class: 'chat-bubble chat-md chat-stream-pre' }, h('pre', {}, h('code', {}, p.text || '')))
|
|
223
|
+
: h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') }, ...renderInline(p.text || '')),
|
|
177
224
|
md: (p) => MdNode(p),
|
|
178
225
|
code: (p) => CodeNode(p),
|
|
179
226
|
tool: (p) => ToolCallNode(p),
|
|
@@ -275,7 +322,8 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
275
322
|
key: 'ma' + i, type: 'button', class: 'chat-msg-action',
|
|
276
323
|
title: a.title || a.label, 'aria-label': a.label || a.title,
|
|
277
324
|
onclick: (e) => { e.preventDefault(); a.onClick && a.onClick(e); },
|
|
278
|
-
}, a.icon ? Icon(a.icon, { size: 14 }) :
|
|
325
|
+
}, a.icon ? Icon(a.icon, { size: 14 }) : null,
|
|
326
|
+
a.label ? h('span', { class: 'chat-msg-action-label' }, a.label) : null)))
|
|
279
327
|
: null;
|
|
280
328
|
const stack = h('div', { class: 'chat-stack' }, ...bodyNodes, reactionRow, actionRow, meta);
|
|
281
329
|
// Centered roles (system/tool/thinking) skip the avatar column entirely so
|
|
@@ -285,7 +333,7 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
285
333
|
return h('div', { key, class: cls }, resolvedWho === 'you' ? stack : av, resolvedWho === 'you' ? av : stack);
|
|
286
334
|
}
|
|
287
335
|
|
|
288
|
-
export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled }) {
|
|
336
|
+
export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, context }) {
|
|
289
337
|
// Keep a handle to the live textarea so send() reads the actual DOM value
|
|
290
338
|
// (not the possibly-lagging `value` prop) and so we can sync the DOM value
|
|
291
339
|
// only when it genuinely differs — re-applying `value` on every parent
|
|
@@ -320,7 +368,19 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
320
368
|
el.style.height = 'auto';
|
|
321
369
|
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
|
322
370
|
};
|
|
371
|
+
// Optional context line shown above the textarea: agent / model / cwd at the
|
|
372
|
+
// point of typing (the way Claude-Desktop surfaces the active target inline).
|
|
373
|
+
// `context` is { bits:[...strings], onClick? }; bits are middot-joined (kept
|
|
374
|
+
// product separator). Clickable when onClick is wired (opens the picker).
|
|
375
|
+
const contextLine = (context && context.bits && context.bits.length)
|
|
376
|
+
? h(context.onClick ? 'button' : 'div', {
|
|
377
|
+
class: 'chat-composer-context', type: context.onClick ? 'button' : null,
|
|
378
|
+
'aria-label': context.onClick ? ('change target: ' + context.bits.join(' · ')) : null,
|
|
379
|
+
onclick: context.onClick ? (e) => { e.preventDefault(); context.onClick(e); } : null,
|
|
380
|
+
}, context.bits.filter(Boolean).join(' · '))
|
|
381
|
+
: null;
|
|
323
382
|
return h('div', { class: 'chat-composer' },
|
|
383
|
+
contextLine,
|
|
324
384
|
h('textarea', { ref: taRef, placeholder, rows: 1, 'aria-label': 'message input',
|
|
325
385
|
oninput: autoGrow,
|
|
326
386
|
onkeydown: (e) => {
|
|
@@ -33,6 +33,16 @@ function fmtTok(n) {
|
|
|
33
33
|
|
|
34
34
|
export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
|
|
35
35
|
const running = Number(toolCount) > 0;
|
|
36
|
+
const hasUsage = usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null);
|
|
37
|
+
// Empty state: before an agent is picked AND with no usage/session, four
|
|
38
|
+
// placeholder rows (agent: none / model: dash / ...) read as a dead panel.
|
|
39
|
+
// Show one honest line instead.
|
|
40
|
+
if (!agent && !hasUsage && !session && !cwd) {
|
|
41
|
+
return h('div', { class: 'ds-context' },
|
|
42
|
+
h('div', { class: 'ds-context-empty', role: 'status' },
|
|
43
|
+
'No active conversation — start a chat to see context here'),
|
|
44
|
+
onSetCwd ? h('div', { class: 'ds-context-actions' }, Btn({ onClick: onSetCwd, children: 'set working dir' })) : null);
|
|
45
|
+
}
|
|
36
46
|
// Each Panel's children array is all-unkeyed (no key prop on any sibling),
|
|
37
47
|
// so webjsx never sees a mixed keyed/unkeyed array here.
|
|
38
48
|
const panels = [
|
|
@@ -58,7 +68,7 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
58
68
|
];
|
|
59
69
|
// Usage block: surface the last turn's token/cost/turn/duration so the
|
|
60
70
|
// result event is no longer silently dropped.
|
|
61
|
-
if (
|
|
71
|
+
if (hasUsage) {
|
|
62
72
|
const tokRows = [];
|
|
63
73
|
if (usage.inputTokens != null) tokRows.push(Row({ title: 'input', meta: fmtTok(usage.inputTokens) + ' tok' }));
|
|
64
74
|
if (usage.outputTokens != null) tokRows.push(Row({ title: 'output', meta: fmtTok(usage.outputTokens) + ' tok' }));
|
|
@@ -155,9 +155,24 @@ export function FilePreviewMedia({ src, type = 'other', name } = {}) {
|
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
export function FilePreviewCode({ content = '', lang } = {}) {
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
export function FilePreviewCode({ content = '', lang, filename } = {}) {
|
|
159
|
+
// A filename/lang header matching the chat CodeNode's .chat-code-head, plus
|
|
160
|
+
// the same copy control (chat A1/A2 ship this run, so preview matches for
|
|
161
|
+
// full cross-surface consistency).
|
|
162
|
+
const onCopy = (e) => {
|
|
163
|
+
const btn = e.currentTarget;
|
|
164
|
+
const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
|
|
165
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(content).then(done).catch(() => {});
|
|
166
|
+
else { try { const t = document.createElement('textarea'); t.value = content; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
|
|
167
|
+
};
|
|
168
|
+
return h('div', { class: 'ds-preview-code-wrap' },
|
|
169
|
+
h('div', { class: 'chat-code-head ds-preview-code-head' },
|
|
170
|
+
h('span', { class: 'lang' }, lang || 'text'),
|
|
171
|
+
filename ? h('span', { class: 'name' }, filename) : null,
|
|
172
|
+
h('span', { class: 'spread' }),
|
|
173
|
+
h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')),
|
|
174
|
+
h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') },
|
|
175
|
+
h('code', { class: lang ? 'language-' + lang : '' }, content))
|
|
161
176
|
);
|
|
162
177
|
}
|
|
163
178
|
|
|
@@ -168,25 +183,62 @@ export function FilePreviewText({ content = '', truncated } = {}) {
|
|
|
168
183
|
);
|
|
169
184
|
}
|
|
170
185
|
|
|
171
|
-
|
|
172
|
-
|
|
186
|
+
// Shared preview-head children for both the modal FileViewer and the inline
|
|
187
|
+
// FilePreviewPane: name + meta + prev/next stepper + download + close. ASCII
|
|
188
|
+
// prev/next words (no glyph arrows). onPrev/onNext are omitted when there is no
|
|
189
|
+
// previewable neighbour in that direction.
|
|
190
|
+
function previewHead({ file, onClose, onAction, onPrev, onNext } = {}) {
|
|
173
191
|
const meta = [file.type, file.size != null ? fmtFileSize(file.size) : null, file.modified || null]
|
|
174
192
|
.filter(Boolean).join(' · ');
|
|
193
|
+
return [
|
|
194
|
+
h('span', { class: 'ds-preview-name', title: file.path || file.name || '' }, file.name || ''),
|
|
195
|
+
h('span', { class: 'ds-preview-meta' }, meta),
|
|
196
|
+
h('span', { class: 'ds-preview-actions' },
|
|
197
|
+
(onPrev || onNext) ? h('span', { class: 'ds-preview-step', role: 'group', 'aria-label': 'step files' },
|
|
198
|
+
h('button', { class: 'ds-file-act', title: 'previous file', 'aria-label': 'previous file', disabled: onPrev ? null : true, onclick: () => onPrev && onPrev() }, 'prev'),
|
|
199
|
+
h('button', { class: 'ds-file-act', title: 'next file', 'aria-label': 'next file', disabled: onNext ? null : true, onclick: () => onNext && onNext() }, 'next')) : null,
|
|
200
|
+
onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
|
|
201
|
+
onClose ? h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x')) : null
|
|
202
|
+
)
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ArrowLeft/Right step the preview when focus is inside it (both pane + modal).
|
|
207
|
+
function previewKeyNav(onPrev, onNext) {
|
|
208
|
+
return (e) => {
|
|
209
|
+
if (e.key === 'ArrowLeft' && onPrev) { e.preventDefault(); onPrev(); }
|
|
210
|
+
else if (e.key === 'ArrowRight' && onNext) { e.preventDefault(); onNext(); }
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function FileViewer({ file, body, onClose, onAction, onPrev, onNext } = {}) {
|
|
215
|
+
if (!file) return null;
|
|
175
216
|
return Modal({
|
|
176
217
|
onClose,
|
|
177
218
|
kind: 'preview',
|
|
178
219
|
headClass: 'ds-preview-head',
|
|
179
|
-
headAttrs: { 'data-file-type': file.type || 'other' },
|
|
180
|
-
head:
|
|
181
|
-
h('span', { class: 'ds-preview-name' }, file.name || ''),
|
|
182
|
-
h('span', { class: 'ds-preview-meta' }, meta),
|
|
183
|
-
h('span', { class: 'ds-preview-actions' },
|
|
184
|
-
onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
|
|
185
|
-
h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x'))
|
|
186
|
-
)
|
|
187
|
-
],
|
|
220
|
+
headAttrs: { 'data-file-type': file.type || 'other', onkeydown: previewKeyNav(onPrev, onNext) },
|
|
221
|
+
head: previewHead({ file, onClose, onAction, onPrev, onNext }),
|
|
188
222
|
bodyClass: 'ds-preview-body',
|
|
189
223
|
bodyAttrs: { 'data-file-type': file.type || 'other' },
|
|
190
224
|
body: Array.isArray(body) ? body : [body],
|
|
191
225
|
});
|
|
192
226
|
}
|
|
227
|
+
|
|
228
|
+
// FilePreviewPane — the SAME preview, but as a persistent, non-modal side pane
|
|
229
|
+
// for the WorkspaceShell's pane slot (the split-view, claude-Desktop file-pane
|
|
230
|
+
// feel). Distinct from the overlay FileViewer (kept as the <900px fallback).
|
|
231
|
+
// Not focus-trapped (it is not modal); ArrowLeft/Right step files when focused.
|
|
232
|
+
export function FilePreviewPane({ file, body, onClose, onAction, onPrev, onNext } = {}) {
|
|
233
|
+
if (!file) {
|
|
234
|
+
return h('div', { class: 'ds-preview-pane ds-preview-pane-empty', role: 'status' },
|
|
235
|
+
h('span', {}, 'Select a file to preview'));
|
|
236
|
+
}
|
|
237
|
+
return h('div', { class: 'ds-preview-pane', role: 'region', 'aria-label': 'file preview: ' + (file.name || ''),
|
|
238
|
+
tabindex: '0', onkeydown: previewKeyNav(onPrev, onNext) },
|
|
239
|
+
h('div', { class: 'ds-preview-head', 'data-file-type': file.type || 'other' },
|
|
240
|
+
...previewHead({ file, onClose, onAction, onPrev, onNext })),
|
|
241
|
+
h('div', { class: 'ds-preview-body', 'data-file-type': file.type || 'other' },
|
|
242
|
+
...(Array.isArray(body) ? body : [body]))
|
|
243
|
+
);
|
|
244
|
+
}
|
package/src/components/files.js
CHANGED
|
@@ -43,26 +43,34 @@ export function FileIcon({ type = 'other' } = {}) {
|
|
|
43
43
|
return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key } = {}) {
|
|
47
|
-
|
|
46
|
+
export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked } = {}) {
|
|
47
|
+
// permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
|
|
48
|
+
// be listed (the dir stat saw it) but not opened — show an ASCII tag and
|
|
49
|
+
// disable the open button so the row reads honestly instead of silently
|
|
50
|
+
// failing on click.
|
|
51
|
+
const noAccess = locked || permissions === 'EACCES' || (Array.isArray(permissions) && permissions.length === 0);
|
|
52
|
+
const readOnly = !noAccess && Array.isArray(permissions) && permissions.indexOf('write') === -1 && permissions.indexOf('read') !== -1;
|
|
53
|
+
const permTag = noAccess ? 'no access' : (readOnly ? 'read-only' : null);
|
|
54
|
+
const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null, permTag].filter(Boolean).join(' · ');
|
|
48
55
|
const typeLabel = TYPE_LABELS[type] || 'file';
|
|
49
56
|
const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}`;
|
|
57
|
+
const canOpen = onOpen && !noAccess;
|
|
50
58
|
// A role=button row containing real <button> action controls is invalid
|
|
51
59
|
// HTML (interactive nesting). Instead the row is a plain container and the
|
|
52
60
|
// primary "open" affordance is itself a real <button> (native keyboard +
|
|
53
61
|
// semantics); the per-file action buttons sit alongside it as siblings.
|
|
54
62
|
return h('div', {
|
|
55
63
|
key,
|
|
56
|
-
class: 'ds-file-row row' + (active ? ' active' : ''),
|
|
64
|
+
class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
|
|
57
65
|
'data-file-type': type,
|
|
58
66
|
},
|
|
59
67
|
h('button', {
|
|
60
68
|
type: 'button',
|
|
61
69
|
class: 'ds-file-open',
|
|
62
|
-
onclick: onOpen
|
|
63
|
-
'aria-label': accessibleLabel,
|
|
70
|
+
onclick: canOpen ? onOpen : null,
|
|
71
|
+
'aria-label': accessibleLabel + (noAccess ? ' (no access)' : ''),
|
|
64
72
|
'aria-pressed': active ? 'true' : 'false',
|
|
65
|
-
disabled:
|
|
73
|
+
disabled: canOpen ? null : true,
|
|
66
74
|
},
|
|
67
75
|
code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
|
|
68
76
|
FileIcon({ type }),
|
|
@@ -117,10 +125,22 @@ export function sortFiles(files = [], sort = 'name', dir = 'asc') {
|
|
|
117
125
|
// Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
|
|
118
126
|
// row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
|
|
119
127
|
// focus state; the grid tracks it on the DOM via roving tabindex.
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
// How many rows to render before the "show more" cap kicks in. A node_modules-
|
|
129
|
+
// scale directory would otherwise flood the DOM with thousands of rows (and make
|
|
130
|
+
// the roving-tabindex querySelectorAll scan O(n) per keypress). Render the first
|
|
131
|
+
// CAP and a "show N more" row, mirroring the History tab's "load N older".
|
|
132
|
+
const FILE_GRID_CAP = 200;
|
|
133
|
+
|
|
134
|
+
export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
|
|
135
|
+
columns = 'auto', sort, filter, loading = false,
|
|
136
|
+
shown, onShowMore } = {}) {
|
|
122
137
|
if (loading) return FileSkeleton({});
|
|
123
138
|
if (!files.length) return EmptyState({ text: emptyText });
|
|
139
|
+
// Cap the rendered rows. `shown` (host-controlled) overrides the default cap
|
|
140
|
+
// so "show more" can grow it; otherwise default to FILE_GRID_CAP.
|
|
141
|
+
const limit = shown != null ? shown : FILE_GRID_CAP;
|
|
142
|
+
const capped = files.length > limit;
|
|
143
|
+
const visible = capped ? files.slice(0, limit) : files;
|
|
124
144
|
const gridAttrs = {};
|
|
125
145
|
if (columns !== 'auto' && columns > 0) {
|
|
126
146
|
const col = Math.max(1, Math.min(4, Math.floor(columns)));
|
|
@@ -151,16 +171,30 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'no f
|
|
|
151
171
|
'aria-label': filter.placeholder || 'Filter files in this directory',
|
|
152
172
|
oninput: (e) => filter.onInput && filter.onInput(e.target.value),
|
|
153
173
|
})) : null;
|
|
154
|
-
|
|
155
|
-
|
|
174
|
+
// role=group not listbox: the rows contain real <button> action controls, so
|
|
175
|
+
// listbox/option semantics are invalid (an option can't host interactive
|
|
176
|
+
// children). Keyboard nav still works via roving focus over the open buttons.
|
|
177
|
+
const grid = h('div', { class: 'ds-file-grid', role: 'group', 'aria-label': 'files', tabindex: '0', onkeydown: onKeyDown, ...gridAttrs },
|
|
178
|
+
...visible.map((f, i) => FileRow({
|
|
156
179
|
key: f.path || f.name + i,
|
|
157
180
|
name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
|
|
181
|
+
permissions: f.permissions, locked: f.locked,
|
|
158
182
|
onOpen: onOpen ? () => onOpen(f) : null,
|
|
159
183
|
onAction: onAction ? (act) => onAction(act, f) : null
|
|
160
184
|
}))
|
|
161
185
|
);
|
|
162
|
-
|
|
163
|
-
|
|
186
|
+
// A count + "show more" affordance so a capped large dir reads as "more
|
|
187
|
+
// exist", not "this is everything". aria-live announces the shown/total.
|
|
188
|
+
const more = capped
|
|
189
|
+
? h('div', { class: 'ds-file-more' },
|
|
190
|
+
h('span', { class: 'ds-file-more-count', role: 'status', 'aria-live': 'polite' },
|
|
191
|
+
'showing ' + visible.length + ' of ' + files.length),
|
|
192
|
+
onShowMore ? h('button', { type: 'button', class: 'ds-file-more-btn',
|
|
193
|
+
onclick: () => onShowMore(Math.min(files.length, limit + FILE_GRID_CAP)) },
|
|
194
|
+
'show ' + Math.min(FILE_GRID_CAP, files.length - limit) + ' more') : null)
|
|
195
|
+
: null;
|
|
196
|
+
return (head || filterBar || more)
|
|
197
|
+
? h('div', { class: 'ds-file-listing' }, filterBar, head, grid, more)
|
|
164
198
|
: grid;
|
|
165
199
|
}
|
|
166
200
|
|
|
@@ -185,6 +219,21 @@ export function FileToolbar({ left = [], right = [] } = {}) {
|
|
|
185
219
|
);
|
|
186
220
|
}
|
|
187
221
|
|
|
222
|
+
// RootsPicker — a segmented control for choosing among multiple allowed FS roots
|
|
223
|
+
// (so the app stops borrowing the history-tab .pill markup). Each root is
|
|
224
|
+
// { id, label }; `selected` is the active id. role=tablist for AT navigation.
|
|
225
|
+
export function RootsPicker({ roots = [], selected, onSelect, label = 'roots' } = {}) {
|
|
226
|
+
if (!roots.length) return null;
|
|
227
|
+
return h('div', { class: 'ds-roots-picker', role: 'tablist', 'aria-label': label },
|
|
228
|
+
...roots.map((r) => h('button', {
|
|
229
|
+
key: 'root-' + (r.id != null ? r.id : r.label),
|
|
230
|
+
type: 'button', role: 'tab',
|
|
231
|
+
class: 'ds-roots-tab' + ((r.id != null ? r.id : r.label) === selected ? ' active' : ''),
|
|
232
|
+
'aria-selected': (r.id != null ? r.id : r.label) === selected ? 'true' : 'false',
|
|
233
|
+
onclick: () => onSelect && onSelect(r.id != null ? r.id : r.label),
|
|
234
|
+
}, r.label || r.id)));
|
|
235
|
+
}
|
|
236
|
+
|
|
188
237
|
export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave, label = 'drop files here', onPick } = {}) {
|
|
189
238
|
return h('div', {
|
|
190
239
|
class: 'ds-dropzone' + (dragover ? ' dragover' : ''),
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
7
7
|
import { Btn, Icon } from './shell.js';
|
|
8
|
+
import { Select, SearchInput } from './content.js';
|
|
8
9
|
const h = webjsx.createElement;
|
|
9
10
|
|
|
10
11
|
// ConversationList — the Claude-Desktop "Chats" column. Sessions grouped by a
|
|
@@ -18,7 +19,7 @@ const h = webjsx.createElement;
|
|
|
18
19
|
// search : { value, onInput, placeholder } inline filter (optional)
|
|
19
20
|
// onSelect(session), onNew() : intents
|
|
20
21
|
// emptyText, loading, error : explicit states
|
|
21
|
-
export function ConversationList({ sessions = [], selected, groups, search,
|
|
22
|
+
export function ConversationList({ sessions = [], selected, groups, search, caption,
|
|
22
23
|
onSelect, onNew, newLabel = 'New chat',
|
|
23
24
|
emptyText = 'No conversations yet', loading = false, error = null } = {}) {
|
|
24
25
|
const rowFor = (s, i) => h('button', {
|
|
@@ -77,6 +78,10 @@ export function ConversationList({ sessions = [], selected, groups, search,
|
|
|
77
78
|
'aria-label': search.placeholder || 'Search conversations',
|
|
78
79
|
oninput: (e) => search.onInput && search.onInput(e.target.value),
|
|
79
80
|
}) : null),
|
|
81
|
+
// Per-tab caption telling the user what selecting a row does on this surface
|
|
82
|
+
// (chat = resume the conversation, history = browse its events) so visually
|
|
83
|
+
// identical rows are disambiguated.
|
|
84
|
+
caption ? h('div', { key: 'cap', class: 'ds-session-caption' }, caption) : null,
|
|
80
85
|
body);
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -90,9 +95,18 @@ export function ConversationList({ sessions = [], selected, groups, search,
|
|
|
90
95
|
// the relative time of the most-recent event ("4s ago"); `currentTool` the tool
|
|
91
96
|
// name a still-running turn is executing - together they distinguish a busy
|
|
92
97
|
// session from a stuck one (a frozen elapsed alone reads identically for both).
|
|
93
|
-
|
|
98
|
+
// `status` is one of: 'error' | 'stale' | 'running'. A 'stale' session is one
|
|
99
|
+
// the host has determined is alive but not making progress (no recent activity,
|
|
100
|
+
// no current tool) — it reads as `idle` with a NON-pulsing disc so a stuck agent
|
|
101
|
+
// is visually distinct from a busy one (a frozen elapsed alone reads identically
|
|
102
|
+
// for both, which is the high-severity oversight gap this closes).
|
|
103
|
+
const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running' };
|
|
104
|
+
const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live' };
|
|
105
|
+
|
|
106
|
+
export function SessionCard({ session = {}, onStop, onOpen, onView, active = false,
|
|
107
|
+
selectable = false, selected = false, onToggleSelect } = {}) {
|
|
94
108
|
const s = session;
|
|
95
|
-
const
|
|
109
|
+
const st = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running');
|
|
96
110
|
// The stat line composes elapsed + live counter; the activity line carries the
|
|
97
111
|
// last-activity time and the current tool so a card shows MOTION, not just a
|
|
98
112
|
// start offset. Both are middot-joined (kept product separator).
|
|
@@ -101,12 +115,19 @@ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } =
|
|
|
101
115
|
s.currentTool ? 'running: ' + s.currentTool : null,
|
|
102
116
|
s.lastActivity ? 'last ' + s.lastActivity : null,
|
|
103
117
|
].filter(Boolean);
|
|
104
|
-
|
|
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 },
|
|
105
120
|
h('div', { class: 'ds-dash-card-head' },
|
|
106
|
-
h('
|
|
121
|
+
selectable ? h('button', {
|
|
122
|
+
type: 'button', class: 'ds-dash-select', role: 'checkbox',
|
|
123
|
+
'aria-checked': selected ? 'true' : 'false',
|
|
124
|
+
'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.agent || s.sid),
|
|
125
|
+
onclick: () => onToggleSelect && onToggleSelect(s),
|
|
126
|
+
}, selected ? '[x]' : '[ ]') : null,
|
|
127
|
+
h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
|
|
107
128
|
// Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
|
|
108
129
|
// aria-hidden, so the visible/AT status word carries the state.
|
|
109
|
-
h('span', { class: 'ds-dash-status
|
|
130
|
+
h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
|
|
110
131
|
h('span', { class: 'ds-dash-agent' }, s.agent || 'agent'),
|
|
111
132
|
s.model ? h('span', { class: 'ds-dash-model' }, s.model) : null),
|
|
112
133
|
h('div', { class: 'ds-dash-meta' },
|
|
@@ -114,8 +135,9 @@ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } =
|
|
|
114
135
|
statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
|
|
115
136
|
activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null),
|
|
116
137
|
h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' },
|
|
138
|
+
// open and resume collapsed into one 'open' action (they both just reopen
|
|
139
|
+
// the session in chat); 'events' kept for the read-only event view.
|
|
117
140
|
onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
|
|
118
|
-
onResume ? Btn({ key: 'resume', onClick: () => onResume(s), children: 'resume' }) : null,
|
|
119
141
|
onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
|
|
120
142
|
onStop ? Btn({ key: 'stop', danger: true, onClick: () => onStop(s), children: 'stop' }) : null));
|
|
121
143
|
}
|
|
@@ -128,20 +150,56 @@ export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } =
|
|
|
128
150
|
// The bulk header is the "manage many at once" affordance: a live count plus a
|
|
129
151
|
// stop-all button, so a user running several agents does not have to hunt each
|
|
130
152
|
// card's stop. Rendered only when there are sessions AND onStopAll is wired.
|
|
131
|
-
|
|
153
|
+
// Streamstate words: the live-stream health signal so "connected, zero running"
|
|
154
|
+
// still tells the user the dashboard is listening (vs a dropped stream).
|
|
155
|
+
const STREAM_WORD = { connected: 'listening for activity', connecting: 'connecting to live stream…', lost: 'live stream lost — retrying…' };
|
|
156
|
+
|
|
157
|
+
export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStopAll, onStopSelected,
|
|
158
|
+
sort, filter, errorsOnly = false, onErrorsOnly,
|
|
159
|
+
selectable = false, selected, onToggleSelect,
|
|
160
|
+
activeSid, streamState,
|
|
132
161
|
emptyText = 'No live sessions', offline = false } = {}) {
|
|
133
162
|
if (offline) {
|
|
134
163
|
return h('div', { class: 'ds-dash-state ds-dash-state-error', role: 'status' }, 'Backend offline — live sessions unavailable');
|
|
135
164
|
}
|
|
165
|
+
const selSet = selected instanceof Set ? selected : new Set(selected || []);
|
|
166
|
+
const selCount = selSet.size;
|
|
167
|
+
// The stream-state line always renders (even with zero sessions) so a
|
|
168
|
+
// connected-but-idle dashboard reads differently from an offline one.
|
|
169
|
+
const streamLine = streamState
|
|
170
|
+
? h('span', { class: 'ds-dash-stream is-' + streamState, role: 'status', 'aria-live': 'polite' }, STREAM_WORD[streamState] || streamState)
|
|
171
|
+
: null;
|
|
172
|
+
const toolbar = (sort || filter || onErrorsOnly)
|
|
173
|
+
? h('div', { class: 'ds-dash-toolbar', role: 'group', 'aria-label': 'sort and filter sessions' },
|
|
174
|
+
filter ? SearchInput({ key: 'filt', value: filter.value || '', label: filter.placeholder || 'Filter sessions', placeholder: filter.placeholder || 'Filter sessions', onInput: (v) => filter.onInput && filter.onInput(v) }) : null,
|
|
175
|
+
sort ? Select({ key: 'sort', value: sort.value || 'status', title: 'Sort sessions',
|
|
176
|
+
options: [
|
|
177
|
+
{ value: 'status', label: 'sort: status' },
|
|
178
|
+
{ value: 'elapsed', label: 'sort: elapsed' },
|
|
179
|
+
{ value: 'activity', label: 'sort: last activity' },
|
|
180
|
+
{ value: 'errors', label: 'sort: errors first' },
|
|
181
|
+
], onChange: (v) => sort.onChange && sort.onChange(v) }) : null,
|
|
182
|
+
onErrorsOnly ? h('button', { key: 'eo', type: 'button', class: 'ds-dash-errors-toggle' + (errorsOnly ? ' active' : ''),
|
|
183
|
+
'aria-pressed': errorsOnly ? 'true' : 'false', onclick: () => onErrorsOnly(!errorsOnly) }, 'errors only') : null)
|
|
184
|
+
: null;
|
|
136
185
|
if (!sessions.length) {
|
|
137
|
-
return h('div', { class: 'ds-dash
|
|
186
|
+
return h('div', { class: 'ds-dash' },
|
|
187
|
+
h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
|
|
188
|
+
h('span', { class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' }, '0 running'), streamLine),
|
|
189
|
+
h('div', { class: 'ds-dash-state', role: 'status' }, emptyText));
|
|
138
190
|
}
|
|
139
191
|
const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
|
|
140
192
|
h('span', { class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' },
|
|
141
|
-
sessions.length + ' running'),
|
|
142
|
-
|
|
193
|
+
selectable && selCount ? selCount + ' selected' : sessions.length + ' running'),
|
|
194
|
+
streamLine,
|
|
195
|
+
h('span', { class: 'spread' }),
|
|
196
|
+
selectable && selCount && onStopSelected
|
|
197
|
+
? Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]), children: 'stop selected' })
|
|
198
|
+
: (onStopAll ? Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions), children: 'stop all' }) : null),
|
|
199
|
+
toolbar);
|
|
143
200
|
const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
|
|
144
201
|
...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
|
|
145
|
-
SessionCard({ session: s, onStop, onOpen,
|
|
202
|
+
SessionCard({ session: s, onStop, onOpen, onView, active: s.sid === activeSid,
|
|
203
|
+
selectable, selected: selSet.has(s.sid), onToggleSelect }))));
|
|
146
204
|
return h('div', { class: 'ds-dash' }, header, grid);
|
|
147
205
|
}
|