anentrypoint-design 0.0.323 → 0.0.325
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/README.md +22 -2
- package/app-shell.css +24 -4793
- package/colors_and_type.css +43 -0
- package/dist/247420.css +104 -49
- package/dist/247420.js +82 -19
- package/package.json +20 -8
- package/scripts/lint-glyphs.mjs +105 -0
- package/scripts/lint-null-children.mjs +129 -0
- package/src/bootstrap.js +10 -1
- package/src/community-app.js +3 -3
- package/src/components/chat-message-parts.js +356 -0
- package/src/components/chat.js +28 -324
- package/src/components/content.js +9 -5
- package/src/components/data-density.js +15 -10
- package/src/components/editor-primitives.js +2 -2
- package/src/components/files-modals.js +5 -4
- package/src/components/files.js +5 -1
- package/src/components/freddie/helpers.js +46 -8
- package/src/components/freddie.js +1 -9
- package/src/components/interaction-primitives.js +6 -6
- package/src/components/sessions.js +15 -0
- package/src/components/shell.js +8 -8
- package/src/components/theme-toggle.js +2 -2
- package/src/components.js +1 -1
- package/src/css/app-shell/base.css +157 -0
- package/src/css/app-shell/catalog-theme.css +140 -0
- package/src/css/app-shell/chat-basic.css +12 -0
- package/src/css/app-shell/chat-polish.css +459 -0
- package/src/css/app-shell/data-density.css +147 -0
- package/src/css/app-shell/files.css +709 -0
- package/src/css/app-shell/hero-content.css +405 -0
- package/src/css/app-shell/kits-appended.css +361 -0
- package/src/css/app-shell/loading-alerts.css +120 -0
- package/src/css/app-shell/panel-row.css +177 -0
- package/src/css/app-shell/primitives.css +197 -0
- package/src/css/app-shell/responsive.css +266 -0
- package/src/css/app-shell/responsive2-workspace.css +465 -0
- package/src/css/app-shell/row-print.css +75 -0
- package/src/css/app-shell/sidebar-misc.css +79 -0
- package/src/css/app-shell/states-interactions.css +707 -0
- package/src/css/app-shell/topbar.css +319 -0
- package/src/deck-stage.js +6 -4
- package/src/highlight.js +20 -4
- package/src/i18n.js +58 -0
- package/src/index.js +22 -7
- package/src/kits/os/freddie/pages-chat.js +5 -5
- package/src/kits/os/freddie/pages-core.js +1 -1
- package/src/kits/os/freddie/pages-os.js +1 -1
- package/src/kits/os/freddie/pages-tools.js +2 -2
- package/src/kits/os/terminal-app.js +1 -1
- package/src/kits/os/wm.js +38 -0
- package/src/kits/slides/deck-stage-overlay.js +68 -0
- package/src/kits/slides/deck-stage-state.js +81 -0
- package/src/kits/slides/deck-stage-style.js +117 -0
- package/src/kits/slides/deck-stage.js +167 -0
- package/src/kits/slides/index.js +5 -0
- package/src/lint.js +10 -0
- package/src/markdown.js +89 -11
- package/src/page-html.js +121 -4
- package/src/theme.js +7 -7
- package/src/uid.js +16 -0
- package/src/kits/os/freddie/helpers.js +0 -33
package/src/components/chat.js
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// Includes ChatMessage, ChatComposer, Chat, AICat, AICatPortrait.
|
|
4
4
|
|
|
5
5
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
6
|
-
import {
|
|
7
|
-
import { isDegraded as isMarkdownDegraded } from '../markdown.js';
|
|
6
|
+
import { initializeCachesEagerly, getCacheStats } from '../markdown-cache.js';
|
|
8
7
|
import { register } from '../debug.js';
|
|
9
8
|
import { Icon } from './shell.js';
|
|
10
9
|
import { fmtFileSize } from './files.js';
|
|
11
10
|
import { EmojiPicker } from './overlay-primitives.js';
|
|
11
|
+
import { t } from '../i18n.js';
|
|
12
|
+
import { renderMessagePart as sharedRenderMessagePart, safeUrl as sharedSafeUrl, renderInline as sharedRenderInline, injectCodeCopy as sharedInjectCodeCopy } from './chat-message-parts.js';
|
|
12
13
|
|
|
13
14
|
// Matches a trailing `:keyword` at the end of the composer draft (optionally
|
|
14
15
|
// preceded by whitespace/start-of-string) so typing `:smile` opens an inline
|
|
@@ -24,52 +25,13 @@ let _cacheInitialized = false;
|
|
|
24
25
|
// existing imports working while rendering the same string as the Files grid.
|
|
25
26
|
export const fmtBytes = fmtFileSize;
|
|
26
27
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
if (!s) return null;
|
|
35
|
-
// Allow relative / anchor / protocol-relative without a scheme.
|
|
36
|
-
if (/^(\/|\.|#|\?)/.test(s) || s.startsWith('//')) return s;
|
|
37
|
-
const scheme = (s.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1];
|
|
38
|
-
if (!scheme) return s; // schemeless relative
|
|
39
|
-
return /^(https?|mailto|tel)$/i.test(scheme) ? s : null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Inline-only markdown subset; safe for chat bubbles.
|
|
43
|
-
export function renderInline(text) {
|
|
44
|
-
if (text == null) return [];
|
|
45
|
-
const out = [];
|
|
46
|
-
const re = /(\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g;
|
|
47
|
-
let last = 0; let m; let i = 0;
|
|
48
|
-
const push = (n) => out.push(n);
|
|
49
|
-
while ((m = re.exec(text)) !== null) {
|
|
50
|
-
if (m.index > last) push(h('span', { key: 's' + i + 'a' }, text.slice(last, m.index)));
|
|
51
|
-
if (m[2] != null) push(h('strong', { key: 's' + i }, m[2]));
|
|
52
|
-
else if (m[3] != null) push(h('em', { key: 's' + i }, m[3]));
|
|
53
|
-
else if (m[4] != null) push(h('code', { key: 's' + i, class: 'chat-tick' }, m[4]));
|
|
54
|
-
else if (m[5] != null) {
|
|
55
|
-
const safe = safeUrl(m[6]);
|
|
56
|
-
// A link with a rejected (unsafe) scheme degrades to its plain label
|
|
57
|
-
// text rather than a clickable, scheme-smuggling anchor.
|
|
58
|
-
if (safe) push(h('a', { key: 's' + i, href: safe, target: '_blank', rel: 'noopener noreferrer' }, m[5]));
|
|
59
|
-
else push(h('span', { key: 's' + i }, m[5]));
|
|
60
|
-
}
|
|
61
|
-
last = m.index + m[0].length; i += 1;
|
|
62
|
-
}
|
|
63
|
-
if (last < text.length) push(h('span', { key: 's' + i + 'a' }, text.slice(last)));
|
|
64
|
-
return out;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Map file extension -> line-icon name (drawn SVG, not a decorative glyph).
|
|
68
|
-
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' };
|
|
69
|
-
function fileIconName(name) {
|
|
70
|
-
const ext = String(name || '').split('.').pop().toLowerCase();
|
|
71
|
-
return FILE_ICONS[ext] || 'file';
|
|
72
|
-
}
|
|
28
|
+
// safeUrl / renderInline now live in chat-message-parts.js (the shared
|
|
29
|
+
// message-part renderer both this file and agent-chat.js render parts
|
|
30
|
+
// through) — re-exported here under their original names so every existing
|
|
31
|
+
// consumer of chat.js's public API (components.js barrel, any host importing
|
|
32
|
+
// directly from './components/chat.js') keeps working unchanged.
|
|
33
|
+
export const safeUrl = sharedSafeUrl;
|
|
34
|
+
export const renderInline = sharedRenderInline;
|
|
73
35
|
|
|
74
36
|
// Eagerly warm the markdown + Prism caches on first chat-surface mount, once.
|
|
75
37
|
function ensureCachesInit() {
|
|
@@ -117,280 +79,22 @@ export function makeThreadAutoScroll(getCount) {
|
|
|
117
79
|
};
|
|
118
80
|
}
|
|
119
81
|
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
export
|
|
127
|
-
if (!container) return;
|
|
128
|
-
container.querySelectorAll('pre').forEach((pre) => {
|
|
129
|
-
if (pre.dataset.copyWired === '1') return;
|
|
130
|
-
pre.dataset.copyWired = '1';
|
|
131
|
-
// Wrap the <pre> in a position:relative shell so the button can sit
|
|
132
|
-
// top-right without disturbing code layout.
|
|
133
|
-
const shell = document.createElement('div');
|
|
134
|
-
shell.className = 'chat-code-block';
|
|
135
|
-
pre.parentNode.insertBefore(shell, pre);
|
|
136
|
-
shell.appendChild(pre);
|
|
137
|
-
// Surface the fenced language as a small header tab (claude.ai/code
|
|
138
|
-
// shows the language on every block, not just the structured CodeNode).
|
|
139
|
-
// The highlighter sets language-xx / lang-xx on the inner <code>.
|
|
140
|
-
const codeEl = pre.querySelector('code');
|
|
141
|
-
const langCls = codeEl && (codeEl.className || '').match(/(?:language|lang)-([a-z0-9+#]+)/i);
|
|
142
|
-
if (langCls && langCls[1]) {
|
|
143
|
-
const lang = document.createElement('span');
|
|
144
|
-
lang.className = 'chat-code-lang';
|
|
145
|
-
lang.setAttribute('aria-hidden', 'true');
|
|
146
|
-
lang.textContent = langCls[1];
|
|
147
|
-
shell.appendChild(lang);
|
|
148
|
-
}
|
|
149
|
-
const btn = document.createElement('button');
|
|
150
|
-
btn.type = 'button';
|
|
151
|
-
btn.className = 'chat-code-copy';
|
|
152
|
-
btn.setAttribute('aria-label', 'copy code');
|
|
153
|
-
btn.textContent = 'copy';
|
|
154
|
-
btn.addEventListener('click', () => {
|
|
155
|
-
const code = pre.innerText;
|
|
156
|
-
const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
|
|
157
|
-
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(done).catch(() => {});
|
|
158
|
-
else { try { const t = document.createElement('textarea'); t.value = code; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
|
|
159
|
-
});
|
|
160
|
-
shell.appendChild(btn);
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const MD_STREAM_THROTTLE_MS = 120;
|
|
165
|
-
const MD_STREAM_MIN_DELTA_CHARS = 40;
|
|
166
|
-
|
|
167
|
-
function MdNode(p) {
|
|
168
|
-
const refSink = (el) => {
|
|
169
|
-
if (!el) return;
|
|
170
|
-
// Version the per-element source key with a degraded marker: a bubble
|
|
171
|
-
// rendered while the markdown loader was down re-renders (real markdown)
|
|
172
|
-
// once the loader recovers, instead of staying plain-escaped forever.
|
|
173
|
-
const srcKey = (isMarkdownDegraded() ? '~degraded~' : '') + (p.text || '');
|
|
174
|
-
if (el.dataset.mdSrc === srcKey) return;
|
|
175
|
-
// While streaming (text still growing, not the final settle), a full
|
|
176
|
-
// re-parse of the WHOLE accumulated text on every rAF tick is the
|
|
177
|
-
// dominant cost of a long stream. Throttle: skip the parse unless
|
|
178
|
-
// enough time or enough new characters landed since the last one.
|
|
179
|
-
// p.streamingCaret (already threaded by the host for the stream-head
|
|
180
|
-
// caret) marks "still streaming"; its absence forces the final parse
|
|
181
|
-
// so nothing is left un-parsed once the turn settles.
|
|
182
|
-
const parsedLen = el.dataset.mdParsedLen ? Number(el.dataset.mdParsedLen) : 0;
|
|
183
|
-
const lastParseAt = el.dataset.mdLastParseAt ? Number(el.dataset.mdLastParseAt) : 0;
|
|
184
|
-
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
185
|
-
if (p.streamingCaret && parsedLen > 0 &&
|
|
186
|
-
(now - lastParseAt) < MD_STREAM_THROTTLE_MS &&
|
|
187
|
-
(srcKey.length - parsedLen) < MD_STREAM_MIN_DELTA_CHARS) {
|
|
188
|
-
// Not enough new content/time yet — paint raw text so the latest
|
|
189
|
-
// characters are visible, but defer the expensive re-parse.
|
|
190
|
-
el.textContent = p.text || '';
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
el.dataset.mdSrc = srcKey;
|
|
194
|
-
el.dataset.mdParsedLen = String(srcKey.length);
|
|
195
|
-
el.dataset.mdLastParseAt = String(now);
|
|
196
|
-
// Markdown stack still loading (or down): paint the raw text
|
|
197
|
-
// synchronously so streamed tokens are visible the same frame they
|
|
198
|
-
// arrive (an empty bubble until the CDN import resolves reads as a
|
|
199
|
-
// hang); the resolved render swaps in sanitized markdown in place.
|
|
200
|
-
if (isMarkdownDegraded()) el.textContent = p.text || '';
|
|
201
|
-
renderMarkdownCached(p.text || '').then((html) => {
|
|
202
|
-
const swap = () => { el.innerHTML = html; injectCodeCopy(el); };
|
|
203
|
-
// Don't blow away an active text selection inside this bubble mid-swap
|
|
204
|
-
// (e.g. the user is mid-copy while a stream tick settles). Defer the
|
|
205
|
-
// swap once, until the selection changes (cleared or moved elsewhere).
|
|
206
|
-
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
|
207
|
-
if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
|
|
208
|
-
const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); swap(); };
|
|
209
|
-
document.addEventListener('selectionchange', onSelChange, { once: true });
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
swap();
|
|
213
|
-
});
|
|
214
|
-
};
|
|
215
|
-
return h('div', { class: 'chat-bubble chat-md', ref: refSink });
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function CodeNode(p) {
|
|
219
|
-
const refSink = (el) => {
|
|
220
|
-
if (!el) return;
|
|
221
|
-
// Key on the full code, not its length: two different blocks of the same
|
|
222
|
-
// length (e.g. an edit that swaps a line) would otherwise share a key and
|
|
223
|
-
// skip re-highlighting, leaving stale syntax coloring.
|
|
224
|
-
const codeKey = (p.lang || '') + '|' + (p.code || '');
|
|
225
|
-
if (el.dataset.codeKey === codeKey) return;
|
|
226
|
-
el.dataset.codeKey = codeKey;
|
|
227
|
-
// Same in-progress-selection guard as MdNode.refSink: don't wipe an
|
|
228
|
-
// active text selection inside this block mid-stream-settle. Defer
|
|
229
|
-
// the highlight swap once, until the selection changes.
|
|
230
|
-
const sel = typeof window !== 'undefined' ? window.getSelection() : null;
|
|
231
|
-
if (sel && sel.anchorNode && el.contains(sel.anchorNode)) {
|
|
232
|
-
const onSelChange = () => { document.removeEventListener('selectionchange', onSelChange); highlightCodeBlockCached(el); };
|
|
233
|
-
document.addEventListener('selectionchange', onSelChange, { once: true });
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
highlightCodeBlockCached(el);
|
|
237
|
-
};
|
|
238
|
-
// Copy the raw code (not the highlighted DOM) for the structured CodeNode.
|
|
239
|
-
const onCopy = (e) => {
|
|
240
|
-
const btn = e.currentTarget;
|
|
241
|
-
const code = p.code || '';
|
|
242
|
-
const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
|
|
243
|
-
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(done).catch(() => {});
|
|
244
|
-
else { try { const t = document.createElement('textarea'); t.value = code; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
|
|
245
|
-
};
|
|
246
|
-
return h('div', { class: 'chat-bubble chat-code', ref: refSink },
|
|
247
|
-
h('div', { class: 'chat-code-head' },
|
|
248
|
-
h('span', { class: 'lang' }, p.lang || 'code'),
|
|
249
|
-
p.filename ? h('span', { class: 'name' }, p.filename) : null,
|
|
250
|
-
h('span', { class: 'spread' }),
|
|
251
|
-
h('button', { type: 'button', class: 'chat-code-copy chat-code-copy-head', 'aria-label': 'copy code', onclick: onCopy }, 'copy')
|
|
252
|
-
),
|
|
253
|
-
h('pre', {}, h('code', { class: p.lang ? 'lang-' + p.lang + ' language-' + p.lang : '' }, p.code || ''))
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Freddie-flavored agent parts: collapsible tool-call card, tool-result, and
|
|
258
|
-
// transient thinking indicator. Each renders as a `chat-bubble` variant so the
|
|
259
|
-
// surrounding ChatMessage chrome (avatar/meta/reactions) stays consistent.
|
|
260
|
-
function ToolCallNode(p) {
|
|
261
|
-
const status = p.status || (p.error ? 'error' : (p.result != null ? 'done' : 'running'));
|
|
262
|
-
// Args/result are re-stringified on every rAF re-render while any part of
|
|
263
|
-
// the turn is streaming, even for collapsed cards whose own args/result
|
|
264
|
-
// haven't changed since the last frame. Cache by identity on the part
|
|
265
|
-
// object itself so an unchanged args/result skips the stringify.
|
|
266
|
-
if (p._argsCache !== p.args) {
|
|
267
|
-
p._argsTextCache = typeof p.args === 'string' ? p.args : JSON.stringify(p.args || {}, null, 2);
|
|
268
|
-
p._argsCache = p.args;
|
|
269
|
-
}
|
|
270
|
-
const argsText = p._argsTextCache;
|
|
271
|
-
if (p._resultCache !== p.result) {
|
|
272
|
-
p._resultTextCache = p.result == null ? '' : (typeof p.result === 'string' ? p.result : JSON.stringify(p.result, null, 2));
|
|
273
|
-
p._resultCache = p.result;
|
|
274
|
-
}
|
|
275
|
-
const resultText = p._resultTextCache;
|
|
276
|
-
const hasArgs = p.args != null && argsText !== '{}' && argsText.trim() !== '';
|
|
277
|
-
// Default-open while running or on error so the user sees live progress / failure detail;
|
|
278
|
-
// collapse on success unless the caller explicitly overrides with open:true.
|
|
279
|
-
const defaultOpen = p.open != null ? !!p.open : (status === 'running' || status === 'error');
|
|
280
|
-
const iconName = status === 'running' ? 'refresh' : (status === 'error' ? 'warn' : 'check');
|
|
281
|
-
const copyText = (txt) => (e) => {
|
|
282
|
-
const b = e.currentTarget;
|
|
283
|
-
const done = () => {
|
|
284
|
-
b.textContent = 'copied';
|
|
285
|
-
b.classList.add('is-copied');
|
|
286
|
-
setTimeout(() => { b.textContent = 'copy'; b.classList.remove('is-copied'); }, 1600);
|
|
287
|
-
};
|
|
288
|
-
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done).catch(() => {});
|
|
289
|
-
else { try { const t = document.createElement('textarea'); t.value = txt; document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t); done(); } catch {} }
|
|
290
|
-
};
|
|
291
|
-
const sectionLabel = (text, txt) => h('div', { class: 'chat-tool-section-label' },
|
|
292
|
-
h('span', {}, text),
|
|
293
|
-
h('button', { type: 'button', class: 'chat-code-copy chat-tool-copy', 'aria-label': 'copy ' + text, onclick: copyText(txt) }, 'copy'));
|
|
294
|
-
return h('details', { class: 'chat-bubble chat-tool tool-' + status, open: defaultOpen },
|
|
295
|
-
h('summary', { class: 'chat-tool-head' },
|
|
296
|
-
h('span', { class: 'chat-tool-icon', 'aria-hidden': 'true' }, Icon(iconName, { size: 14 })),
|
|
297
|
-
h('span', { class: 'chat-tool-name' }, p.name || 'tool'),
|
|
298
|
-
p.label ? h('span', { class: 'chat-tool-label' }, p.label) : null,
|
|
299
|
-
h('span', { class: 'chat-tool-status' }, status)
|
|
300
|
-
),
|
|
301
|
-
h('div', { class: 'chat-tool-body' },
|
|
302
|
-
...[
|
|
303
|
-
hasArgs ? h('div', { class: 'chat-tool-section' },
|
|
304
|
-
sectionLabel('args', argsText),
|
|
305
|
-
h('pre', { class: 'chat-tool-pre' }, h('code', {}, argsText))) : null,
|
|
306
|
-
resultText ? h('div', { class: 'chat-tool-section' },
|
|
307
|
-
sectionLabel(p.error ? 'error' : 'result', resultText),
|
|
308
|
-
h('pre', { class: 'chat-tool-pre' + (p.error ? ' is-error' : '') }, h('code', {}, resultText)))
|
|
309
|
-
// A finished tool with no output would otherwise render no result
|
|
310
|
-
// section, reading identically to a still-running tool. Show an
|
|
311
|
-
// explicit placeholder so "done, empty" is distinguishable.
|
|
312
|
-
: (status === 'done' ? h('div', { class: 'chat-tool-section' },
|
|
313
|
-
h('div', { class: 'chat-tool-section-label' }, 'result'),
|
|
314
|
-
h('pre', { class: 'chat-tool-pre chat-tool-empty' }, h('code', {}, '(no output)'))) : null)
|
|
315
|
-
].filter(Boolean)
|
|
316
|
-
)
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function ThinkingNode(p) {
|
|
321
|
-
if (p.settled) {
|
|
322
|
-
return h('details', { class: 'chat-bubble chat-thinking-settled' },
|
|
323
|
-
h('summary', {}, 'View thinking'),
|
|
324
|
-
h('div', { class: 'chat-thinking-body' }, p.text)
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
return h('div', { class: 'chat-bubble chat-thinking', role: 'status', 'aria-live': 'polite' },
|
|
328
|
-
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
329
|
-
h('span', { class: 'chat-thinking-text' }, p.text || 'thinking…')
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const PART_RENDERERS = {
|
|
334
|
-
text: (p) => p.preShell
|
|
335
|
-
// Streaming prose that already contains a code fence (or a huge tail
|
|
336
|
-
// window) renders as a plain monospaced <pre> so it does not reflow from
|
|
337
|
-
// prose to a styled block on settle (no Prism mid-stream). The settled
|
|
338
|
-
// turn renders real markdown. `streamHead` is an optional head line for
|
|
339
|
-
// the tail-window path ('streaming · N KB so far').
|
|
340
|
-
? h('div', { class: 'chat-bubble chat-md chat-stream-pre' },
|
|
341
|
-
...[p.streamHead ? h('div', { key: 'sh', class: 'chat-stream-head', role: 'status', 'aria-live': 'polite' }, p.streamHead) : null,
|
|
342
|
-
h('pre', { key: 'pre' }, h('code', {}, p.text || '')),
|
|
343
|
-
p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null].filter(Boolean))
|
|
344
|
-
: h('div', { class: 'chat-bubble' + (p.mdShell ? ' chat-md' : '') },
|
|
345
|
-
...renderInline(p.text || ''),
|
|
346
|
-
p.streamingCaret ? h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' }) : null),
|
|
347
|
-
md: (p) => MdNode(p),
|
|
348
|
-
code: (p) => CodeNode(p),
|
|
349
|
-
tool: (p) => ToolCallNode(p),
|
|
350
|
-
tool_call: (p) => ToolCallNode(p),
|
|
351
|
-
tool_result: (p) => ToolCallNode({ ...p, name: p.name || 'tool_result', result: p.text != null ? p.text : p.result }),
|
|
352
|
-
thinking: (p) => ThinkingNode(p),
|
|
353
|
-
image: (p) => {
|
|
354
|
-
// Guard both the wrapping link and the img src against unsafe schemes
|
|
355
|
-
// (e.g. a data:text/html src) so an embedded-image part from untrusted
|
|
356
|
-
// markdown can't smuggle an active payload.
|
|
357
|
-
const imgSrc = safeUrl(p.src);
|
|
358
|
-
const linkHref = safeUrl(p.href || p.src);
|
|
359
|
-
if (!imgSrc) return h('span', { class: 'chat-image-blocked' }, p.alt || 'image blocked (unsafe url)');
|
|
360
|
-
return h('a', { class: 'chat-image', href: linkHref || imgSrc, target: '_blank', rel: 'noopener noreferrer', 'aria-label': p.alt || `embedded image: ${imgSrc}` },
|
|
361
|
-
h('img', { src: imgSrc, alt: p.alt || `embedded image from ${imgSrc}`, loading: 'lazy' }),
|
|
362
|
-
p.caption ? h('span', { class: 'cap' }, p.caption) : null);
|
|
363
|
-
},
|
|
364
|
-
pdf: (p) => h('div', { class: 'chat-pdf' },
|
|
365
|
-
h('div', { class: 'chat-pdf-head' },
|
|
366
|
-
h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon('file-pdf', { size: 18 })),
|
|
367
|
-
h('span', { class: 'name' }, p.name || 'document.pdf'),
|
|
368
|
-
p.size != null ? h('span', { class: 'size' }, fmtBytes(p.size)) : null,
|
|
369
|
-
h('a', { class: 'open', href: p.src, target: '_blank', rel: 'noopener', 'aria-label': `open PDF: ${p.name || 'document.pdf'}` }, 'open ->')
|
|
370
|
-
),
|
|
371
|
-
h('embed', { src: p.src, type: 'application/pdf', 'aria-label': `PDF document: ${p.name || 'document.pdf'}` })),
|
|
372
|
-
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()})` },
|
|
373
|
-
h('span', { class: 'glyph', 'aria-hidden': 'true' }, Icon(fileIconName(p.name), { size: 22 })),
|
|
374
|
-
h('span', { class: 'meta' },
|
|
375
|
-
h('span', { class: 'name' }, p.name || 'attachment'),
|
|
376
|
-
h('span', { class: 'size' }, [p.kindLabel || (p.name || '').split('.').pop().toUpperCase(), p.size != null ? fmtBytes(p.size) : null].filter(Boolean).join(' · '))
|
|
377
|
-
),
|
|
378
|
-
h('span', { class: 'go', 'aria-hidden': 'true' }, Icon('arrow-down'))),
|
|
379
|
-
link: (p) => h('a', { class: 'chat-link', href: safeUrl(p.href) || '#', target: '_blank', rel: 'noopener noreferrer', 'aria-label': `link: ${p.title || p.href}` },
|
|
380
|
-
p.thumb ? h('img', { class: 'thumb', src: p.thumb, alt: `preview for ${p.title || p.href}` }) : null,
|
|
381
|
-
h('span', { class: 'meta' },
|
|
382
|
-
h('span', { class: 'host' }, p.host || (() => { try { return new URL(p.href).host; } catch { return ''; } })()),
|
|
383
|
-
h('span', { class: 'title' }, p.title || p.href),
|
|
384
|
-
p.desc ? h('span', { class: 'desc' }, p.desc) : null
|
|
385
|
-
))
|
|
386
|
-
};
|
|
82
|
+
// injectCodeCopy / MdNode / CodeNode / ToolCallNode / ThinkingNode /
|
|
83
|
+
// PART_RENDERERS all now live in chat-message-parts.js — the one dispatch
|
|
84
|
+
// table this file, agent-chat.js, and any future chat surface render message
|
|
85
|
+
// parts through. injectCodeCopy stays exported here (re-exported, same
|
|
86
|
+
// signature) since it was part of this file's public surface before the
|
|
87
|
+
// extraction, even though nothing in-repo imports it directly today.
|
|
88
|
+
export const injectCodeCopy = sharedInjectCodeCopy;
|
|
387
89
|
|
|
90
|
+
// Thin wrapper around the shared renderer that keeps this file's own debug
|
|
91
|
+
// counter (_stats.lastKindCounts, surfaced via register('chat', ...) below)
|
|
92
|
+
// wired exactly as before. sharedRenderMessagePart already applies the
|
|
93
|
+
// 'p' + key VElement key itself.
|
|
388
94
|
function renderPart(p, key) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
_stats.lastKindCounts[p.kind] = (_stats.lastKindCounts[p.kind] || 0) + 1;
|
|
393
|
-
return node;
|
|
95
|
+
return sharedRenderMessagePart(p, key, (kind) => {
|
|
96
|
+
_stats.lastKindCounts[kind] = (_stats.lastKindCounts[kind] || 0) + 1;
|
|
97
|
+
});
|
|
394
98
|
}
|
|
395
99
|
|
|
396
100
|
export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions, incomplete, stopped, flat }) {
|
|
@@ -488,7 +192,7 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
488
192
|
// Flat layout leads the turn with a small role label (You / agent name)
|
|
489
193
|
// above the content, the way claude.ai/code titles each turn.
|
|
490
194
|
const roleLabel = isFlat
|
|
491
|
-
? h('div', { class: 'chat-role', key: '_role' }, resolvedWho === 'you' ? 'You' : (name || 'Assistant'))
|
|
195
|
+
? h('div', { class: 'chat-role', key: '_role' }, resolvedWho === 'you' ? t('chat.roleYou', 'You') : (name || t('chat.roleAssistant', 'Assistant')))
|
|
492
196
|
: null;
|
|
493
197
|
const stack = h('div', { class: 'chat-stack' }, roleLabel, ...bodyNodes, reactionRow, actionRow, meta);
|
|
494
198
|
// Centered roles (system/tool/thinking) skip the avatar column entirely so
|
|
@@ -560,7 +264,7 @@ export function ChatComposer({ value, onInput, onSend, onEmoji, onCancel, busy,
|
|
|
560
264
|
sessionStorage.setItem('ds.composer.undoNoteShown', '1');
|
|
561
265
|
flashComposerNote(taEl.closest('.chat-composer'), 'inserted — undo history does not include this insert');
|
|
562
266
|
}
|
|
563
|
-
} catch { /* sessionStorage unavailable (private mode etc) — skip the note */ }
|
|
267
|
+
} catch { /* swallow: sessionStorage unavailable (private mode etc) — skip the note */ }
|
|
564
268
|
taEl.value = next;
|
|
565
269
|
taEl.focus();
|
|
566
270
|
taEl.selectionStart = taEl.selectionEnd = next.length;
|
|
@@ -740,8 +444,8 @@ export function Chat({ title = 'chat', sub, messages = [], composer, header, sug
|
|
|
740
444
|
h('div', { class: 'chat-thread', ref: threadRef, role: 'log', 'aria-label': 'chat messages' },
|
|
741
445
|
messages.length === 0
|
|
742
446
|
? h('div', { key: '_empty', class: 'chat-empty', role: 'status' },
|
|
743
|
-
h('p', { class: 'chat-empty-title' }, 'start a conversation'),
|
|
744
|
-
h('p', { class: 'chat-empty-sub' }, sub || 'Send a message to start the conversation'),
|
|
447
|
+
h('p', { class: 'chat-empty-title' }, t('chat.startConversation', 'start a conversation')),
|
|
448
|
+
h('p', { class: 'chat-empty-sub' }, sub || t('chat.emptySub', 'Send a message to start the conversation')),
|
|
745
449
|
(suggestions && suggestions.length)
|
|
746
450
|
? h('div', { class: 'chat-empty-suggestions' },
|
|
747
451
|
...suggestions.map((s, i) => h('button', { key: 'sug' + i, type: 'button', class: 'chat-empty-suggestion',
|
|
@@ -267,13 +267,17 @@ export function Install({ cmd, copied, onCopy }) {
|
|
|
267
267
|
// every portfolio consumer theme.mjs (zellous/wireweave/247420) had hand-rolled
|
|
268
268
|
// identically: lines.map((l,i) => a div per line holding a prompt span ('$' or
|
|
269
269
|
// '#' for a comment line) and a cmd span, all wrapped in a Panel. This factory
|
|
270
|
-
// targets the multi-line `.cli` contract
|
|
271
|
-
//
|
|
272
|
-
//
|
|
270
|
+
// targets the multi-line `.ds-cli-block` contract defined in gm-prose.css
|
|
271
|
+
// (`.ds-cli-block` holding `.ds-cli-row` rows — each a prompt+cmd pair — and
|
|
272
|
+
// `.ds-cli-comment` comment rows). `lines` is [{kind, text}] where kind: 'cmt' renders a
|
|
273
273
|
// comment-only row (no prompt glyph); any other kind (or omitted) renders a
|
|
274
274
|
// command row prefixed '$'. `heading` titles the wrapping Panel ('quick start'
|
|
275
275
|
// default, matching every hand-rolled instance); pass `heading: null` to
|
|
276
|
-
// render the bare `.cli` block with no Panel chrome.
|
|
276
|
+
// render the bare `.ds-cli-block` block with no Panel chrome.
|
|
277
|
+
// Note: this is a different component than the bare `.cli` single prompt+cmd
|
|
278
|
+
// row primitive (app-shell.css; see Install() above and the per-line usage
|
|
279
|
+
// in terminal/site quickstart renderers) — the two used to collide on the
|
|
280
|
+
// same `.cli` class name with incompatible display models.
|
|
277
281
|
export function CliBlock({ lines = [], heading = 'quick start', className = '' } = {}) {
|
|
278
282
|
if (!lines || !lines.length) return null;
|
|
279
283
|
const rows = lines.map((l, i) => {
|
|
@@ -285,7 +289,7 @@ export function CliBlock({ lines = [], heading = 'quick start', className = '' }
|
|
|
285
289
|
h('span', { class: 'prompt' }, '$'),
|
|
286
290
|
h('span', { class: 'cmd' }, text));
|
|
287
291
|
});
|
|
288
|
-
const body = h('div', { class: 'cli' + (className ? ' ' + className : '') }, ...rows);
|
|
292
|
+
const body = h('div', { class: 'ds-cli-block' + (className ? ' ' + className : '') }, ...rows);
|
|
289
293
|
return heading == null ? body : Panel({ title: heading, children: body });
|
|
290
294
|
}
|
|
291
295
|
|
|
@@ -60,11 +60,14 @@ export function TreeNode({ ts, kind, variant = '', phase, id, keyLabel, reason,
|
|
|
60
60
|
// ---------------------------------------------------------------------------
|
|
61
61
|
export function BarRow({ label, value, pct = 0, tone } = {}) {
|
|
62
62
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
63
|
-
return h('div', {
|
|
63
|
+
return h('div', {
|
|
64
|
+
class: 'ds-bar-row', role: 'meter', 'aria-label': label != null ? String(label) : 'value',
|
|
65
|
+
'aria-valuenow': String(clamped), 'aria-valuemin': '0', 'aria-valuemax': '100',
|
|
66
|
+
},
|
|
64
67
|
h('span', { class: 'ds-bar-row-label', style: tone ? `color:${tone}` : null }, label),
|
|
65
|
-
h('div', { class: 'ds-bar-bg' },
|
|
68
|
+
h('div', { class: 'ds-bar-bg', 'aria-hidden': 'true' },
|
|
66
69
|
h('div', { class: 'ds-bar-fill', style: `width:${clamped}%` + (tone ? `;background:${tone}` : '') })),
|
|
67
|
-
h('span', { class: 'ds-bar-row-value' }, value));
|
|
70
|
+
h('span', { class: 'ds-bar-row-value', 'aria-hidden': 'true' }, value));
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
// ---------------------------------------------------------------------------
|
|
@@ -72,15 +75,15 @@ export function BarRow({ label, value, pct = 0, tone } = {}) {
|
|
|
72
75
|
// cls on StatTile selects an accent variant: '' | 'rate-big' | 'err-rate'.
|
|
73
76
|
// ---------------------------------------------------------------------------
|
|
74
77
|
export function StatTile({ val, lbl, cls = '' } = {}) {
|
|
75
|
-
return h('div', { class: 'ds-stat' },
|
|
76
|
-
h('div', { class: 'ds-stat-val' + (cls ? ' ' + cls : '') }, val),
|
|
77
|
-
h('div', { class: 'ds-stat-lbl' }, lbl));
|
|
78
|
+
return h('div', { class: 'ds-stat', role: 'group', 'aria-label': `${lbl || 'stat'}: ${val}` },
|
|
79
|
+
h('div', { class: 'ds-stat-val' + (cls ? ' ' + cls : ''), 'aria-hidden': 'true' }, val),
|
|
80
|
+
h('div', { class: 'ds-stat-lbl', 'aria-hidden': 'true' }, lbl));
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
export function StatsGrid({ items = [] } = {}) {
|
|
81
84
|
if (!items.length) return h('div', { class: 'ds-stats-grid ds-stats-grid-empty' },
|
|
82
85
|
h('span', { class: 'ds-stat-lbl' }, 'no stats'));
|
|
83
|
-
return h('div', { class: 'ds-stats-grid' },
|
|
86
|
+
return h('div', { class: 'ds-stats-grid', role: 'group', 'aria-label': 'stats' },
|
|
84
87
|
...items.map((it, i) => h('div', { key: it.key || i }, StatTile(it))));
|
|
85
88
|
}
|
|
86
89
|
|
|
@@ -90,11 +93,12 @@ export function StatsGrid({ items = [] } = {}) {
|
|
|
90
93
|
export function SubGrid({ items = [] } = {}) {
|
|
91
94
|
if (!items.length) return h('div', { class: 'ds-sub-grid ds-sub-grid-empty' },
|
|
92
95
|
h('span', { class: 'ds-stat-lbl' }, 'no items'));
|
|
93
|
-
return h('div', { class: 'ds-sub-grid' },
|
|
96
|
+
return h('div', { class: 'ds-sub-grid', role: 'group', 'aria-label': 'categories' },
|
|
94
97
|
...items.map((it, i) => h('button', {
|
|
95
98
|
key: it.key || i, type: 'button', class: 'ds-sub-btn',
|
|
96
99
|
onclick: it.onClick || null,
|
|
97
|
-
|
|
100
|
+
'aria-label': `${it.label}: ${String(it.count)}`,
|
|
101
|
+
}, h('span', { 'aria-hidden': 'true' }, String(it.count)), it.label)));
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
// ---------------------------------------------------------------------------
|
|
@@ -115,6 +119,7 @@ export function SessionRow({ sessId, phaseWalkProps, events, verbs, prd, muts, r
|
|
|
115
119
|
class: 'ds-session-row', onclick: onClick || null,
|
|
116
120
|
role: onClick ? 'button' : null, tabindex: onClick ? '0' : null,
|
|
117
121
|
onkeydown: onClick ? (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onClick(e); } } : null,
|
|
122
|
+
'aria-label': onClick ? `session ${sessId}: ${counts}` : null,
|
|
118
123
|
},
|
|
119
124
|
h('span', { class: 'ds-session-row-id' }, sessId),
|
|
120
125
|
h('span', { class: 'ds-session-row-counts' }, counts),
|
|
@@ -163,6 +168,6 @@ export function LiveLog({ entries = [], autoScroll = true } = {}) {
|
|
|
163
168
|
};
|
|
164
169
|
if (!entries.length) return h('div', { class: 'ds-live-log ds-live-log-empty' },
|
|
165
170
|
h('span', { class: 'ds-stat-lbl' }, 'no log entries'));
|
|
166
|
-
return h('div', { class: 'ds-live-log', ref: seedScroll },
|
|
171
|
+
return h('div', { class: 'ds-live-log', ref: seedScroll, role: 'log', 'aria-label': 'live log' },
|
|
167
172
|
...entries.map((e, i) => h('div', { key: e.key || i }, LiveLogEntry(e))));
|
|
168
173
|
}
|
|
@@ -345,7 +345,7 @@ export function ResizeHandle({ axis = 'horizontal', onResize, ariaLabel } = {})
|
|
|
345
345
|
};
|
|
346
346
|
const onPointerUp = (e) => {
|
|
347
347
|
dragOrigin = null;
|
|
348
|
-
try { e.currentTarget.releasePointerCapture && e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
|
|
348
|
+
try { e.currentTarget.releasePointerCapture && e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
|
|
349
349
|
};
|
|
350
350
|
const onKeyDown = (e) => {
|
|
351
351
|
const k = e.key;
|
|
@@ -829,7 +829,7 @@ export function JsonViewer({ value, emptyText = 'no data', maxHeight, mode = 'pl
|
|
|
829
829
|
if (!text) return h('div', { class: 'ds-ep-json ds-ep-json-empty' }, emptyText);
|
|
830
830
|
const style = maxHeight ? ('max-height:' + maxHeight) : null;
|
|
831
831
|
if (!knownJson && (mode === 'highlight' || mode === 'tree')) {
|
|
832
|
-
try { parsed = JSON.parse(text); knownJson = true; } catch { /* not JSON — render plain */ }
|
|
832
|
+
try { parsed = JSON.parse(text); knownJson = true; } catch { /* swallow: not JSON — render plain */ }
|
|
833
833
|
}
|
|
834
834
|
let body;
|
|
835
835
|
if (mode === 'tree' && knownJson && parsed !== null && typeof parsed === 'object') {
|
|
@@ -4,6 +4,7 @@ import * as webjsx from '../../vendor/webjsx/index.js';
|
|
|
4
4
|
import { Btn, Icon } from './shell.js';
|
|
5
5
|
import { fileGlyph, fmtFileSize } from './files.js';
|
|
6
6
|
import { highlightAllUnder } from '../highlight.js';
|
|
7
|
+
import { shortUid } from '../uid.js';
|
|
7
8
|
const h = webjsx.createElement;
|
|
8
9
|
|
|
9
10
|
// Full focusable set for the modal Tab trap — omitting textarea/select/a[href]
|
|
@@ -81,7 +82,7 @@ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {
|
|
|
81
82
|
// Only restore focus when the modal is genuinely going away (not a
|
|
82
83
|
// re-render remount) and focus is not already somewhere useful.
|
|
83
84
|
if (removed && Backdrop._invoker && Backdrop._invoker.focus && Backdrop._invoker.isConnected) {
|
|
84
|
-
try { Backdrop._invoker.focus(); } catch {}
|
|
85
|
+
try { Backdrop._invoker.focus(); } catch { /* swallow: restoring focus on close is best-effort, teardown still completes */ }
|
|
85
86
|
}
|
|
86
87
|
if (removed) Backdrop._invoker = null;
|
|
87
88
|
};
|
|
@@ -140,7 +141,7 @@ function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body,
|
|
|
140
141
|
// stays constant across re-renders of the same dialog instance — an
|
|
141
142
|
// incrementing counter advances on every render, leaving the previous
|
|
142
143
|
// aria-labelledby reference pointing at a now-absent element.
|
|
143
|
-
const headId = head != null ? ('ds-modal-head-' +
|
|
144
|
+
const headId = head != null ? ('ds-modal-head-' + shortUid(6)) : null;
|
|
144
145
|
return Backdrop({
|
|
145
146
|
onClose,
|
|
146
147
|
kind,
|
|
@@ -270,7 +271,7 @@ export function FilePreviewCode({ content = '', lang, filename } = {}) {
|
|
|
270
271
|
const btn = e.currentTarget;
|
|
271
272
|
const done = () => { btn.textContent = 'copied'; btn.classList.add('is-copied'); setTimeout(() => { btn.textContent = 'copy'; btn.classList.remove('is-copied'); }, 1600); };
|
|
272
273
|
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(content).then(done).catch(() => {});
|
|
273
|
-
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 {} }
|
|
274
|
+
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 */ } }
|
|
274
275
|
};
|
|
275
276
|
return h('div', { class: 'ds-preview-code-wrap' },
|
|
276
277
|
h('div', { class: 'chat-code-head ds-preview-code-head' },
|
|
@@ -295,7 +296,7 @@ function codeBody({ content = '', lang } = {}) {
|
|
|
295
296
|
: null;
|
|
296
297
|
const highlightRef = (el) => {
|
|
297
298
|
if (!el) return;
|
|
298
|
-
try { highlightAllUnder(el); } catch {}
|
|
299
|
+
try { highlightAllUnder(el); } catch { /* swallow: syntax highlighting is a progressive enhancement, plain code still renders */ }
|
|
299
300
|
};
|
|
300
301
|
return h('pre', { class: 'ds-preview-code' + (lang ? ' lang-' + lang : '') + (wantGutter ? ' has-gutter' : ''), ref: highlightRef },
|
|
301
302
|
gutter,
|
package/src/components/files.js
CHANGED
|
@@ -28,9 +28,13 @@ export function fileGlyph(type) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
|
|
31
|
-
// format everywhere: '0 B' for zero; the em-dash means unknown/null
|
|
31
|
+
// format everywhere: '0 B' for zero; the em-dash means unknown/null/invalid
|
|
32
|
+
// (NaN, a negative count, or anything non-numeric never reaches the divide
|
|
33
|
+
// loop — previously NaN fell through to the loop unchanged and rendered the
|
|
34
|
+
// literal string "NaN B").
|
|
32
35
|
export function fmtFileSize(bytes) {
|
|
33
36
|
if (bytes == null) return '—';
|
|
37
|
+
if (typeof bytes !== 'number' || Number.isNaN(bytes) || bytes < 0) return '—';
|
|
34
38
|
if (bytes === 0) return '0 B';
|
|
35
39
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
36
40
|
let i = 0, n = bytes;
|