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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.202",
|
|
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",
|
|
@@ -28,6 +28,14 @@ const baseAutoScroll = (msgCount) => makeThreadAutoScroll(() => msgCount);
|
|
|
28
28
|
// the bottom (the IntersectionObserver gate), so reading back-history is no
|
|
29
29
|
// longer fought; the button is the explicit way back to the live edge.
|
|
30
30
|
const NEAR_BOTTOM_PX = 80;
|
|
31
|
+
|
|
32
|
+
// Thread window: how many trailing turns render by default (hosts override via
|
|
33
|
+
// shownMessages; grow with onShowEarlier).
|
|
34
|
+
export const MESSAGE_CAP = 100;
|
|
35
|
+
// A single streaming message beyond this many chars renders only a tail window
|
|
36
|
+
// per frame (O(tail), not O(turn)); the settled turn renders full markdown once.
|
|
37
|
+
const STREAM_TAIL_THRESHOLD = 20000;
|
|
38
|
+
const STREAM_TAIL_WINDOW = 4000;
|
|
31
39
|
const threadRef = (msgCount) => {
|
|
32
40
|
const auto = baseAutoScroll(msgCount);
|
|
33
41
|
return (el) => {
|
|
@@ -55,7 +63,7 @@ function scrollThreadToBottom(btn) {
|
|
|
55
63
|
// The agent picker: agent-then-model, not a flat model list. Unavailable agents
|
|
56
64
|
// are disabled (unless installable via npx). Ordering is the host's concern.
|
|
57
65
|
function AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading,
|
|
58
|
-
onSelectAgent, onSelectModel, onNewChat, onStop }) {
|
|
66
|
+
onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }) {
|
|
59
67
|
const agentOptions = (agents || []).map((a) => ({
|
|
60
68
|
value: a.id,
|
|
61
69
|
label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
|
|
@@ -85,18 +93,36 @@ function AgentControls({ agents, selectedAgent, models, selectedModel, busy, sta
|
|
|
85
93
|
h('span', { key: 'st', class: 'agentchat-status', role: 'status', 'aria-live': 'polite' },
|
|
86
94
|
h('span', { class: 'status-dot-disc ' + (busy ? 'status-dot-live' : ''), 'aria-hidden': 'true' }),
|
|
87
95
|
h('span', {}, status || (busy ? 'streaming…' : 'ready'))),
|
|
96
|
+
// Host-supplied transcript actions (copy-all / export-md / export-json):
|
|
97
|
+
// small text-labeled buttons riding the same controls row. All siblings in
|
|
98
|
+
// this h(...) call are keyed VElements or null — never bare strings.
|
|
99
|
+
...(exportActions && exportActions.length
|
|
100
|
+
? exportActions.map((a, i) => h('button', {
|
|
101
|
+
key: 'exp' + i, type: 'button', class: 'agentchat-export-act',
|
|
102
|
+
title: a.title || a.label,
|
|
103
|
+
onclick: () => a.onClick && a.onClick(),
|
|
104
|
+
}, a.label))
|
|
105
|
+
: []),
|
|
88
106
|
);
|
|
89
107
|
}
|
|
90
108
|
|
|
91
109
|
// A working-directory bar: shows where the agent will run, editable + clearable.
|
|
92
|
-
|
|
110
|
+
// `error`/`checking` give inline validation feedback while typing/blur (the host
|
|
111
|
+
// debounces its /api/stat probe and sets these): a plain-language line renders
|
|
112
|
+
// under the input (aria-describedby) and save stays disabled while either is set.
|
|
113
|
+
function CwdBar({ cwd, editing, draft, onEdit, onSave, onCancel, onClear, onDraft, error, checking }) {
|
|
93
114
|
if (editing) {
|
|
115
|
+
const hint = checking ? 'checking…' : (error || null);
|
|
94
116
|
return h('div', { class: 'agentchat-cwd agentchat-cwd-editing', role: 'group', 'aria-label': 'Set working directory' },
|
|
95
117
|
h('input', { class: 'agentchat-cwd-input', type: 'text', value: draft ?? cwd ?? '',
|
|
96
118
|
placeholder: 'absolute path (blank = server default)',
|
|
119
|
+
'aria-describedby': hint ? 'agentchat-cwd-hint' : null,
|
|
120
|
+
'aria-invalid': error ? 'true' : null,
|
|
97
121
|
oninput: (e) => onDraft && onDraft(e.target.value) }),
|
|
98
|
-
Btn({ key: 'save', primary: true, onClick: () => onSave && onSave(), children: 'save' }),
|
|
99
|
-
Btn({ key: 'cancel', onClick: () => onCancel && onCancel(), children: 'cancel' })
|
|
122
|
+
Btn({ key: 'save', primary: true, disabled: !!(error || checking), onClick: () => onSave && onSave(), children: 'save' }),
|
|
123
|
+
Btn({ key: 'cancel', onClick: () => onCancel && onCancel(), children: 'cancel' }),
|
|
124
|
+
hint ? h('span', { key: 'hint', id: 'agentchat-cwd-hint', role: 'status', 'aria-live': 'polite',
|
|
125
|
+
class: 'agentchat-cwd-hint' + (error ? ' is-error' : ' is-checking') }, hint) : null);
|
|
100
126
|
}
|
|
101
127
|
return h('div', { class: 'agentchat-cwd', role: 'group', 'aria-label': 'Working directory' },
|
|
102
128
|
h('span', { class: 'agentchat-cwd-text', title: cwd || 'server default working directory' },
|
|
@@ -117,20 +143,31 @@ export function AgentChat(props = {}) {
|
|
|
117
143
|
const {
|
|
118
144
|
agents = [], selectedAgent = '', models = [], selectedModel = '', modelsLoading = false,
|
|
119
145
|
messages = [], busy = false, draft = '', status, banners = [],
|
|
120
|
-
cwd = '', cwdEditing = false, cwdDraft,
|
|
146
|
+
cwd = '', cwdEditing = false, cwdDraft, cwdError, cwdChecking = false,
|
|
121
147
|
agentName, placeholder,
|
|
122
148
|
onSelectAgent, onSelectModel, onSend, onStop, onNewChat, onInput,
|
|
123
149
|
onCwdEdit, onCwdSave, onCwdCancel, onCwdClear, onCwdDraft,
|
|
124
150
|
canSend = true,
|
|
125
151
|
suggestions = [], onSuggestionClick,
|
|
126
152
|
onCopyMessage, onRetryMessage, onEditMessage,
|
|
153
|
+
confirmEdit = false, onArmEdit,
|
|
127
154
|
avatar, composerContext,
|
|
128
155
|
followups = [], onFollowupClick,
|
|
156
|
+
installHint, exportActions = [],
|
|
157
|
+
onPasteFiles, onDropFiles,
|
|
158
|
+
shownMessages, onShowEarlier,
|
|
129
159
|
} = props;
|
|
130
160
|
|
|
131
161
|
const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
|
|
132
162
|
const lastIdx = messages.length - 1;
|
|
133
163
|
const lastMsg = messages[lastIdx];
|
|
164
|
+
// Windowed thread render (mirrors FileGrid's cap): only the last `limit`
|
|
165
|
+
// turns build vnodes each frame; a keyed 'show N earlier turns' row at the
|
|
166
|
+
// top grows the window via onShowEarlier (host keeps state.chat.shownMessages
|
|
167
|
+
// and resets it on newChat/loadSession). A 500-turn conversation no longer
|
|
168
|
+
// rebuilds 500 ChatMessage vnodes per streaming rAF tick.
|
|
169
|
+
const msgLimit = shownMessages != null ? shownMessages : MESSAGE_CAP;
|
|
170
|
+
const msgStart = Math.max(0, messages.length - msgLimit);
|
|
134
171
|
// True when streaming but the live assistant turn already shows content/parts,
|
|
135
172
|
// so its inline typing dots have stopped — a long silent tool call would
|
|
136
173
|
// otherwise read as frozen. We append a standalone "working" indicator below.
|
|
@@ -139,7 +176,8 @@ export function AgentChat(props = {}) {
|
|
|
139
176
|
// so an interleaved turn (parts-only, no m.content) is not treated as empty.
|
|
140
177
|
const msgHasBody = (m) => !!(m.content || (Array.isArray(m.parts) && m.parts.length));
|
|
141
178
|
const showWorkingTail = busy && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg);
|
|
142
|
-
const rows = messages.map((m,
|
|
179
|
+
const rows = messages.slice(msgStart).map((m, wi) => {
|
|
180
|
+
const i = wi + msgStart; // absolute index — streaming/caret/actions logic keys off the real lastIdx
|
|
143
181
|
const isAssistant = m.role === 'assistant';
|
|
144
182
|
const isStreaming = busy && i === lastIdx && isAssistant;
|
|
145
183
|
const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
|
|
@@ -167,6 +205,17 @@ export function AgentChat(props = {}) {
|
|
|
167
205
|
// use — only the inner content swaps on settle, so the bubble box does
|
|
168
206
|
// not reflow/jump when the turn finishes and renders real markdown.
|
|
169
207
|
if (isStreaming && part.kind === 'md') {
|
|
208
|
+
const txt = part.text || '';
|
|
209
|
+
// Giant streamed block: re-rendering the whole accumulated string per
|
|
210
|
+
// rAF is O(n^2) across the turn. Past the threshold, render a preShell
|
|
211
|
+
// bubble with a 'streaming · N KB so far' head plus only the last
|
|
212
|
+
// STREAM_TAIL_WINDOW chars; full markdown renders once on settle.
|
|
213
|
+
if (txt.length > STREAM_TAIL_THRESHOLD) {
|
|
214
|
+
parts.push({ kind: 'text', mdShell: true, preShell: true,
|
|
215
|
+
text: txt.slice(-STREAM_TAIL_WINDOW),
|
|
216
|
+
streamHead: 'streaming · ' + Math.round(txt.length / 1024) + ' KB so far' });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
170
219
|
// If the streaming prose contains a code fence, the inline renderer
|
|
171
220
|
// (which has no triple-backtick handling) would show it as run-on text
|
|
172
221
|
// with literal ``` and no monospace, then snap into a styled <pre> on
|
|
@@ -194,7 +243,10 @@ export function AgentChat(props = {}) {
|
|
|
194
243
|
const built = [];
|
|
195
244
|
if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
|
|
196
245
|
if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
|
|
197
|
-
|
|
246
|
+
// With confirmEdit the host arms its own confirm affordance (onArmEdit)
|
|
247
|
+
// instead of resending immediately; the kit stays stateless either way.
|
|
248
|
+
if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend',
|
|
249
|
+
onClick: () => (confirmEdit && onArmEdit) ? onArmEdit(m) : onEditMessage(m) });
|
|
198
250
|
if (built.length) actions = built;
|
|
199
251
|
}
|
|
200
252
|
return ChatMessage({
|
|
@@ -209,9 +261,24 @@ export function AgentChat(props = {}) {
|
|
|
209
261
|
typing: emptyStreaming,
|
|
210
262
|
streaming,
|
|
211
263
|
actions,
|
|
264
|
+
// Out-of-band notices (plain copy, neutral tone): m.stopped marks a
|
|
265
|
+
// cancelled turn; m.incomplete marks a turn whose stream dropped without
|
|
266
|
+
// replay. Retry rides the existing actions row.
|
|
267
|
+
stopped: m.stopped,
|
|
268
|
+
incomplete: m.incomplete,
|
|
212
269
|
parts: emptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
|
|
213
270
|
});
|
|
214
271
|
});
|
|
272
|
+
// Keyed 'show N earlier turns' control at the top of the window. A keyed
|
|
273
|
+
// VElement like every row sibling (webjsx keying discipline).
|
|
274
|
+
const earlierRow = msgStart > 0
|
|
275
|
+
? h('div', { key: '_earlier', class: 'agentchat-earlier' },
|
|
276
|
+
h('span', { class: 'agentchat-earlier-count', role: 'status', 'aria-live': 'polite' },
|
|
277
|
+
'showing ' + (messages.length - msgStart) + ' of ' + messages.length + ' turns'),
|
|
278
|
+
onShowEarlier ? h('button', { type: 'button', class: 'agentchat-earlier-btn',
|
|
279
|
+
onclick: () => onShowEarlier(Math.min(messages.length, msgLimit + MESSAGE_CAP)) },
|
|
280
|
+
'show ' + Math.min(MESSAGE_CAP, msgStart) + ' earlier turns') : null)
|
|
281
|
+
: null;
|
|
215
282
|
|
|
216
283
|
// While streaming, the composer's send button becomes an inline stop button
|
|
217
284
|
// (busy + onCancel) so the user can halt the turn from where their hands
|
|
@@ -226,6 +293,11 @@ export function AgentChat(props = {}) {
|
|
|
226
293
|
onCancel: busy && onStop ? () => onStop() : undefined,
|
|
227
294
|
// The active target (agent / model / cwd-basename) at the point of typing.
|
|
228
295
|
context: composerContext,
|
|
296
|
+
// Paste/drop file intents (image paste, file drop) — host-wired; the
|
|
297
|
+
// composer itself always preventDefaults the drop so the browser never
|
|
298
|
+
// navigates away from a live session.
|
|
299
|
+
onPasteFiles,
|
|
300
|
+
onDropFiles,
|
|
229
301
|
});
|
|
230
302
|
|
|
231
303
|
// Contextual follow-up chips below the last SETTLED assistant turn (claude.ai/
|
|
@@ -253,13 +325,41 @@ export function AgentChat(props = {}) {
|
|
|
253
325
|
key: 'sug' + i, type: 'button', class: 'agentchat-empty-suggestion',
|
|
254
326
|
onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onSuggestionClick) onSuggestionClick(t); },
|
|
255
327
|
}, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
|
|
328
|
+
: null,
|
|
329
|
+
// Guided install path for a brand-new user with zero installed agents:
|
|
330
|
+
// a plain copy line, a monospaced command per row (each with its own
|
|
331
|
+
// copy button, pure-DOM label flip like the code-block copy), and a
|
|
332
|
+
// recheck button so the user needn't reload after installing.
|
|
333
|
+
installHint
|
|
334
|
+
? h('div', { class: 'agentchat-install', role: 'group', 'aria-label': 'install an agent' },
|
|
335
|
+
installHint.text ? h('p', { class: 'agentchat-install-text' }, installHint.text) : null,
|
|
336
|
+
(installHint.commands && installHint.commands.length)
|
|
337
|
+
? h('ul', { class: 'agentchat-install-list' },
|
|
338
|
+
...installHint.commands.map((c, i) => h('li', { key: 'inst' + i, class: 'agentchat-install-row' },
|
|
339
|
+
h('span', { class: 'agentchat-install-agent' }, c.agent),
|
|
340
|
+
h('code', { class: 'agentchat-install-cmd' }, c.command),
|
|
341
|
+
h('button', {
|
|
342
|
+
type: 'button', class: 'agentchat-install-copy',
|
|
343
|
+
'aria-label': 'copy install command for ' + c.agent, title: 'copy command',
|
|
344
|
+
onclick: (e) => {
|
|
345
|
+
const btn = e.currentTarget;
|
|
346
|
+
navigator.clipboard && navigator.clipboard.writeText(c.command);
|
|
347
|
+
btn.textContent = 'copied';
|
|
348
|
+
setTimeout(() => { btn.textContent = 'copy'; }, 1200);
|
|
349
|
+
},
|
|
350
|
+
}, 'copy'))))
|
|
351
|
+
: null,
|
|
352
|
+
installHint.onRecheck
|
|
353
|
+
? h('div', { class: 'agentchat-install-actions' },
|
|
354
|
+
Btn({ onClick: () => installHint.onRecheck(), children: 'recheck agents', title: 'Re-check installed agents' }))
|
|
355
|
+
: null)
|
|
256
356
|
: null)
|
|
257
357
|
: null;
|
|
258
358
|
|
|
259
359
|
return h('div', { class: 'agentchat' },
|
|
260
360
|
AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading,
|
|
261
|
-
onSelectAgent, onSelectModel, onNewChat, onStop }),
|
|
262
|
-
CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft,
|
|
361
|
+
onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }),
|
|
362
|
+
CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft, error: cwdError, checking: cwdChecking,
|
|
263
363
|
onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft }),
|
|
264
364
|
...(banners || []).filter(Boolean),
|
|
265
365
|
h('div', { class: 'agentchat-head', role: 'banner' },
|
|
@@ -272,6 +372,7 @@ export function AgentChat(props = {}) {
|
|
|
272
372
|
h('div', { class: 'agentchat-thread-wrap' },
|
|
273
373
|
h('div', { class: 'agentchat-thread', ref: threadRef(messages.length), role: 'log', 'aria-label': 'conversation' },
|
|
274
374
|
emptyState,
|
|
375
|
+
earlierRow,
|
|
275
376
|
...rows.filter(Boolean),
|
|
276
377
|
showWorkingTail
|
|
277
378
|
? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
|
package/src/components/chat.js
CHANGED
|
@@ -4,20 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
6
6
|
import { renderMarkdownCached, highlightCodeBlockCached, initializeCachesEagerly, getCacheStats } from '../markdown-cache.js';
|
|
7
|
+
import { isDegraded as isMarkdownDegraded } from '../markdown.js';
|
|
7
8
|
import { register } from '../debug.js';
|
|
8
9
|
import { Icon } from './shell.js';
|
|
10
|
+
import { fmtFileSize } from './files.js';
|
|
9
11
|
|
|
10
12
|
const h = webjsx.createElement;
|
|
11
13
|
let _stats = { messages: 0, lastKindCounts: {} };
|
|
12
14
|
let _cacheInitialized = false;
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
|
19
|
-
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
|
20
|
-
}
|
|
16
|
+
// ONE byte format across the kit: fmtFileSize (files.js) is canonical; the old
|
|
17
|
+
// divergent fmtBytes ('0.0 KB' for zero, no B tier) is gone — this alias keeps
|
|
18
|
+
// existing imports working while rendering the same string as the Files grid.
|
|
19
|
+
export const fmtBytes = fmtFileSize;
|
|
21
20
|
|
|
22
21
|
// Reject dangerous URL schemes (javascript:, data:, vbscript:, file:) so an
|
|
23
22
|
// inline markdown link or an image src built from untrusted text can't smuggle a
|
|
@@ -73,10 +72,21 @@ function ensureCachesInit() {
|
|
|
73
72
|
initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
|
|
74
73
|
}
|
|
75
74
|
|
|
75
|
+
// True when the user has a non-collapsed text selection anchored inside `el`.
|
|
76
|
+
// Used to pause auto-scroll (and by hosts to pause streaming re-renders) so
|
|
77
|
+
// select-and-copy from a still-streaming message is not wiped every frame.
|
|
78
|
+
export function hasSelectionInside(el) {
|
|
79
|
+
const sel = typeof document !== 'undefined' && document.getSelection ? document.getSelection() : null;
|
|
80
|
+
return !!(sel && !sel.isCollapsed && sel.anchorNode && el && el.contains(sel.anchorNode));
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
// Build a ref callback that keeps a scroll container pinned to the bottom when
|
|
77
84
|
// new messages arrive AND the user is already at the bottom (sentinel visible).
|
|
78
85
|
// `getCount` returns the current message count so the observer compares against
|
|
79
86
|
// live state. Shared by Chat, AICat, and AgentChat.
|
|
87
|
+
// CONTRACT: auto-scroll pauses while the user holds a non-collapsed selection
|
|
88
|
+
// inside the thread (hasSelectionInside) — the same guard hosts apply to their
|
|
89
|
+
// streaming re-render pass — and resumes once the selection collapses.
|
|
80
90
|
export function makeThreadAutoScroll(getCount) {
|
|
81
91
|
return (el) => {
|
|
82
92
|
if (!el) return;
|
|
@@ -88,6 +98,7 @@ export function makeThreadAutoScroll(getCount) {
|
|
|
88
98
|
el.appendChild(sentinel);
|
|
89
99
|
}
|
|
90
100
|
const obs = new IntersectionObserver((entries) => {
|
|
101
|
+
if (hasSelectionInside(el)) return; // don't fight an active selection
|
|
91
102
|
const count = String(getCount());
|
|
92
103
|
if (entries[0]?.isIntersecting && el.dataset.msgCount !== count) {
|
|
93
104
|
el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
@@ -135,8 +146,12 @@ export function injectCodeCopy(container) {
|
|
|
135
146
|
function MdNode(p) {
|
|
136
147
|
const refSink = (el) => {
|
|
137
148
|
if (!el) return;
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
// Version the per-element source key with a degraded marker: a bubble
|
|
150
|
+
// rendered while the markdown loader was down re-renders (real markdown)
|
|
151
|
+
// once the loader recovers, instead of staying plain-escaped forever.
|
|
152
|
+
const srcKey = (isMarkdownDegraded() ? '~degraded~' : '') + (p.text || '');
|
|
153
|
+
if (el.dataset.mdSrc === srcKey) return;
|
|
154
|
+
el.dataset.mdSrc = srcKey;
|
|
140
155
|
renderMarkdownCached(p.text || '').then((html) => { el.innerHTML = html; injectCodeCopy(el); });
|
|
141
156
|
};
|
|
142
157
|
return h('div', { class: 'chat-bubble chat-md', ref: refSink });
|
|
@@ -216,10 +231,14 @@ function ThinkingNode(p) {
|
|
|
216
231
|
|
|
217
232
|
const PART_RENDERERS = {
|
|
218
233
|
text: (p) => p.preShell
|
|
219
|
-
// Streaming prose that already contains a code fence
|
|
220
|
-
// monospaced <pre> so it does not reflow from
|
|
221
|
-
// settle (no Prism mid-stream). The settled
|
|
222
|
-
|
|
234
|
+
// Streaming prose that already contains a code fence (or a huge tail
|
|
235
|
+
// window) renders as a plain monospaced <pre> so it does not reflow from
|
|
236
|
+
// prose to a styled block on settle (no Prism mid-stream). The settled
|
|
237
|
+
// turn renders real markdown. `streamHead` is an optional head line for
|
|
238
|
+
// the tail-window path ('streaming · N KB so far').
|
|
239
|
+
? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
|
|
240
|
+
...[p.streamHead ? h('div', { key: 'sh', class: 'chat-stream-head', role: 'status', 'aria-live': 'polite' }, p.streamHead) : null,
|
|
241
|
+
h('pre', { key: 'pre' }, h('code', {}, p.text || ''))].filter(Boolean))
|
|
223
242
|
: h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') }, ...renderInline(p.text || '')),
|
|
224
243
|
md: (p) => MdNode(p),
|
|
225
244
|
code: (p) => CodeNode(p),
|
|
@@ -270,7 +289,7 @@ function renderPart(p, key) {
|
|
|
270
289
|
return node;
|
|
271
290
|
}
|
|
272
291
|
|
|
273
|
-
export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions }) {
|
|
292
|
+
export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped }) {
|
|
274
293
|
_stats.messages += 1;
|
|
275
294
|
// Support legacy 'who' prop, prefer 'role' with mapping:
|
|
276
295
|
// 'user' -> 'you' (right-aligned, accent bubble)
|
|
@@ -299,6 +318,17 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
299
318
|
// a thin caret so the live edge reads as "still writing", not "done". Drawn as
|
|
300
319
|
// a CSS element, not a glyph character.
|
|
301
320
|
if (streaming && !typing) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
|
|
321
|
+
// Out-of-band turn notices, plain copy in a NEUTRAL tone (not error red):
|
|
322
|
+
// stopped — the turn was cancelled (locally or remotely); truncated
|
|
323
|
+
// output must not read as a finished answer.
|
|
324
|
+
// incomplete — the connection dropped mid-turn and events were not
|
|
325
|
+
// replayed; the response may be missing content.
|
|
326
|
+
// Pass true for the default copy or a string to override it. Retry rides
|
|
327
|
+
// the existing per-message actions row.
|
|
328
|
+
if (stopped) bodyNodes = [...bodyNodes, h('div', { key: '_stopped', class: 'chat-msg-notice is-stopped', role: 'status' },
|
|
329
|
+
typeof stopped === 'string' ? stopped : 'stopped — this turn was cancelled before it finished')];
|
|
330
|
+
if (incomplete) bodyNodes = [...bodyNodes, h('div', { key: '_incomplete', class: 'chat-msg-notice is-incomplete', role: 'status' },
|
|
331
|
+
typeof incomplete === 'string' ? incomplete : 'connection dropped mid-turn — the response may be incomplete')];
|
|
302
332
|
const reactionRow = reactions && reactions.length
|
|
303
333
|
? h('div', { class: 'chat-reactions' },
|
|
304
334
|
...reactions.map((r, i) => h('span', { class: 'rxn' + (r.you ? ' you' : ''), key: 'r' + i, 'aria-label': `${r.emoji} reaction (${String(r.count)} ${String(r.count) === '1' ? 'reaction' : 'reactions'})${r.you ? ' - you reacted' : ''}` },
|
|
@@ -333,7 +363,24 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
333
363
|
return h('div', { key, class: cls }, resolvedWho === 'you' ? stack : av, resolvedWho === 'you' ? av : stack);
|
|
334
364
|
}
|
|
335
365
|
|
|
336
|
-
|
|
366
|
+
// Transient, non-blocking composer note (aria-live polite): e.g. a pasted image
|
|
367
|
+
// when no onPasteFiles handler is wired. Pure-DOM, auto-clears.
|
|
368
|
+
function flashComposerNote(composerEl, text) {
|
|
369
|
+
if (!composerEl) return;
|
|
370
|
+
let note = composerEl.querySelector('.chat-composer-note');
|
|
371
|
+
if (!note) {
|
|
372
|
+
note = document.createElement('div');
|
|
373
|
+
note.className = 'chat-composer-note';
|
|
374
|
+
note.setAttribute('role', 'status');
|
|
375
|
+
note.setAttribute('aria-live', 'polite');
|
|
376
|
+
composerEl.appendChild(note);
|
|
377
|
+
}
|
|
378
|
+
note.textContent = text;
|
|
379
|
+
clearTimeout(note._dsNoteTimer);
|
|
380
|
+
note._dsNoteTimer = setTimeout(() => { note.remove(); }, 2600);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, context, onPasteFiles, onDropFiles }) {
|
|
337
384
|
// Keep a handle to the live textarea so send() reads the actual DOM value
|
|
338
385
|
// (not the possibly-lagging `value` prop) and so we can sync the DOM value
|
|
339
386
|
// only when it genuinely differs — re-applying `value` on every parent
|
|
@@ -370,23 +417,81 @@ export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu
|
|
|
370
417
|
};
|
|
371
418
|
// Optional context line shown above the textarea: agent / model / cwd at the
|
|
372
419
|
// point of typing (the way Claude-Desktop surfaces the active target inline).
|
|
373
|
-
// `context` is { bits:[...
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
420
|
+
// `context` is { bits:[...], onClick? }. Bits may be plain strings (inert
|
|
421
|
+
// text) or { text, onClick, title } objects — a bit with its own onClick
|
|
422
|
+
// renders as an inline button (.chat-composer-context-bit) so e.g. the cwd
|
|
423
|
+
// segment routes to the cwd editor WITHOUT making the whole line one giant
|
|
424
|
+
// click target. Legacy whole-line context.onClick is honored only when no
|
|
425
|
+
// bit carries its own handler. All children are keyed VElements.
|
|
426
|
+
const ctxBits = (context && context.bits) ? context.bits.filter(Boolean) : [];
|
|
427
|
+
const hasBitClicks = ctxBits.some((b) => b && typeof b === 'object' && b.onClick);
|
|
428
|
+
let contextLine = null;
|
|
429
|
+
if (ctxBits.length && hasBitClicks) {
|
|
430
|
+
const kids = [];
|
|
431
|
+
ctxBits.forEach((b, i) => {
|
|
432
|
+
if (i) kids.push(h('span', { key: 'csep' + i, class: 'chat-composer-context-sep', 'aria-hidden': 'true' }, ' · '));
|
|
433
|
+
const isObj = b && typeof b === 'object';
|
|
434
|
+
const text = isObj ? (b.text || '') : String(b);
|
|
435
|
+
if (isObj && b.onClick) kids.push(h('button', {
|
|
436
|
+
key: 'cbit' + i, type: 'button', class: 'chat-composer-context-bit',
|
|
437
|
+
title: b.title || null, 'aria-label': b.title || text,
|
|
438
|
+
onclick: (e) => { e.preventDefault(); b.onClick(e); },
|
|
439
|
+
}, text));
|
|
440
|
+
else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, text));
|
|
441
|
+
});
|
|
442
|
+
contextLine = h('div', { class: 'chat-composer-context' }, ...kids);
|
|
443
|
+
} else if (ctxBits.length) {
|
|
444
|
+
const joined = ctxBits.map((b) => (b && typeof b === 'object') ? (b.text || '') : String(b)).filter(Boolean).join(' · ');
|
|
445
|
+
contextLine = h(context.onClick ? 'button' : 'div', {
|
|
377
446
|
class: 'chat-composer-context', type: context.onClick ? 'button' : null,
|
|
378
|
-
'aria-label': context.onClick ? ('change target: ' +
|
|
447
|
+
'aria-label': context.onClick ? ('change target: ' + joined) : null,
|
|
379
448
|
onclick: context.onClick ? (e) => { e.preventDefault(); context.onClick(e); } : null,
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
449
|
+
}, joined);
|
|
450
|
+
}
|
|
451
|
+
const hasDraft = !!(value && value.trim());
|
|
452
|
+
return h('div', {
|
|
453
|
+
class: 'chat-composer' + (hasDraft ? ' has-draft' : ''),
|
|
454
|
+
// A drop on the composer must NEVER navigate the browser away from the
|
|
455
|
+
// live session: preventDefault on both dragover and drop, route files to
|
|
456
|
+
// the optional onDropFiles handler, ring via .dragover.
|
|
457
|
+
ondragover: (e) => { e.preventDefault(); e.currentTarget.classList.add('dragover'); },
|
|
458
|
+
ondragleave: (e) => { e.currentTarget.classList.remove('dragover'); },
|
|
459
|
+
ondrop: (e) => {
|
|
460
|
+
e.preventDefault();
|
|
461
|
+
e.currentTarget.classList.remove('dragover');
|
|
462
|
+
const files = e.dataTransfer && e.dataTransfer.files;
|
|
463
|
+
if (files && files.length) {
|
|
464
|
+
if (onDropFiles) onDropFiles(files);
|
|
465
|
+
else flashComposerNote(e.currentTarget, 'dropped files are not supported here yet');
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
},
|
|
383
469
|
contextLine,
|
|
384
470
|
h('textarea', { ref: taRef, placeholder, rows: 1, 'aria-label': 'message input',
|
|
385
471
|
oninput: autoGrow,
|
|
472
|
+
onpaste: (e) => {
|
|
473
|
+
const cd = e.clipboardData;
|
|
474
|
+
// Image/file clipboard data with no accompanying text: never
|
|
475
|
+
// silently dropped — route to onPasteFiles or tell the user.
|
|
476
|
+
if (cd && cd.files && cd.files.length && !cd.getData('text')) {
|
|
477
|
+
e.preventDefault();
|
|
478
|
+
if (onPasteFiles) onPasteFiles(cd.files);
|
|
479
|
+
else flashComposerNote(e.currentTarget.closest('.chat-composer'), 'images are not supported yet');
|
|
480
|
+
}
|
|
481
|
+
},
|
|
386
482
|
onkeydown: (e) => {
|
|
387
|
-
|
|
483
|
+
// Escape stops generation (the stop button's "(Esc)" title is
|
|
484
|
+
// now truthful) before falling through to any host blur handling.
|
|
485
|
+
if (e.key === 'Escape' && busy && onCancel) { e.preventDefault(); onCancel(e); return; }
|
|
486
|
+
// IME guard: the Enter that commits a CJK composition must never
|
|
487
|
+
// send (isComposing; keyCode 229 covers older engines).
|
|
488
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); send(); }
|
|
388
489
|
if (e.key === ';' && e.ctrlKey) { e.preventDefault(); onEmoji && onEmoji(e); }
|
|
389
490
|
} }),
|
|
491
|
+
// Enter-to-send affordance (Claude-Desktop style): a muted hint visible
|
|
492
|
+
// while the composer is focused or carries a draft; hidden under 420px
|
|
493
|
+
// (CSS) to save rows. Middot is kept product typography.
|
|
494
|
+
h('div', { class: 'chat-composer-hint', 'aria-hidden': 'true' }, 'Enter to send · Shift+Enter for a new line'),
|
|
390
495
|
h('div', { class: 'chat-composer-toolbar' },
|
|
391
496
|
onAttach ? h('button', { type: 'button', class: 'composer-btn', onclick: (e) => { e.preventDefault(); onAttach(e); }, 'aria-label': 'attach file', title: 'attach file' }, Icon('paperclip')) : null,
|
|
392
497
|
onEmoji ? h('button', { type: 'button', class: 'composer-btn', onclick: (e) => { e.preventDefault(); onEmoji(e); }, 'aria-label': 'emoji picker', title: 'emoji picker (Ctrl+;)' }, Icon('smile')) : null,
|
|
@@ -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,
|
|
@@ -275,7 +321,8 @@ export function SearchInput({ value = '', placeholder = 'search…', onInput, on
|
|
|
275
321
|
'aria-label': label || placeholder,
|
|
276
322
|
value,
|
|
277
323
|
oninput: onInput ? (e) => onInput(e.target.value, e) : null,
|
|
278
|
-
|
|
324
|
+
// IME guard: the Enter that commits a CJK composition must not submit.
|
|
325
|
+
onkeydown: onSubmit ? (e) => { if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) onSubmit(e.target.value, e); } : null
|
|
279
326
|
});
|
|
280
327
|
}
|
|
281
328
|
|
|
@@ -409,6 +456,21 @@ export function Skeleton({ height = '1em', width = '100%', count = 1, label = 'l
|
|
|
409
456
|
);
|
|
410
457
|
}
|
|
411
458
|
|
|
459
|
+
// FilterPills — a role=group of pill toggle buttons for quick category filters.
|
|
460
|
+
// `options` is [{ id, label }]; `selected` the active id; clicking a pill calls
|
|
461
|
+
// onSelect(id). Pressed state is announced via aria-pressed.
|
|
462
|
+
export function FilterPills({ options = [], selected, onSelect, label = 'filters' } = {}) {
|
|
463
|
+
if (!options.length) return null;
|
|
464
|
+
return h('div', { class: 'ds-filter-pills', role: 'group', 'aria-label': label },
|
|
465
|
+
...options.map((o) => h('button', {
|
|
466
|
+
key: 'fp-' + o.id,
|
|
467
|
+
type: 'button',
|
|
468
|
+
class: 'ds-filter-pill' + (o.id === selected ? ' active' : ''),
|
|
469
|
+
'aria-pressed': o.id === selected ? 'true' : 'false',
|
|
470
|
+
onclick: () => onSelect && onSelect(o.id),
|
|
471
|
+
}, o.label != null ? o.label : o.id)));
|
|
472
|
+
}
|
|
473
|
+
|
|
412
474
|
export function Alert({ kind = 'info', children, onDismiss, title, key } = {}) {
|
|
413
475
|
const icons = { info: 'info', success: 'check', warn: 'warn', error: 'x' };
|
|
414
476
|
const cls = 'ds-alert ds-alert-' + kind;
|