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,106 @@
1
+ // URL safety, the inline-only markdown subset, and the shared
2
+ // copy-with-label-flip helpers — the pieces every part renderer builds on and
3
+ // the only ones a caller may reasonably use standalone.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ const h = webjsx.createElement;
7
+
8
+ // Reject dangerous URL schemes (javascript:, data:, vbscript:, file:) so an
9
+ // inline markdown link or an image src built from untrusted text can't smuggle
10
+ // a script-executing or data-exfiltrating URL past the inline renderer (which
11
+ // does NOT pass through DOMPurify the way the full md path does). http(s),
12
+ // mailto, protocol-relative, root/relative, and anchor links are allowed.
13
+ export function safeUrl(url) {
14
+ const s = String(url == null ? '' : url).trim();
15
+ if (!s) return null;
16
+ if (/^(\/|\.|#|\?)/.test(s) || s.startsWith('//')) return s;
17
+ const scheme = (s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1];
18
+ if (!scheme) return s; // schemeless relative
19
+ return /^(https?|mailto|tel)$/i.test(scheme) ? s : null;
20
+ }
21
+
22
+ // Inline-only markdown subset; safe for chat bubbles.
23
+ export function renderInline(text) {
24
+ if (text == null) return [];
25
+ const out = [];
26
+ const re = /(\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
27
+ let last = 0; let m; let i = 0;
28
+ const push = (n) => out.push(n);
29
+ while ((m = re.exec(text)) !== null) {
30
+ if (m.index > last) push(h('span', { key: 's' + i + 'a' }, text.slice(last, m.index)));
31
+ if (m[2] != null) push(h('strong', { key: 's' + i }, m[2]));
32
+ else if (m[3] != null) push(h('em', { key: 's' + i }, m[3]));
33
+ else if (m[4] != null) push(h('code', { key: 's' + i, class: 'chat-tick' }, m[4]));
34
+ else if (m[5] != null) {
35
+ const safe = safeUrl(m[6]);
36
+ // A link with a rejected (unsafe) scheme degrades to its plain label
37
+ // text rather than a clickable, scheme-smuggling anchor.
38
+ if (safe) push(h('a', { key: 's' + i, href: safe, target: '_blank', rel: 'noopener noreferrer' }, m[5]));
39
+ else push(h('span', { key: 's' + i }, m[5]));
40
+ }
41
+ last = m.index + m[0].length; i += 1;
42
+ }
43
+ if (last < text.length) push(h('span', { key: 's' + i + 'a' }, text.slice(last)));
44
+ return out;
45
+ }
46
+
47
+ // Map file extension -> line-icon name (drawn SVG, not a decorative glyph).
48
+ 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' };
49
+ export function fileIconName(name) {
50
+ const ext = String(name || '').split('.').pop().toLowerCase();
51
+ return FILE_ICONS[ext] || 'file';
52
+ }
53
+
54
+ // Shared clipboard-copy-with-label-flip: was three verbatim-identical blocks
55
+ // (chat.js's injectCodeCopy, CodeNode.onCopy, ToolCallNode.copyText) before
56
+ // this extraction. Same behavior in every caller: try the async Clipboard
57
+ // API, fall back to a hidden textarea + execCommand('copy') when unavailable,
58
+ // flip the trigger button's own label/class to "copied" for ~1.6s either way.
59
+ export function copyToClipboardWithFeedback(text, btn) {
60
+ const done = () => {
61
+ btn.textContent = 'copied';
62
+ btn.classList.add('is-copied');
63
+ setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600);
64
+ };
65
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done).catch(() => {});
66
+ 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 */ } }
67
+ }
68
+
69
+ // Inject a per-block copy button into every <pre> inside a rendered-markdown
70
+ // container. claude.ai/code and Claude Desktop give EVERY fenced block a hover
71
+ // copy affordance; the chat surface had only a whole-message copy. Idempotent:
72
+ // marks each <pre> with data-copy-wired so re-renders don't stack buttons. The
73
+ // button reveals on .chat-code-block:hover/:focus-within (CSS) and flips its
74
+ // label copy -> copied for ~1.6s. Drawn with a real icon + word, no glyph.
75
+ export function injectCodeCopy(container) {
76
+ if (!container) return;
77
+ container.querySelectorAll('pre').forEach((pre) => {
78
+ if (pre.dataset.copyWired === '1') return;
79
+ pre.dataset.copyWired = '1';
80
+ // Wrap the <pre> in a position:relative shell so the button can sit
81
+ // top-right without disturbing code layout.
82
+ const shell = document.createElement('div');
83
+ shell.className = 'chat-code-block';
84
+ pre.parentNode.insertBefore(shell, pre);
85
+ shell.appendChild(pre);
86
+ // Surface the fenced language as a small header tab (claude.ai/code
87
+ // shows the language on every block, not just the structured CodeNode).
88
+ // The highlighter sets language-xx / lang-xx on the inner <code>.
89
+ const codeEl = pre.querySelector('code');
90
+ const langCls = codeEl && (codeEl.className || '').match(/(?:language|lang)-([a-z0-9+#]+)/i);
91
+ if (langCls && langCls[1]) {
92
+ const lang = document.createElement('span');
93
+ lang.className = 'chat-code-lang';
94
+ lang.setAttribute('aria-hidden', 'true');
95
+ lang.textContent = langCls[1];
96
+ shell.appendChild(lang);
97
+ }
98
+ const btn = document.createElement('button');
99
+ btn.type = 'button';
100
+ btn.className = 'chat-code-copy';
101
+ btn.setAttribute('aria-label', 'copy code');
102
+ btn.textContent = 'copy';
103
+ btn.addEventListener('click', () => copyToClipboardWithFeedback(pre.innerText, btn));
104
+ shell.appendChild(btn);
105
+ });
106
+ }
@@ -0,0 +1,133 @@
1
+ // The two heavyweight prose bubbles: MdNode (markdown parse with a streaming
2
+ // throttle, idle-deferred settled parse, selection-safe innerHTML swap, and
3
+ // mermaid/math enrichment) and CodeNode (cached Prism highlight + copy head).
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { renderMarkdownCached, highlightCodeBlockCached } from '../../markdown-cache.js';
7
+ import { isDegraded as isMarkdownDegraded } from '../../markdown.js';
8
+ import { renderMermaidBlocksUnder } from '../../mermaid.js';
9
+ import { renderMathBlocksUnder } from '../../math.js';
10
+ import { injectCodeCopy, copyToClipboardWithFeedback } from './inline.js';
11
+
12
+ const h = webjsx.createElement;
13
+
14
+ const MD_STREAM_THROTTLE_MS = 120;
15
+ const MD_STREAM_MIN_DELTA_CHARS = 40;
16
+
17
+ // requestIdleCallback with a setTimeout fallback (Safari/non-browser test
18
+ // contexts lack the real API). A settled historical message's parse is not
19
+ // latency-critical the way a streaming turn's is — deferring it off the
20
+ // critical path keeps a session-load burst of N historical bubbles from
21
+ // racing N synchronous parses on the same tick.
22
+ const scheduleIdle = typeof requestIdleCallback === 'function'
23
+ ? (fn) => requestIdleCallback(fn, { timeout: 500 })
24
+ : (fn) => setTimeout(fn, 0);
25
+
26
+ export function MdNode(p) {
27
+ const refSink = (el) => {
28
+ if (!el) return;
29
+ // Version the per-element source key with a degraded marker: a bubble
30
+ // rendered while the markdown loader was down re-renders (real markdown)
31
+ // once the loader recovers, instead of staying plain-escaped forever.
32
+ const srcKey = (isMarkdownDegraded() ? '~degraded~' : '') + (p.text || '');
33
+ if (el.dataset.mdSrc === srcKey) return;
34
+ // While streaming (text still growing, not the final settle), a full
35
+ // re-parse of the WHOLE accumulated text on every rAF tick is the
36
+ // dominant cost of a long stream. Throttle: skip the parse unless
37
+ // enough time or enough new characters landed since the last one.
38
+ // p.streamingCaret (already threaded by the host for the stream-head
39
+ // caret) marks "still streaming"; its absence forces the final parse
40
+ // so nothing is left un-parsed once the turn settles.
41
+ const parsedLen = el.dataset.mdParsedLen ? Number(el.dataset.mdParsedLen) : 0;
42
+ const lastParseAt = el.dataset.mdLastParseAt ? Number(el.dataset.mdLastParseAt) : 0;
43
+ const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
44
+ if (p.streamingCaret && parsedLen > 0 &&
45
+ (now - lastParseAt) < MD_STREAM_THROTTLE_MS &&
46
+ (srcKey.length - parsedLen) < MD_STREAM_MIN_DELTA_CHARS) {
47
+ // Not enough new content/time yet — paint raw text so the latest
48
+ // characters are visible, but defer the expensive re-parse.
49
+ el.textContent = p.text || '';
50
+ return;
51
+ }
52
+ el.dataset.mdSrc = srcKey;
53
+ el.dataset.mdParsedLen = String(srcKey.length);
54
+ el.dataset.mdLastParseAt = String(now);
55
+ // Markdown stack still loading (or down): paint the raw text
56
+ // synchronously so streamed tokens are visible the same frame they
57
+ // arrive (an empty bubble until the CDN import resolves reads as a
58
+ // hang); the resolved render swaps in sanitized markdown in place.
59
+ if (isMarkdownDegraded()) el.textContent = p.text || '';
60
+ function doParse() {
61
+ renderMarkdownCached(p.text || '').then((html) => {
62
+ // The element may have been recycled (webjsx applyDiff reused this
63
+ // DOM node for a different message) or detached by the time an
64
+ // idle-deferred parse resolves -- re-check the source key still
65
+ // matches before swapping innerHTML into what could now be a
66
+ // completely different message's bubble.
67
+ if (el.dataset.mdSrc !== srcKey) return;
68
+ const swap = () => {
69
+ el.innerHTML = html;
70
+ delete el.dataset.mathWired;
71
+ injectCodeCopy(el);
72
+ // Diagram/math enrichment runs AFTER sanitized HTML is in the DOM
73
+ // (never on raw markdown source) and is best-effort: a failed or
74
+ // still-loading mermaid/katex CDN leaves the fenced/literal source
75
+ // visible rather than blocking or blanking the bubble.
76
+ renderMermaidBlocksUnder(el).catch(() => {});
77
+ renderMathBlocksUnder(el).catch(() => {});
78
+ };
79
+ // Don't blow away an active text selection inside this bubble mid-swap
80
+ // (e.g. the user is mid-copy while a stream tick settles). Defer the
81
+ // swap once, until the selection changes (cleared or moved elsewhere).
82
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
83
+ if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
84
+ const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); swap(); };
85
+ document.addEventListener('selectionchange', onSelChange, { once: true });
86
+ return;
87
+ }
88
+ swap();
89
+ });
90
+ }
91
+ // Streaming turns parse immediately (latency-critical: the user is
92
+ // watching this bubble grow). A settled historical message (no
93
+ // streamingCaret) is deferred to an idle slot -- not on the critical
94
+ // render path, so a page mounting many historical bubbles at once
95
+ // (session load) doesn't burst-parse them all synchronously.
96
+ if (p.streamingCaret) doParse();
97
+ else scheduleIdle(doParse);
98
+ };
99
+ return h('div', { class: 'chat-bubble chat-md', ref: refSink });
100
+ }
101
+
102
+ export function CodeNode(p) {
103
+ const refSink = (el) => {
104
+ if (!el) return;
105
+ // Key on the full code, not its length: two different blocks of the same
106
+ // length (e.g. an edit that swaps a line) would otherwise share a key and
107
+ // skip re-highlighting, leaving stale syntax coloring.
108
+ const codeKey = (p.lang || '') + '|' + (p.code || '');
109
+ if (el.dataset.codeKey === codeKey) return;
110
+ el.dataset.codeKey = codeKey;
111
+ // Same in-progress-selection guard as MdNode.refSink: don't wipe an
112
+ // active text selection inside this block mid-stream-settle. Defer
113
+ // the highlight swap once, until the selection changes.
114
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
115
+ if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
116
+ const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); highlightCodeBlockCached(el); };
117
+ document.addEventListener('selectionchange', onSelChange, { once: true });
118
+ return;
119
+ }
120
+ highlightCodeBlockCached(el);
121
+ };
122
+ // Copy the raw code (not the highlighted DOM) for the structured CodeNode.
123
+ const onCopy = (e) => copyToClipboardWithFeedback(p.code || '', e.currentTarget);
124
+ return h('div', { class: 'chat-bubble chat-code', ref: refSink },
125
+ h('div', { class: 'chat-code-head' },
126
+ h('span', { class: 'lang' }, p.lang || 'code'),
127
+ p.filename ? h('span', { class: 'name' }, p.filename) : null,
128
+ h('span', { class: 'spread' }),
129
+ h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')
130
+ ),
131
+ h('pre', {}, h('code', { class: p.lang ? 'lang-' + p.lang + ' language-' + p.lang : '' }, p.code || ''))
132
+ );
133
+ }
@@ -0,0 +1,89 @@
1
+ // The one PART_RENDERERS dispatch table every chat surface's message parts
2
+ // render through, plus the attachment part kinds (image / pdf / file / link)
3
+ // that live nowhere else, and the two entry points callers actually use.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Icon } from '../shell.js';
7
+ import { fmtFileSize } from '../files.js';
8
+ import { safeUrl, renderInline, fileIconName } from './inline.js';
9
+ import { MdNode, CodeNode } from './prose-nodes.js';
10
+ import { ToolCallNode, ThinkingNode } from './agent-nodes.js';
11
+
12
+ const h = webjsx.createElement;
13
+
14
+ // ONE byte format across the kit (mirrors chat.js's own fmtBytes alias).
15
+ const fmtBytes = fmtFileSize;
16
+
17
+ // The one dispatch table every chat surface's message parts render through.
18
+ export const PART_RENDERERS = {
19
+ text: (p) => p.preShell
20
+ // Streaming prose that already contains a code fence (or a huge tail
21
+ // window) renders as a plain monospaced <pre> so it does not reflow from
22
+ // prose to a styled block on settle (no Prism mid-stream). The settled
23
+ // turn renders real markdown. `streamHead` is an optional head line for
24
+ // the tail-window path ('streaming · N KB so far').
25
+ ? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
26
+ ...[p.streamHead ? h('div', { key: 'sh', class: 'chat-stream-head', role: 'status', 'aria-live': 'polite' }, p.streamHead) : null,
27
+ h('pre', { key: 'pre' }, h('code', {}, p.text || '')),
28
+ p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null].filter(Boolean))
29
+ : h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') },
30
+ ...renderInline(p.text || ''),
31
+ p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null),
32
+ md: (p) => MdNode(p),
33
+ code: (p) => CodeNode(p),
34
+ tool: (p) => ToolCallNode(p),
35
+ tool_call: (p) => ToolCallNode(p),
36
+ tool_result: (p) => ToolCallNode({ ...p, name: p.name || 'tool_result', result: p.text != null ? p.text : p.result }),
37
+ thinking: (p) => ThinkingNode(p),
38
+ image: (p) => {
39
+ // Guard both the wrapping link and the img src against unsafe schemes
40
+ // (e.g. a data:text/html src) so an embedded-image part from untrusted
41
+ // markdown can't smuggle an active payload.
42
+ const imgSrc = safeUrl(p.src);
43
+ const linkHref = safeUrl(p.href || p.src);
44
+ if (!imgSrc) return h('span', { class: 'chat-image-blocked' }, p.alt || 'image blocked (unsafe url)');
45
+ return h('a', { class: 'chat-image', href: linkHref || imgSrc, target: '_blank', rel: 'noopener noreferrer', 'aria-label': p.alt || `embedded image: ${imgSrc}` },
46
+ h('img', { src: imgSrc, alt: p.alt || `embedded image from ${imgSrc}`, loading: 'lazy' }),
47
+ p.caption ? h('span', { class: 'cap' }, p.caption) : null);
48
+ },
49
+ pdf: (p) => h('div', { class: 'chat-pdf' },
50
+ h('div', { class: 'chat-pdf-head' },
51
+ h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('file-pdf', { size: 18 })),
52
+ h('span', { class: 'name' }, p.name || 'document.pdf'),
53
+ p.size != null ? h('span', { class: 'size' }, fmtBytes(p.size)) : null,
54
+ h('a', { class: 'open', href: p.src, target: '_blank', rel: 'noopener', 'aria-label': `open PDF: ${p.name || 'document.pdf'}` }, 'open ->')
55
+ ),
56
+ h('embed', { src: p.src, type: 'application/pdf', 'aria-label': `PDF document: ${p.name || 'document.pdf'}` })),
57
+ 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()})` },
58
+ h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon(fileIconName(p.name), { size: 22 })),
59
+ h('span', { class: 'meta' },
60
+ h('span', { class: 'name' }, p.name || 'attachment'),
61
+ h('span', { class: 'size' }, [p.kindLabel || (p.name || '').split('.').pop().toUpperCase(), p.size != null ? fmtBytes(p.size) : null].filter(Boolean).join(' · '))
62
+ ),
63
+ h('span', { class: 'go', 'aria-hidden': 'true' }, Icon('arrow-down'))),
64
+ link: (p) => h('a', { class: 'chat-link', href: safeUrl(p.href) || '#', target: '_blank', rel: 'noopener noreferrer', 'aria-label': `link: ${p.title || p.href}` },
65
+ p.thumb ? h('img', { class: 'thumb', src: p.thumb, alt: `preview for ${p.title || p.href}` }) : null,
66
+ h('span', { class: 'meta' },
67
+ h('span', { class: 'host' }, p.host || (() => { try { return new URL(p.href).host; } catch { return ''; } })()),
68
+ h('span', { class: 'title' }, p.title || p.href),
69
+ p.desc ? h('span', { class: 'desc' }, p.desc) : null
70
+ ))
71
+ };
72
+
73
+ // Render one message part {kind, ...} to a vnode, keyed for webjsx diffing.
74
+ // `onKindRendered` is an optional (kind) => void hook so a caller can track
75
+ // per-kind render stats (chat.js uses this to keep its existing debug counter
76
+ // wired without this module owning that state itself).
77
+ export function renderMessagePart(p, key, onKindRendered) {
78
+ const fn = PART_RENDERERS[p.kind] || PART_RENDERERS.text;
79
+ const node = fn(p);
80
+ if (node && typeof node === 'object') node.props = { ...(node.props || {}), key: 'p' + key };
81
+ if (onKindRendered) onKindRendered(p.kind);
82
+ return node;
83
+ }
84
+
85
+ // Render a full `parts` array in order — the common case every chat surface
86
+ // actually calls (ChatMessage.bodyNodes today, any future host tomorrow).
87
+ export function renderMessageParts(parts, onKindRendered) {
88
+ return (parts || []).map((p, i) => renderMessagePart(p, i, onKindRendered));
89
+ }