anentrypoint-design 0.0.385 → 0.0.387

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.
Files changed (71) hide show
  1. package/dist/247420.css +33 -0
  2. package/dist/247420.js +53 -51
  3. package/package.json +1 -1
  4. package/src/components/agent-chat/controls.js +143 -0
  5. package/src/components/agent-chat/empty-state.js +63 -0
  6. package/src/components/agent-chat/message-rows.js +141 -0
  7. package/src/components/agent-chat/surface.js +206 -0
  8. package/src/components/agent-chat/thread-behaviour.js +50 -0
  9. package/src/components/agent-chat.js +6 -546
  10. package/src/components/chat/composer-affordances.js +109 -0
  11. package/src/components/chat/composer.js +256 -0
  12. package/src/components/chat/threads.js +105 -0
  13. package/src/components/chat-message-parts/agent-nodes.js +103 -0
  14. package/src/components/chat-message-parts/inline.js +106 -0
  15. package/src/components/chat-message-parts/prose-nodes.js +133 -0
  16. package/src/components/chat-message-parts/renderers.js +89 -0
  17. package/src/components/chat-message-parts.js +12 -408
  18. package/src/components/chat-minimap/minimap.js +167 -0
  19. package/src/components/chat-minimap/paint.js +73 -0
  20. package/src/components/chat-minimap/preview.js +41 -0
  21. package/src/components/chat-minimap.js +7 -263
  22. package/src/components/chat.js +20 -628
  23. package/src/components/community/chrome.js +63 -0
  24. package/src/components/community/navigation.js +167 -0
  25. package/src/components/community/presence.js +106 -0
  26. package/src/components/community/shell.js +17 -0
  27. package/src/components/community/views.js +131 -0
  28. package/src/components/community.js +18 -447
  29. package/src/components/files/chrome.js +140 -0
  30. package/src/components/files/entries.js +156 -0
  31. package/src/components/files/grid-controls.js +63 -0
  32. package/src/components/files/grid.js +177 -0
  33. package/src/components/files/types.js +69 -0
  34. package/src/components/files-modals/dialogs.js +118 -0
  35. package/src/components/files-modals/modal-shell.js +153 -0
  36. package/src/components/files-modals/preview-bodies.js +129 -0
  37. package/src/components/files-modals/preview-containers.js +96 -0
  38. package/src/components/files-modals.js +14 -475
  39. package/src/components/files.js +15 -564
  40. package/src/components/freddie/pages-config.js +3 -94
  41. package/src/components/freddie/pages-models.js +97 -0
  42. package/src/components/freddie.js +2 -1
  43. package/src/components/interaction-primitives/mobile.js +25 -0
  44. package/src/components/interaction-primitives/pointer.js +214 -0
  45. package/src/components/interaction-primitives/reorderable.js +47 -0
  46. package/src/components/interaction-primitives/shortcuts.js +119 -0
  47. package/src/components/interaction-primitives.js +16 -388
  48. package/src/components/sessions/conversation-list.js +230 -0
  49. package/src/components/sessions/dashboard.js +202 -0
  50. package/src/components/sessions/detail-bits.js +49 -0
  51. package/src/components/sessions/format.js +42 -0
  52. package/src/components/sessions/session-card.js +92 -0
  53. package/src/components/sessions.js +16 -579
  54. package/src/components/voice/audio-cue.js +39 -0
  55. package/src/components/voice/capture.js +90 -0
  56. package/src/components/voice/playback.js +75 -0
  57. package/src/components/voice/settings-modal.js +104 -0
  58. package/src/components/voice.js +16 -293
  59. package/src/css/app-shell/base.css +23 -0
  60. package/src/css/app-shell/states-interactions.css +10 -0
  61. package/src/kits/os/freddie/chat-protocol.js +32 -0
  62. package/src/kits/os/freddie/chat-transport.js +178 -0
  63. package/src/kits/os/freddie/pages-chat.js +10 -180
  64. package/src/kits/os/shell-chrome.js +163 -0
  65. package/src/kits/os/shell-geometry.js +59 -0
  66. package/src/kits/os/shell.js +178 -335
  67. package/src/page-html/client-script.js +151 -0
  68. package/src/page-html/head-tags.js +59 -0
  69. package/src/page-html/markdown.js +68 -0
  70. package/src/page-html/page-styles.js +44 -0
  71. package/src/page-html.js +14 -291
@@ -1,26 +1,22 @@
1
1
  // Chat surface — matches upstream signatures (parts, typing, reactions,
2
2
  // receipts, aicat). Pure factories — props in, vnode out.
3
3
  // Includes ChatMessage, ChatComposer, Chat, AICat, AICatPortrait.
4
+ //
5
+ // This module is a barrel: every component lives in a single-responsibility
6
+ // submodule under ./chat/, and the public export surface here is unchanged
7
+ // — no consumer import needs to move.
4
8
 
5
- import * as webjsx from '../../vendor/webjsx/index.js';
6
- import { initializeCachesEagerly, getCacheStats } from '../markdown-cache.js';
7
- import { register } from '../debug.js';
8
- import { Icon } from './shell.js';
9
9
  import { fmtFileSize } from './files.js';
10
- import { EmojiPicker, CommandPalette } from './overlay-primitives.js';
11
- import { t } from '../i18n.js';
12
- import { renderMessagePart as sharedRenderMessagePart, safeUrl as sharedSafeUrl, renderInline as sharedRenderInline, injectCodeCopy as sharedInjectCodeCopy } from './chat-message-parts.js';
13
- import { avatarInitial } from './content.js';
14
- import { extractAtQuery, filterFileEntries, buildAtInsertText } from '../file-mention.js';
15
-
16
- // Matches a trailing `:keyword` at the end of the composer draft (optionally
17
- // preceded by whitespace/start-of-string) so typing `:smile` opens an inline
18
- // filtered EmojiPicker without requiring the toolbar button or Ctrl+;.
19
- const EMOJI_TRIGGER_RE = /(?:^|\s)(:([a-zA-Z0-9_+-]{0,24}))$/;
20
-
21
- const h = webjsx.createElement;
22
- let _stats = { messages: 0, lastKindCounts: {} };
23
- let _cacheInitialized = false;
10
+ import { safeUrl as sharedSafeUrl, renderInline as sharedRenderInline, injectCodeCopy as sharedInjectCodeCopy } from './chat-message-parts.js';
11
+ import { hasSelectionInside, makeThreadAutoScroll } from './chat/thread-scroll.js';
12
+ import { ChatMessage } from './chat/message.js';
13
+ import { ChatComposer } from './chat/composer.js';
14
+ import { flashComposerNote } from './chat/composer-affordances.js';
15
+ import { Chat, AICat, AICatPortrait, ChatSuggestions, AICAT_FACE } from './chat/threads.js';
16
+ // Imported for its side effect: registers the 'chat' snapshot into the single
17
+ // window.__debug registry at module load, exactly as this file did before the
18
+ // split. Nothing here consumes the symbol directly.
19
+ import './chat/stats.js';
24
20
 
25
21
  // ONE byte format across the kit: fmtFileSize (files.js) is canonical; the old
26
22
  // divergent fmtBytes ('0.0 KB' for zero, no B tier) is gone — this alias keeps
@@ -35,52 +31,6 @@ export const fmtBytes = fmtFileSize;
35
31
  export const safeUrl = sharedSafeUrl;
36
32
  export const renderInline = sharedRenderInline;
37
33
 
38
- // Eagerly warm the markdown + Prism caches on first chat-surface mount, once.
39
- function ensureCachesInit() {
40
- if (_cacheInitialized) return;
41
- _cacheInitialized = true;
42
- initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
43
- }
44
-
45
- // True when the user has a non-collapsed text selection anchored inside `el`.
46
- // Used to pause auto-scroll (and by hosts to pause streaming re-renders) so
47
- // select-and-copy from a still-streaming message is not wiped every frame.
48
- export function hasSelectionInside(el) {
49
- const sel = typeof document !== 'undefined' && document.getSelection ? document.getSelection() : null;
50
- return !!(sel && !sel.isCollapsed && sel.anchorNode && el && el.contains(sel.anchorNode));
51
- }
52
-
53
- // Build a ref callback that keeps a scroll container pinned to the bottom when
54
- // new messages arrive AND the user is already at the bottom (sentinel visible).
55
- // `getCount` returns the current message count so the observer compares against
56
- // live state. Shared by Chat, AICat, and AgentChat.
57
- // CONTRACT: auto-scroll pauses while the user holds a non-collapsed selection
58
- // inside the thread (hasSelectionInside) — the same guard hosts apply to their
59
- // streaming re-render pass — and resumes once the selection collapses.
60
- export function makeThreadAutoScroll(getCount) {
61
- return (el) => {
62
- if (!el) return;
63
- let sentinel = el.querySelector('[data-scroll-sentinel]');
64
- if (!sentinel) {
65
- sentinel = document.createElement('div');
66
- sentinel.setAttribute('data-scroll-sentinel', '');
67
- sentinel.style.height = '1px';
68
- el.appendChild(sentinel);
69
- }
70
- const obs = new IntersectionObserver((entries) => {
71
- if (hasSelectionInside(el)) return; // don't fight an active selection
72
- const count = String(getCount());
73
- if (entries[0]?.isIntersecting && el.dataset.msgCount !== count) {
74
- el.scrollTop = el.scrollHeight - el.clientHeight;
75
- el.dataset.msgCount = count;
76
- }
77
- }, { root: el, threshold: 0 });
78
- obs.observe(sentinel);
79
- el.dataset.msgCount = String(getCount());
80
- return () => obs.disconnect();
81
- };
82
- }
83
-
84
34
  // injectCodeCopy / MdNode / CodeNode / ToolCallNode / ThinkingNode /
85
35
  // PART_RENDERERS all now live in chat-message-parts.js — the one dispatch
86
36
  // table this file, agent-chat.js, and any future chat surface render message
@@ -89,567 +39,9 @@ export function makeThreadAutoScroll(getCount) {
89
39
  // extraction, even though nothing in-repo imports it directly today.
90
40
  export const injectCodeCopy = sharedInjectCodeCopy;
91
41
 
92
- // Thin wrapper around the shared renderer that keeps this file's own debug
93
- // counter (_stats.lastKindCounts, surfaced via register('chat', ...) below)
94
- // wired exactly as before. sharedRenderMessagePart already applies the
95
- // 'p' + key VElement key itself.
96
- function renderPart(p, key) {
97
- return sharedRenderMessagePart(p, key, (kind) => {
98
- _stats.lastKindCounts[kind] = (_stats.lastKindCounts[kind] || 0) + 1;
99
- });
100
- }
101
-
102
- export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped, flat, error, onRetry }) {
103
- _stats.messages += 1;
104
- // Support legacy 'who' prop, prefer 'role' with mapping:
105
- // 'user' -> 'you' (right-aligned, accent bubble)
106
- // 'assistant' -> 'them' (left-aligned, paper bubble)
107
- // 'system' -> 'system' (centered, italic muted)
108
- // 'tool' -> 'tool' (centered, collapsible card chrome)
109
- // 'thinking' -> 'thinking' (centered, transient typing dots)
110
- const resolvedWho = role
111
- ? (role === 'user' ? 'you'
112
- : role === 'assistant' ? 'them'
113
- : (role === 'system' || role === 'tool' || role === 'thinking') ? role
114
- : role)
115
- : who;
116
- const isCentered = resolvedWho === 'system' || resolvedWho === 'tool' || resolvedWho === 'thinking';
117
- // Flat layout (Claude-Code-web): full-width, avatar-less turns with a role
118
- // label above the content and a faint assistant background, instead of the
119
- // messenger avatar-disc + colored-bubble layout (kept for the chat demo).
120
- const isFlat = flat && !isCentered;
121
- const cls = 'chat-msg ' + resolvedWho + (aicat && resolvedWho === 'them' ? ' aicat' : '') + (isCentered ? ' centered' : '') + (isFlat ? ' chat-msg-flat' : '');
122
- const fallbackAvatar = avatar != null
123
- ? avatar
124
- : (resolvedWho === 'you' ? 'u' : avatarInitial(name));
125
- const av = h('span', { class: 'chat-avatar' }, fallbackAvatar);
126
- let bodyNodes;
127
- if (typing) bodyNodes = [h('div', { class: 'chat-bubble', key: 'typb' }, h('span', { class: 'chat-typing' }, h('span'), h('span'), h('span')))];
128
- else if (parts && parts.length) bodyNodes = parts.map((p, i) => renderPart(p, i));
129
- else bodyNodes = [h('div', { class: 'chat-bubble', key: 't' }, ...renderInline(text || ''))];
130
- // A blinking caret at the stream head: while an assistant turn is streaming
131
- // AND already shows content (so the inline typing dots have stopped), append
132
- // a thin caret so the live edge reads as "still writing", not "done". Drawn as
133
- // a CSS element, not a glyph character.
134
- // Only append the caret as a sibling if the last part did not already embed
135
- // it inline (streamingCaret flag on the last text/md part in parts array).
136
- const lastPartHasCaret = parts && parts.length && parts[parts.length - 1] && parts[parts.length - 1].streamingCaret;
137
- if (streaming && !typing && !lastPartHasCaret) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
138
- // Out-of-band turn notices, plain copy in a NEUTRAL tone (not error red):
139
- // stopped — the turn was cancelled (locally or remotely); truncated
140
- // output must not read as a finished answer.
141
- // incomplete — the connection dropped mid-turn and events were not
142
- // replayed; the response may be missing content.
143
- // Pass true for the default copy or a string to override it. Retry rides
144
- // the existing per-message actions row.
145
- if (stopped) bodyNodes = [...bodyNodes, h('div', { key: '_stopped', class: 'chat-msg-notice is-stopped', role: 'status' },
146
- typeof stopped === 'string' ? stopped : 'stopped — this turn was cancelled before it finished')];
147
- if (incomplete) bodyNodes = [...bodyNodes, h('div', { key: '_incomplete', class: 'chat-msg-notice is-incomplete', role: 'status' },
148
- typeof incomplete === 'string' ? incomplete : 'connection dropped mid-turn — the response may be incomplete')];
149
- // Inline per-turn error: unlike a global toast, this pins the failure to
150
- // the specific turn that failed (docstudio pattern) with a retry action
151
- // right there instead of forcing the user to hunt for what broke.
152
- if (error) bodyNodes = [...bodyNodes, h('div', { key: '_error', class: 'chat-msg-notice is-error', role: 'alert' },
153
- h('span', {}, typeof error === 'string' ? error : 'this turn failed'),
154
- onRetry ? h('button', {
155
- type: 'button', class: 'chat-msg-retry-btn',
156
- onclick: (e) => { e.preventDefault(); onRetry(e); },
157
- }, 'retry') : null)];
158
- const reactionRow = reactions && reactions.length
159
- ? h('div', { class: 'chat-reactions' },
160
- ...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' : ''}` },
161
- h('span', { class: 'e', 'aria-hidden': 'true' }, r.emoji), h('span', { class: 'n', 'aria-hidden': 'true' }, String(r.count)))))
162
- : null;
163
- const tickNode = resolvedWho === 'you' && receipt
164
- ? h('span', { class: 'tick' + (receipt === 'read' ? ' read' : ''), role: 'img', 'aria-label': receipt === 'read' ? 'message read' : 'message sent' }, Icon(receipt === 'read' ? 'check-check' : 'check', { size: 14 }))
165
- : null;
166
- const metaItems = [];
167
- if (name && resolvedWho === 'them') metaItems.push(h('span', { class: 'who', key: 'w' }, name));
168
- if (time) metaItems.push(h('span', { class: 't', key: 'ti' }, time));
169
- if (tickNode) metaItems.push(tickNode);
170
- const meta = metaItems.length ? h('div', { class: 'chat-meta' }, ...metaItems) : null;
171
- // Per-message actions (copy / retry / edit) — a hover-revealed control row
172
- // below the bubble, the way Claude-Desktop surfaces message-level actions.
173
- // Each action is { label, icon, onClick, title }. Kept icon-only with an
174
- // accessible name; no decorative glyphs (the Icon set is line-SVG).
175
- const actionRow = (actions && actions.length)
176
- ? h('div', { class: 'chat-msg-actions', role: 'group', 'aria-label': 'message actions' },
177
- ...actions.filter(Boolean).map((a, i) => h('button', {
178
- key: 'ma' + i, type: 'button', class: 'chat-msg-action',
179
- title: a.title || a.label, 'aria-label': a.label || a.title,
180
- onclick: (e) => {
181
- e.preventDefault();
182
- a.onClick && a.onClick(e);
183
- // Copy is the highest-traffic per-message action and, unlike
184
- // code-block/tool-result copy elsewhere in this file, had no
185
- // self-contained visual feedback — a sighted/mouse user saw
186
- // nothing happen. Flip the button's own label/icon the same
187
- // way those sibling copy controls already do.
188
- if (a.label === 'copy') {
189
- const btn = e.currentTarget;
190
- const labelEl = btn.querySelector('.chat-msg-action-label');
191
- clearTimeout(btn._dsCopyTimer);
192
- btn.classList.add('is-copied');
193
- if (labelEl) labelEl.textContent = 'copied';
194
- btn._dsCopyTimer = setTimeout(() => {
195
- btn.classList.remove('is-copied');
196
- if (labelEl) labelEl.textContent = 'copy';
197
- }, 1600);
198
- }
199
- },
200
- }, a.icon ? Icon(a.icon, { size: 14 }) : null,
201
- a.label ? h('span', { class: 'chat-msg-action-label' }, a.label) : null)))
202
- : null;
203
- // Flat layout leads the turn with a small role label (You / agent name)
204
- // above the content, the way claude.ai/code titles each turn.
205
- const roleLabel = isFlat
206
- ? h('div', { class: 'chat-role', key: '_role' }, resolvedWho === 'you' ? t('chat.roleYou', 'You') : (name || t('chat.roleAssistant', 'Assistant')))
207
- : null;
208
- const stack = h('div', { class: 'chat-stack' }, roleLabel, ...bodyNodes, reactionRow, actionRow, meta);
209
- // Centered roles (system/tool/thinking) skip the avatar column entirely so
210
- // the bubble owns the full row — the chrome reads as out-of-band signal,
211
- // not a participant turn.
212
- if (isCentered) return h('div', { key, class: cls }, stack);
213
- // Flat turns drop the avatar column entirely (full-width content).
214
- if (isFlat) return h('div', { key, class: cls }, stack);
215
- return h('div', { key, class: cls }, resolvedWho === 'you' ? stack : av, resolvedWho === 'you' ? av : stack);
216
- }
217
-
218
- // Transient, non-blocking composer note (aria-live polite): e.g. a pasted image
219
- // when no onPasteFiles handler is wired. Pure-DOM, auto-clears.
220
- export function flashComposerNote(composerEl, text) {
221
- if (!composerEl) return;
222
- let note = composerEl.querySelector('.chat-composer-note');
223
- if (!note) {
224
- note = document.createElement('div');
225
- note.className = 'chat-composer-note';
226
- note.setAttribute('role', 'status');
227
- note.setAttribute('aria-live', 'polite');
228
- composerEl.appendChild(note);
229
- }
230
- // A single shared node means a second call before the first note's timeout
231
- // fires used to silently overwrite it (lost message, not just an early
232
- // dismiss). Queue instead: show immediately if idle, otherwise append and
233
- // let the display loop drain the queue in order.
234
- note._dsNoteQueue = note._dsNoteQueue || [];
235
- note._dsNoteQueue.push(text);
236
- if (note._dsNoteTimer) return; // already draining the queue
237
- const showNext = () => {
238
- const next = note._dsNoteQueue.shift();
239
- if (next === undefined) { note.remove(); note._dsNoteTimer = null; return; }
240
- note.textContent = next;
241
- note._dsNoteTimer = setTimeout(showNext, 2600);
242
- };
243
- showNext();
244
- }
245
-
246
- // ChatSuggestions — centered blank-thread heading + subtext + a wrapped row
247
- // of prompt chips that fill the composer textarea on click and auto-dismiss
248
- // on first send. Ported from docstudio's empty-state composer-priming CTA
249
- // (distinct from a generic list EmptyState: this exists to seed a first
250
- // message, not to describe an empty list). `onPick(prompt, item)` is the
251
- // caller's single hook — the component does not touch the composer DOM
252
- // itself, so the host decides how "fill the composer" actually happens.
253
- // A rapid double-click on the same chip (or a click racing the first send)
254
- // is guarded by a one-shot `_picked` flag: only the first click of any kind
255
- // dispatches onPick, so the composer is never filled twice and the chips
256
- // never reappear having already been "used".
257
- export function ChatSuggestions({ heading = 'What can I help with?', subtext = '', suggestions = [] } = {}) {
258
- let picked = false;
259
- return h('div', { class: 'chat-suggestions', role: 'group', 'aria-label': heading },
260
- h('h2', { class: 'chat-suggestions-heading' }, heading),
261
- subtext ? h('p', { class: 'chat-suggestions-subtext' }, subtext) : null,
262
- h('div', { class: 'chat-suggestions-list' },
263
- ...suggestions.map((s, i) => h('button', {
264
- key: s.id || i, type: 'button', class: 'chat-suggestions-chip',
265
- onclick: () => { if (picked) return; picked = true; s.onPick ? s.onPick(s) : null; }
266
- }, s.label))
267
- )
268
- );
269
- }
270
-
271
- // Cached once per session: coarse-pointer (touch/no-hover) devices get a
272
- // newline on Enter instead of send (mirrors the one-time-cache pattern
273
- // editor-primitives.js uses for its own pointer/matchMedia checks).
274
- let _coarsePointerCache = null;
275
- function isCoarsePointer() {
276
- if (_coarsePointerCache != null) return _coarsePointerCache;
277
- _coarsePointerCache = !!(typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
278
- return _coarsePointerCache;
279
- }
280
-
281
- // m:ss elapsed-time formatter for the streaming counter.
282
- function fmtElapsedMs(ms) {
283
- const total = Math.max(0, Math.floor(ms / 1000));
284
- const m = Math.floor(total / 60);
285
- const s = total % 60;
286
- return m + ':' + String(s).padStart(2, '0');
287
- }
288
-
289
- // A small ticking `m:ss` counter, keyed off the streamingSince timestamp so a
290
- // parent re-render does not reset the interval — the ref only (re)starts the
291
- // interval when the timestamp actually changes, and clears it when streaming
292
- // goes false or the node unmounts.
293
- function ChatComposerElapsed({ streamingSince }) {
294
- return h('span', {
295
- class: 'chat-composer-elapsed', role: 'status', 'aria-live': 'off',
296
- ref: (el) => {
297
- if (!el) return;
298
- if (el._dsElapsedTimer && el._dsElapsedSince === streamingSince) return; // already ticking for this timestamp
299
- if (el._dsElapsedTimer) clearInterval(el._dsElapsedTimer);
300
- el._dsElapsedSince = streamingSince;
301
- const tick = () => { el.textContent = fmtElapsedMs(Date.now() - streamingSince); };
302
- tick();
303
- el._dsElapsedTimer = setInterval(tick, 1000);
304
- },
305
- });
306
- }
307
-
308
- export function ChatComposer({ value, onInput, onSend, onEmoji, onCancel, busy, placeholder = 'message…', disabled, disabledReason, label, context, onPasteFiles, onDropFiles, streamingSince, detectAttachment, mentionFiles }) {
309
- // Keep a handle to the live textarea so send() reads the actual DOM value
310
- // (not the possibly-lagging `value` prop) and so we can sync the DOM value
311
- // only when it genuinely differs — re-applying `value` on every parent
312
- // re-render otherwise resets the caret and drops fast keystrokes.
313
- let taEl = null;
314
- const send = () => {
315
- const v = ((taEl && taEl.value) || value || '').trim();
316
- if (!v || disabled) return;
317
- if (onSend) onSend(v);
318
- };
319
- const triggerMatch = EMOJI_TRIGGER_RE.exec(value || '');
320
- // `@`-file-mention autocomplete: host supplies mentionFiles (a flat list of
321
- // {path,isDir} entries, or a plain string[] of paths — filterFileEntries
322
- // normalizes both), we own detection/filtering/insertion. Caret-position-
323
- // aware (extractAtQuery needs the text BEFORE the caret, not the whole
324
- // draft) so an "@" earlier in an already-sent-past part of the text doesn't
325
- // re-trigger after the cursor has moved on.
326
- const caretPos = taEl ? taEl.selectionStart : (value || '').length;
327
- const atQuery = mentionFiles ? extractAtQuery((value || '').slice(0, caretPos)) : null;
328
- // taEl is only assigned by taRef during DOM diffing, which happens AFTER
329
- // this render function returns — so on first paint of a trigger it is
330
- // still null here. Fall back to the live DOM textarea from the previous
331
- // paint (same composer, content patched in place) so the picker anchors
332
- // near the input instead of the viewport origin.
333
- const anchorEl = taEl || (typeof document !== 'undefined' ? document.querySelector('.chat-composer textarea') : null);
334
- const insertEmoji = (ch) => {
335
- const v = (taEl && taEl.value) || value || '';
336
- const m = EMOJI_TRIGGER_RE.exec(v);
337
- const next = m ? (v.slice(0, m.index) + (m[0].startsWith(':') ? '' : v[m.index]) + ch + ' ') : (v + ch);
338
- if (onInput) onInput(next);
339
- if (taEl) {
340
- // Programmatic .value= (like the draft-restore path) discards the
341
- // native undo stack the same way — surface that once, matching the
342
- // existing one-time draft-restore note pattern.
343
- try {
344
- if (!sessionStorage.getItem('ds.composer.undoNoteShown')) {
345
- sessionStorage.setItem('ds.composer.undoNoteShown', '1');
346
- flashComposerNote(taEl.closest('.chat-composer'), 'inserted — undo history does not include this insert');
347
- }
348
- } catch { /* swallow: sessionStorage unavailable (private mode etc) — skip the note */ }
349
- taEl.value = next;
350
- taEl.focus();
351
- taEl.selectionStart = taEl.selectionEnd = next.length;
352
- }
353
- };
354
- let autoGrowScheduled = false;
355
- // detectAttachment(text) -> {type,label,id}|null runs on every input change;
356
- // the badge above the textarea shows/clears based on its result, and clears
357
- // outright when the textarea empties (mirrors the dismissible-badge pattern
358
- // via a DOM-owned dismissed flag so a re-render with the same detection
359
- // doesn't resurrect a badge the user just dismissed).
360
- const updateDetectedBadge = (composerEl, text) => {
361
- if (!composerEl) return;
362
- let badge = composerEl.querySelector('.chat-composer-detected-badge');
363
- const detected = (text && detectAttachment) ? detectAttachment(text) : null;
364
- if (!detected) {
365
- if (badge) badge.remove();
366
- composerEl._dsDetectedId = null;
367
- return;
368
- }
369
- if (composerEl._dsDismissedId === detected.id) return; // user dismissed this exact detection
370
- if (composerEl._dsDetectedId === detected.id && badge) return; // unchanged
371
- composerEl._dsDetectedId = detected.id;
372
- if (!badge) {
373
- badge = document.createElement('div');
374
- badge.className = 'chat-composer-detected-badge';
375
- badge.setAttribute('role', 'status');
376
- composerEl.insertBefore(badge, composerEl.firstChild);
377
- }
378
- badge.textContent = '';
379
- const label = document.createElement('span');
380
- label.className = 'chat-composer-detected-label';
381
- label.textContent = detected.label;
382
- badge.appendChild(label);
383
- const dismiss = document.createElement('button');
384
- dismiss.type = 'button';
385
- dismiss.className = 'chat-composer-detected-dismiss';
386
- dismiss.setAttribute('aria-label', 'dismiss ' + detected.label);
387
- dismiss.textContent = 'x';
388
- dismiss.onclick = (e) => { e.preventDefault(); composerEl._dsDismissedId = detected.id; badge.remove(); };
389
- badge.appendChild(dismiss);
390
- };
391
- const autoGrow = (e) => {
392
- const ta = e.target;
393
- if (onInput) onInput(ta.value);
394
- if (detectAttachment) updateDetectedBadge(ta.closest('.chat-composer'), ta.value);
395
- // Debounce scrollHeight read with rAF to prevent sync reflow thrashing
396
- if (!autoGrowScheduled) {
397
- autoGrowScheduled = true;
398
- requestAnimationFrame(() => {
399
- ta.style.height = 'auto';
400
- // Respect the CSS max-height cap (120px in short-landscape via
401
- // app-shell.css) instead of a hardcoded 200px.
402
- const cap = parseFloat(getComputedStyle(ta).maxHeight) || 200;
403
- ta.style.height = Math.min(ta.scrollHeight, cap) + 'px';
404
- autoGrowScheduled = false;
405
- });
406
- }
407
- };
408
- const taRef = (el) => {
409
- if (!el) return;
410
- taEl = el;
411
- // Sync the controlled value into the DOM only when it actually differs,
412
- // so a re-render mid-type does not clobber the caret or pending input.
413
- const next = value || '';
414
- if (el.value !== next) el.value = next;
415
- el.style.height = 'auto';
416
- const cap = parseFloat(getComputedStyle(el).maxHeight) || 200;
417
- el.style.height = Math.min(el.scrollHeight, cap) + 'px';
418
- if (detectAttachment) updateDetectedBadge(el.closest('.chat-composer'), next);
419
- };
420
- // Optional context line shown above the textarea: agent / model / cwd at the
421
- // point of typing (the way Claude-Desktop surfaces the active target inline).
422
- // `context` is { bits:[...], onClick? }. Bits may be plain strings (inert
423
- // text) or { text, onClick, title } objects — a bit with its own onClick
424
- // renders as an inline button (.chat-composer-context-bit) so e.g. the cwd
425
- // segment routes to the cwd editor WITHOUT making the whole line one giant
426
- // click target. Legacy whole-line context.onClick is honored only when no
427
- // bit carries its own handler. All children are keyed VElements.
428
- // Normalize FIRST (a bit may carry `text` or `label`), then drop empties -
429
- // separators must only ever sit between bits that actually render. An
430
- // object bit whose text resolved empty used to leave a dangling trailing
431
- // middot AND an invisible zero-width button.
432
- const ctxBits = ((context && context.bits) ? context.bits : [])
433
- .map((b) => {
434
- if (b == null) return null;
435
- if (typeof b === 'object') {
436
- const text = b.text || b.label || '';
437
- return text ? { text, onClick: b.onClick, title: b.title } : null;
438
- }
439
- const text = String(b);
440
- return text ? { text } : null;
441
- })
442
- .filter(Boolean);
443
- const hasBitClicks = ctxBits.some((b) => b.onClick);
444
- let contextLine = null;
445
- if (ctxBits.length && hasBitClicks) {
446
- const kids = [];
447
- ctxBits.forEach((b, i) => {
448
- if (i) kids.push(h('span', { key: 'csep' + i, class: 'chat-composer-context-sep', 'aria-hidden': 'true' }, ' · '));
449
- if (b.onClick) kids.push(h('button', {
450
- key: 'cbit' + i, type: 'button', class: 'chat-composer-context-bit',
451
- title: b.title || null, 'aria-label': b.title || b.text,
452
- onclick: (e) => { e.preventDefault(); b.onClick(e); },
453
- }, b.text));
454
- else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, b.text));
455
- });
456
- contextLine = h('div', { class: 'chat-composer-context', role: 'group', 'aria-label': 'active session: ' + ctxBits.map((b) => b.text).join(', ') }, ...kids);
457
- } else if (ctxBits.length) {
458
- const joined = ctxBits.map((b) => b.text).join(' · ');
459
- contextLine = h(context.onClick ? 'button' : 'div', {
460
- class: 'chat-composer-context', type: context.onClick ? 'button' : null,
461
- 'aria-label': context.onClick ? ('change target: ' + joined) : null,
462
- onclick: context.onClick ? (e) => { e.preventDefault(); context.onClick(e); } : null,
463
- }, joined);
464
- }
465
- const hasDraft = !!(value && value.trim());
466
- // Clamp the picker anchor to the visual viewport: with the on-screen
467
- // keyboard open the composer sits near the visual-viewport bottom, and on
468
- // narrow screens the picker width can overflow the right edge.
469
- const anchorRect = (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect() : null;
470
- const vvWidth = (typeof window !== 'undefined')
471
- ? ((window.visualViewport && window.visualViewport.width) || window.innerWidth)
472
- : 0;
473
- const triggerPicker = triggerMatch ? EmojiPicker({
474
- open: true,
475
- anchorX: anchorRect ? Math.max(0, Math.min(anchorRect.left, vvWidth - 280)) : 0,
476
- anchorY: anchorRect ? Math.max(8, anchorRect.top - 8) : 0,
477
- query: triggerMatch[2] || '',
478
- onSelect: (ch) => insertEmoji(ch),
479
- onClose: () => { if (taEl) { const v = taEl.value.replace(EMOJI_TRIGGER_RE, (full, tail) => full.slice(0, full.length - tail.length)); if (onInput) onInput(v); taEl.value = v; taEl.focus(); } },
480
- }) : null;
481
- // insertMention: replace the in-progress @token (atQuery.start..caretPos)
482
- // with the built mention text, mirroring insertEmoji's DOM-authoritative
483
- // read/write-back so the native undo stack isn't clobbered any more than
484
- // the existing emoji path already accepts.
485
- const insertMention = (entry) => {
486
- const v = (taEl && taEl.value) || value || '';
487
- if (!atQuery) return;
488
- // buildAtInsertText returns {text, cursorOffset} — cursorOffset is
489
- // relative to the start of the inserted text, matching the emoji
490
- // path's absolute-caret style once added to atQuery.start.
491
- const { text: insertText, cursorOffset } = buildAtInsertText(entry.path, entry.isDir);
492
- const next = v.slice(0, atQuery.start) + insertText + v.slice(caretPos);
493
- if (onInput) onInput(next);
494
- if (taEl) {
495
- taEl.value = next;
496
- taEl.focus();
497
- const pos = atQuery.start + cursorOffset;
498
- taEl.selectionStart = taEl.selectionEnd = pos;
499
- }
500
- };
501
- const mentionEntries = atQuery ? filterFileEntries(mentionFiles, atQuery.query) : [];
502
- const mentionPicker = atQuery ? CommandPalette({
503
- open: true,
504
- items: mentionEntries.map((e) => ({ label: e.path, group: null, icon: e.isDir ? '📁' : null, _entry: e })),
505
- onSelect: (it) => insertMention(it._entry),
506
- onClose: () => { if (taEl) { const v = taEl.value.slice(0, atQuery.start) + taEl.value.slice(caretPos); if (onInput) onInput(v); taEl.value = v; taEl.focus(); taEl.selectionStart = taEl.selectionEnd = atQuery.start; } },
507
- }) : null;
508
- return h('div', {
509
- class: 'chat-composer' + (hasDraft ? ' has-draft' : '') + (disabled ? ' is-disabled' : ''),
510
- // A drop on the composer must NEVER navigate the browser away from the
511
- // live session: preventDefault on both dragover and drop, route files to
512
- // the optional onDropFiles handler, ring via .dragover.
513
- ondragover: (e) => { e.preventDefault(); e.currentTarget.classList.add('dragover'); },
514
- ondragleave: (e) => { e.currentTarget.classList.remove('dragover'); },
515
- ondrop: (e) => {
516
- e.preventDefault();
517
- e.currentTarget.classList.remove('dragover');
518
- const files = e.dataTransfer && e.dataTransfer.files;
519
- if (files && files.length) {
520
- if (onDropFiles) onDropFiles(files);
521
- else flashComposerNote(e.currentTarget, 'dropped files are not supported here yet');
522
- }
523
- },
524
- },
525
- contextLine,
526
- triggerPicker,
527
- mentionPicker,
528
- h('textarea', { ref: taRef, placeholder, rows: 1,
529
- 'aria-label': label || (disabled && disabledReason ? 'message input — ' + disabledReason : 'message input'),
530
- disabled: !!disabled, 'aria-disabled': disabled ? 'true' : null,
531
- oninput: autoGrow,
532
- onpaste: (e) => {
533
- const cd = e.clipboardData;
534
- // If the clipboard contains files, always route them — even when
535
- // text is also present (some apps attach a filename as text).
536
- if (cd && cd.files && cd.files.length) {
537
- e.preventDefault();
538
- if (onPasteFiles) onPasteFiles(cd.files);
539
- else flashComposerNote(e.currentTarget.closest('.chat-composer'), 'images are not supported yet');
540
- return;
541
- }
542
- // Large plain-text pastes (e.g. a whole file/log dropped into the
543
- // composer) get no feedback otherwise — the textarea just grows to
544
- // its max-height cap with no signal of how much landed. Note the
545
- // character count; this does not change any truncation behavior.
546
- const text = cd && cd.getData ? cd.getData('text/plain') : '';
547
- if (text && text.length > 2000) {
548
- flashComposerNote(e.currentTarget.closest('.chat-composer'), 'pasted ' + text.length + ' characters');
549
- }
550
- },
551
- onkeydown: (e) => {
552
- // Escape blurs the textarea when idle; stops generation when busy.
553
- if (e.key === 'Escape') {
554
- if (!busy) { e.currentTarget.blur(); return; }
555
- if (onCancel) { e.preventDefault(); onCancel(e); return; }
556
- }
557
- // IME guard: the Enter that commits a CJK composition must never
558
- // send (isComposing; keyCode 229 covers older engines).
559
- // Coarse-pointer (touch) devices get a newline on Enter instead of
560
- // send — there is no keyboard shortcut discoverability benefit on
561
- // touch, and Enter-to-send is a frequent accidental-send source on
562
- // phones/tablets where "Tap Send to send" is the predictable model.
563
- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229 && !isCoarsePointer()) { e.preventDefault(); send(); }
564
- if (e.key === ';' && e.ctrlKey) { e.preventDefault(); onEmoji && onEmoji(e); }
565
- } }),
566
- // Enter-to-send affordance (Claude-Desktop style): a muted hint visible
567
- // at rest so it's discoverable without focusing the composer first;
568
- // hidden under 420px (CSS) to save rows. Middot is kept product typography.
569
- h('div', { class: 'chat-composer-hint' }, isCoarsePointer() ? 'Tap Send to send' : 'Enter to send · Shift+Enter for a new line'),
570
- (busy && streamingSince) ? ChatComposerElapsed({ streamingSince }) : null,
571
- h('div', { class: 'chat-composer-toolbar' },
572
- 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,
573
- busy && onCancel
574
- ? h('button', { type: 'button', class: 'send cancel', onclick: (e) => { e.preventDefault(); onCancel(e); }, 'aria-label': 'stop generating', title: 'stop generating (Esc)' }, Icon('square'))
575
- : h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send,
576
- 'aria-label': disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message',
577
- title: disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message (Enter)' }, Icon('arrow-up'))
578
- )
579
- );
580
- }
581
-
582
- export function Chat({ title = 'chat', sub, messages = [], composer, header, suggestions, onSuggestionClick } = {}) {
583
- // Warm markdown/Prism caches once so library loading parallelizes.
584
- ensureCachesInit();
585
- const threadRef = makeThreadAutoScroll(() => messages.length);
586
- const msgCount = messages.length;
587
- return h('div', { class: 'chat' },
588
- header || h('div', { class: 'chat-head', role: 'banner' },
589
- h('h2', { class: 'ds-chat-title' }, title),
590
- sub ? h('span', { class: 'sub', 'aria-label': `subtitle: ${sub}` }, ' · ' + sub) : null,
591
- h('span', { class: 'spread' }),
592
- msgCount > 0
593
- ? h('span', { class: 'sub', 'aria-live': 'polite' }, msgCount + (msgCount === 1 ? ' message' : ' messages'))
594
- : null
595
- ),
596
- h('div', { class: 'chat-thread', ref: threadRef, role: 'log', 'aria-label': 'chat messages', 'aria-live': 'polite', 'aria-relevant': 'additions' },
597
- messages.length === 0
598
- ? h('div', { key: '_empty', class: 'chat-empty', role: 'status' },
599
- h('p', { class: 'chat-empty-title' }, t('chat.startConversation', 'start a conversation')),
600
- h('p', { class: 'chat-empty-sub' }, sub || t('chat.emptySub', 'Send a message to start the conversation')),
601
- (suggestions && suggestions.length)
602
- ? h('div', { class: 'chat-empty-suggestions' },
603
- ...suggestions.map((s, i) => h('button', { key: 'sug' + i, type: 'button', class: 'chat-empty-suggestion',
604
- onclick: () => { if (onSuggestionClick) onSuggestionClick(typeof s === 'string' ? s : (s.prompt || s.text || '')); } },
605
- typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
606
- : null)
607
- : null,
608
- ...messages.map((m, i) => ChatMessage({ ...m, key: m.key != null ? m.key : i }))
609
- ),
610
- composer || null
611
- );
612
- }
613
-
614
- export const AICAT_FACE = ` /\\_/\\\n( o.o )\n > ^ <`;
615
-
616
- export function AICatPortrait({ name = 'aicat', status = 'idle', face } = {}) {
617
- return h('div', { class: 'aicat-portrait' },
618
- h('pre', { class: 'aicat-face', 'aria-label': `${name} portrait` }, face || AICAT_FACE),
619
- h('div', { class: 'aicat-meta' },
620
- h('span', { class: 'name' }, name),
621
- h('span', { class: 'status', 'aria-label': `status: ${status}` }, h('span', { class: 'dot ds-dot ds-dot-on', 'aria-hidden': 'true' }), ' ', status)
622
- )
623
- );
624
- }
625
-
626
- export function AICat({ name = 'aicat', messages = [], thinking, composer, status = 'online · purring' } = {}) {
627
- ensureCachesInit();
628
- const annotated = messages.map((m) =>
629
- m.who === 'them' ? { ...m, aicat: true, avatar: m.avatar || '=^.^=' } : m);
630
- const all = thinking
631
- ? [...annotated, { who: 'them', aicat: true, avatar: '=^.^=', typing: true, key: '_thinking' }]
632
- : annotated;
633
- const threadRef = makeThreadAutoScroll(() => all.length);
634
- return h('div', { class: 'chat' },
635
- h('div', { class: 'chat-head', role: 'banner' },
636
- h('span', { class: 'dot', 'aria-hidden': 'true' }),
637
- h('h2', { class: 'ds-chat-title' }, name),
638
- h('span', { class: 'sub', 'aria-label': `status: ${status}` }, ' · ' + status),
639
- h('span', { class: 'spread' }),
640
- messages.length > 0
641
- ? h('span', { class: 'sub', 'aria-live': 'polite' }, messages.length + (messages.length === 1 ? ' turn' : ' turns'))
642
- : null
643
- ),
644
- h('div', { class: 'chat-thread', ref: threadRef, role: 'log', 'aria-label': 'conversation turns', 'aria-live': 'polite', 'aria-relevant': 'additions' },
645
- ...all.map((m, i) => ChatMessage({ ...m, key: m.key != null ? m.key : i }))
646
- ),
647
- composer || null
648
- );
649
- }
650
-
651
- register('chat', () => ({
652
- messages: _stats.messages,
653
- lastKindCounts: { ..._stats.lastKindCounts },
654
- cacheStats: getCacheStats(),
655
- }));
42
+ export {
43
+ hasSelectionInside, makeThreadAutoScroll,
44
+ ChatMessage,
45
+ ChatComposer, flashComposerNote,
46
+ Chat, AICat, AICatPortrait, ChatSuggestions, AICAT_FACE,
47
+ };