anentrypoint-design 0.0.369 → 0.0.371

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.369",
3
+ "version": "0.0.371",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -18,9 +18,12 @@
18
18
  import * as webjsx from '../../vendor/webjsx/index.js';
19
19
  import { renderMarkdownCached, highlightCodeBlockCached } from '../markdown-cache.js';
20
20
  import { isDegraded as isMarkdownDegraded } from '../markdown.js';
21
+ import { renderMermaidBlocksUnder } from '../mermaid.js';
22
+ import { renderMathBlocksUnder } from '../math.js';
21
23
  import { Icon } from './shell.js';
22
24
  import { fmtFileSize } from './files.js';
23
25
  import { t } from '../i18n.js';
26
+ import { GitDiffView } from './git-status.js';
24
27
 
25
28
  const h = webjsx.createElement;
26
29
 
@@ -181,7 +184,17 @@ function MdNode(p) {
181
184
  // matches before swapping innerHTML into what could now be a
182
185
  // completely different message's bubble.
183
186
  if (el.dataset.mdSrc !== srcKey) return;
184
- const swap = () => { el.innerHTML = html; injectCodeCopy(el); };
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
+ };
185
198
  // Don't blow away an active text selection inside this bubble mid-swap
186
199
  // (e.g. the user is mid-copy while a stream tick settles). Defer the
187
200
  // swap once, until the selection changes (cleared or moved elsewhere).
@@ -238,6 +251,22 @@ function CodeNode(p) {
238
251
  );
239
252
  }
240
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
+
241
270
  // Freddie-flavored agent parts: collapsible tool-call card, tool-result, and
242
271
  // transient thinking indicator. Each renders as a `chat-bubble` variant so the
243
272
  // surrounding ChatMessage chrome (avatar/meta/reactions) stays consistent.
@@ -278,9 +307,18 @@ function ToolCallNode(p) {
278
307
  hasArgs ? h('div', { class: 'chat-tool-section' },
279
308
  sectionLabel('args', argsText),
280
309
  h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))) : null,
281
- resultText ? h('div', { class: 'chat-tool-section' },
282
- sectionLabel(p.error ? 'error' : 'result', resultText),
283
- h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText)))
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))))
284
322
  // A finished tool with no output would otherwise render no result
285
323
  // section, reading identically to a still-running tool. Show an
286
324
  // explicit placeholder so "done, empty" is distinguishable.
@@ -0,0 +1,309 @@
1
+ // ChatMinimap — a compact scroll-position overview for a long chat thread.
2
+ //
3
+ // Ported from pi-web's ChatMinimap.tsx (github.com/agegr/pi-web) behavior,
4
+ // reimplemented as a pure webjsx factory over this kit's own DOM-ref pattern
5
+ // (no React refs/hooks — a single `ref` callback owns measurement + listeners,
6
+ // matching makeThreadAutoScroll's shape in chat.js).
7
+ //
8
+ // What it does:
9
+ // - Renders one dot per message that has visible text, positioned/spaced
10
+ // proportionally to that message's real position within the thread's
11
+ // scrollHeight (a message-density map, not a fixed-step list).
12
+ // - Color-codes dots by role: user vs assistant (two tones, tokens only).
13
+ // - Draws a viewport-position box (drag to scroll) reflecting scrollTop/
14
+ // scrollHeight ratios, updated on the thread's own scroll events.
15
+ // - Click (or drag) anywhere in the strip scrolls the thread so that point
16
+ // maps to the equivalent scroll ratio.
17
+ // - Hover reveals collision-resolved preview tooltips (message text, first
18
+ // ~200 chars) to the left of the strip; the nearest dot to the cursor is
19
+ // highlighted (scaled) while hovering.
20
+ // - Re-measures on ResizeObserver (thread container + content) and on
21
+ // message-count change (debounced), never synchronously in the scroll
22
+ // handler — scroll only updates the (cheap) viewport ratio.
23
+ //
24
+ // Usage: mount inside the same flex row as the scrollable thread, e.g.
25
+ // h('div', { class: 'chat-minimap-row' },
26
+ // h('div', { class: 'chat-thread', ref: threadRef, ... }, ...),
27
+ // ChatMinimap({ getThreadEl: () => threadEl, messages }))
28
+ //
29
+ // Props:
30
+ // messages : [{ role: 'user'|'assistant'|..., text?, content?, parts? }]
31
+ // — same shape chat.js/agent-chat.js already carry. Only
32
+ // 'user'/'assistant' roles get a node; others are skipped
33
+ // (matches upstream, which only maps user/assistant).
34
+ // getThreadEl : () => HTMLElement | null — returns the live scroll
35
+ // container to observe/scroll. A getter (not the element
36
+ // itself) so the minimap can be built before the thread ref
37
+ // fires and still resolve the container lazily on measure.
38
+ // getMessageEl : optional (index) => HTMLElement | null — returns the DOM
39
+ // node for message `index` (its top/height inside the
40
+ // thread drive the dot's position). Falls back to querying
41
+ // '[data-msg-index]' children of the thread element when
42
+ // omitted, so a host that tags its message rows with
43
+ // data-msg-index="N" needs no extra wiring.
44
+ // width : minimap strip width in px (default 36, matches upstream).
45
+ export const CHAT_MINIMAP_WIDTH = 36;
46
+ const MEASURE_THROTTLE_MS = 150;
47
+ const TOOLTIP_HEIGHT = 22;
48
+ const TOOLTIP_GAP = 2;
49
+ const TOOLTIP_WIDTH = 200;
50
+ const PREVIEW_CHARS = 200;
51
+ const MIN_SCROLLABLE_PX = 20;
52
+
53
+ import * as webjsx from '../../vendor/webjsx/index.js';
54
+ const h = webjsx.createElement;
55
+
56
+ // Extract a short preview string the same way upstream's getMessagePreview
57
+ // does: prefer flat `text`, then `content` (string or array-of-parts), then
58
+ // `parts` (this kit's structured shape) joined and trimmed.
59
+ function messagePreview(m) {
60
+ if (!m) return '';
61
+ if (typeof m.text === 'string' && m.text) return m.text.slice(0, PREVIEW_CHARS);
62
+ if (typeof m.content === 'string' && m.content) return m.content.slice(0, PREVIEW_CHARS);
63
+ const partsSrc = Array.isArray(m.content) ? m.content : (Array.isArray(m.parts) ? m.parts : null);
64
+ if (partsSrc) {
65
+ const joined = partsSrc
66
+ .map((p) => (typeof p === 'string' ? p : (p && (p.text || (p.type === 'text' && p.text)) || '')))
67
+ .filter(Boolean)
68
+ .join(' ');
69
+ if (joined) return joined.slice(0, PREVIEW_CHARS);
70
+ }
71
+ return '';
72
+ }
73
+
74
+ // True when a message carries any renderable text (dots skip empty/tool-only
75
+ // turns the way upstream's hasTextContent does).
76
+ function hasTextContent(m) {
77
+ return !!messagePreview(m);
78
+ }
79
+
80
+ function isMappedRole(role) {
81
+ return role === 'user' || role === 'assistant';
82
+ }
83
+
84
+ // Resolve the DOM node for message index `i`, via the host's getter or the
85
+ // data-msg-index fallback.
86
+ function resolveMessageEl(threadEl, getMessageEl, i) {
87
+ if (typeof getMessageEl === 'function') return getMessageEl(i) || null;
88
+ return threadEl.querySelector('[data-msg-index="' + i + '"]') || null;
89
+ }
90
+
91
+ export function ChatMinimap({ messages = [], getThreadEl, getMessageEl, width = CHAT_MINIMAP_WIDTH } = {}) {
92
+ // All mutable state lives on the container element itself (webjsx factories
93
+ // are pure-render; the ref callback owns the imperative lifecycle, same
94
+ // pattern as makeThreadAutoScroll in chat.js).
95
+ const state = {
96
+ scrollRatio: 0,
97
+ viewportRatio: 1,
98
+ visible: false,
99
+ nodes: /** @type {Array<{topRatio:number, heightRatio:number, msg:any, index:number}>} */ ([]),
100
+ hovered: false,
101
+ mouseYRatio: null,
102
+ };
103
+
104
+ const containerRef = (el) => {
105
+ if (!el) return;
106
+ if (el._dsMinimapCleanup) return; // already wired for this DOM node
107
+ let measureTimer = null;
108
+ let ro = null;
109
+ let threadEl = null;
110
+ let scrollListenerEl = null;
111
+
112
+ const render = () => paintMinimap(el, state, messages, width);
113
+
114
+ const updateScroll = () => {
115
+ const t = typeof getThreadEl === 'function' ? getThreadEl() : null;
116
+ if (!t) return;
117
+ const totalH = t.scrollHeight;
118
+ const clientH = t.clientHeight;
119
+ const scrollable = totalH - clientH;
120
+ state.visible = scrollable > MIN_SCROLLABLE_PX;
121
+ if (scrollable <= 0) {
122
+ state.scrollRatio = 0;
123
+ state.viewportRatio = 1;
124
+ } else {
125
+ state.scrollRatio = t.scrollTop / scrollable;
126
+ state.viewportRatio = clientH / totalH;
127
+ }
128
+ render();
129
+ };
130
+
131
+ const measureNodes = () => {
132
+ if (measureTimer) return; // throttled — one pending pass at a time
133
+ measureTimer = setTimeout(() => {
134
+ measureTimer = null;
135
+ const t = typeof getThreadEl === 'function' ? getThreadEl() : null;
136
+ if (!t) return;
137
+ const totalH = t.scrollHeight;
138
+ if (totalH <= 0) return;
139
+ const containerRect = t.getBoundingClientRect();
140
+ const newNodes = [];
141
+ for (let i = 0; i < messages.length; i++) {
142
+ const msg = messages[i];
143
+ if (!isMappedRole(msg && msg.role)) continue;
144
+ if (!hasTextContent(msg)) continue;
145
+ const msgEl = resolveMessageEl(t, getMessageEl, i);
146
+ if (!msgEl) continue;
147
+ const elRect = msgEl.getBoundingClientRect();
148
+ const top = elRect.top - containerRect.top + t.scrollTop;
149
+ newNodes.push({
150
+ topRatio: top / totalH,
151
+ heightRatio: elRect.height / totalH,
152
+ msg,
153
+ index: newNodes.length,
154
+ });
155
+ }
156
+ state.nodes = newNodes;
157
+ render();
158
+ }, MEASURE_THROTTLE_MS);
159
+ };
160
+
161
+ const syncLayout = () => { updateScroll(); measureNodes(); };
162
+
163
+ // Rebinds scroll listener + ResizeObserver to whichever thread element
164
+ // getThreadEl currently resolves to (it may be null on first paint and
165
+ // become available once the thread's own ref fires).
166
+ const rebind = () => {
167
+ const t = typeof getThreadEl === 'function' ? getThreadEl() : null;
168
+ if (t === threadEl) return;
169
+ if (scrollListenerEl) scrollListenerEl.removeEventListener('scroll', updateScroll);
170
+ if (ro) { ro.disconnect(); ro = null; }
171
+ threadEl = t;
172
+ scrollListenerEl = t;
173
+ if (!t) return;
174
+ t.addEventListener('scroll', updateScroll, { passive: true });
175
+ ro = new ResizeObserver(syncLayout);
176
+ ro.observe(t);
177
+ if (t.firstElementChild) ro.observe(t.firstElementChild);
178
+ syncLayout();
179
+ };
180
+ rebind();
181
+ // Thread element may not exist yet on first mount; poll briefly (mirrors
182
+ // upstream's 50ms post-message-change settle) until it appears, then the
183
+ // ResizeObserver takes over for everything after.
184
+ const rebindPoll = setInterval(rebind, 200);
185
+
186
+ // Drag-to-scroll + click-to-jump on the strip itself.
187
+ let dragging = false;
188
+ const scrollToRatio = (viewportTopRatio) => {
189
+ const t = typeof getThreadEl === 'function' ? getThreadEl() : null;
190
+ if (!t) return;
191
+ const scrollable = t.scrollHeight - t.clientHeight;
192
+ if (scrollable <= 0) return;
193
+ const clamped = Math.max(0, Math.min(1 - state.viewportRatio, viewportTopRatio));
194
+ t.scrollTop = (clamped / (1 - state.viewportRatio)) * scrollable;
195
+ };
196
+ const ratioFromEvent = (ev) => {
197
+ const rect = el.getBoundingClientRect();
198
+ return (ev.clientY - rect.top) / rect.height;
199
+ };
200
+ const onMouseDown = (ev) => {
201
+ if (!state.visible) return;
202
+ dragging = true;
203
+ const clickRatio = ratioFromEvent(ev);
204
+ const grabOffset = clickRatio - state.scrollRatio * (1 - state.viewportRatio);
205
+ const insideBox = grabOffset >= 0 && grabOffset <= state.viewportRatio;
206
+ const offset = insideBox ? grabOffset : state.viewportRatio / 2;
207
+ scrollToRatio(clickRatio - offset);
208
+ const onMove = (mv) => { if (dragging) scrollToRatio(ratioFromEvent(mv) - offset); };
209
+ const onUp = () => { dragging = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
210
+ window.addEventListener('mousemove', onMove);
211
+ window.addEventListener('mouseup', onUp);
212
+ };
213
+ const onMouseEnter = () => { state.hovered = true; render(); };
214
+ const onMouseLeave = () => { state.hovered = false; state.mouseYRatio = null; render(); };
215
+ const onMouseMove = (ev) => { state.mouseYRatio = ratioFromEvent(ev); render(); };
216
+ el.addEventListener('mousedown', onMouseDown);
217
+ el.addEventListener('mouseenter', onMouseEnter);
218
+ el.addEventListener('mouseleave', onMouseLeave);
219
+ el.addEventListener('mousemove', onMouseMove);
220
+
221
+ el._dsMinimapCleanup = () => {
222
+ clearInterval(rebindPoll);
223
+ if (measureTimer) clearTimeout(measureTimer);
224
+ if (scrollListenerEl) scrollListenerEl.removeEventListener('scroll', updateScroll);
225
+ if (ro) ro.disconnect();
226
+ el.removeEventListener('mousedown', onMouseDown);
227
+ el.removeEventListener('mouseenter', onMouseEnter);
228
+ el.removeEventListener('mouseleave', onMouseLeave);
229
+ el.removeEventListener('mousemove', onMouseMove);
230
+ };
231
+
232
+ render();
233
+ };
234
+
235
+ return h('div', {
236
+ class: 'chat-minimap',
237
+ ref: containerRef,
238
+ role: 'navigation',
239
+ 'aria-label': 'conversation scroll overview',
240
+ style: 'width:' + width + 'px',
241
+ });
242
+ }
243
+
244
+ // Pure DOM paint: rebuilds the minimap's children from current `state`. Kept
245
+ // outside webjsx's own vdom diff (this subtree is imperative, like a canvas)
246
+ // because dot count/positions and hover/tooltip visibility change far more
247
+ // often than a full component re-render is warranted for.
248
+ function paintMinimap(el, state, messages, width) {
249
+ el.style.display = state.visible ? '' : 'none';
250
+ el.innerHTML = '';
251
+ if (!state.visible) return;
252
+
253
+ const viewportBox = document.createElement('div');
254
+ viewportBox.className = 'chat-minimap-viewport';
255
+ viewportBox.style.top = (state.scrollRatio * (1 - state.viewportRatio) * 100) + '%';
256
+ viewportBox.style.height = (state.viewportRatio * 100) + '%';
257
+ el.appendChild(viewportBox);
258
+
259
+ const centerLine = document.createElement('div');
260
+ centerLine.className = 'chat-minimap-centerline';
261
+ el.appendChild(centerLine);
262
+
263
+ const nodes = state.nodes;
264
+ let nearestIndex = null;
265
+ if (state.mouseYRatio != null && nodes.length) {
266
+ let best = 0;
267
+ for (let i = 1; i < nodes.length; i++) {
268
+ if (Math.abs(nodes[i].topRatio - state.mouseYRatio) < Math.abs(nodes[best].topRatio - state.mouseYRatio)) best = i;
269
+ }
270
+ nearestIndex = nodes[best].index;
271
+ }
272
+
273
+ for (const node of nodes) {
274
+ const dot = document.createElement('div');
275
+ const isUser = node.msg && node.msg.role === 'user';
276
+ dot.className = 'chat-minimap-dot ' + (isUser ? 'is-user' : 'is-assistant') + (state.hovered && nearestIndex === node.index ? ' is-nearest' : '');
277
+ dot.style.top = (node.topRatio * 100) + '%';
278
+ el.appendChild(dot);
279
+ }
280
+
281
+ if (state.hovered && nodes.length) {
282
+ const minimapHeightPx = el.clientHeight || 600;
283
+ const positions = nodes.map((n) => Math.round(n.topRatio * minimapHeightPx - TOOLTIP_HEIGHT / 2));
284
+ for (let pass = 0; pass < 10; pass++) {
285
+ for (let i = 1; i < positions.length; i++) {
286
+ const minTop = positions[i - 1] + TOOLTIP_HEIGHT + TOOLTIP_GAP;
287
+ if (positions[i] < minTop) positions[i] = minTop;
288
+ }
289
+ for (let i = positions.length - 2; i >= 0; i--) {
290
+ const maxTop = positions[i + 1] - TOOLTIP_HEIGHT - TOOLTIP_GAP;
291
+ if (positions[i] > maxTop) positions[i] = maxTop;
292
+ }
293
+ }
294
+ for (let i = 0; i < positions.length; i++) {
295
+ positions[i] = Math.max(0, Math.min(minimapHeightPx - TOOLTIP_HEIGHT, positions[i]));
296
+ }
297
+ nodes.forEach((node, i) => {
298
+ const preview = messagePreview(node.msg);
299
+ if (!preview) return;
300
+ const isNearest = nearestIndex === node.index;
301
+ const tip = document.createElement('div');
302
+ tip.className = 'chat-minimap-tooltip' + (isNearest ? ' is-nearest' : '') + (node.msg.role === 'user' ? ' is-user' : ' is-assistant');
303
+ tip.style.top = positions[i] + 'px';
304
+ tip.style.width = TOOLTIP_WIDTH + 'px';
305
+ tip.textContent = preview;
306
+ el.appendChild(tip);
307
+ });
308
+ }
309
+ }
@@ -7,10 +7,11 @@ import { initializeCachesEagerly, getCacheStats } from '../markdown-cache.js';
7
7
  import { register } from '../debug.js';
8
8
  import { Icon } from './shell.js';
9
9
  import { fmtFileSize } from './files.js';
10
- import { EmojiPicker } from './overlay-primitives.js';
10
+ import { EmojiPicker, CommandPalette } from './overlay-primitives.js';
11
11
  import { t } from '../i18n.js';
12
12
  import { renderMessagePart as sharedRenderMessagePart, safeUrl as sharedSafeUrl, renderInline as sharedRenderInline, injectCodeCopy as sharedInjectCodeCopy } from './chat-message-parts.js';
13
13
  import { avatarInitial } from './content.js';
14
+ import { extractAtQuery, filterFileEntries, buildAtInsertText } from '../file-mention.js';
14
15
 
15
16
  // Matches a trailing `:keyword` at the end of the composer draft (optionally
16
17
  // preceded by whitespace/start-of-string) so typing `:smile` opens an inline
@@ -32,7 +32,7 @@ export function ToolbarRow(...actions) {
32
32
  return h('div', { class: 'ds-ep-toolbar-row', role: 'toolbar' }, ...kids(flat));
33
33
  }
34
34
 
35
- export function Tabs({ items = [], active, onChange, children, 'aria-label': ariaLabel } = {}) {
35
+ export function Tabs({ items = [], active, onChange, children, 'aria-label': ariaLabel, onClose, scroll = false } = {}) {
36
36
  // Roving tabindex + arrow nav per WAI-ARIA tabs pattern.
37
37
  // Only the active tab is in the tab order; arrows move focus + activate.
38
38
  const activeIdx = Math.max(0, items.findIndex(it => it.id === active));
@@ -76,21 +76,48 @@ export function Tabs({ items = [], active, onChange, children, 'aria-label': ari
76
76
  ind.style.top = (head.offsetTop + head.offsetHeight - 2) + 'px';
77
77
  head.classList.add('has-slider');
78
78
  };
79
+ // scroll=true: tabs size to content (min/max-width) instead of stretching
80
+ // equally (flex:1) — the shape pi-web's TabBar uses for an open-file strip
81
+ // where tab count is unbounded and overflow-x scroll (already on
82
+ // .ds-ep-tabs-head) needs real per-tab widths to have something to scroll.
83
+ // onClose: per-item close affordance — a close button plus middle-click
84
+ // (auxclick button 1) to close, matching pi-web's TabBar. Opt-in: passing
85
+ // onClose without scroll still renders close buttons on the flex:1 tabs.
86
+ const closable = typeof onClose === 'function';
79
87
  return h('div', { class: 'ds-ep-tabs', ref: positionSlider },
80
- h('div', { class: 'ds-ep-tabs-head', role: 'tablist', 'aria-label': ariaLabel || 'tabs' },
81
- ...items.map((it, idx) => h('button', {
88
+ h('div', { class: 'ds-ep-tabs-head' + (scroll ? ' scroll' : ''), role: 'tablist', 'aria-label': ariaLabel || 'tabs' },
89
+ ...items.map((it, idx) => h('span', {
82
90
  key: it.id,
83
- type: 'button',
84
- class: 'ds-ep-tab' + (it.id === active ? ' active' : ''),
85
- role: 'tab',
86
- id: 'tab-' + it.id,
87
- 'aria-selected': it.id === active ? 'true' : 'false',
88
- 'aria-controls': 'tabpanel-' + it.id,
89
- 'aria-label': typeof it.label === 'string' ? it.label : ('tab ' + (idx + 1)),
90
- tabindex: idx === activeIdx ? '0' : '-1',
91
- onclick: () => onChange && onChange(it.id),
92
- onkeydown: (e) => onTabKeyDown(e, idx)
93
- }, it.label))
91
+ class: 'ds-ep-tab-wrap' + (it.id === active ? ' active' : ''),
92
+ onmousedown: closable ? (e) => { if (e.button === 1) e.preventDefault(); } : null,
93
+ onauxclick: closable ? (e) => {
94
+ if (e.button !== 1) return;
95
+ e.preventDefault();
96
+ e.stopPropagation();
97
+ onClose(it.id);
98
+ } : null
99
+ },
100
+ h('button', {
101
+ type: 'button',
102
+ class: 'ds-ep-tab' + (it.id === active ? ' active' : ''),
103
+ role: 'tab',
104
+ id: 'tab-' + it.id,
105
+ title: typeof it.label === 'string' ? it.label : undefined,
106
+ 'aria-selected': it.id === active ? 'true' : 'false',
107
+ 'aria-controls': 'tabpanel-' + it.id,
108
+ 'aria-label': typeof it.label === 'string' ? it.label : ('tab ' + (idx + 1)),
109
+ tabindex: idx === activeIdx ? '0' : '-1',
110
+ onclick: () => onChange && onChange(it.id),
111
+ onkeydown: (e) => onTabKeyDown(e, idx)
112
+ }, it.label),
113
+ closable ? h('button', {
114
+ type: 'button',
115
+ class: 'ds-ep-tab-close',
116
+ title: 'Close',
117
+ 'aria-label': 'Close ' + (typeof it.label === 'string' ? it.label : 'tab'),
118
+ onclick: (e) => { e.stopPropagation(); onClose(it.id); }
119
+ }, Icon('x', { size: 14 })) : null
120
+ ))
94
121
  ),
95
122
  // The sliding underline — child of the outer column (see positionSlider).
96
123
  // Keyed + decorative. Renders at 0-width until positioned (no-JS: hidden).
@@ -303,7 +303,20 @@ export function FilePreviewMedia({ src, type = 'other', name } = {}) {
303
303
  );
304
304
  }
305
305
 
306
- export function FilePreviewCode({ content = '', lang, filename } = {}) {
306
+ // FilePreviewCode the code/source pane. Two additive, opt-in affordances
307
+ // ported from pi-web's FileViewer (behavior only, not its React/SSE plumbing):
308
+ // wrap : host-controlled wrap-lines toggle. Pass `wrap` (current
309
+ // state) + `onWrapToggle` to show the control; omitted host
310
+ // keeps the old always-'pre' behavior (no regression).
311
+ // previewHtml : when the host has already rendered markdown/HTML to a safe
312
+ // HTML string (e.g. via markdown-cache.js's
313
+ // renderMarkdownCached, or a sanitized srcDoc for raw HTML
314
+ // files) it passes it here + `previewLabel` (defaults
315
+ // 'preview') to get a source/preview mode switcher, mirroring
316
+ // pi-web's DisplayMode tabs. This component never renders
317
+ // unsanitized markdown itself — that stays the host's job
318
+ // (chat.js already owns the sanitize+render pipeline).
319
+ export function FilePreviewCode({ content = '', lang, filename, wrap, onWrapToggle, previewHtml, previewLabel = 'preview', mode, onModeChange } = {}) {
307
320
  // A filename/lang header matching the chat CodeNode's .chat-code-head, plus
308
321
  // the same copy control (chat A1/A2 ship this run, so preview matches for
309
322
  // full cross-surface consistency).
@@ -313,13 +326,37 @@ export function FilePreviewCode({ content = '', lang, filename } = {}) {
313
326
  if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(content).then(done).catch(() => {});
314
327
  else { try { const t = document.createElement('textarea'); t.value = content; 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 */ } }
315
328
  };
329
+ const hasPreview = previewHtml != null && onModeChange;
330
+ const activeMode = hasPreview ? (mode || 'source') : 'source';
331
+ const modeSwitch = hasPreview ? h('div', { class: 'ds-preview-mode-switch', role: 'group', 'aria-label': 'file view mode' },
332
+ h('button', { type: 'button', class: 'ds-preview-mode-btn' + (activeMode === 'source' ? ' active' : ''),
333
+ 'aria-pressed': activeMode === 'source' ? 'true' : 'false', onclick: () => onModeChange('source') }, 'source'),
334
+ h('button', { type: 'button', class: 'ds-preview-mode-btn' + (activeMode === 'preview' ? ' active' : ''),
335
+ 'aria-pressed': activeMode === 'preview' ? 'true' : 'false', onclick: () => onModeChange('preview') }, previewLabel)
336
+ ) : null;
337
+ const wrapCtl = (onWrapToggle && activeMode === 'source') ? h('button', {
338
+ type: 'button', class: 'chat-code-copy ds-preview-wrap-toggle' + (wrap ? ' active' : ''),
339
+ title: wrap ? 'disable word wrap' : 'enable word wrap',
340
+ 'aria-label': wrap ? 'disable word wrap' : 'enable word wrap',
341
+ 'aria-pressed': wrap ? 'true' : 'false',
342
+ onclick: () => onWrapToggle(!wrap),
343
+ }, 'wrap') : null;
316
344
  return h('div', { class: 'ds-preview-code-wrap' },
317
345
  h('div', { class: 'chat-code-head ds-preview-code-head' },
318
346
  h('span', { class: 'lang' }, lang || 'text'),
319
347
  filename ? h('span', { class: 'name' }, filename) : null,
320
348
  h('span', { class: 'spread' }),
349
+ modeSwitch,
350
+ wrapCtl,
321
351
  h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')),
322
- codeBody({ content, lang })
352
+ activeMode === 'preview'
353
+ // webjsx has no innerHTML prop — set it imperatively via ref, same
354
+ // pattern as chat-message-parts.js/community.js. `previewHtml` is
355
+ // the HOST's already-sanitized HTML (e.g. via markdown-cache.js's
356
+ // renderMarkdownCached, which owns its own sanitize step); this
357
+ // component never sanitizes or fetches on its own.
358
+ ? h('div', { class: 'ds-preview-html', ref: (el) => { if (el) el.innerHTML = previewHtml; } })
359
+ : codeBody({ content, lang, wrap })
323
360
  );
324
361
  }
325
362
 
@@ -327,7 +364,7 @@ export function FilePreviewCode({ content = '', lang, filename } = {}) {
327
364
  // A ref triggers Prism over the <code> after mount (the bundle only auto-runs
328
365
  // Prism in the chat path), so the file preview is token-colored like Claude
329
366
  // Code's file pane. lineNumbers defaults on for code, off for plaintext.
330
- function codeBody({ content = '', lang } = {}) {
367
+ function codeBody({ content = '', lang, wrap = false } = {}) {
331
368
  const wantGutter = !!lang;
332
369
  const lineCount = content ? content.split('\n').length : 1;
333
370
  const gutter = wantGutter
@@ -338,7 +375,10 @@ function codeBody({ content = '', lang } = {}) {
338
375
  if (!el) return;
339
376
  try { highlightAllUnder(el); } catch { /* swallow: syntax highlighting is a progressive enhancement, plain code still renders */ }
340
377
  };
341
- return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') + (wantGutter ? ' has-gutter' : ''), ref: highlightRef },
378
+ // wrap: ported from pi-web's wrapLines toggle pre-wrap + anywhere-break
379
+ // instead of the default horizontal-scroll 'pre', for long unbroken lines
380
+ // (minified JS, long log lines) that are easier to read wrapped.
381
+ return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') + (wantGutter ? ' has-gutter' : '') + (wrap ? ' is-wrapped' : ''), ref: highlightRef },
342
382
  gutter,
343
383
  h('code', { class: lang ? 'language-' + lang : '' }, content));
344
384
  }
@@ -33,16 +33,32 @@ function statusMeta(status) {
33
33
  const EXT_TYPE = {
34
34
  js: 'code', mjs: 'code', cjs: 'code', ts: 'code', tsx: 'code', jsx: 'code',
35
35
  py: 'code', rs: 'code', go: 'code', java: 'code', c: 'code', cpp: 'code', h: 'code',
36
- css: 'code', scss: 'code', html: 'code', json: 'code', yml: 'code', yaml: 'code', toml: 'code', sh: 'code',
37
- md: 'document', mdx: 'document', txt: 'text',
38
- png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', svg: 'image', webp: 'image',
39
- mp4: 'video', mov: 'video', webm: 'video',
40
- mp3: 'audio', wav: 'audio',
41
- zip: 'archive', tar: 'archive', gz: 'archive',
36
+ css: 'code', less: 'code', scss: 'code', html: 'code', htm: 'code', json: 'code', jsonl: 'code',
37
+ yml: 'code', yaml: 'code', toml: 'code', sh: 'code', bash: 'code', zsh: 'code', fish: 'code',
38
+ sql: 'code', graphql: 'code', gql: 'code', tf: 'code', hcl: 'code',
39
+ md: 'document', mdx: 'document', txt: 'text', pdf: 'document', docx: 'document', doc: 'document',
40
+ png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', svg: 'image', webp: 'image', bmp: 'image', ico: 'image', avif: 'image',
41
+ mp4: 'video', mov: 'video', webm: 'video', avi: 'video', mkv: 'video',
42
+ mp3: 'audio', wav: 'audio', flac: 'audio', ogg: 'audio', m4a: 'audio',
43
+ zip: 'archive', tar: 'archive', gz: 'archive', '7z': 'archive', rar: 'archive', bz2: 'archive',
44
+ };
45
+
46
+ // Filenames (not extensions) that need a specific bucket regardless of any
47
+ // trailing dot-segment — lockfiles have no meaningful "extension" split and
48
+ // dotfiles like .gitignore/.env would otherwise fall through to 'other'.
49
+ const NAME_TYPE = {
50
+ 'package-lock.json': 'code', 'yarn.lock': 'code', 'bun.lock': 'code',
51
+ 'pnpm-lock.yaml': 'code', 'cargo.lock': 'code', 'composer.lock': 'code',
52
+ '.gitignore': 'text', '.gitattributes': 'text', '.gitmodules': 'text',
53
+ '.env': 'text', '.editorconfig': 'text', '.npmrc': 'text',
42
54
  };
43
55
 
44
56
  function fileTypeFromPath(pathname = '') {
45
57
  const base = pathname.split('/').pop() || pathname;
58
+ const lower = base.toLowerCase();
59
+ if (NAME_TYPE[lower]) return NAME_TYPE[lower];
60
+ if (lower.startsWith('.env.')) return 'text';
61
+ if (lower === 'dockerfile' || lower.startsWith('dockerfile.')) return 'code';
46
62
  const dot = base.lastIndexOf('.');
47
63
  if (dot <= 0) return 'other';
48
64
  return EXT_TYPE[base.slice(dot + 1).toLowerCase()] || 'other';
@@ -318,6 +318,33 @@ export function ShortcutList({ shortcuts = [] } = {}) {
318
318
  h('span', { class: 'ds-kbd-label' }, s.desc || s.description || s.label || ''))));
319
319
  }
320
320
 
321
+ // ---------------------------------------------------------------------------
322
+ // Mobile-breakpoint detection — subscribe-style (webjsx has no hooks/
323
+ // useSyncExternalStore equivalent; consumers re-render on the callback).
324
+ // Builds on editor-primitives.js's BP_SM (480px) rather than pi-web's
325
+ // hardcoded 640px so it stays one source of truth with the Grid/GridItem
326
+ // breakpoint tiers already in this design system.
327
+ // ---------------------------------------------------------------------------
328
+ const MOBILE_QUERY = '(max-width: 480px)';
329
+
330
+ export function isMobileNow() {
331
+ if (typeof window === 'undefined' || !window.matchMedia) return false;
332
+ return window.matchMedia(MOBILE_QUERY).matches;
333
+ }
334
+
335
+ // Returns an unsubscribe function, mirroring theme.js's onThemeChange shape.
336
+ export function onMobileChange(cb) {
337
+ if (typeof window === 'undefined' || !window.matchMedia) return () => {};
338
+ const mql = window.matchMedia(MOBILE_QUERY);
339
+ const handler = () => cb(mql.matches);
340
+ if (mql.addEventListener) mql.addEventListener('change', handler);
341
+ else mql.addListener(handler);
342
+ return () => {
343
+ if (mql.removeEventListener) mql.removeEventListener('change', handler);
344
+ else mql.removeListener(handler);
345
+ };
346
+ }
347
+
321
348
  export function useKeyboardShortcutHelp() { return { registry: Array.from(SHORTCUT_REGISTRY) }; }
322
349
  export function ShortcutHelpDialog({ open = false, onClose, registry } = {}) {
323
350
  if (!open) return null;