anentrypoint-design 0.0.199 → 0.0.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app-shell.css +53 -0
- package/chat.css +220 -5
- package/dist/247420.css +273 -5
- package/dist/247420.js +12 -12
- package/package.json +1 -1
- package/src/components/agent-chat.js +77 -6
- package/src/components/chat.js +65 -5
- package/src/components/content.js +69 -8
- package/src/components/context-pane.js +21 -2
- package/src/components/files-modals.js +66 -14
- package/src/components/files.js +93 -16
- package/src/components/sessions.js +112 -14
- package/src/components/shell.js +14 -4
- package/src/components.js +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.201",
|
|
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",
|
|
@@ -55,7 +55,7 @@ function scrollThreadToBottom(btn) {
|
|
|
55
55
|
// The agent picker: agent-then-model, not a flat model list. Unavailable agents
|
|
56
56
|
// are disabled (unless installable via npx). Ordering is the host's concern.
|
|
57
57
|
function AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading,
|
|
58
|
-
onSelectAgent, onSelectModel, onNewChat, onStop }) {
|
|
58
|
+
onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }) {
|
|
59
59
|
const agentOptions = (agents || []).map((a) => ({
|
|
60
60
|
value: a.id,
|
|
61
61
|
label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
|
|
@@ -85,6 +85,16 @@ function AgentControls({ agents, selectedAgent, models, selectedModel, busy, sta
|
|
|
85
85
|
h('span', { key: 'st', class: 'agentchat-status', role: 'status', 'aria-live': 'polite' },
|
|
86
86
|
h('span', { class: 'status-dot-disc ' + (busy ? 'status-dot-live' : ''), 'aria-hidden': 'true' }),
|
|
87
87
|
h('span', {}, status || (busy ? 'streaming…' : 'ready'))),
|
|
88
|
+
// Host-supplied transcript actions (copy-all / export-md / export-json):
|
|
89
|
+
// small text-labeled buttons riding the same controls row. All siblings in
|
|
90
|
+
// this h(...) call are keyed VElements or null — never bare strings.
|
|
91
|
+
...(exportActions && exportActions.length
|
|
92
|
+
? exportActions.map((a, i) => h('button', {
|
|
93
|
+
key: 'exp' + i, type: 'button', class: 'agentchat-export-act',
|
|
94
|
+
title: a.title || a.label,
|
|
95
|
+
onclick: () => a.onClick && a.onClick(),
|
|
96
|
+
}, a.label))
|
|
97
|
+
: []),
|
|
88
98
|
);
|
|
89
99
|
}
|
|
90
100
|
|
|
@@ -124,6 +134,10 @@ export function AgentChat(props = {}) {
|
|
|
124
134
|
canSend = true,
|
|
125
135
|
suggestions = [], onSuggestionClick,
|
|
126
136
|
onCopyMessage, onRetryMessage, onEditMessage,
|
|
137
|
+
confirmEdit = false, onArmEdit,
|
|
138
|
+
avatar, composerContext,
|
|
139
|
+
followups = [], onFollowupClick,
|
|
140
|
+
installHint, exportActions = [],
|
|
127
141
|
} = props;
|
|
128
142
|
|
|
129
143
|
const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
|
|
@@ -164,7 +178,16 @@ export function AgentChat(props = {}) {
|
|
|
164
178
|
// container shape (.chat-md padding/spacing) the settled markdown will
|
|
165
179
|
// use — only the inner content swaps on settle, so the bubble box does
|
|
166
180
|
// not reflow/jump when the turn finishes and renders real markdown.
|
|
167
|
-
if (isStreaming && part.kind === 'md')
|
|
181
|
+
if (isStreaming && part.kind === 'md') {
|
|
182
|
+
// If the streaming prose contains a code fence, the inline renderer
|
|
183
|
+
// (which has no triple-backtick handling) would show it as run-on text
|
|
184
|
+
// with literal ``` and no monospace, then snap into a styled <pre> on
|
|
185
|
+
// settle (a visible reflow during the most-watched moment). Detect a
|
|
186
|
+
// fence and render the bubble as a cheap monospaced <pre> shell instead
|
|
187
|
+
// (no Prism mid-stream, so no O(n^2)) so it does not reflow on settle.
|
|
188
|
+
if (part.text && part.text.indexOf('```') !== -1) parts.push({ kind: 'text', text: part.text, mdShell: true, preShell: true });
|
|
189
|
+
else parts.push({ kind: 'text', text: part.text, mdShell: true });
|
|
190
|
+
}
|
|
168
191
|
else parts.push(part);
|
|
169
192
|
}
|
|
170
193
|
}
|
|
@@ -181,15 +204,21 @@ export function AgentChat(props = {}) {
|
|
|
181
204
|
let actions;
|
|
182
205
|
if (!isStreaming && msgHasBody(m)) {
|
|
183
206
|
const built = [];
|
|
184
|
-
if (onCopyMessage) built.push({ label: 'copy', icon: '
|
|
207
|
+
if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
|
|
185
208
|
if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
|
|
186
|
-
|
|
209
|
+
// With confirmEdit the host arms its own confirm affordance (onArmEdit)
|
|
210
|
+
// instead of resending immediately; the kit stays stateless either way.
|
|
211
|
+
if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend',
|
|
212
|
+
onClick: () => (confirmEdit && onArmEdit) ? onArmEdit(m) : onEditMessage(m) });
|
|
187
213
|
if (built.length) actions = built;
|
|
188
214
|
}
|
|
189
215
|
return ChatMessage({
|
|
190
216
|
key: m.id || String(i),
|
|
191
217
|
who: isAssistant ? 'them' : 'you',
|
|
192
218
|
aicat: isAssistant,
|
|
219
|
+
// A stable per-agent product mark (host passes a small line-SVG via
|
|
220
|
+
// `avatar`) instead of a per-agent letter initial that shifts identity.
|
|
221
|
+
avatar: isAssistant ? (m.avatar != null ? m.avatar : avatar) : undefined,
|
|
193
222
|
name: isAssistant ? name : 'you',
|
|
194
223
|
time: m.time || '',
|
|
195
224
|
typing: emptyStreaming,
|
|
@@ -210,8 +239,21 @@ export function AgentChat(props = {}) {
|
|
|
210
239
|
onInput: (v) => onInput && onInput(v),
|
|
211
240
|
onSend: (v) => onSend && onSend(v),
|
|
212
241
|
onCancel: busy && onStop ? () => onStop() : undefined,
|
|
242
|
+
// The active target (agent / model / cwd-basename) at the point of typing.
|
|
243
|
+
context: composerContext,
|
|
213
244
|
});
|
|
214
245
|
|
|
246
|
+
// Contextual follow-up chips below the last SETTLED assistant turn (claude.ai/
|
|
247
|
+
// code / cowork surface these after a turn, not only on an empty thread). Shown
|
|
248
|
+
// only when not busy and the last message is an assistant turn with body.
|
|
249
|
+
const followupRow = (!busy && followups && followups.length && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg))
|
|
250
|
+
? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups' },
|
|
251
|
+
...followups.map((s, i) => h('button', {
|
|
252
|
+
key: 'fu' + i, type: 'button', class: 'agentchat-empty-suggestion agentchat-followup',
|
|
253
|
+
onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onFollowupClick) onFollowupClick(t); else if (onSuggestionClick) onSuggestionClick(t); },
|
|
254
|
+
}, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
|
|
255
|
+
: null;
|
|
256
|
+
|
|
215
257
|
// Empty state: a fresh thread is a void without this. Mirrors the kit's Chat
|
|
216
258
|
// empty surface (title, sub, optional starter prompts) so AgentChat opens to
|
|
217
259
|
// an invitation, not a blank panel.
|
|
@@ -226,12 +268,40 @@ export function AgentChat(props = {}) {
|
|
|
226
268
|
key: 'sug' + i, type: 'button', class: 'agentchat-empty-suggestion',
|
|
227
269
|
onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onSuggestionClick) onSuggestionClick(t); },
|
|
228
270
|
}, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
|
|
271
|
+
: null,
|
|
272
|
+
// Guided install path for a brand-new user with zero installed agents:
|
|
273
|
+
// a plain copy line, a monospaced command per row (each with its own
|
|
274
|
+
// copy button, pure-DOM label flip like the code-block copy), and a
|
|
275
|
+
// recheck button so the user needn't reload after installing.
|
|
276
|
+
installHint
|
|
277
|
+
? h('div', { class: 'agentchat-install', role: 'group', 'aria-label': 'install an agent' },
|
|
278
|
+
installHint.text ? h('p', { class: 'agentchat-install-text' }, installHint.text) : null,
|
|
279
|
+
(installHint.commands && installHint.commands.length)
|
|
280
|
+
? h('ul', { class: 'agentchat-install-list' },
|
|
281
|
+
...installHint.commands.map((c, i) => h('li', { key: 'inst' + i, class: 'agentchat-install-row' },
|
|
282
|
+
h('span', { class: 'agentchat-install-agent' }, c.agent),
|
|
283
|
+
h('code', { class: 'agentchat-install-cmd' }, c.command),
|
|
284
|
+
h('button', {
|
|
285
|
+
type: 'button', class: 'agentchat-install-copy',
|
|
286
|
+
'aria-label': 'copy install command for ' + c.agent, title: 'copy command',
|
|
287
|
+
onclick: (e) => {
|
|
288
|
+
const btn = e.currentTarget;
|
|
289
|
+
navigator.clipboard && navigator.clipboard.writeText(c.command);
|
|
290
|
+
btn.textContent = 'copied';
|
|
291
|
+
setTimeout(() => { btn.textContent = 'copy'; }, 1200);
|
|
292
|
+
},
|
|
293
|
+
}, 'copy'))))
|
|
294
|
+
: null,
|
|
295
|
+
installHint.onRecheck
|
|
296
|
+
? h('div', { class: 'agentchat-install-actions' },
|
|
297
|
+
Btn({ onClick: () => installHint.onRecheck(), children: 'recheck agents', title: 'Re-check installed agents' }))
|
|
298
|
+
: null)
|
|
229
299
|
: null)
|
|
230
300
|
: null;
|
|
231
301
|
|
|
232
302
|
return h('div', { class: 'agentchat' },
|
|
233
303
|
AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading,
|
|
234
|
-
onSelectAgent, onSelectModel, onNewChat, onStop }),
|
|
304
|
+
onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }),
|
|
235
305
|
CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft,
|
|
236
306
|
onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft }),
|
|
237
307
|
...(banners || []).filter(Boolean),
|
|
@@ -250,7 +320,8 @@ export function AgentChat(props = {}) {
|
|
|
250
320
|
? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
|
|
251
321
|
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
252
322
|
h('span', { class: 'agentchat-working-text' }, 'working…'))
|
|
253
|
-
: null
|
|
323
|
+
: null,
|
|
324
|
+
followupRow),
|
|
254
325
|
// Jump-to-latest: hidden until the scroll listener adds .show (user scrolled
|
|
255
326
|
// up). Clicking returns to the live edge. Pure-DOM, like the kit's other
|
|
256
327
|
// 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) => {
|
|
@@ -6,9 +6,9 @@ import * as webjsx from '../../vendor/webjsx/index.js';
|
|
|
6
6
|
import { Btn, Heading, Lede, Dot, Icon } from './shell.js';
|
|
7
7
|
const h = webjsx.createElement;
|
|
8
8
|
|
|
9
|
-
export function Panel({ title, count, right, style = '', children, kind }) {
|
|
9
|
+
export function Panel({ title, count, right, style = '', children, kind, id }) {
|
|
10
10
|
const cls = 'panel' + (kind ? ' panel-' + kind : '');
|
|
11
|
-
return h('div', { class: cls, style },
|
|
11
|
+
return h('div', { class: cls, style, id: id || null },
|
|
12
12
|
title != null ? h('div', { class: 'panel-head' },
|
|
13
13
|
h('span', {}, title),
|
|
14
14
|
right != null ? right : (count != null ? h('span', {}, String(count)) : null)
|
|
@@ -20,7 +20,30 @@ export function Panel({ title, count, right, style = '', children, kind }) {
|
|
|
20
20
|
// Card — semantic alias of Panel; behaves identically.
|
|
21
21
|
export const Card = Panel;
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// Split a title string around case-insensitive matches of `highlight`, wrapping
|
|
24
|
+
// hits in <mark class="ds-hl">. Every segment is a keyed span so the children
|
|
25
|
+
// array never mixes keyed VElements with bare strings (webjsx applyDiff crashes
|
|
26
|
+
// on mixed keying).
|
|
27
|
+
function highlightTitle(title, highlight) {
|
|
28
|
+
const text = String(title);
|
|
29
|
+
const needle = String(highlight).toLowerCase();
|
|
30
|
+
if (!needle) return text;
|
|
31
|
+
const lower = text.toLowerCase();
|
|
32
|
+
const segs = [];
|
|
33
|
+
let pos = 0, n = 0;
|
|
34
|
+
while (pos <= text.length) {
|
|
35
|
+
const hit = lower.indexOf(needle, pos);
|
|
36
|
+
if (hit === -1) break;
|
|
37
|
+
if (hit > pos) segs.push(h('span', { key: 'hs' + n++ }, text.slice(pos, hit)));
|
|
38
|
+
segs.push(h('mark', { key: 'hs' + n++, class: 'ds-hl' }, text.slice(hit, hit + needle.length)));
|
|
39
|
+
pos = hit + needle.length;
|
|
40
|
+
}
|
|
41
|
+
if (!segs.length) return text;
|
|
42
|
+
if (pos < text.length) segs.push(h('span', { key: 'hs' + n++ }, text.slice(pos)));
|
|
43
|
+
return segs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Row({ code, rank, title, sub, meta, active, state = 'default', onClick, key, style, href, kind, cols, leading, trailing, target, selected, rail, expanded, highlight, actions }) {
|
|
24
47
|
// `rank` is an alias for `code` (the leading monospace index); callers use
|
|
25
48
|
// either name. `rail` renders a thin colour bar at the row's leading edge as
|
|
26
49
|
// a status indicator (tone: green | purple | flame | <any token>).
|
|
@@ -52,10 +75,32 @@ export function Row({ code, rank, title, sub, meta, active, state = 'default', o
|
|
|
52
75
|
}
|
|
53
76
|
if (isDisabled) props['aria-disabled'] = 'true';
|
|
54
77
|
if (isActive && (isLink || isButton)) props['aria-current'] = isActive ? 'page' : null;
|
|
78
|
+
// `highlight` wraps case-insensitive matches in the title in <mark class="ds-hl">.
|
|
79
|
+
// The segments live inside a single wrapper span so the title's child list
|
|
80
|
+
// never mixes keyed and unkeyed siblings.
|
|
81
|
+
const titleNode = (highlight && typeof title === 'string')
|
|
82
|
+
? h('span', {}, ...[].concat(highlightTitle(title, highlight)))
|
|
83
|
+
: title;
|
|
84
|
+
// `actions` render ONLY when the row is expanded, as a sibling action strip
|
|
85
|
+
// inside the row container; each button stops propagation so it never fires
|
|
86
|
+
// the row onClick.
|
|
87
|
+
const actionRow = (expanded === true && Array.isArray(actions) && actions.length)
|
|
88
|
+
? h('span', { class: 'row-actions', role: 'group', 'aria-label': 'row actions' },
|
|
89
|
+
...actions.map((a, i) => h('button', {
|
|
90
|
+
key: 'ract' + i,
|
|
91
|
+
type: 'button',
|
|
92
|
+
class: 'row-act',
|
|
93
|
+
title: a.title || a.label,
|
|
94
|
+
'aria-label': a.title || a.label,
|
|
95
|
+
onclick: (e) => { e.stopPropagation(); a.onClick && a.onClick(e); },
|
|
96
|
+
onkeydown: (e) => { e.stopPropagation(); },
|
|
97
|
+
}, a.label)))
|
|
98
|
+
: null;
|
|
55
99
|
return h(isLink ? 'a' : 'div', props,
|
|
56
100
|
leading != null ? leading : (codeVal != null ? h('span', { class: 'code' }, codeVal) : null),
|
|
57
|
-
h('span', { class: 'title' },
|
|
58
|
-
trailing != null ? trailing : (meta != null ? h('span', { class: 'meta' }, meta) : null)
|
|
101
|
+
h('span', { class: 'title' }, titleNode, sub ? h('span', { class: 'sub' }, sub) : null),
|
|
102
|
+
trailing != null ? trailing : (meta != null ? h('span', { class: 'meta' }, meta) : null),
|
|
103
|
+
actionRow);
|
|
59
104
|
}
|
|
60
105
|
|
|
61
106
|
export function RowLink({ code, title, sub, meta, href = '#', key, target }) {
|
|
@@ -253,11 +298,12 @@ export function ProjectView({ project = {}, copied, onCopy } = {}) {
|
|
|
253
298
|
].filter(Boolean).flat();
|
|
254
299
|
}
|
|
255
300
|
|
|
256
|
-
export function PageHeader({ title, lede, eyebrow, right, compact }) {
|
|
301
|
+
export function PageHeader({ title, lede, eyebrow, right, compact, id }) {
|
|
257
302
|
// `compact` drops the large leading/trailing section margins so a PageHeader
|
|
258
303
|
// used as a page's first element top-aligns cleanly without the consumer
|
|
259
|
-
// having to !important-override the .ds-section margin.
|
|
260
|
-
|
|
304
|
+
// having to !important-override the .ds-section margin. `id` lands on the
|
|
305
|
+
// outermost section so the header can serve as a deep-link anchor.
|
|
306
|
+
return h('section', { class: 'ds-section' + (compact ? ' ds-section-compact' : ''), id: id || null },
|
|
261
307
|
eyebrow ? h('span', { class: 'eyebrow' }, eyebrow) : null,
|
|
262
308
|
title != null ? h('h1', {}, title) : null,
|
|
263
309
|
lede != null ? h('p', { class: 'lede' }, lede) : null,
|
|
@@ -409,6 +455,21 @@ export function Skeleton({ height = '1em', width = '100%', count = 1, label = 'l
|
|
|
409
455
|
);
|
|
410
456
|
}
|
|
411
457
|
|
|
458
|
+
// FilterPills — a role=group of pill toggle buttons for quick category filters.
|
|
459
|
+
// `options` is [{ id, label }]; `selected` the active id; clicking a pill calls
|
|
460
|
+
// onSelect(id). Pressed state is announced via aria-pressed.
|
|
461
|
+
export function FilterPills({ options = [], selected, onSelect, label = 'filters' } = {}) {
|
|
462
|
+
if (!options.length) return null;
|
|
463
|
+
return h('div', { class: 'ds-filter-pills', role: 'group', 'aria-label': label },
|
|
464
|
+
...options.map((o) => h('button', {
|
|
465
|
+
key: 'fp-' + o.id,
|
|
466
|
+
type: 'button',
|
|
467
|
+
class: 'ds-filter-pill' + (o.id === selected ? ' active' : ''),
|
|
468
|
+
'aria-pressed': o.id === selected ? 'true' : 'false',
|
|
469
|
+
onclick: () => onSelect && onSelect(o.id),
|
|
470
|
+
}, o.label != null ? o.label : o.id)));
|
|
471
|
+
}
|
|
472
|
+
|
|
412
473
|
export function Alert({ kind = 'info', children, onDismiss, title, key } = {}) {
|
|
413
474
|
const icons = { info: 'info', success: 'check', warn: 'warn', error: 'x' };
|
|
414
475
|
const cls = 'ds-alert ds-alert-' + kind;
|
|
@@ -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.
|
|
@@ -33,6 +33,17 @@ 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
|
+
const hasSession = session && (session.turns != null || session.cost != null);
|
|
38
|
+
// Empty state: before an agent is picked AND with no usage/session, four
|
|
39
|
+
// placeholder rows (agent: none / model: dash / ...) read as a dead panel.
|
|
40
|
+
// Show one honest line instead.
|
|
41
|
+
if (!agent && !hasUsage && !hasSession && !cwd) {
|
|
42
|
+
return h('div', { class: 'ds-context' },
|
|
43
|
+
h('div', { class: 'ds-context-empty', role: 'status' },
|
|
44
|
+
'No active conversation — start a chat to see context here'),
|
|
45
|
+
onSetCwd ? h('div', { class: 'ds-context-actions' }, Btn({ onClick: onSetCwd, children: 'set working dir' })) : null);
|
|
46
|
+
}
|
|
36
47
|
// Each Panel's children array is all-unkeyed (no key prop on any sibling),
|
|
37
48
|
// so webjsx never sees a mixed keyed/unkeyed array here.
|
|
38
49
|
const panels = [
|
|
@@ -56,9 +67,17 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
56
67
|
],
|
|
57
68
|
}),
|
|
58
69
|
];
|
|
70
|
+
// Conversation block: whole-session totals (turn count + accumulated cost)
|
|
71
|
+
// between the context panel and the per-turn usage panel.
|
|
72
|
+
if (hasSession) {
|
|
73
|
+
const sesRows = [];
|
|
74
|
+
if (session.turns != null) sesRows.push(Row({ title: 'turns', meta: String(session.turns) }));
|
|
75
|
+
if (session.cost != null) sesRows.push(Row({ title: 'total cost', meta: '$' + Number(session.cost).toFixed(4) }));
|
|
76
|
+
panels.push(Panel({ title: 'conversation', children: sesRows }));
|
|
77
|
+
}
|
|
59
78
|
// Usage block: surface the last turn's token/cost/turn/duration so the
|
|
60
79
|
// result event is no longer silently dropped.
|
|
61
|
-
if (
|
|
80
|
+
if (hasUsage) {
|
|
62
81
|
const tokRows = [];
|
|
63
82
|
if (usage.inputTokens != null) tokRows.push(Row({ title: 'input', meta: fmtTok(usage.inputTokens) + ' tok' }));
|
|
64
83
|
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
|
+
}
|