anentrypoint-design 0.0.201 → 0.0.203

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.201",
3
+ "version": "0.0.203",
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
- function CwdBar({ cwd, editing, draft, onEdit, onSave, onCancel, onClear, onDraft }) {
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, i) => {
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' },
@@ -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
- export function fmtBytes(n) {
15
- if (n == null) return '';
16
- if (n < 1024) return n + ' B';
17
- if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
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
- if (el.dataset.mdSrc === p.text) return;
139
- el.dataset.mdSrc = p.text || '';
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 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 || '')))
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
- export function ChatComposer({ value, onInput, onSend, onAttach, onEmoji, onMenu, onCancel, busy, placeholder = 'message…', disabled, context }) {
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:[...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', {
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: ' + context.bits.join(' · ')) : null,
447
+ 'aria-label': context.onClick ? ('change target: ' + joined) : null,
379
448
  onclick: context.onClick ? (e) => { e.preventDefault(); context.onClick(e); } : null,
380
- }, context.bits.filter(Boolean).join(' · '))
381
- : null;
382
- return h('div', { class: 'chat-composer' },
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
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
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
- onkeydown: onSubmit ? (e) => { if (e.key === 'Enter') onSubmit(e.target.value, e); } : null
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
- if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: (usage.durationMs / 1000).toFixed(1) + 's' }));
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
- el.addEventListener('keydown', handleKeydown);
56
- el._dsModalTeardown = () => el.removeEventListener('keydown', handleKeydown);
57
- // Auto-focus on open — always, not only when focus sits on <body>.
58
- // Prefer an element explicitly marked [autofocus].
59
- const preferred = modal.querySelector('[autofocus]') || firstFocusable;
60
- if (preferred) preferred.focus();
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) backdropRef(el);
67
- else if (Backdrop._last && Backdrop._last._dsModalTeardown) { Backdrop._last._dsModalTeardown(); Backdrop._last = null; }
68
- if (el) Backdrop._last = el;
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) => { if (e.target === e.currentTarget && onClose) onClose(); }
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
- export function ConfirmDialog({ title = 'confirm', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel } = {}) {
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
- if (e.key === 'Enter') { e.preventDefault(); onConfirm && onConfirm(e.target.value); }
127
- if (e.key === 'Escape') { e.preventDefault(); onCancel && onCancel(); }
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
  });