anentrypoint-design 0.0.201 → 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 +25 -2
- package/chat.css +91 -0
- package/dist/247420.css +116 -2
- package/dist/247420.js +13 -13
- package/package.json +1 -1
- package/src/components/agent-chat.js +64 -6
- package/src/components/chat.js +129 -24
- package/src/components/content.js +2 -1
- package/src/components/context-pane.js +3 -1
- package/src/components/files-modals.js +83 -24
- package/src/components/files.js +23 -3
- package/src/components/sessions.js +59 -16
- package/src/components.js +3 -3
- 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) => {
|
|
@@ -99,14 +107,22 @@ function AgentControls({ agents, selectedAgent, models, selectedModel, busy, sta
|
|
|
99
107
|
}
|
|
100
108
|
|
|
101
109
|
// A working-directory bar: shows where the agent will run, editable + clearable.
|
|
102
|
-
|
|
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 }) {
|
|
103
114
|
if (editing) {
|
|
115
|
+
const hint = checking ? 'checking…' : (error || null);
|
|
104
116
|
return h('div', { class: 'agentchat-cwd agentchat-cwd-editing', role: 'group', 'aria-label': 'Set working directory' },
|
|
105
117
|
h('input', { class: 'agentchat-cwd-input', type: 'text', value: draft ?? cwd ?? '',
|
|
106
118
|
placeholder: 'absolute path (blank = server default)',
|
|
119
|
+
'aria-describedby': hint ? 'agentchat-cwd-hint' : null,
|
|
120
|
+
'aria-invalid': error ? 'true' : null,
|
|
107
121
|
oninput: (e) => onDraft && onDraft(e.target.value) }),
|
|
108
|
-
Btn({ key: 'save', primary: true, onClick: () => onSave && onSave(), children: 'save' }),
|
|
109
|
-
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);
|
|
110
126
|
}
|
|
111
127
|
return h('div', { class: 'agentchat-cwd', role: 'group', 'aria-label': 'Working directory' },
|
|
112
128
|
h('span', { class: 'agentchat-cwd-text', title: cwd || 'server default working directory' },
|
|
@@ -127,7 +143,7 @@ export function AgentChat(props = {}) {
|
|
|
127
143
|
const {
|
|
128
144
|
agents = [], selectedAgent = '', models = [], selectedModel = '', modelsLoading = false,
|
|
129
145
|
messages = [], busy = false, draft = '', status, banners = [],
|
|
130
|
-
cwd = '', cwdEditing = false, cwdDraft,
|
|
146
|
+
cwd = '', cwdEditing = false, cwdDraft, cwdError, cwdChecking = false,
|
|
131
147
|
agentName, placeholder,
|
|
132
148
|
onSelectAgent, onSelectModel, onSend, onStop, onNewChat, onInput,
|
|
133
149
|
onCwdEdit, onCwdSave, onCwdCancel, onCwdClear, onCwdDraft,
|
|
@@ -138,11 +154,20 @@ export function AgentChat(props = {}) {
|
|
|
138
154
|
avatar, composerContext,
|
|
139
155
|
followups = [], onFollowupClick,
|
|
140
156
|
installHint, exportActions = [],
|
|
157
|
+
onPasteFiles, onDropFiles,
|
|
158
|
+
shownMessages, onShowEarlier,
|
|
141
159
|
} = props;
|
|
142
160
|
|
|
143
161
|
const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
|
|
144
162
|
const lastIdx = messages.length - 1;
|
|
145
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);
|
|
146
171
|
// True when streaming but the live assistant turn already shows content/parts,
|
|
147
172
|
// so its inline typing dots have stopped — a long silent tool call would
|
|
148
173
|
// otherwise read as frozen. We append a standalone "working" indicator below.
|
|
@@ -151,7 +176,8 @@ export function AgentChat(props = {}) {
|
|
|
151
176
|
// so an interleaved turn (parts-only, no m.content) is not treated as empty.
|
|
152
177
|
const msgHasBody = (m) => !!(m.content || (Array.isArray(m.parts) && m.parts.length));
|
|
153
178
|
const showWorkingTail = busy && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg);
|
|
154
|
-
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
|
|
155
181
|
const isAssistant = m.role === 'assistant';
|
|
156
182
|
const isStreaming = busy && i === lastIdx && isAssistant;
|
|
157
183
|
const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
|
|
@@ -179,6 +205,17 @@ export function AgentChat(props = {}) {
|
|
|
179
205
|
// use — only the inner content swaps on settle, so the bubble box does
|
|
180
206
|
// not reflow/jump when the turn finishes and renders real markdown.
|
|
181
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
|
+
}
|
|
182
219
|
// If the streaming prose contains a code fence, the inline renderer
|
|
183
220
|
// (which has no triple-backtick handling) would show it as run-on text
|
|
184
221
|
// with literal ``` and no monospace, then snap into a styled <pre> on
|
|
@@ -224,9 +261,24 @@ export function AgentChat(props = {}) {
|
|
|
224
261
|
typing: emptyStreaming,
|
|
225
262
|
streaming,
|
|
226
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,
|
|
227
269
|
parts: emptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
|
|
228
270
|
});
|
|
229
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;
|
|
230
282
|
|
|
231
283
|
// While streaming, the composer's send button becomes an inline stop button
|
|
232
284
|
// (busy + onCancel) so the user can halt the turn from where their hands
|
|
@@ -241,6 +293,11 @@ export function AgentChat(props = {}) {
|
|
|
241
293
|
onCancel: busy && onStop ? () => onStop() : undefined,
|
|
242
294
|
// The active target (agent / model / cwd-basename) at the point of typing.
|
|
243
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,
|
|
244
301
|
});
|
|
245
302
|
|
|
246
303
|
// Contextual follow-up chips below the last SETTLED assistant turn (claude.ai/
|
|
@@ -302,7 +359,7 @@ export function AgentChat(props = {}) {
|
|
|
302
359
|
return h('div', { class: 'agentchat' },
|
|
303
360
|
AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading,
|
|
304
361
|
onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }),
|
|
305
|
-
CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft,
|
|
362
|
+
CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft, error: cwdError, checking: cwdChecking,
|
|
306
363
|
onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft }),
|
|
307
364
|
...(banners || []).filter(Boolean),
|
|
308
365
|
h('div', { class: 'agentchat-head', role: 'banner' },
|
|
@@ -315,6 +372,7 @@ export function AgentChat(props = {}) {
|
|
|
315
372
|
h('div', { class: 'agentchat-thread-wrap' },
|
|
316
373
|
h('div', { class: 'agentchat-thread', ref: threadRef(messages.length), role: 'log', 'aria-label': 'conversation' },
|
|
317
374
|
emptyState,
|
|
375
|
+
earlierRow,
|
|
318
376
|
...rows.filter(Boolean),
|
|
319
377
|
showWorkingTail
|
|
320
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,
|
|
@@ -321,7 +321,8 @@ export function SearchInput({ value = '', placeholder = 'search…', onInput, on
|
|
|
321
321
|
'aria-label': label || placeholder,
|
|
322
322
|
value,
|
|
323
323
|
oninput: onInput ? (e) => onInput(e.target.value, e) : null,
|
|
324
|
-
|
|
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
|
|
325
326
|
});
|
|
326
327
|
}
|
|
327
328
|
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
22
22
|
import { Panel, Row } from './content.js';
|
|
23
23
|
import { Btn } from './shell.js';
|
|
24
|
+
import { fmtDuration } from './sessions.js';
|
|
24
25
|
|
|
25
26
|
const h = webjsx.createElement;
|
|
26
27
|
|
|
@@ -83,7 +84,8 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
|
|
|
83
84
|
if (usage.outputTokens != null) tokRows.push(Row({ title: 'output', meta: fmtTok(usage.outputTokens) + ' tok' }));
|
|
84
85
|
if (usage.costUsd != null) tokRows.push(Row({ title: 'cost', meta: '$' + usage.costUsd.toFixed(4) }));
|
|
85
86
|
if (usage.turns != null) tokRows.push(Row({ title: 'turns', meta: String(usage.turns) }));
|
|
86
|
-
|
|
87
|
+
// One duration vocabulary kit-wide: shared fmtDuration (s -> m -> h).
|
|
88
|
+
if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: fmtDuration(usage.durationMs) }));
|
|
87
89
|
panels.push(Panel({ title: 'last turn', children: tokRows }));
|
|
88
90
|
}
|
|
89
91
|
return h('div', { class: 'ds-context' },
|
|
@@ -8,7 +8,7 @@ const h = webjsx.createElement;
|
|
|
8
8
|
// Monotonic id source for aria-labelledby wiring between a modal and its head.
|
|
9
9
|
let _modalSeq = 0;
|
|
10
10
|
|
|
11
|
-
function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
|
|
11
|
+
function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {}) {
|
|
12
12
|
// webjsx invokes a ref callback with the element on mount and with null on
|
|
13
13
|
// unmount. We stash the per-element keydown teardown on the node itself so
|
|
14
14
|
// the null branch can run it — otherwise the document/element listener leaks
|
|
@@ -26,9 +26,12 @@ function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
|
|
|
26
26
|
const lastFocusable = focusables[focusables.length - 1];
|
|
27
27
|
|
|
28
28
|
const handleKeydown = (e) => {
|
|
29
|
-
// Escape closes the modal
|
|
29
|
+
// Escape closes the modal — unless a mutation is in flight (the live
|
|
30
|
+
// busy state is read off the data-busy attribute, which re-renders;
|
|
31
|
+
// this handler's closure is bound once at mount).
|
|
30
32
|
if (e.key === 'Escape') {
|
|
31
33
|
e.preventDefault();
|
|
34
|
+
if (el.dataset.busy === '1') return;
|
|
32
35
|
if (onClose) onClose();
|
|
33
36
|
return;
|
|
34
37
|
}
|
|
@@ -52,22 +55,61 @@ function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
|
|
|
52
55
|
}
|
|
53
56
|
};
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// Escape must close the modal no matter where focus sits (re-renders
|
|
59
|
+
// can bounce focus out of the dialog), so listen at document level
|
|
60
|
+
// for the modal's lifetime.
|
|
61
|
+
document.addEventListener('keydown', handleKeydown, true);
|
|
62
|
+
// Record the invoker BEFORE the modal steals focus, so close (confirm,
|
|
63
|
+
// cancel, Escape, backdrop click) restores keyboard/AT focus to where
|
|
64
|
+
// the user was (e.g. the FileGrid row button) instead of <body>.
|
|
65
|
+
// Re-mounts mid-lifetime (every app render re-runs this ref) keep the
|
|
66
|
+
// ORIGINAL invoker and never re-steal focus from the user.
|
|
67
|
+
const invoker = el.contains(document.activeElement) ? (Backdrop._invoker || document.activeElement) : document.activeElement;
|
|
68
|
+
if (!Backdrop._invoker) Backdrop._invoker = invoker;
|
|
69
|
+
el._dsModalTeardown = (removed) => {
|
|
70
|
+
document.removeEventListener('keydown', handleKeydown, true);
|
|
71
|
+
// Only restore focus when the modal is genuinely going away (not a
|
|
72
|
+
// re-render remount) and focus is not already somewhere useful.
|
|
73
|
+
if (removed && Backdrop._invoker && Backdrop._invoker.focus && Backdrop._invoker.isConnected) {
|
|
74
|
+
try { Backdrop._invoker.focus(); } catch {}
|
|
75
|
+
}
|
|
76
|
+
if (removed) Backdrop._invoker = null;
|
|
77
|
+
};
|
|
78
|
+
// Auto-focus on open - only when focus is not already inside the modal
|
|
79
|
+
// (re-renders must not yank the caret around).
|
|
80
|
+
if (!el.contains(document.activeElement)) {
|
|
81
|
+
const preferred = modal.querySelector('[autofocus]') || firstFocusable;
|
|
82
|
+
if (preferred) preferred.focus();
|
|
83
|
+
}
|
|
61
84
|
};
|
|
62
85
|
|
|
63
86
|
return h('div', {
|
|
64
87
|
class: 'ds-modal-backdrop',
|
|
88
|
+
// Live busy flag read by the mount-bound Escape handler + backdrop click.
|
|
89
|
+
'data-busy': busy ? '1' : '0',
|
|
65
90
|
ref: (el) => {
|
|
66
|
-
if (el)
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
if (el) {
|
|
92
|
+
// A remount in the same tick (render churn) is not a close:
|
|
93
|
+
// cancel the pending removal teardown before re-binding.
|
|
94
|
+
Backdrop._pendingRemoval = false;
|
|
95
|
+
backdropRef(el);
|
|
96
|
+
Backdrop._last = el;
|
|
97
|
+
} else if (Backdrop._last && Backdrop._last._dsModalTeardown) {
|
|
98
|
+
const t = Backdrop._last._dsModalTeardown;
|
|
99
|
+
Backdrop._last = null;
|
|
100
|
+
Backdrop._pendingRemoval = true;
|
|
101
|
+
t(false); // always unhook the document listener now
|
|
102
|
+
queueMicrotask(() => {
|
|
103
|
+
// Still gone next microtask -> genuine close: restore focus.
|
|
104
|
+
if (Backdrop._pendingRemoval) { t(true); Backdrop._pendingRemoval = false; }
|
|
105
|
+
});
|
|
106
|
+
}
|
|
69
107
|
},
|
|
70
|
-
onclick: (e) => {
|
|
108
|
+
onclick: (e) => {
|
|
109
|
+
if (e.target !== e.currentTarget) return;
|
|
110
|
+
if (e.currentTarget.dataset.busy === '1') return; // no mid-flight close
|
|
111
|
+
if (onClose) onClose();
|
|
112
|
+
}
|
|
71
113
|
},
|
|
72
114
|
h('div', {
|
|
73
115
|
class: 'ds-modal' + (kind ? ' ds-modal-' + kind : ''),
|
|
@@ -81,13 +123,14 @@ function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
|
|
|
81
123
|
// FileViewer all funnel through this so the ds-modal markup is authored once.
|
|
82
124
|
// `actions` is an array of vnodes (already using the Btn primitive). Any of the
|
|
83
125
|
// slots may be omitted.
|
|
84
|
-
function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions } = {}) {
|
|
126
|
+
function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions, busy = false } = {}) {
|
|
85
127
|
// Give the head a stable id so the dialog can point aria-labelledby at it,
|
|
86
128
|
// exposing the title as the dialog's accessible name to screen readers.
|
|
87
129
|
const headId = head != null ? ('ds-modal-head-' + (++_modalSeq)) : null;
|
|
88
130
|
return Backdrop({
|
|
89
131
|
onClose,
|
|
90
132
|
kind,
|
|
133
|
+
busy,
|
|
91
134
|
labelledBy: headId,
|
|
92
135
|
children: [
|
|
93
136
|
head != null ? h('div', { id: headId, class: ('ds-modal-head' + (headClass ? ' ' + headClass : '')), ...headAttrs }, ...(Array.isArray(head) ? head : [head])) : null,
|
|
@@ -97,40 +140,56 @@ function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body,
|
|
|
97
140
|
});
|
|
98
141
|
}
|
|
99
142
|
|
|
100
|
-
|
|
143
|
+
// A role=alert error line rendered INSIDE the modal body (so a 409/403 from a
|
|
144
|
+
// mutation is visible at the point of action, inside the focus trap — not a
|
|
145
|
+
// sibling stuck in page flow behind the fixed backdrop).
|
|
146
|
+
function modalError(error) {
|
|
147
|
+
return error ? h('p', { class: 'ds-modal-error field-error', role: 'alert' }, String(error)) : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `error` renders inside .ds-modal-body (role=alert, error tone). `busy`
|
|
151
|
+
// disables both action buttons AND the Escape/backdrop close paths; the confirm
|
|
152
|
+
// label flips to `busyLabel` (default 'working…') so the in-flight state reads.
|
|
153
|
+
export function ConfirmDialog({ title = 'confirm', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel, error, busy = false, busyLabel = 'working…' } = {}) {
|
|
101
154
|
return Modal({
|
|
102
155
|
onClose: onCancel,
|
|
103
156
|
kind: 'small',
|
|
157
|
+
busy,
|
|
104
158
|
head: title,
|
|
105
|
-
body: message || '',
|
|
159
|
+
body: [message || '', modalError(error)].filter(Boolean),
|
|
106
160
|
actions: [
|
|
107
|
-
Btn({ onClick: onCancel, children: cancelLabel }),
|
|
108
|
-
Btn({ primary: true, danger: !!destructive, onClick: onConfirm, children: confirmLabel })
|
|
161
|
+
Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
|
|
162
|
+
Btn({ primary: true, danger: !!destructive, disabled: busy, onClick: onConfirm, children: busy ? busyLabel : confirmLabel })
|
|
109
163
|
]
|
|
110
164
|
});
|
|
111
165
|
}
|
|
112
166
|
|
|
113
|
-
export function PromptDialog({ title = 'name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput } = {}) {
|
|
167
|
+
export function PromptDialog({ title = 'name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput, error, busy = false, busyLabel = 'working…' } = {}) {
|
|
114
168
|
return Modal({
|
|
115
169
|
onClose: onCancel,
|
|
116
170
|
kind: 'small',
|
|
171
|
+
busy,
|
|
117
172
|
head: title,
|
|
118
|
-
body: h('input', {
|
|
173
|
+
body: [h('input', {
|
|
119
174
|
class: 'input ds-modal-input',
|
|
120
175
|
type: 'text',
|
|
121
176
|
value,
|
|
122
177
|
placeholder,
|
|
123
178
|
autofocus: true,
|
|
179
|
+
disabled: busy ? true : null,
|
|
180
|
+
'aria-invalid': error ? 'true' : null,
|
|
124
181
|
oninput: (e) => onInput && onInput(e.target.value),
|
|
125
182
|
onkeydown: (e) => {
|
|
126
|
-
|
|
127
|
-
if (e.key === '
|
|
183
|
+
// IME guard: the Enter that commits a CJK composition must not confirm.
|
|
184
|
+
if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); if (!busy) onConfirm && onConfirm(e.target.value); }
|
|
185
|
+
if (e.key === 'Escape') { e.preventDefault(); if (!busy) onCancel && onCancel(); }
|
|
128
186
|
}
|
|
129
|
-
}),
|
|
187
|
+
}), modalError(error)].filter(Boolean),
|
|
130
188
|
actions: [
|
|
131
|
-
Btn({ onClick: onCancel, children: cancelLabel }),
|
|
189
|
+
Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
|
|
132
190
|
Btn({
|
|
133
191
|
primary: true,
|
|
192
|
+
disabled: busy,
|
|
134
193
|
// Read the live input value, not the closed-over `value` prop:
|
|
135
194
|
// consumers update their state in oninput without re-rendering
|
|
136
195
|
// (to avoid caret jump), so the prop is stale at click time.
|
|
@@ -139,7 +198,7 @@ export function PromptDialog({ title = 'name', value = '', placeholder = '', con
|
|
|
139
198
|
const inp = e.currentTarget.closest('.ds-modal')?.querySelector('.ds-modal-input');
|
|
140
199
|
onConfirm(inp ? inp.value : value);
|
|
141
200
|
},
|
|
142
|
-
children: confirmLabel
|
|
201
|
+
children: busy ? busyLabel : confirmLabel
|
|
143
202
|
})
|
|
144
203
|
]
|
|
145
204
|
});
|