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
|
@@ -7,412 +7,16 @@
|
|
|
7
7
|
// void for the imperative copy-button wiring) — no module-level render state
|
|
8
8
|
// beyond the markdown/Prism cache singletons chat-cache.js already owns.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import { renderMathBlocksUnder } from '../math.js';
|
|
23
|
-
import { Icon } from './shell.js';
|
|
24
|
-
import { fmtFileSize } from './files.js';
|
|
25
|
-
import { t } from '../i18n.js';
|
|
26
|
-
import { GitDiffView } from './git-status.js';
|
|
27
|
-
|
|
28
|
-
const h = webjsx.createElement;
|
|
29
|
-
|
|
30
|
-
// ONE byte format across the kit (mirrors chat.js's own fmtBytes alias).
|
|
31
|
-
const fmtBytes = fmtFileSize;
|
|
32
|
-
|
|
33
|
-
// Reject dangerous URL schemes (javascript:, data:, vbscript:, file:) so an
|
|
34
|
-
// inline markdown link or an image src built from untrusted text can't smuggle
|
|
35
|
-
// a script-executing or data-exfiltrating URL past the inline renderer (which
|
|
36
|
-
// does NOT pass through DOMPurify the way the full md path does). http(s),
|
|
37
|
-
// mailto, protocol-relative, root/relative, and anchor links are allowed.
|
|
38
|
-
export function safeUrl(url) {
|
|
39
|
-
const s = String(url == null ? '' : url).trim();
|
|
40
|
-
if (!s) return null;
|
|
41
|
-
if (/^(\/|\.|#|\?)/.test(s) || s.startsWith('//')) return s;
|
|
42
|
-
const scheme = (s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1];
|
|
43
|
-
if (!scheme) return s; // schemeless relative
|
|
44
|
-
return /^(https?|mailto|tel)$/i.test(scheme) ? s : null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Inline-only markdown subset; safe for chat bubbles.
|
|
48
|
-
export function renderInline(text) {
|
|
49
|
-
if (text == null) return [];
|
|
50
|
-
const out = [];
|
|
51
|
-
const re = /(\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
|
|
52
|
-
let last = 0; let m; let i = 0;
|
|
53
|
-
const push = (n) => out.push(n);
|
|
54
|
-
while ((m = re.exec(text)) !== null) {
|
|
55
|
-
if (m.index > last) push(h('span', { key: 's' + i + 'a' }, text.slice(last, m.index)));
|
|
56
|
-
if (m[2] != null) push(h('strong', { key: 's' + i }, m[2]));
|
|
57
|
-
else if (m[3] != null) push(h('em', { key: 's' + i }, m[3]));
|
|
58
|
-
else if (m[4] != null) push(h('code', { key: 's' + i, class: 'chat-tick' }, m[4]));
|
|
59
|
-
else if (m[5] != null) {
|
|
60
|
-
const safe = safeUrl(m[6]);
|
|
61
|
-
// A link with a rejected (unsafe) scheme degrades to its plain label
|
|
62
|
-
// text rather than a clickable, scheme-smuggling anchor.
|
|
63
|
-
if (safe) push(h('a', { key: 's' + i, href: safe, target: '_blank', rel: 'noopener noreferrer' }, m[5]));
|
|
64
|
-
else push(h('span', { key: 's' + i }, m[5]));
|
|
65
|
-
}
|
|
66
|
-
last = m.index + m[0].length; i += 1;
|
|
67
|
-
}
|
|
68
|
-
if (last < text.length) push(h('span', { key: 's' + i + 'a' }, text.slice(last)));
|
|
69
|
-
return out;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Map file extension -> line-icon name (drawn SVG, not a decorative glyph).
|
|
73
|
-
const FILE_ICONS = { pdf: 'file-pdf', zip: 'file-zip', tar: 'file-zip', gz: 'file-zip', mp4: 'file-video', mov: 'file-video', mp3: 'file-audio', wav: 'file-audio', csv: 'file-sheet', json: 'file-code', js: 'file-code', ts: 'file-code', md: 'file-text', txt: 'file-text' };
|
|
74
|
-
function fileIconName(name) {
|
|
75
|
-
const ext = String(name || '').split('.').pop().toLowerCase();
|
|
76
|
-
return FILE_ICONS[ext] || 'file';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Shared clipboard-copy-with-label-flip: was three verbatim-identical blocks
|
|
80
|
-
// (chat.js's injectCodeCopy, CodeNode.onCopy, ToolCallNode.copyText) before
|
|
81
|
-
// this extraction. Same behavior in every caller: try the async Clipboard
|
|
82
|
-
// API, fall back to a hidden textarea + execCommand('copy') when unavailable,
|
|
83
|
-
// flip the trigger button's own label/class to "copied" for ~1.6s either way.
|
|
84
|
-
export function copyToClipboardWithFeedback(text, btn) {
|
|
85
|
-
const done = () => {
|
|
86
|
-
btn.textContent = 'copied';
|
|
87
|
-
btn.classList.add('is-copied');
|
|
88
|
-
setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600);
|
|
89
|
-
};
|
|
90
|
-
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done).catch(() => {});
|
|
91
|
-
else { try { const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch { /* swallow: legacy execCommand copy fallback unsupported, nothing more to try */ } }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Inject a per-block copy button into every <pre> inside a rendered-markdown
|
|
95
|
-
// container. claude.ai/code and Claude Desktop give EVERY fenced block a hover
|
|
96
|
-
// copy affordance; the chat surface had only a whole-message copy. Idempotent:
|
|
97
|
-
// marks each <pre> with data-copy-wired so re-renders don't stack buttons. The
|
|
98
|
-
// button reveals on .chat-code-block:hover/:focus-within (CSS) and flips its
|
|
99
|
-
// label copy -> copied for ~1.6s. Drawn with a real icon + word, no glyph.
|
|
100
|
-
export function injectCodeCopy(container) {
|
|
101
|
-
if (!container) return;
|
|
102
|
-
container.querySelectorAll('pre').forEach((pre) => {
|
|
103
|
-
if (pre.dataset.copyWired === '1') return;
|
|
104
|
-
pre.dataset.copyWired = '1';
|
|
105
|
-
// Wrap the <pre> in a position:relative shell so the button can sit
|
|
106
|
-
// top-right without disturbing code layout.
|
|
107
|
-
const shell = document.createElement('div');
|
|
108
|
-
shell.className = 'chat-code-block';
|
|
109
|
-
pre.parentNode.insertBefore(shell, pre);
|
|
110
|
-
shell.appendChild(pre);
|
|
111
|
-
// Surface the fenced language as a small header tab (claude.ai/code
|
|
112
|
-
// shows the language on every block, not just the structured CodeNode).
|
|
113
|
-
// The highlighter sets language-xx / lang-xx on the inner <code>.
|
|
114
|
-
const codeEl = pre.querySelector('code');
|
|
115
|
-
const langCls = codeEl && (codeEl.className || '').match(/(?:language|lang)-([a-z0-9+#]+)/i);
|
|
116
|
-
if (langCls && langCls[1]) {
|
|
117
|
-
const lang = document.createElement('span');
|
|
118
|
-
lang.className = 'chat-code-lang';
|
|
119
|
-
lang.setAttribute('aria-hidden', 'true');
|
|
120
|
-
lang.textContent = langCls[1];
|
|
121
|
-
shell.appendChild(lang);
|
|
122
|
-
}
|
|
123
|
-
const btn = document.createElement('button');
|
|
124
|
-
btn.type = 'button';
|
|
125
|
-
btn.className = 'chat-code-copy';
|
|
126
|
-
btn.setAttribute('aria-label', 'copy code');
|
|
127
|
-
btn.textContent = 'copy';
|
|
128
|
-
btn.addEventListener('click', () => copyToClipboardWithFeedback(pre.innerText, btn));
|
|
129
|
-
shell.appendChild(btn);
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const MD_STREAM_THROTTLE_MS = 120;
|
|
134
|
-
const MD_STREAM_MIN_DELTA_CHARS = 40;
|
|
135
|
-
|
|
136
|
-
// requestIdleCallback with a setTimeout fallback (Safari/non-browser test
|
|
137
|
-
// contexts lack the real API). A settled historical message's parse is not
|
|
138
|
-
// latency-critical the way a streaming turn's is — deferring it off the
|
|
139
|
-
// critical path keeps a session-load burst of N historical bubbles from
|
|
140
|
-
// racing N synchronous parses on the same tick.
|
|
141
|
-
const scheduleIdle = typeof requestIdleCallback === 'function'
|
|
142
|
-
? (fn) => requestIdleCallback(fn, { timeout: 500 })
|
|
143
|
-
: (fn) => setTimeout(fn, 0);
|
|
144
|
-
|
|
145
|
-
function MdNode(p) {
|
|
146
|
-
const refSink = (el) => {
|
|
147
|
-
if (!el) return;
|
|
148
|
-
// Version the per-element source key with a degraded marker: a bubble
|
|
149
|
-
// rendered while the markdown loader was down re-renders (real markdown)
|
|
150
|
-
// once the loader recovers, instead of staying plain-escaped forever.
|
|
151
|
-
const srcKey = (isMarkdownDegraded() ? '~degraded~' : '') + (p.text || '');
|
|
152
|
-
if (el.dataset.mdSrc === srcKey) return;
|
|
153
|
-
// While streaming (text still growing, not the final settle), a full
|
|
154
|
-
// re-parse of the WHOLE accumulated text on every rAF tick is the
|
|
155
|
-
// dominant cost of a long stream. Throttle: skip the parse unless
|
|
156
|
-
// enough time or enough new characters landed since the last one.
|
|
157
|
-
// p.streamingCaret (already threaded by the host for the stream-head
|
|
158
|
-
// caret) marks "still streaming"; its absence forces the final parse
|
|
159
|
-
// so nothing is left un-parsed once the turn settles.
|
|
160
|
-
const parsedLen = el.dataset.mdParsedLen ? Number(el.dataset.mdParsedLen) : 0;
|
|
161
|
-
const lastParseAt = el.dataset.mdLastParseAt ? Number(el.dataset.mdLastParseAt) : 0;
|
|
162
|
-
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
163
|
-
if (p.streamingCaret && parsedLen > 0 &&
|
|
164
|
-
(now - lastParseAt) < MD_STREAM_THROTTLE_MS &&
|
|
165
|
-
(srcKey.length - parsedLen) < MD_STREAM_MIN_DELTA_CHARS) {
|
|
166
|
-
// Not enough new content/time yet — paint raw text so the latest
|
|
167
|
-
// characters are visible, but defer the expensive re-parse.
|
|
168
|
-
el.textContent = p.text || '';
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
el.dataset.mdSrc = srcKey;
|
|
172
|
-
el.dataset.mdParsedLen = String(srcKey.length);
|
|
173
|
-
el.dataset.mdLastParseAt = String(now);
|
|
174
|
-
// Markdown stack still loading (or down): paint the raw text
|
|
175
|
-
// synchronously so streamed tokens are visible the same frame they
|
|
176
|
-
// arrive (an empty bubble until the CDN import resolves reads as a
|
|
177
|
-
// hang); the resolved render swaps in sanitized markdown in place.
|
|
178
|
-
if (isMarkdownDegraded()) el.textContent = p.text || '';
|
|
179
|
-
function doParse() {
|
|
180
|
-
renderMarkdownCached(p.text || '').then((html) => {
|
|
181
|
-
// The element may have been recycled (webjsx applyDiff reused this
|
|
182
|
-
// DOM node for a different message) or detached by the time an
|
|
183
|
-
// idle-deferred parse resolves -- re-check the source key still
|
|
184
|
-
// matches before swapping innerHTML into what could now be a
|
|
185
|
-
// completely different message's bubble.
|
|
186
|
-
if (el.dataset.mdSrc !== srcKey) return;
|
|
187
|
-
const swap = () => {
|
|
188
|
-
el.innerHTML = html;
|
|
189
|
-
delete el.dataset.mathWired;
|
|
190
|
-
injectCodeCopy(el);
|
|
191
|
-
// Diagram/math enrichment runs AFTER sanitized HTML is in the DOM
|
|
192
|
-
// (never on raw markdown source) and is best-effort: a failed or
|
|
193
|
-
// still-loading mermaid/katex CDN leaves the fenced/literal source
|
|
194
|
-
// visible rather than blocking or blanking the bubble.
|
|
195
|
-
renderMermaidBlocksUnder(el).catch(() => {});
|
|
196
|
-
renderMathBlocksUnder(el).catch(() => {});
|
|
197
|
-
};
|
|
198
|
-
// Don't blow away an active text selection inside this bubble mid-swap
|
|
199
|
-
// (e.g. the user is mid-copy while a stream tick settles). Defer the
|
|
200
|
-
// swap once, until the selection changes (cleared or moved elsewhere).
|
|
201
|
-
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
|
202
|
-
if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
|
|
203
|
-
const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); swap(); };
|
|
204
|
-
document.addEventListener('selectionchange', onSelChange, { once: true });
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
swap();
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
// Streaming turns parse immediately (latency-critical: the user is
|
|
211
|
-
// watching this bubble grow). A settled historical message (no
|
|
212
|
-
// streamingCaret) is deferred to an idle slot -- not on the critical
|
|
213
|
-
// render path, so a page mounting many historical bubbles at once
|
|
214
|
-
// (session load) doesn't burst-parse them all synchronously.
|
|
215
|
-
if (p.streamingCaret) doParse();
|
|
216
|
-
else scheduleIdle(doParse);
|
|
217
|
-
};
|
|
218
|
-
return h('div', { class: 'chat-bubble chat-md', ref: refSink });
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function CodeNode(p) {
|
|
222
|
-
const refSink = (el) => {
|
|
223
|
-
if (!el) return;
|
|
224
|
-
// Key on the full code, not its length: two different blocks of the same
|
|
225
|
-
// length (e.g. an edit that swaps a line) would otherwise share a key and
|
|
226
|
-
// skip re-highlighting, leaving stale syntax coloring.
|
|
227
|
-
const codeKey = (p.lang || '') + '|' + (p.code || '');
|
|
228
|
-
if (el.dataset.codeKey === codeKey) return;
|
|
229
|
-
el.dataset.codeKey = codeKey;
|
|
230
|
-
// Same in-progress-selection guard as MdNode.refSink: don't wipe an
|
|
231
|
-
// active text selection inside this block mid-stream-settle. Defer
|
|
232
|
-
// the highlight swap once, until the selection changes.
|
|
233
|
-
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
|
234
|
-
if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
|
|
235
|
-
const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); highlightCodeBlockCached(el); };
|
|
236
|
-
document.addEventListener('selectionchange', onSelChange, { once: true });
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
highlightCodeBlockCached(el);
|
|
240
|
-
};
|
|
241
|
-
// Copy the raw code (not the highlighted DOM) for the structured CodeNode.
|
|
242
|
-
const onCopy = (e) => copyToClipboardWithFeedback(p.code || '', e.currentTarget);
|
|
243
|
-
return h('div', { class: 'chat-bubble chat-code', ref: refSink },
|
|
244
|
-
h('div', { class: 'chat-code-head' },
|
|
245
|
-
h('span', { class: 'lang' }, p.lang || 'code'),
|
|
246
|
-
p.filename ? h('span', { class: 'name' }, p.filename) : null,
|
|
247
|
-
h('span', { class: 'spread' }),
|
|
248
|
-
h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')
|
|
249
|
-
),
|
|
250
|
-
h('pre', {}, h('code', { class: p.lang ? 'lang-' + p.lang + ' language-' + p.lang : '' }, p.code || ''))
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// A tool result reads as a unified diff when it has at least one `@@ ... @@`
|
|
255
|
-
// hunk header and a +/- line — cheap enough to check on every render (no
|
|
256
|
-
// caching) since it only runs once per settled tool card, not per rAF tick.
|
|
257
|
-
function looksLikeUnifiedDiff(text) {
|
|
258
|
-
if (!text || text.indexOf('@@') === -1) return false;
|
|
259
|
-
return /^@@ .* @@/m.test(text) && /^[+-]/m.test(text);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Pull a filename out of a unified diff's `+++ b/path` (or `--- a/path`)
|
|
263
|
-
// header line, for the GitDiffView head label — best-effort, no filename is
|
|
264
|
-
// fine (GitDiffView renders headerless).
|
|
265
|
-
function filenameFromDiff(text) {
|
|
266
|
-
const m = /^\+\+\+ b?\/?(.+)$/m.exec(text) || /^--- a?\/?(.+)$/m.exec(text);
|
|
267
|
-
return m ? m[1].trim() : undefined;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Freddie-flavored agent parts: collapsible tool-call card, tool-result, and
|
|
271
|
-
// transient thinking indicator. Each renders as a `chat-bubble` variant so the
|
|
272
|
-
// surrounding ChatMessage chrome (avatar/meta/reactions) stays consistent.
|
|
273
|
-
function ToolCallNode(p) {
|
|
274
|
-
const status = p.status || (p.error ? 'error' : (p.result != null ? 'done' : 'running'));
|
|
275
|
-
// Args/result are re-stringified on every rAF re-render while any part of
|
|
276
|
-
// the turn is streaming, even for collapsed cards whose own args/result
|
|
277
|
-
// haven't changed since the last frame. Cache by identity on the part
|
|
278
|
-
// object itself so an unchanged args/result skips the stringify.
|
|
279
|
-
if (p._argsCache !== p.args) {
|
|
280
|
-
p._argsTextCache = typeof p.args === 'string' ? p.args : JSON.stringify(p.args || {}, null, 2);
|
|
281
|
-
p._argsCache = p.args;
|
|
282
|
-
}
|
|
283
|
-
const argsText = p._argsTextCache;
|
|
284
|
-
if (p._resultCache !== p.result) {
|
|
285
|
-
p._resultTextCache = p.result == null ? '' : (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2));
|
|
286
|
-
p._resultCache = p.result;
|
|
287
|
-
}
|
|
288
|
-
const resultText = p._resultTextCache;
|
|
289
|
-
const hasArgs = p.args != null && argsText !== '{}' && argsText.trim() !== '';
|
|
290
|
-
// Default-open while running or on error so the user sees live progress / failure detail;
|
|
291
|
-
// collapse on success unless the caller explicitly overrides with open:true.
|
|
292
|
-
const defaultOpen = p.open != null ? !!p.open : (status === 'running' || status === 'error');
|
|
293
|
-
const iconName = status === 'running' ? 'refresh' : (status === 'error' ? 'warn' : 'check');
|
|
294
|
-
const copyText = (txt) => (e) => copyToClipboardWithFeedback(txt, e.currentTarget);
|
|
295
|
-
const sectionLabel = (text, txt) => h('div', { class: 'chat-tool-section-label' },
|
|
296
|
-
h('span', {}, text),
|
|
297
|
-
h('button', { type: 'button', class: 'chat-code-copy chat-tool-copy', 'aria-label': 'copy ' + text, onclick: copyText(txt) }, 'copy'));
|
|
298
|
-
return h('details', { class: 'chat-bubble chat-tool tool-' + status, open: defaultOpen },
|
|
299
|
-
h('summary', { class: 'chat-tool-head' },
|
|
300
|
-
h('span', { class: 'chat-tool-icon', 'aria-hidden': 'true' }, Icon(iconName, { size: 14 })),
|
|
301
|
-
h('span', { class: 'chat-tool-name' }, p.name || 'tool'),
|
|
302
|
-
p.label ? h('span', { class: 'chat-tool-label' }, p.label) : null,
|
|
303
|
-
h('span', { class: 'chat-tool-status' }, status)
|
|
304
|
-
),
|
|
305
|
-
h('div', { class: 'chat-tool-body' },
|
|
306
|
-
...[
|
|
307
|
-
hasArgs ? h('div', { class: 'chat-tool-section' },
|
|
308
|
-
sectionLabel('args', argsText),
|
|
309
|
-
h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))) : null,
|
|
310
|
-
resultText
|
|
311
|
-
? (!p.error && looksLikeUnifiedDiff(resultText)
|
|
312
|
-
// A patch-shaped tool result (edit/write/diff tools) renders
|
|
313
|
-
// through the same split unified-diff view git-status.js's
|
|
314
|
-
// GitDiffView already owns, instead of a raw JSON/text dump —
|
|
315
|
-
// colored +/- hunks read far better than escaped plaintext.
|
|
316
|
-
? h('div', { class: 'chat-tool-section' },
|
|
317
|
-
sectionLabel('result', resultText),
|
|
318
|
-
GitDiffView({ diff: resultText, filename: filenameFromDiff(resultText) }))
|
|
319
|
-
: h('div', { class: 'chat-tool-section' },
|
|
320
|
-
sectionLabel(p.error ? 'error' : 'result', resultText),
|
|
321
|
-
h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText))))
|
|
322
|
-
// A finished tool with no output would otherwise render no result
|
|
323
|
-
// section, reading identically to a still-running tool. Show an
|
|
324
|
-
// explicit placeholder so "done, empty" is distinguishable.
|
|
325
|
-
: (status === 'done' ? h('div', { class: 'chat-tool-section' },
|
|
326
|
-
h('div', { class: 'chat-tool-section-label' }, 'result'),
|
|
327
|
-
h('pre', { class: 'chat-tool-pre chat-tool-empty' }, h('code', {}, '(no output)'))) : null)
|
|
328
|
-
].filter(Boolean)
|
|
329
|
-
)
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function ThinkingNode(p) {
|
|
334
|
-
if (p.settled) {
|
|
335
|
-
return h('details', { class: 'chat-bubble chat-thinking-settled' },
|
|
336
|
-
h('summary', {}, t('chat.viewThinking', 'View thinking')),
|
|
337
|
-
h('div', { class: 'chat-thinking-body' }, p.text)
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
|
|
341
|
-
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
342
|
-
h('span', { class: 'chat-thinking-text' }, p.text || t('chat.thinking', 'thinking…'))
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// The one dispatch table every chat surface's message parts render through.
|
|
347
|
-
export const PART_RENDERERS = {
|
|
348
|
-
text: (p) => p.preShell
|
|
349
|
-
// Streaming prose that already contains a code fence (or a huge tail
|
|
350
|
-
// window) renders as a plain monospaced <pre> so it does not reflow from
|
|
351
|
-
// prose to a styled block on settle (no Prism mid-stream). The settled
|
|
352
|
-
// turn renders real markdown. `streamHead` is an optional head line for
|
|
353
|
-
// the tail-window path ('streaming · N KB so far').
|
|
354
|
-
? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
|
|
355
|
-
...[p.streamHead ? h('div', { key: 'sh', class: 'chat-stream-head', role: 'status', 'aria-live': 'polite' }, p.streamHead) : null,
|
|
356
|
-
h('pre', { key: 'pre' }, h('code', {}, p.text || '')),
|
|
357
|
-
p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null].filter(Boolean))
|
|
358
|
-
: h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') },
|
|
359
|
-
...renderInline(p.text || ''),
|
|
360
|
-
p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null),
|
|
361
|
-
md: (p) => MdNode(p),
|
|
362
|
-
code: (p) => CodeNode(p),
|
|
363
|
-
tool: (p) => ToolCallNode(p),
|
|
364
|
-
tool_call: (p) => ToolCallNode(p),
|
|
365
|
-
tool_result: (p) => ToolCallNode({ ...p, name: p.name || 'tool_result', result: p.text != null ? p.text : p.result }),
|
|
366
|
-
thinking: (p) => ThinkingNode(p),
|
|
367
|
-
image: (p) => {
|
|
368
|
-
// Guard both the wrapping link and the img src against unsafe schemes
|
|
369
|
-
// (e.g. a data:text/html src) so an embedded-image part from untrusted
|
|
370
|
-
// markdown can't smuggle an active payload.
|
|
371
|
-
const imgSrc = safeUrl(p.src);
|
|
372
|
-
const linkHref = safeUrl(p.href || p.src);
|
|
373
|
-
if (!imgSrc) return h('span', { class: 'chat-image-blocked' }, p.alt || 'image blocked (unsafe url)');
|
|
374
|
-
return h('a', { class: 'chat-image', href: linkHref || imgSrc, target: '_blank', rel: 'noopener noreferrer', 'aria-label': p.alt || `embedded image: ${imgSrc}` },
|
|
375
|
-
h('img', { src: imgSrc, alt: p.alt || `embedded image from ${imgSrc}`, loading: 'lazy' }),
|
|
376
|
-
p.caption ? h('span', { class: 'cap' }, p.caption) : null);
|
|
377
|
-
},
|
|
378
|
-
pdf: (p) => h('div', { class: 'chat-pdf' },
|
|
379
|
-
h('div', { class: 'chat-pdf-head' },
|
|
380
|
-
h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('file-pdf', { size: 18 })),
|
|
381
|
-
h('span', { class: 'name' }, p.name || 'document.pdf'),
|
|
382
|
-
p.size != null ? h('span', { class: 'size' }, fmtBytes(p.size)) : null,
|
|
383
|
-
h('a', { class: 'open', href: p.src, target: '_blank', rel: 'noopener', 'aria-label': `open PDF: ${p.name || 'document.pdf'}` }, 'open ->')
|
|
384
|
-
),
|
|
385
|
-
h('embed', { src: p.src, type: 'application/pdf', 'aria-label': `PDF document: ${p.name || 'document.pdf'}` })),
|
|
386
|
-
file: (p) => h('a', { class: 'chat-file', href: p.src, target: '_blank', rel: 'noopener', download: p.name || true, 'aria-label': `download file: ${p.name || 'attachment'} (${p.kindLabel || (p.name || '').split('.').pop().toUpperCase()})` },
|
|
387
|
-
h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon(fileIconName(p.name), { size: 22 })),
|
|
388
|
-
h('span', { class: 'meta' },
|
|
389
|
-
h('span', { class: 'name' }, p.name || 'attachment'),
|
|
390
|
-
h('span', { class: 'size' }, [p.kindLabel || (p.name || '').split('.').pop().toUpperCase(), p.size != null ? fmtBytes(p.size) : null].filter(Boolean).join(' · '))
|
|
391
|
-
),
|
|
392
|
-
h('span', { class: 'go', 'aria-hidden': 'true' }, Icon('arrow-down'))),
|
|
393
|
-
link: (p) => h('a', { class: 'chat-link', href: safeUrl(p.href) || '#', target: '_blank', rel: 'noopener noreferrer', 'aria-label': `link: ${p.title || p.href}` },
|
|
394
|
-
p.thumb ? h('img', { class: 'thumb', src: p.thumb, alt: `preview for ${p.title || p.href}` }) : null,
|
|
395
|
-
h('span', { class: 'meta' },
|
|
396
|
-
h('span', { class: 'host' }, p.host || (() => { try { return new URL(p.href).host; } catch { return ''; } })()),
|
|
397
|
-
h('span', { class: 'title' }, p.title || p.href),
|
|
398
|
-
p.desc ? h('span', { class: 'desc' }, p.desc) : null
|
|
399
|
-
))
|
|
10
|
+
// This module is a barrel: the renderers live in single-responsibility
|
|
11
|
+
// submodules under ./chat-message-parts/ (inline utilities, the markdown/code
|
|
12
|
+
// prose nodes, the agent tool/thinking nodes, and the PART_RENDERERS dispatch
|
|
13
|
+
// table plus attachment kinds), and the public export surface here is
|
|
14
|
+
// unchanged — no consumer import needs to move.
|
|
15
|
+
|
|
16
|
+
import { safeUrl, renderInline, copyToClipboardWithFeedback, injectCodeCopy } from './chat-message-parts/inline.js';
|
|
17
|
+
import { PART_RENDERERS, renderMessagePart, renderMessageParts } from './chat-message-parts/renderers.js';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
safeUrl, renderInline, copyToClipboardWithFeedback, injectCodeCopy,
|
|
21
|
+
PART_RENDERERS, renderMessagePart, renderMessageParts,
|
|
400
22
|
};
|
|
401
|
-
|
|
402
|
-
// Render one message part {kind, ...} to a vnode, keyed for webjsx diffing.
|
|
403
|
-
// `onKindRendered` is an optional (kind) => void hook so a caller can track
|
|
404
|
-
// per-kind render stats (chat.js uses this to keep its existing debug counter
|
|
405
|
-
// wired without this module owning that state itself).
|
|
406
|
-
export function renderMessagePart(p, key, onKindRendered) {
|
|
407
|
-
const fn = PART_RENDERERS[p.kind] || PART_RENDERERS.text;
|
|
408
|
-
const node = fn(p);
|
|
409
|
-
if (node && typeof node === 'object') node.props = { ...(node.props || {}), key: 'p' + key };
|
|
410
|
-
if (onKindRendered) onKindRendered(p.kind);
|
|
411
|
-
return node;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Render a full `parts` array in order — the common case every chat surface
|
|
415
|
-
// actually calls (ChatMessage.bodyNodes today, any future host tomorrow).
|
|
416
|
-
export function renderMessageParts(parts, onKindRendered) {
|
|
417
|
-
return (parts || []).map((p, i) => renderMessagePart(p, i, onKindRendered));
|
|
418
|
-
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// ChatMinimap — the component itself: a single ref callback owning the
|
|
2
|
+
// imperative lifecycle (scroll listener, ResizeObserver rebinding, throttled
|
|
3
|
+
// node measurement, drag/click-to-scroll, hover tracking, teardown), painting
|
|
4
|
+
// through ./paint.js.
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
7
|
+
import { hasTextContent, isMappedRole, resolveMessageEl } from './preview.js';
|
|
8
|
+
import { paintMinimap } from './paint.js';
|
|
9
|
+
|
|
10
|
+
const h = webjsx.createElement;
|
|
11
|
+
|
|
12
|
+
export const CHAT_MINIMAP_WIDTH = 36;
|
|
13
|
+
const MEASURE_THROTTLE_MS = 150;
|
|
14
|
+
const MIN_SCROLLABLE_PX = 20;
|
|
15
|
+
|
|
16
|
+
export function ChatMinimap({ messages = [], getThreadEl, getMessageEl, width = CHAT_MINIMAP_WIDTH } = {}) {
|
|
17
|
+
// All mutable state lives on the container element itself (webjsx factories
|
|
18
|
+
// are pure-render; the ref callback owns the imperative lifecycle, same
|
|
19
|
+
// pattern as makeThreadAutoScroll in chat.js).
|
|
20
|
+
const state = {
|
|
21
|
+
scrollRatio: 0,
|
|
22
|
+
viewportRatio: 1,
|
|
23
|
+
visible: false,
|
|
24
|
+
nodes: /** @type {Array<{topRatio:number, heightRatio:number, msg:any, index:number}>} */ ([]),
|
|
25
|
+
hovered: false,
|
|
26
|
+
mouseYRatio: null,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const containerRef = (el) => {
|
|
30
|
+
if (!el) return;
|
|
31
|
+
if (el._dsMinimapCleanup) return; // already wired for this DOM node
|
|
32
|
+
let measureTimer = null;
|
|
33
|
+
let ro = null;
|
|
34
|
+
let threadEl = null;
|
|
35
|
+
let scrollListenerEl = null;
|
|
36
|
+
|
|
37
|
+
const render = () => paintMinimap(el, state, messages, width);
|
|
38
|
+
|
|
39
|
+
const updateScroll = () => {
|
|
40
|
+
const t = typeof getThreadEl === 'function' ? getThreadEl() : null;
|
|
41
|
+
if (!t) return;
|
|
42
|
+
const totalH = t.scrollHeight;
|
|
43
|
+
const clientH = t.clientHeight;
|
|
44
|
+
const scrollable = totalH - clientH;
|
|
45
|
+
state.visible = scrollable > MIN_SCROLLABLE_PX;
|
|
46
|
+
if (scrollable <= 0) {
|
|
47
|
+
state.scrollRatio = 0;
|
|
48
|
+
state.viewportRatio = 1;
|
|
49
|
+
} else {
|
|
50
|
+
state.scrollRatio = t.scrollTop / scrollable;
|
|
51
|
+
state.viewportRatio = clientH / totalH;
|
|
52
|
+
}
|
|
53
|
+
render();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const measureNodes = () => {
|
|
57
|
+
if (measureTimer) return; // throttled — one pending pass at a time
|
|
58
|
+
measureTimer = setTimeout(() => {
|
|
59
|
+
measureTimer = null;
|
|
60
|
+
const t = typeof getThreadEl === 'function' ? getThreadEl() : null;
|
|
61
|
+
if (!t) return;
|
|
62
|
+
const totalH = t.scrollHeight;
|
|
63
|
+
if (totalH <= 0) return;
|
|
64
|
+
const containerRect = t.getBoundingClientRect();
|
|
65
|
+
const newNodes = [];
|
|
66
|
+
for (let i = 0; i < messages.length; i++) {
|
|
67
|
+
const msg = messages[i];
|
|
68
|
+
if (!isMappedRole(msg && msg.role)) continue;
|
|
69
|
+
if (!hasTextContent(msg)) continue;
|
|
70
|
+
const msgEl = resolveMessageEl(t, getMessageEl, i);
|
|
71
|
+
if (!msgEl) continue;
|
|
72
|
+
const elRect = msgEl.getBoundingClientRect();
|
|
73
|
+
const top = elRect.top - containerRect.top + t.scrollTop;
|
|
74
|
+
newNodes.push({
|
|
75
|
+
topRatio: top / totalH,
|
|
76
|
+
heightRatio: elRect.height / totalH,
|
|
77
|
+
msg,
|
|
78
|
+
index: newNodes.length,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
state.nodes = newNodes;
|
|
82
|
+
render();
|
|
83
|
+
}, MEASURE_THROTTLE_MS);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const syncLayout = () => { updateScroll(); measureNodes(); };
|
|
87
|
+
|
|
88
|
+
// Rebinds scroll listener + ResizeObserver to whichever thread element
|
|
89
|
+
// getThreadEl currently resolves to (it may be null on first paint and
|
|
90
|
+
// become available once the thread's own ref fires).
|
|
91
|
+
const rebind = () => {
|
|
92
|
+
const t = typeof getThreadEl === 'function' ? getThreadEl() : null;
|
|
93
|
+
if (t === threadEl) return;
|
|
94
|
+
if (scrollListenerEl) scrollListenerEl.removeEventListener('scroll', updateScroll);
|
|
95
|
+
if (ro) { ro.disconnect(); ro = null; }
|
|
96
|
+
threadEl = t;
|
|
97
|
+
scrollListenerEl = t;
|
|
98
|
+
if (!t) return;
|
|
99
|
+
t.addEventListener('scroll', updateScroll, { passive: true });
|
|
100
|
+
ro = new ResizeObserver(syncLayout);
|
|
101
|
+
ro.observe(t);
|
|
102
|
+
if (t.firstElementChild) ro.observe(t.firstElementChild);
|
|
103
|
+
syncLayout();
|
|
104
|
+
};
|
|
105
|
+
rebind();
|
|
106
|
+
// Thread element may not exist yet on first mount; poll briefly (mirrors
|
|
107
|
+
// upstream's 50ms post-message-change settle) until it appears, then the
|
|
108
|
+
// ResizeObserver takes over for everything after.
|
|
109
|
+
const rebindPoll = setInterval(rebind, 200);
|
|
110
|
+
|
|
111
|
+
// Drag-to-scroll + click-to-jump on the strip itself.
|
|
112
|
+
let dragging = false;
|
|
113
|
+
const scrollToRatio = (viewportTopRatio) => {
|
|
114
|
+
const t = typeof getThreadEl === 'function' ? getThreadEl() : null;
|
|
115
|
+
if (!t) return;
|
|
116
|
+
const scrollable = t.scrollHeight - t.clientHeight;
|
|
117
|
+
if (scrollable <= 0) return;
|
|
118
|
+
const clamped = Math.max(0, Math.min(1 - state.viewportRatio, viewportTopRatio));
|
|
119
|
+
t.scrollTop = (clamped / (1 - state.viewportRatio)) * scrollable;
|
|
120
|
+
};
|
|
121
|
+
const ratioFromEvent = (ev) => {
|
|
122
|
+
const rect = el.getBoundingClientRect();
|
|
123
|
+
return (ev.clientY - rect.top) / rect.height;
|
|
124
|
+
};
|
|
125
|
+
const onMouseDown = (ev) => {
|
|
126
|
+
if (!state.visible) return;
|
|
127
|
+
dragging = true;
|
|
128
|
+
const clickRatio = ratioFromEvent(ev);
|
|
129
|
+
const grabOffset = clickRatio - state.scrollRatio * (1 - state.viewportRatio);
|
|
130
|
+
const insideBox = grabOffset >= 0 && grabOffset <= state.viewportRatio;
|
|
131
|
+
const offset = insideBox ? grabOffset : state.viewportRatio / 2;
|
|
132
|
+
scrollToRatio(clickRatio - offset);
|
|
133
|
+
const onMove = (mv) => { if (dragging) scrollToRatio(ratioFromEvent(mv) - offset); };
|
|
134
|
+
const onUp = () => { dragging = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
135
|
+
window.addEventListener('mousemove', onMove);
|
|
136
|
+
window.addEventListener('mouseup', onUp);
|
|
137
|
+
};
|
|
138
|
+
const onMouseEnter = () => { state.hovered = true; render(); };
|
|
139
|
+
const onMouseLeave = () => { state.hovered = false; state.mouseYRatio = null; render(); };
|
|
140
|
+
const onMouseMove = (ev) => { state.mouseYRatio = ratioFromEvent(ev); render(); };
|
|
141
|
+
el.addEventListener('mousedown', onMouseDown);
|
|
142
|
+
el.addEventListener('mouseenter', onMouseEnter);
|
|
143
|
+
el.addEventListener('mouseleave', onMouseLeave);
|
|
144
|
+
el.addEventListener('mousemove', onMouseMove);
|
|
145
|
+
|
|
146
|
+
el._dsMinimapCleanup = () => {
|
|
147
|
+
clearInterval(rebindPoll);
|
|
148
|
+
if (measureTimer) clearTimeout(measureTimer);
|
|
149
|
+
if (scrollListenerEl) scrollListenerEl.removeEventListener('scroll', updateScroll);
|
|
150
|
+
if (ro) ro.disconnect();
|
|
151
|
+
el.removeEventListener('mousedown', onMouseDown);
|
|
152
|
+
el.removeEventListener('mouseenter', onMouseEnter);
|
|
153
|
+
el.removeEventListener('mouseleave', onMouseLeave);
|
|
154
|
+
el.removeEventListener('mousemove', onMouseMove);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
render();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return h('div', {
|
|
161
|
+
class: 'chat-minimap',
|
|
162
|
+
ref: containerRef,
|
|
163
|
+
role: 'navigation',
|
|
164
|
+
'aria-label': 'conversation scroll overview',
|
|
165
|
+
style: 'width:' + width + 'px',
|
|
166
|
+
});
|
|
167
|
+
}
|