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
@@ -0,0 +1,109 @@
1
+ // Composer side-affordances that live in the DOM rather than the vnode tree:
2
+ // the queued transient note strip, the detected-attachment badge, the
3
+ // coarse-pointer probe that decides Enter-to-send vs Enter-for-newline, and
4
+ // the ticking m:ss streaming counter.
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ const h = webjsx.createElement;
8
+
9
+ // Transient, non-blocking composer note (aria-live polite): e.g. a pasted image
10
+ // when no onPasteFiles handler is wired. Pure-DOM, auto-clears.
11
+ export function flashComposerNote(composerEl, text) {
12
+ if (!composerEl) return;
13
+ let note = composerEl.querySelector('.chat-composer-note');
14
+ if (!note) {
15
+ note = document.createElement('div');
16
+ note.className = 'chat-composer-note';
17
+ note.setAttribute('role', 'status');
18
+ note.setAttribute('aria-live', 'polite');
19
+ composerEl.appendChild(note);
20
+ }
21
+ // A single shared node means a second call before the first note's timeout
22
+ // fires used to silently overwrite it (lost message, not just an early
23
+ // dismiss). Queue instead: show immediately if idle, otherwise append and
24
+ // let the display loop drain the queue in order.
25
+ note._dsNoteQueue = note._dsNoteQueue || [];
26
+ note._dsNoteQueue.push(text);
27
+ if (note._dsNoteTimer) return; // already draining the queue
28
+ const showNext = () => {
29
+ const next = note._dsNoteQueue.shift();
30
+ if (next === undefined) { note.remove(); note._dsNoteTimer = null; return; }
31
+ note.textContent = next;
32
+ note._dsNoteTimer = setTimeout(showNext, 2600);
33
+ };
34
+ showNext();
35
+ }
36
+
37
+ // Cached once per session: coarse-pointer (touch/no-hover) devices get a
38
+ // newline on Enter instead of send (mirrors the one-time-cache pattern
39
+ // editor-primitives.js uses for its own pointer/matchMedia checks).
40
+ let _coarsePointerCache = null;
41
+ export function isCoarsePointer() {
42
+ if (_coarsePointerCache != null) return _coarsePointerCache;
43
+ _coarsePointerCache = !!(typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
44
+ return _coarsePointerCache;
45
+ }
46
+
47
+ // m:ss elapsed-time formatter for the streaming counter.
48
+ function fmtElapsedMs(ms) {
49
+ const total = Math.max(0, Math.floor(ms / 1000));
50
+ const m = Math.floor(total / 60);
51
+ const s = total % 60;
52
+ return m + ':' + String(s).padStart(2, '0');
53
+ }
54
+
55
+ // A small ticking `m:ss` counter, keyed off the streamingSince timestamp so a
56
+ // parent re-render does not reset the interval — the ref only (re)starts the
57
+ // interval when the timestamp actually changes, and clears it when streaming
58
+ // goes false or the node unmounts.
59
+ export function ChatComposerElapsed({ streamingSince }) {
60
+ return h('span', {
61
+ class: 'chat-composer-elapsed', role: 'status', 'aria-live': 'off',
62
+ ref: (el) => {
63
+ if (!el) return;
64
+ if (el._dsElapsedTimer && el._dsElapsedSince === streamingSince) return; // already ticking for this timestamp
65
+ if (el._dsElapsedTimer) clearInterval(el._dsElapsedTimer);
66
+ el._dsElapsedSince = streamingSince;
67
+ const tick = () => { el.textContent = fmtElapsedMs(Date.now() - streamingSince); };
68
+ tick();
69
+ el._dsElapsedTimer = setInterval(tick, 1000);
70
+ },
71
+ });
72
+ }
73
+
74
+ // detectAttachment(text) -> {type,label,id} runs on every input change; the
75
+ // badge above the textarea shows/clears based on its result, and clears
76
+ // outright when the textarea empties (mirrors the dismissible-badge pattern
77
+ // via a DOM-owned dismissed flag so a re-render with the same detection
78
+ // doesn't resurrect a badge the user just dismissed).
79
+ export function updateDetectedBadge(composerEl, text, detectAttachment) {
80
+ if (!composerEl) return;
81
+ let badge = composerEl.querySelector('.chat-composer-detected-badge');
82
+ const detected = (text && detectAttachment) ? detectAttachment(text) : null;
83
+ if (!detected) {
84
+ if (badge) badge.remove();
85
+ composerEl._dsDetectedId = null;
86
+ return;
87
+ }
88
+ if (composerEl._dsDismissedId === detected.id) return; // user dismissed this exact detection
89
+ if (composerEl._dsDetectedId === detected.id && badge) return; // unchanged
90
+ composerEl._dsDetectedId = detected.id;
91
+ if (!badge) {
92
+ badge = document.createElement('div');
93
+ badge.className = 'chat-composer-detected-badge';
94
+ badge.setAttribute('role', 'status');
95
+ composerEl.insertBefore(badge, composerEl.firstChild);
96
+ }
97
+ badge.textContent = '';
98
+ const label = document.createElement('span');
99
+ label.className = 'chat-composer-detected-label';
100
+ label.textContent = detected.label;
101
+ badge.appendChild(label);
102
+ const dismiss = document.createElement('button');
103
+ dismiss.type = 'button';
104
+ dismiss.className = 'chat-composer-detected-dismiss';
105
+ dismiss.setAttribute('aria-label', 'dismiss ' + detected.label);
106
+ dismiss.textContent = 'x';
107
+ dismiss.onclick = (e) => { e.preventDefault(); composerEl._dsDismissedId = detected.id; badge.remove(); };
108
+ badge.appendChild(dismiss);
109
+ }
@@ -0,0 +1,256 @@
1
+ // ChatComposer — the message input: controlled textarea with caret-safe value
2
+ // sync and auto-grow, the inline `:emoji` and `@file` autocomplete triggers,
3
+ // paste/drop file routing, the optional context line, and the send/stop
4
+ // toolbar. DOM-side affordances (notes, detected badge, elapsed counter,
5
+ // pointer probe) live in ./composer-affordances.js.
6
+
7
+ import * as webjsx from '../../../vendor/webjsx/index.js';
8
+ import { Icon } from '../shell.js';
9
+ import { EmojiPicker, CommandPalette } from '../overlay-primitives.js';
10
+ import { extractAtQuery, filterFileEntries, buildAtInsertText } from '../../file-mention.js';
11
+ import { flashComposerNote, isCoarsePointer, ChatComposerElapsed, updateDetectedBadge } from './composer-affordances.js';
12
+
13
+ const h = webjsx.createElement;
14
+
15
+ // Matches a trailing `:keyword` at the end of the composer draft (optionally
16
+ // preceded by whitespace/start-of-string) so typing `:smile` opens an inline
17
+ // filtered EmojiPicker without requiring the toolbar button or Ctrl+;.
18
+ const EMOJI_TRIGGER_RE = /(?:^|\s)(:([a-zA-Z0-9_+-]{0,24}))$/;
19
+
20
+ export function ChatComposer({ value, onInput, onSend, onEmoji, onCancel, busy, placeholder = 'message…', disabled, disabledReason, label, context, onPasteFiles, onDropFiles, streamingSince, detectAttachment, mentionFiles }) {
21
+ // Keep a handle to the live textarea so send() reads the actual DOM value
22
+ // (not the possibly-lagging `value` prop) and so we can sync the DOM value
23
+ // only when it genuinely differs — re-applying `value` on every parent
24
+ // re-render otherwise resets the caret and drops fast keystrokes.
25
+ let taEl = null;
26
+ const send = () => {
27
+ const v = ((taEl && taEl.value) || value || '').trim();
28
+ if (!v || disabled) return;
29
+ if (onSend) onSend(v);
30
+ };
31
+ const triggerMatch = EMOJI_TRIGGER_RE.exec(value || '');
32
+ // `@`-file-mention autocomplete: host supplies mentionFiles (a flat list of
33
+ // {path,isDir} entries, or a plain string[] of paths — filterFileEntries
34
+ // normalizes both), we own detection/filtering/insertion. Caret-position-
35
+ // aware (extractAtQuery needs the text BEFORE the caret, not the whole
36
+ // draft) so an "@" earlier in an already-sent-past part of the text doesn't
37
+ // re-trigger after the cursor has moved on.
38
+ const caretPos = taEl ? taEl.selectionStart : (value || '').length;
39
+ const atQuery = mentionFiles ? extractAtQuery((value || '').slice(0, caretPos)) : null;
40
+ // taEl is only assigned by taRef during DOM diffing, which happens AFTER
41
+ // this render function returns — so on first paint of a trigger it is
42
+ // still null here. Fall back to the live DOM textarea from the previous
43
+ // paint (same composer, content patched in place) so the picker anchors
44
+ // near the input instead of the viewport origin.
45
+ const anchorEl = taEl || (typeof document !== 'undefined' ? document.querySelector('.chat-composer textarea') : null);
46
+ const insertEmoji = (ch) => {
47
+ const v = (taEl && taEl.value) || value || '';
48
+ const m = EMOJI_TRIGGER_RE.exec(v);
49
+ const next = m ? (v.slice(0, m.index) + (m[0].startsWith(':') ? '' : v[m.index]) + ch + ' ') : (v + ch);
50
+ if (onInput) onInput(next);
51
+ if (taEl) {
52
+ // Programmatic .value= (like the draft-restore path) discards the
53
+ // native undo stack the same way — surface that once, matching the
54
+ // existing one-time draft-restore note pattern.
55
+ try {
56
+ if (!sessionStorage.getItem('ds.composer.undoNoteShown')) {
57
+ sessionStorage.setItem('ds.composer.undoNoteShown', '1');
58
+ flashComposerNote(taEl.closest('.chat-composer'), 'inserted — undo history does not include this insert');
59
+ }
60
+ } catch { /* swallow: sessionStorage unavailable (private mode etc) — skip the note */ }
61
+ taEl.value = next;
62
+ taEl.focus();
63
+ taEl.selectionStart = taEl.selectionEnd = next.length;
64
+ }
65
+ };
66
+ let autoGrowScheduled = false;
67
+ const autoGrow = (e) => {
68
+ const ta = e.target;
69
+ if (onInput) onInput(ta.value);
70
+ if (detectAttachment) updateDetectedBadge(ta.closest('.chat-composer'), ta.value, detectAttachment);
71
+ // Debounce scrollHeight read with rAF to prevent sync reflow thrashing
72
+ if (!autoGrowScheduled) {
73
+ autoGrowScheduled = true;
74
+ requestAnimationFrame(() => {
75
+ ta.style.height = 'auto';
76
+ // Respect the CSS max-height cap (120px in short-landscape via
77
+ // app-shell.css) instead of a hardcoded 200px.
78
+ const cap = parseFloat(getComputedStyle(ta).maxHeight) || 200;
79
+ ta.style.height = Math.min(ta.scrollHeight, cap) + 'px';
80
+ autoGrowScheduled = false;
81
+ });
82
+ }
83
+ };
84
+ const taRef = (el) => {
85
+ if (!el) return;
86
+ taEl = el;
87
+ // Sync the controlled value into the DOM only when it actually differs,
88
+ // so a re-render mid-type does not clobber the caret or pending input.
89
+ const next = value || '';
90
+ if (el.value !== next) el.value = next;
91
+ el.style.height = 'auto';
92
+ const cap = parseFloat(getComputedStyle(el).maxHeight) || 200;
93
+ el.style.height = Math.min(el.scrollHeight, cap) + 'px';
94
+ if (detectAttachment) updateDetectedBadge(el.closest('.chat-composer'), next, detectAttachment);
95
+ };
96
+ // Optional context line shown above the textarea: agent / model / cwd at the
97
+ // point of typing (the way Claude-Desktop surfaces the active target inline).
98
+ // `context` is { bits:[...], onClick? }. Bits may be plain strings (inert
99
+ // text) or { text, onClick, title } objects — a bit with its own onClick
100
+ // renders as an inline button (.chat-composer-context-bit) so e.g. the cwd
101
+ // segment routes to the cwd editor WITHOUT making the whole line one giant
102
+ // click target. Legacy whole-line context.onClick is honored only when no
103
+ // bit carries its own handler. All children are keyed VElements.
104
+ // Normalize FIRST (a bit may carry `text` or `label`), then drop empties -
105
+ // separators must only ever sit between bits that actually render. An
106
+ // object bit whose text resolved empty used to leave a dangling trailing
107
+ // middot AND an invisible zero-width button.
108
+ const ctxBits = ((context && context.bits) ? context.bits : [])
109
+ .map((b) => {
110
+ if (b == null) return null;
111
+ if (typeof b === 'object') {
112
+ const text = b.text || b.label || '';
113
+ return text ? { text, onClick: b.onClick, title: b.title } : null;
114
+ }
115
+ const text = String(b);
116
+ return text ? { text } : null;
117
+ })
118
+ .filter(Boolean);
119
+ const hasBitClicks = ctxBits.some((b) => b.onClick);
120
+ let contextLine = null;
121
+ if (ctxBits.length && hasBitClicks) {
122
+ const kids = [];
123
+ ctxBits.forEach((b, i) => {
124
+ if (i) kids.push(h('span', { key: 'csep' + i, class: 'chat-composer-context-sep', 'aria-hidden': 'true' }, ' · '));
125
+ if (b.onClick) kids.push(h('button', {
126
+ key: 'cbit' + i, type: 'button', class: 'chat-composer-context-bit',
127
+ title: b.title || null, 'aria-label': b.title || b.text,
128
+ onclick: (e) => { e.preventDefault(); b.onClick(e); },
129
+ }, b.text));
130
+ else kids.push(h('span', { key: 'cbit' + i, class: 'chat-composer-context-text' }, b.text));
131
+ });
132
+ contextLine = h('div', { class: 'chat-composer-context', role: 'group', 'aria-label': 'active session: ' + ctxBits.map((b) => b.text).join(', ') }, ...kids);
133
+ } else if (ctxBits.length) {
134
+ const joined = ctxBits.map((b) => b.text).join(' · ');
135
+ contextLine = h(context.onClick ? 'button' : 'div', {
136
+ class: 'chat-composer-context', type: context.onClick ? 'button' : null,
137
+ 'aria-label': context.onClick ? ('change target: ' + joined) : null,
138
+ onclick: context.onClick ? (e) => { e.preventDefault(); context.onClick(e); } : null,
139
+ }, joined);
140
+ }
141
+ const hasDraft = !!(value && value.trim());
142
+ // Clamp the picker anchor to the visual viewport: with the on-screen
143
+ // keyboard open the composer sits near the visual-viewport bottom, and on
144
+ // narrow screens the picker width can overflow the right edge.
145
+ const anchorRect = (anchorEl && anchorEl.getBoundingClientRect) ? anchorEl.getBoundingClientRect() : null;
146
+ const vvWidth = (typeof window !== 'undefined')
147
+ ? ((window.visualViewport && window.visualViewport.width) || window.innerWidth)
148
+ : 0;
149
+ const triggerPicker = triggerMatch ? EmojiPicker({
150
+ open: true,
151
+ anchorX: anchorRect ? Math.max(0, Math.min(anchorRect.left, vvWidth - 280)) : 0,
152
+ anchorY: anchorRect ? Math.max(8, anchorRect.top - 8) : 0,
153
+ query: triggerMatch[2] || '',
154
+ onSelect: (ch) => insertEmoji(ch),
155
+ 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(); } },
156
+ }) : null;
157
+ // insertMention: replace the in-progress @token (atQuery.start..caretPos)
158
+ // with the built mention text, mirroring insertEmoji's DOM-authoritative
159
+ // read/write-back so the native undo stack isn't clobbered any more than
160
+ // the existing emoji path already accepts.
161
+ const insertMention = (entry) => {
162
+ const v = (taEl && taEl.value) || value || '';
163
+ if (!atQuery) return;
164
+ // buildAtInsertText returns {text, cursorOffset} — cursorOffset is
165
+ // relative to the start of the inserted text, matching the emoji
166
+ // path's absolute-caret style once added to atQuery.start.
167
+ const { text: insertText, cursorOffset } = buildAtInsertText(entry.path, entry.isDir);
168
+ const next = v.slice(0, atQuery.start) + insertText + v.slice(caretPos);
169
+ if (onInput) onInput(next);
170
+ if (taEl) {
171
+ taEl.value = next;
172
+ taEl.focus();
173
+ const pos = atQuery.start + cursorOffset;
174
+ taEl.selectionStart = taEl.selectionEnd = pos;
175
+ }
176
+ };
177
+ const mentionEntries = atQuery ? filterFileEntries(mentionFiles, atQuery.query) : [];
178
+ const mentionPicker = atQuery ? CommandPalette({
179
+ open: true,
180
+ items: mentionEntries.map((e) => ({ label: e.path, group: null, icon: e.isDir ? '📁' : null, _entry: e })),
181
+ onSelect: (it) => insertMention(it._entry),
182
+ 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; } },
183
+ }) : null;
184
+ return h('div', {
185
+ class: 'chat-composer' + (hasDraft ? ' has-draft' : '') + (disabled ? ' is-disabled' : ''),
186
+ // A drop on the composer must NEVER navigate the browser away from the
187
+ // live session: preventDefault on both dragover and drop, route files to
188
+ // the optional onDropFiles handler, ring via .dragover.
189
+ ondragover: (e) => { e.preventDefault(); e.currentTarget.classList.add('dragover'); },
190
+ ondragleave: (e) => { e.currentTarget.classList.remove('dragover'); },
191
+ ondrop: (e) => {
192
+ e.preventDefault();
193
+ e.currentTarget.classList.remove('dragover');
194
+ const files = e.dataTransfer && e.dataTransfer.files;
195
+ if (files && files.length) {
196
+ if (onDropFiles) onDropFiles(files);
197
+ else flashComposerNote(e.currentTarget, 'dropped files are not supported here yet');
198
+ }
199
+ },
200
+ },
201
+ contextLine,
202
+ triggerPicker,
203
+ mentionPicker,
204
+ h('textarea', { ref: taRef, placeholder, rows: 1,
205
+ 'aria-label': label || (disabled && disabledReason ? 'message input — ' + disabledReason : 'message input'),
206
+ disabled: !!disabled, 'aria-disabled': disabled ? 'true' : null,
207
+ oninput: autoGrow,
208
+ onpaste: (e) => {
209
+ const cd = e.clipboardData;
210
+ // If the clipboard contains files, always route them — even when
211
+ // text is also present (some apps attach a filename as text).
212
+ if (cd && cd.files && cd.files.length) {
213
+ e.preventDefault();
214
+ if (onPasteFiles) onPasteFiles(cd.files);
215
+ else flashComposerNote(e.currentTarget.closest('.chat-composer'), 'images are not supported yet');
216
+ return;
217
+ }
218
+ // Large plain-text pastes (e.g. a whole file/log dropped into the
219
+ // composer) get no feedback otherwise — the textarea just grows to
220
+ // its max-height cap with no signal of how much landed. Note the
221
+ // character count; this does not change any truncation behavior.
222
+ const text = cd && cd.getData ? cd.getData('text/plain') : '';
223
+ if (text && text.length > 2000) {
224
+ flashComposerNote(e.currentTarget.closest('.chat-composer'), 'pasted ' + text.length + ' characters');
225
+ }
226
+ },
227
+ onkeydown: (e) => {
228
+ // Escape blurs the textarea when idle; stops generation when busy.
229
+ if (e.key === 'Escape') {
230
+ if (!busy) { e.currentTarget.blur(); return; }
231
+ if (onCancel) { e.preventDefault(); onCancel(e); return; }
232
+ }
233
+ // IME guard: the Enter that commits a CJK composition must never
234
+ // send (isComposing; keyCode 229 covers older engines).
235
+ // Coarse-pointer (touch) devices get a newline on Enter instead of
236
+ // send — there is no keyboard shortcut discoverability benefit on
237
+ // touch, and Enter-to-send is a frequent accidental-send source on
238
+ // phones/tablets where "Tap Send to send" is the predictable model.
239
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229 && !isCoarsePointer()) { e.preventDefault(); send(); }
240
+ if (e.key === ';' && e.ctrlKey) { e.preventDefault(); onEmoji && onEmoji(e); }
241
+ } }),
242
+ // Enter-to-send affordance (Claude-Desktop style): a muted hint visible
243
+ // at rest so it's discoverable without focusing the composer first;
244
+ // hidden under 420px (CSS) to save rows. Middot is kept product typography.
245
+ h('div', { class: 'chat-composer-hint' }, isCoarsePointer() ? 'Tap Send to send' : 'Enter to send · Shift+Enter for a new line'),
246
+ (busy && streamingSince) ? ChatComposerElapsed({ streamingSince }) : null,
247
+ h('div', { class: 'chat-composer-toolbar' },
248
+ 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,
249
+ busy && onCancel
250
+ ? h('button', { type: 'button', class: 'send cancel', onclick: (e) => { e.preventDefault(); onCancel(e); }, 'aria-label': 'stop generating', title: 'stop generating (Esc)' }, Icon('square'))
251
+ : h('button', { type: 'button', class: 'send', disabled: disabled || !(value && value.trim()), onclick: send,
252
+ 'aria-label': disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message',
253
+ title: disabled && disabledReason ? 'send message (' + disabledReason + ')' : 'send message (Enter)' }, Icon('arrow-up'))
254
+ )
255
+ );
256
+ }
@@ -0,0 +1,105 @@
1
+ // Thread containers: Chat (the standard header + log + composer surface),
2
+ // AICat / AICatPortrait (the ascii-mascot variant), and ChatSuggestions (the
3
+ // blank-thread composer-priming CTA).
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { t } from '../../i18n.js';
7
+ import { ChatMessage } from './message.js';
8
+ import { makeThreadAutoScroll } from './thread-scroll.js';
9
+ import { ensureCachesInit } from './stats.js';
10
+
11
+ const h = webjsx.createElement;
12
+
13
+ // ChatSuggestions — centered blank-thread heading + subtext + a wrapped row
14
+ // of prompt chips that fill the composer textarea on click and auto-dismiss
15
+ // on first send. Ported from docstudio's empty-state composer-priming CTA
16
+ // (distinct from a generic list EmptyState: this exists to seed a first
17
+ // message, not to describe an empty list). `onPick(prompt, item)` is the
18
+ // caller's single hook — the component does not touch the composer DOM
19
+ // itself, so the host decides how "fill the composer" actually happens.
20
+ // A rapid double-click on the same chip (or a click racing the first send)
21
+ // is guarded by a one-shot `_picked` flag: only the first click of any kind
22
+ // dispatches onPick, so the composer is never filled twice and the chips
23
+ // never reappear having already been "used".
24
+ export function ChatSuggestions({ heading = 'What can I help with?', subtext = '', suggestions = [] } = {}) {
25
+ let picked = false;
26
+ return h('div', { class: 'chat-suggestions', role: 'group', 'aria-label': heading },
27
+ h('h2', { class: 'chat-suggestions-heading' }, heading),
28
+ subtext ? h('p', { class: 'chat-suggestions-subtext' }, subtext) : null,
29
+ h('div', { class: 'chat-suggestions-list' },
30
+ ...suggestions.map((s, i) => h('button', {
31
+ key: s.id || i, type: 'button', class: 'chat-suggestions-chip',
32
+ onclick: () => { if (picked) return; picked = true; s.onPick ? s.onPick(s) : null; }
33
+ }, s.label))
34
+ )
35
+ );
36
+ }
37
+
38
+ export function Chat({ title = 'chat', sub, messages = [], composer, header, suggestions, onSuggestionClick } = {}) {
39
+ // Warm markdown/Prism caches once so library loading parallelizes.
40
+ ensureCachesInit();
41
+ const threadRef = makeThreadAutoScroll(() => messages.length);
42
+ const msgCount = messages.length;
43
+ return h('div', { class: 'chat' },
44
+ header || h('div', { class: 'chat-head', role: 'banner' },
45
+ h('h2', { class: 'ds-chat-title' }, title),
46
+ sub ? h('span', { class: 'sub', 'aria-label': `subtitle: ${sub}` }, ' · ' + sub) : null,
47
+ h('span', { class: 'spread' }),
48
+ msgCount > 0
49
+ ? h('span', { class: 'sub', 'aria-live': 'polite' }, msgCount + (msgCount === 1 ? ' message' : ' messages'))
50
+ : null
51
+ ),
52
+ h('div', { class: 'chat-thread', ref: threadRef, role: 'log', 'aria-label': 'chat messages', 'aria-live': 'polite', 'aria-relevant': 'additions' },
53
+ messages.length === 0
54
+ ? h('div', { key: '_empty', class: 'chat-empty', role: 'status' },
55
+ h('p', { class: 'chat-empty-title' }, t('chat.startConversation', 'start a conversation')),
56
+ h('p', { class: 'chat-empty-sub' }, sub || t('chat.emptySub', 'Send a message to start the conversation')),
57
+ (suggestions && suggestions.length)
58
+ ? h('div', { class: 'chat-empty-suggestions' },
59
+ ...suggestions.map((s, i) => h('button', { key: 'sug' + i, type: 'button', class: 'chat-empty-suggestion',
60
+ onclick: () => { if (onSuggestionClick) onSuggestionClick(typeof s === 'string' ? s : (s.prompt || s.text || '')); } },
61
+ typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
62
+ : null)
63
+ : null,
64
+ ...messages.map((m, i) => ChatMessage({ ...m, key: m.key != null ? m.key : i }))
65
+ ),
66
+ composer || null
67
+ );
68
+ }
69
+
70
+ export const AICAT_FACE = ` /\\_/\\\n( o.o )\n > ^ <`;
71
+
72
+ export function AICatPortrait({ name = 'aicat', status = 'idle', face } = {}) {
73
+ return h('div', { class: 'aicat-portrait' },
74
+ h('pre', { class: 'aicat-face', 'aria-label': `${name} portrait` }, face || AICAT_FACE),
75
+ h('div', { class: 'aicat-meta' },
76
+ h('span', { class: 'name' }, name),
77
+ h('span', { class: 'status', 'aria-label': `status: ${status}` }, h('span', { class: 'dot ds-dot ds-dot-on', 'aria-hidden': 'true' }), ' ', status)
78
+ )
79
+ );
80
+ }
81
+
82
+ export function AICat({ name = 'aicat', messages = [], thinking, composer, status = 'online · purring' } = {}) {
83
+ ensureCachesInit();
84
+ const annotated = messages.map((m) =>
85
+ m.who === 'them' ? { ...m, aicat: true, avatar: m.avatar || '=^.^=' } : m);
86
+ const all = thinking
87
+ ? [...annotated, { who: 'them', aicat: true, avatar: '=^.^=', typing: true, key: '_thinking' }]
88
+ : annotated;
89
+ const threadRef = makeThreadAutoScroll(() => all.length);
90
+ return h('div', { class: 'chat' },
91
+ h('div', { class: 'chat-head', role: 'banner' },
92
+ h('span', { class: 'dot', 'aria-hidden': 'true' }),
93
+ h('h2', { class: 'ds-chat-title' }, name),
94
+ h('span', { class: 'sub', 'aria-label': `status: ${status}` }, ' · ' + status),
95
+ h('span', { class: 'spread' }),
96
+ messages.length > 0
97
+ ? h('span', { class: 'sub', 'aria-live': 'polite' }, messages.length + (messages.length === 1 ? ' turn' : ' turns'))
98
+ : null
99
+ ),
100
+ h('div', { class: 'chat-thread', ref: threadRef, role: 'log', 'aria-label': 'conversation turns', 'aria-live': 'polite', 'aria-relevant': 'additions' },
101
+ ...all.map((m, i) => ChatMessage({ ...m, key: m.key != null ? m.key : i }))
102
+ ),
103
+ composer || null
104
+ );
105
+ }
@@ -0,0 +1,103 @@
1
+ // Agent-turn bubbles: the collapsible tool-call card (with per-section copy,
2
+ // stringify caching, and unified-diff detection that routes a patch-shaped
3
+ // result through GitDiffView) and the transient/settled thinking indicator.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Icon } from '../shell.js';
7
+ import { t } from '../../i18n.js';
8
+ import { GitDiffView } from '../git-status.js';
9
+ import { copyToClipboardWithFeedback } from './inline.js';
10
+
11
+ const h = webjsx.createElement;
12
+
13
+ // A tool result reads as a unified diff when it has at least one `@@ ... @@`
14
+ // hunk header and a +/- line — cheap enough to check on every render (no
15
+ // caching) since it only runs once per settled tool card, not per rAF tick.
16
+ function looksLikeUnifiedDiff(text) {
17
+ if (!text || text.indexOf('@@') === -1) return false;
18
+ return /^@@ .* @@/m.test(text) && /^[+-]/m.test(text);
19
+ }
20
+
21
+ // Pull a filename out of a unified diff's `+++ b/path` (or `--- a/path`)
22
+ // header line, for the GitDiffView head label — best-effort, no filename is
23
+ // fine (GitDiffView renders headerless).
24
+ function filenameFromDiff(text) {
25
+ const m = /^\+\+\+ b?\/?(.+)$/m.exec(text) || /^--- a?\/?(.+)$/m.exec(text);
26
+ return m ? m[1].trim() : undefined;
27
+ }
28
+
29
+ // Freddie-flavored agent parts: collapsible tool-call card, tool-result, and
30
+ // transient thinking indicator. Each renders as a `chat-bubble` variant so the
31
+ // surrounding ChatMessage chrome (avatar/meta/reactions) stays consistent.
32
+ export function ToolCallNode(p) {
33
+ const status = p.status || (p.error ? 'error' : (p.result != null ? 'done' : 'running'));
34
+ // Args/result are re-stringified on every rAF re-render while any part of
35
+ // the turn is streaming, even for collapsed cards whose own args/result
36
+ // haven't changed since the last frame. Cache by identity on the part
37
+ // object itself so an unchanged args/result skips the stringify.
38
+ if (p._argsCache !== p.args) {
39
+ p._argsTextCache = typeof p.args === 'string' ? p.args : JSON.stringify(p.args || {}, null, 2);
40
+ p._argsCache = p.args;
41
+ }
42
+ const argsText = p._argsTextCache;
43
+ if (p._resultCache !== p.result) {
44
+ p._resultTextCache = p.result == null ? '' : (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2));
45
+ p._resultCache = p.result;
46
+ }
47
+ const resultText = p._resultTextCache;
48
+ const hasArgs = p.args != null && argsText !== '{}' && argsText.trim() !== '';
49
+ // Default-open while running or on error so the user sees live progress / failure detail;
50
+ // collapse on success unless the caller explicitly overrides with open:true.
51
+ const defaultOpen = p.open != null ? !!p.open : (status === 'running' || status === 'error');
52
+ const iconName = status === 'running' ? 'refresh' : (status === 'error' ? 'warn' : 'check');
53
+ const copyText = (txt) => (e) => copyToClipboardWithFeedback(txt, e.currentTarget);
54
+ const sectionLabel = (text, txt) => h('div', { class: 'chat-tool-section-label' },
55
+ h('span', {}, text),
56
+ h('button', { type: 'button', class: 'chat-code-copy chat-tool-copy', 'aria-label': 'copy ' + text, onclick: copyText(txt) }, 'copy'));
57
+ return h('details', { class: 'chat-bubble chat-tool tool-' + status, open: defaultOpen },
58
+ h('summary', { class: 'chat-tool-head' },
59
+ h('span', { class: 'chat-tool-icon', 'aria-hidden': 'true' }, Icon(iconName, { size: 14 })),
60
+ h('span', { class: 'chat-tool-name' }, p.name || 'tool'),
61
+ p.label ? h('span', { class: 'chat-tool-label' }, p.label) : null,
62
+ h('span', { class: 'chat-tool-status' }, status)
63
+ ),
64
+ h('div', { class: 'chat-tool-body' },
65
+ ...[
66
+ hasArgs ? h('div', { class: 'chat-tool-section' },
67
+ sectionLabel('args', argsText),
68
+ h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))) : null,
69
+ resultText
70
+ ? (!p.error && looksLikeUnifiedDiff(resultText)
71
+ // A patch-shaped tool result (edit/write/diff tools) renders
72
+ // through the same split unified-diff view git-status.js's
73
+ // GitDiffView already owns, instead of a raw JSON/text dump —
74
+ // colored +/- hunks read far better than escaped plaintext.
75
+ ? h('div', { class: 'chat-tool-section' },
76
+ sectionLabel('result', resultText),
77
+ GitDiffView({ diff: resultText, filename: filenameFromDiff(resultText) }))
78
+ : h('div', { class: 'chat-tool-section' },
79
+ sectionLabel(p.error ? 'error' : 'result', resultText),
80
+ h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText))))
81
+ // A finished tool with no output would otherwise render no result
82
+ // section, reading identically to a still-running tool. Show an
83
+ // explicit placeholder so "done, empty" is distinguishable.
84
+ : (status === 'done' ? h('div', { class: 'chat-tool-section' },
85
+ h('div', { class: 'chat-tool-section-label' }, 'result'),
86
+ h('pre', { class: 'chat-tool-pre chat-tool-empty' }, h('code', {}, '(no output)'))) : null)
87
+ ].filter(Boolean)
88
+ )
89
+ );
90
+ }
91
+
92
+ export function ThinkingNode(p) {
93
+ if (p.settled) {
94
+ return h('details', { class: 'chat-bubble chat-thinking-settled' },
95
+ h('summary', {}, t('chat.viewThinking', 'View thinking')),
96
+ h('div', { class: 'chat-thinking-body' }, p.text)
97
+ );
98
+ }
99
+ return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
100
+ h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
101
+ h('span', { class: 'chat-thinking-text' }, p.text || t('chat.thinking', 'thinking…'))
102
+ );
103
+ }