anentrypoint-design 0.0.369 → 0.0.370
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/chat.css +128 -0
- package/dist/247420.css +454 -1
- package/dist/247420.js +27 -27
- package/package.json +1 -1
- package/src/components/chat-message-parts.js +42 -4
- package/src/components/chat-minimap.js +309 -0
- package/src/components/chat.js +2 -1
- package/src/components/editor-primitives.js +41 -14
- package/src/components/files-modals.js +44 -4
- package/src/components/git-status.js +22 -6
- package/src/components/interaction-primitives.js +27 -0
- package/src/components/models-config.js +229 -0
- package/src/components/plugins-config.js +154 -0
- package/src/components/sessions.js +138 -38
- package/src/components/skills-config.js +188 -0
- package/src/components/voice.js +41 -0
- package/src/components.js +12 -2
- package/src/css/app-shell/chat-polish.css +25 -0
- package/src/css/app-shell/files.css +18 -0
- package/src/css/app-shell/models-config.css +93 -0
- package/src/css/app-shell/plugins-config.css +139 -0
- package/src/css/app-shell/skills-config.css +27 -0
- package/src/file-mention.js +142 -0
- package/src/index.js +6 -0
- package/src/math.js +158 -0
- package/src/mermaid.js +105 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.370",
|
|
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 = () => {
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
}
|
package/src/components/chat.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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;
|