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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.200",
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
- 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 }) {
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, 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
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
- if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend', onClick: () => onEditMessage(m) });
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' },
@@ -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,
@@ -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
- export function Row({ code, rank, title, sub, meta, active, state = 'default', onClick, key, style, href, kind, cols, leading, trailing, target, selected, rail, expanded }) {
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' }, title, sub ? h('span', { class: 'sub' }, sub) : null),
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
- return h('section', { class: 'ds-section' + (compact ? ' ds-section-compact' : '') },
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
- 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
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;