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.
- package/dist/247420.css +33 -0
- package/dist/247420.js +53 -51
- package/package.json +1 -1
- package/src/components/agent-chat/controls.js +143 -0
- package/src/components/agent-chat/empty-state.js +63 -0
- package/src/components/agent-chat/message-rows.js +141 -0
- package/src/components/agent-chat/surface.js +206 -0
- package/src/components/agent-chat/thread-behaviour.js +50 -0
- package/src/components/agent-chat.js +6 -546
- package/src/components/chat/composer-affordances.js +109 -0
- package/src/components/chat/composer.js +256 -0
- package/src/components/chat/threads.js +105 -0
- package/src/components/chat-message-parts/agent-nodes.js +103 -0
- package/src/components/chat-message-parts/inline.js +106 -0
- package/src/components/chat-message-parts/prose-nodes.js +133 -0
- package/src/components/chat-message-parts/renderers.js +89 -0
- package/src/components/chat-message-parts.js +12 -408
- package/src/components/chat-minimap/minimap.js +167 -0
- package/src/components/chat-minimap/paint.js +73 -0
- package/src/components/chat-minimap/preview.js +41 -0
- package/src/components/chat-minimap.js +7 -263
- package/src/components/chat.js +20 -628
- package/src/components/community/chrome.js +63 -0
- package/src/components/community/navigation.js +167 -0
- package/src/components/community/presence.js +106 -0
- package/src/components/community/shell.js +17 -0
- package/src/components/community/views.js +131 -0
- package/src/components/community.js +18 -447
- package/src/components/files/chrome.js +140 -0
- package/src/components/files/entries.js +156 -0
- package/src/components/files/grid-controls.js +63 -0
- package/src/components/files/grid.js +177 -0
- package/src/components/files/types.js +69 -0
- package/src/components/files-modals/dialogs.js +118 -0
- package/src/components/files-modals/modal-shell.js +153 -0
- package/src/components/files-modals/preview-bodies.js +129 -0
- package/src/components/files-modals/preview-containers.js +96 -0
- package/src/components/files-modals.js +14 -475
- package/src/components/files.js +15 -564
- package/src/components/freddie/pages-config.js +3 -94
- package/src/components/freddie/pages-models.js +97 -0
- package/src/components/freddie.js +2 -1
- package/src/components/interaction-primitives/mobile.js +25 -0
- package/src/components/interaction-primitives/pointer.js +214 -0
- package/src/components/interaction-primitives/reorderable.js +47 -0
- package/src/components/interaction-primitives/shortcuts.js +119 -0
- package/src/components/interaction-primitives.js +16 -388
- package/src/components/sessions/conversation-list.js +230 -0
- package/src/components/sessions/dashboard.js +202 -0
- package/src/components/sessions/detail-bits.js +49 -0
- package/src/components/sessions/format.js +42 -0
- package/src/components/sessions/session-card.js +92 -0
- package/src/components/sessions.js +16 -579
- package/src/components/voice/audio-cue.js +39 -0
- package/src/components/voice/capture.js +90 -0
- package/src/components/voice/playback.js +75 -0
- package/src/components/voice/settings-modal.js +104 -0
- package/src/components/voice.js +16 -293
- package/src/css/app-shell/base.css +23 -0
- package/src/css/app-shell/states-interactions.css +10 -0
- package/src/kits/os/freddie/chat-protocol.js +32 -0
- package/src/kits/os/freddie/chat-transport.js +178 -0
- package/src/kits/os/freddie/pages-chat.js +10 -180
- package/src/kits/os/shell-chrome.js +163 -0
- package/src/kits/os/shell-geometry.js +59 -0
- package/src/kits/os/shell.js +178 -335
- package/src/page-html/client-script.js +151 -0
- package/src/page-html/head-tags.js +59 -0
- package/src/page-html/markdown.js +68 -0
- package/src/page-html/page-styles.js +44 -0
- package/src/page-html.js +14 -291
package/src/components/chat.js
CHANGED
|
@@ -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 {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
};
|