preview: the sidebar shows the file's current content after it changed on disk (follow-up to #5180)

The preview kept a file's first content: openPreview dropped the freshly read item when its id was already a tab, the panel's loading effect had no
content version, and the effect that re-adds a message's artifacts put the stored first copy back. All three change together, through pure rules in
lib/preview-merge.ts: opening a changed file replaces its tab in place; a message's artifact only adds a missing tab or replaces an older, different
copy (each copy carries the time it was read or made) and never overwrites a newer one; both return the same array when nothing changed, so no update
and no loop follows. The panel reloads when (id, content version) changes: a hash of the text, or the host's mtime and size for pictures and PDFs
(/api/files/preview now sends mtime). The reading view is reset by the file, not by its content. Tests: stale case, 2000 alternations without a
change, one load per real change. Real Edge 156 with the real panel: the old rules keep Version One, the new ones show Version Two through 300
sync/render alternations with 0 Markdown renders. No visible text.
This commit is contained in:
Niko1221 committed 2026-11-04 18:45:06 +01:00
1 parent 792b626980
commit 68bead1082
7 files changed
+257 -18

No files matched your search

+1 -1
View File
@@ -153,7 +153,7 @@ export function parseCsv(text, delimiter = ',', maxRows = 500) {
export function previewFile(root, rawPath, sessionId) {
const file = resolveChatFile(root, rawPath);
const st = statSync(file); const kind = fileType(file);
const base = { kind, name: basename(file), path: file, size: st.size, url: pathToFileURL(file).href };
const base = { kind, name: basename(file), path: file, size: st.size, mtime: Math.round(st.mtimeMs), url: pathToFileURL(file).href };
const raw = `/api/files/raw?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(file)}`;
if (kind === 'image' || kind === 'pdf') return { ...base, rawUrl: raw, mime: mimeOf(file) };
if (kind === 'other') return { ...base, note: 'This kind of file has no preview. Open it in its own app.' };
+11 -5
View File
@@ -1,6 +1,6 @@
import { tr } from '../lib/i18n';
import { PreviewControls } from './PreviewControls';
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Dialog, Icon, IconBtn, Seg, type IconName } from './ui';
import { CopyBtn, CopyMenuItem } from './CopyBtn';
@@ -10,6 +10,7 @@ import { FloatingMenu } from './FloatingMenu';
import { TablePreview } from './TablePreview';
import { artifactFilename, artifactIcon, safePreviewUrl, type PreviewArtifact } from '../lib/artifacts';
import { scrollBehavior } from '../lib/responsive';
import { previewVersion } from '../lib/preview-merge';
const PdfPreview = lazy(() => import('./PdfPreview'));
export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab, inline }: {
items: PreviewArtifact[];
@@ -20,17 +21,22 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
inline: boolean;
}) {
const current = items.find(a => a.id === selected);
// What the text loading depends on: the file's identity and its content version, not the object (the chat hands over new objects often).
const version = useMemo(() => current ? previewVersion(current) : '', [current]);
const [text, setText] = useState(''), [error, setError] = useState(''), [mode, setMode] = useState<'preview' | 'source'>('preview'), [revision, setRevision] = useState(0), [loading, setLoading] = useState(false), [imageSize, setImageSize] = useState('fit');
const [toolbarHost, setToolbarHost] = useState<HTMLElement | null>(null);
const [fileMenu, setFileMenu] = useState(false);
const fileMenuRef = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => { setToolbarHost(inline && current ? document.getElementById('preview-file-actions') : null); }, [inline, current?.id]);
// A different file starts in the reading view; the same file with new content keeps the person's view.
useEffect(() => {
setError('');
setMode('preview');
setLoading(false);
setImageSize('fit');
setFileMenu(false);
}, [current?.id]);
useEffect(() => {
setError('');
setLoading(false);
if (!current || !['markdown', 'code', 'table'].includes(current.kind))
return;
if (current.content !== undefined) {
@@ -61,7 +67,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
setLoading(false);
});
return () => abort.abort();
}, [current?.id, revision]);
}, [current?.id, version, revision]);
// A closed panel leaves with a short slide instead of vanishing (a bare shell; the page itself is already gone).
const hadCurrent = useRef(false);
const [leaving, setLeaving] = useState(false);
@@ -84,7 +90,7 @@ export function ArtifactPanel({ items, selected, onSelect, onClose, onCloseTab,
const content = <aside className="artifact-panel" aria-label={tr("{0} preview", [current.kind])}>
{tr(!inline ? <ArtifactTabs onCloseTab={onCloseTab} items={items} selected={selected} onSelect={onSelect}/> : null)}
<div key={current.id} id="artifact-content" role="tabpanel" aria-labelledby={`artifact-tab-${items.findIndex(a => a.id === selected)}`} className="artifact-content">
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><PreviewMarkdown text={text}/></article>)}
{tr(current.kind === 'website' ? <><iframe key={`${current.id}-${revision}-${version}`} title={current.title} src={current.content ? undefined : url ?? undefined} srcDoc={current.content} sandbox="allow-scripts" referrerPolicy="no-referrer"/></> : current.kind === 'pdf' ? url ? <Suspense fallback={<p className="muted preview-placeholder">{tr("Opening PDF viewer\u2026")}</p>}><PdfPreview key={`${current.id}-${revision}-${version}`} url={url}/></Suspense> : <p>{tr("No PDF to display.")}</p> : current.kind === 'image' ? <div className="image-preview" data-size={imageSize === '1' ? 'actual' : 'fit'}>{tr(url ? <img key={`${current.id}-${revision}-${version}`} src={url} alt={current.title} onError={() => setError('Couldn’t load this image.')}/> : null)}{tr(error ? <p role="alert">{tr(error)}</p> : null)}</div> : loading ? <p className="muted preview-placeholder">{tr("Loading document\u2026")}</p> : error ? <p role="alert" className="inline-error preview-placeholder">{tr(error)}</p> : current.kind === 'code' ? <SyntaxCode text={text} filename={artifactFilename(current)} lineNumbers/> : current.kind === 'table' ? <TablePreview key={current.id} text={text}/> : mode === 'source' ? <SyntaxCode text={text} language="markdown" lineNumbers/> : <article className="document-markdown"><PreviewMarkdown text={text}/></article>)}
</div>
{tr(viewControls ? <div className="artifact-view-controls">{tr(viewControls)}</div> : null)}
</aside>;
+5 -1
View File
@@ -1,4 +1,8 @@
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean; }
export interface PreviewArtifact { id: string; kind: 'website' | 'markdown' | 'pdf' | 'image' | 'code' | 'table'; title: string; url?: string; content?: string; demo?: boolean;
/** The host's version of the file this was read from (modified time and size), for a preview with an address instead of text. */
version?: string;
/** When this copy was read or made (ms): a message's copy never replaces a later one (lib/preview-merge.ts). */
at?: number; }
export const PREVIEW_EXAMPLES: PreviewArtifact[] = [
{ id:'sample-website', kind:'website', title:'Fieldnotes — website', url:'/examples/fieldnotes.html', demo:true },
{ id:'sample-markdown', kind:'markdown', title:'project-brief.md', url:'/examples/project-brief.md', demo:true },
+8 -8
View File
@@ -145,18 +145,18 @@ export function fileSize(bytes: number): string {
const csvText = (rows: string[][]) => rows.map(r => r.map(c => (/[",\n]/.test(c) ? `"${c.replace(/"/g, '""')}"` : c)).join(',')).join('\n');
interface PreviewAnswer { kind: DeliverableKind; name: string; path: string; size: number; url?: string; text?: string; rows?: string[][]; rawUrl?: string; note?: string }
interface PreviewAnswer { kind: DeliverableKind; name: string; path: string; size: number; mtime?: number; url?: string; text?: string; rows?: string[][]; rawUrl?: string; note?: string }
/** The sidebar preview of a file: the host reads it (only inside the chat's folder) and the page draws it as a website, markdown, table, picture, PDF or code. */
export async function previewOf(sessionId: string, file: { path: string; name: string }): Promise<PreviewArtifact> {
const a = await api.get<PreviewAnswer>(`/api/files/preview?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(file.path)}`);
const id = `file:${a.path}`, title = a.name || file.name;
if (a.kind === 'image' && a.rawUrl) return { id, kind: 'image', title, url: a.rawUrl };
if (a.kind === 'pdf' && a.rawUrl) return { id, kind: 'pdf', title, url: a.rawUrl };
if (a.kind === 'html' && a.text !== undefined) return { id, kind: 'website', title, content: a.text };
if (a.kind === 'markdown' && a.text !== undefined) return { id, kind: 'markdown', title, content: a.text };
if (a.kind === 'csv' && a.rows) return { id, kind: 'table', title, content: csvText(a.rows) };
if (a.text !== undefined) return { id, kind: 'code', title, content: a.text };
const id = `file:${a.path}`, title = a.name || file.name, at = Date.now(), version = a.mtime !== undefined ? `${a.mtime}:${a.size}` : undefined;
if (a.kind === 'image' && a.rawUrl) return { id, kind: 'image', title, url: a.rawUrl, version, at };
if (a.kind === 'pdf' && a.rawUrl) return { id, kind: 'pdf', title, url: a.rawUrl, version, at };
if (a.kind === 'html' && a.text !== undefined) return { id, kind: 'website', title, content: a.text, at };
if (a.kind === 'markdown' && a.text !== undefined) return { id, kind: 'markdown', title, content: a.text, at };
if (a.kind === 'csv' && a.rows) return { id, kind: 'table', title, content: csvText(a.rows), at };
if (a.text !== undefined) return { id, kind: 'code', title, content: a.text, at };
throw new Error(a.note || 'This file has no preview. Open it in its own app.');
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Which copy of a file the sidebar shows (#5180 follow-up: the preview kept a file's first content after the file changed).
*
* A preview tab can come from two places: the person (or a finished run) opening a file, which reads it from the host now, and the
* message the file was made in, which carries the content it had then. They share one id (`file:<path>`). The rules, as pure functions:
* - opening a file replaces the tab in place when the content differs, and keeps the very same array when it does not;
* - a message's artifact only adds a tab that is missing, or replaces one that is OLDER than the message and different; it never
* overwrites a copy that was read later;
* - both return the array they were given when nothing changed, so no state update, no render and no effect follows: there is no loop to
* start however often they are called (the same artifacts are handed over again whenever a message arrives).
* The panel reloads when `previewVersion` changes, and only then.
*/
import type { PreviewArtifact } from './artifacts';
import { textRevision } from './preview-render';
/** What identifies the content of a preview: the host's version for a file (modified time and size), else a hash of the text, else the address. */
export function previewVersion(a: Pick<PreviewArtifact, 'version' | 'content' | 'url'>): string {
if (a.content !== undefined)
return 'c' + textRevision(a.content);
return a.version ? 'v' + a.version : 'u' + (a.url ?? '');
}
const sameContent = (a: PreviewArtifact, b: PreviewArtifact) => a.kind === b.kind && a.title === b.title && previewVersion(a) === previewVersion(b);
/** A file was opened (read from the host just now): it takes the place of its tab when it differs. */
export function openItem(old: PreviewArtifact[], item: PreviewArtifact): PreviewArtifact[] {
const at = old.findIndex(a => a.id === item.id);
if (at < 0)
return [...old, item];
if (sameContent(old[at], item))
return old;
const next = old.slice();
next[at] = item;
return next;
}
/**
* The artifacts the messages carry, merged into the tabs. `items` are in message order, each stamped with the time of its message (`at`);
* `closed` are the ids the person closed.
*/
export function syncArtifacts(old: PreviewArtifact[], items: PreviewArtifact[], closed: readonly string[] | undefined = []): PreviewArtifact[] {
let next = old;
const edit = () => { if (next === old) next = old.slice(); };
for (const item of items) {
const at = next.findIndex(a => a.id === item.id);
if (closed?.includes(item.id)) {
if (at >= 0) { edit(); next.splice(at, 1); }
continue;
}
if (at < 0) {
edit();
next.push(item);
continue;
}
const have = next[at];
// only a message newer than the copy on show, with different content, replaces it
if (item.at !== undefined && have.at !== undefined && item.at > have.at && !sameContent(have, item)) {
edit();
next[at] = item;
}
}
return next;
}
/** The message artifacts of a session, in order and de-duplicated by id (the last of an id wins), each stamped with its message's time. */
export function messageArtifacts(messages: ReadonlyArray<{ at?: string; artifacts?: PreviewArtifact[] }> | undefined): PreviewArtifact[] {
const byId = new Map<string, PreviewArtifact>();
for (const m of messages ?? []) {
const at = m.at ? Date.parse(m.at) : NaN;
for (const a of m.artifacts ?? [])
byId.set(a.id, Number.isFinite(at) && a.at === undefined ? { ...a, at } : a);
}
return [...byId.values()];
}
+6 -3
View File
@@ -11,6 +11,7 @@ import { ArtifactCard } from '../components/ArtifactCard';
import { ChatMedia } from '../components/ChatMedia';
import { closePreviewTab } from '../lib/preview-tabs';
import { endPreviewDeferral, previewsDeferred } from '../lib/recovery';
import { messageArtifacts, openItem, syncArtifacts } from '../lib/preview-merge';
import { createImeGuard } from '../lib/ime-enter';
import { mainCodePreview, setSelectedPreviewId } from '../lib/code-preview';
import { ArtifactPanel } from '../components/ArtifactPanel';
@@ -171,9 +172,10 @@ export function ChatScreen() {
const previewCount = session?.messages.reduce((n, m) => n + (m.artifacts?.length ?? 0), 0) ?? 0;
useEffect(() => { const items = [...new Map((session?.messages.flatMap(m => m.artifacts ?? []) ?? []).map(a => [a.id, a])).values()]; setPreviews(items.filter(a => !session?.closedPreviews?.includes(a.id))); setSelectedPreview(session && previewsDeferred(session.id) || session?.closedPreviews?.includes(session.previewOpen ?? '') ? null : session?.previewOpen?.startsWith('time-skip:') && !timeSkipRuns.some(r => timeSkipTabId(r.id) === session.previewOpen) ? null : session?.previewOpen ?? null); }, [session?.id]);
useEffect(() => {
const items = [...new Map((session?.messages.flatMap(m => m.artifacts ?? []) ?? []).map(a => [a.id, a])).values()];
// A message's copy of a file never replaces one that was read later (lib/preview-merge.ts); nothing changed = the same array = no update.
const items = messageArtifacts(session?.messages);
if (items.length)
setPreviews(old => [...old.filter(a => !items.some(b => a.id === b.id)), ...items.filter(a => !session?.closedPreviews?.includes(a.id))]);
setPreviews(old => syncArtifacts(old, items, session?.closedPreviews));
}, [previewCount]);
const localUrls = useRef<string[]>([]);
useEffect(() => {
@@ -187,7 +189,8 @@ export function ChatScreen() {
if (item.url?.startsWith('blob:'))
localUrls.current.push(item.url);
setPreviewTabClosed(session?.id, item.id, false, item.id);
setPreviews(old => old.some(a => a.id === item.id) ? old : [...old, item]);
// The file was read just now: it replaces the tab of the same id when its content differs (it used to be dropped, keeping the first copy).
setPreviews(old => openItem(old, item));
setSelectedPreview(item.id);
}
/* A run that finishes with a file the sidebar can show opens its main result there by itself (one file, never several). Only a run seen
+152
View File
@@ -0,0 +1,152 @@
// The sidebar preview kept a file's FIRST content after the file changed on disk (QAGUI, 4d482e627). Three places had to change together:
// openPreview dropped the freshly read file, the panel's loading effect had no content version, and the effect that re-adds a message's own
// artifacts put the stored first copy back over the newer one. A fix of the first two alone ran the renderer out of memory: a ping-pong
// between the message copy and the opened copy. These tests pin the rules (lib/preview-merge.ts) and that nothing can loop.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync, writeFileSync, mkdtempSync, utimesSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { build } from 'esbuild';
import { createRequire } from 'node:module';
const root = resolve(import.meta.dirname, '..');
const bundle = await build({ stdin: { contents: `export * from './src/lib/preview-merge.ts'; export { textRevision } from './src/lib/preview-render.ts';`, resolveDir: root, loader: 'ts' }, bundle: true, write: false, platform: 'node', format: 'cjs' });
const mod = { exports: {} };
new Function('require', 'module', 'exports', bundle.outputFiles[0].text)(createRequire(import.meta.url), mod, mod.exports);
const { previewVersion, openItem, syncArtifacts, messageArtifacts } = mod.exports;
const { previewFile } = await import('../server/chat-files.mjs');
const md = (content, at, extra = {}) => ({ id: 'file:/w/notes.md', kind: 'markdown', title: 'notes.md', content, at, ...extra });
const T = (n) => new Date(Date.UTC(2026, 10, 4, 10, 0, n)).toISOString();
test('the version follows the content: the text, else the host version, else the address', () => {
assert.equal(previewVersion(md('# One')), previewVersion(md('# One')));
assert.notEqual(previewVersion(md('# One')), previewVersion(md('# Two')));
assert.notEqual(previewVersion({ url: '/a.png', version: '1:5' }), previewVersion({ url: '/a.png', version: '2:5' }));
assert.equal(previewVersion({ url: '/a.png', version: '1:5' }), previewVersion({ url: '/a.png', version: '1:5' }));
assert.notEqual(previewVersion({ url: '/a.png' }), previewVersion({ url: '/b.png' }));
assert.notEqual(previewVersion({ content: '' }), previewVersion({ url: '' }));
});
test('opening a file that changed replaces its tab in place (it used to be dropped)', () => {
const old = [md('# Version One', 1000), { id: 'other', kind: 'code', title: 'x.ts', content: 'x' }];
const next = openItem(old, md('# Version Two', 2000));
assert.equal(next.length, 2);
assert.equal(next[0].content, '# Version Two');
assert.equal(next[1].id, 'other', 'its place in the strip is kept');
assert.equal(old[0].content, '# Version One', 'the old array is not mutated');
});
test('opening a file that did not change, or a new file, behaves as before; the same array comes back when nothing changed', () => {
const old = [md('# Version One', 1000)];
assert.equal(openItem(old, md('# Version One', 5000)), old);
const added = openItem(old, { id: 'file:/w/b.md', kind: 'markdown', title: 'b.md', content: 'b' });
assert.equal(added.length, 2);
assert.equal(added[0], old[0]);
});
test('the QAGUI case: the message still carries Version One, the file was opened as Version Two, and Version Two stays', () => {
const messages = [{ at: T(0), artifacts: [md('# Version One')] }];
let tabs = syncArtifacts([], messageArtifacts(messages), []); // the first message arrives: the tab shows Version One
assert.equal(tabs[0].content, '# Version One');
tabs = openItem(tabs, md('# Version Two', Date.parse(T(60)))); // "Open in sidebar" reads the file again
assert.equal(tabs[0].content, '# Version Two');
for (let i = 0; i < 5; i++) { // a later message count change runs the sync again
const again = syncArtifacts(tabs, messageArtifacts(messages), []);
assert.equal(again, tabs, 'the same array: nothing to update');
assert.equal(again[0].content, '# Version Two', 'the message copy did not come back');
}
});
test('a message newer than the copy on show, with other content, does replace it; an older or equal one never does', () => {
const shown = [md('# Version Two', Date.parse(T(60)))];
const older = syncArtifacts(shown, messageArtifacts([{ at: T(30), artifacts: [md('# Version One')] }]), []);
assert.equal(older, shown);
const equal = syncArtifacts(shown, messageArtifacts([{ at: T(60), artifacts: [md('# Version Three')] }]), []);
assert.equal(equal, shown);
const newer = syncArtifacts(shown, messageArtifacts([{ at: T(90), artifacts: [md('# Version Three')] }]), []);
assert.equal(newer[0].content, '# Version Three');
assert.equal(shown[0].content, '# Version Two');
// the same content in a newer message is not a change
assert.equal(syncArtifacts(shown, messageArtifacts([{ at: T(90), artifacts: [md('# Version Two')] }]), []), shown);
});
test('a missing tab is added, a closed one is removed and not brought back', () => {
const items = messageArtifacts([{ at: T(0), artifacts: [md('a'), { id: 'b', kind: 'code', title: 'b', content: 'b' }] }]);
const added = syncArtifacts([], items, []);
assert.deepEqual(added.map(a => a.id), ['file:/w/notes.md', 'b']);
const closed = syncArtifacts(added, items, ['b']);
assert.deepEqual(closed.map(a => a.id), ['file:/w/notes.md']);
assert.deepEqual(syncArtifacts(closed, items, ['b']).map(a => a.id), ['file:/w/notes.md'], 'and it stays closed');
assert.equal(syncArtifacts(closed, items, ['b']), closed);
// a tab opened by hand that no message knows stays
const mine = [{ id: 'mine', kind: 'code', title: 'm', content: 'm' }];
assert.equal(syncArtifacts(mine, [], []), mine);
});
test('message artifacts: stamped with their message time, the last of an id wins, a bad time is left unstamped', () => {
const list = messageArtifacts([{ at: T(0), artifacts: [md('one')] }, { at: T(30), artifacts: [md('two')] }, { at: 'not a time', artifacts: [{ id: 'z', kind: 'code', title: 'z', content: 'z' }] }, {}]);
assert.equal(list.length, 2);
assert.equal(list[0].content, 'two');
assert.equal(list[0].at, Date.parse(T(30)));
assert.equal(list[1].at, undefined);
assert.deepEqual(messageArtifacts(undefined), []);
});
test('no loop: the open and the sync handed to each other 2000 times change the tabs only when the content changes', () => {
const messages = [{ at: T(0), artifacts: [md('# Version One')] }];
let tabs = [], changes = 0, loads = 0, lastKey = '';
const set = next => { if (next !== tabs) changes++; tabs = next; };
// what the panel does: reload its text only when (id, version) of the shown tab changes
const panel = () => { const cur = tabs[0]; const key = cur ? cur.id + '|' + previewVersion(cur) : ''; if (key !== lastKey) { loads++; lastKey = key; } };
set(syncArtifacts(tabs, messageArtifacts(messages), [])); panel();
for (let i = 0; i < 2000; i++) {
// the chat renders for every streamed token; the sync runs when the artifact count changes, the open when a run finishes
set(openItem(tabs, md('# Version Two', Date.parse(T(60)))));
set(syncArtifacts(tabs, messageArtifacts(messages), []));
set(openItem(tabs, md('# Version Two', Date.parse(T(60 + i)))));
panel();
}
assert.equal(changes, 2, 'the first tab, then Version Two once');
assert.equal(loads, 2, 'the panel loaded its text twice: Version One, then Version Two');
});
test('no loop: a file that changes every second is one load per change, not one per render', () => {
let tabs = [], loads = 0, lastKey = '';
for (let s = 0; s < 300; s++) {
for (let render = 0; render < 20; render++) { // 20 renders per second of streamed tokens
if (render === 0) tabs = openItem(tabs, md('# v' + s, 1000 + s));
const cur = tabs[0]; const key = cur.id + '|' + previewVersion(cur);
if (key !== lastKey) { loads++; lastKey = key; }
}
}
assert.equal(loads, 300);
});
test('the host sends the file version: it moves when the file is rewritten', () => {
const dir = mkdtempSync(join(tmpdir(), 'strata-prev-'));
writeFileSync(join(dir, 'n.md'), '# Version One\n');
const a = previewFile(dir, 'n.md', 's');
writeFileSync(join(dir, 'n.md'), '# Version Two\n');
utimesSync(join(dir, 'n.md'), new Date(Date.now() + 5000), new Date(Date.now() + 5000));
const b = previewFile(dir, 'n.md', 's');
assert.equal(typeof a.mtime, 'number');
assert.notEqual(a.mtime, b.mtime);
assert.equal(b.text, '# Version Two\n');
});
test('the three places are changed together', () => {
const chat = readFileSync(resolve(root, 'src/screens/Chat.tsx'), 'utf8');
assert.match(chat, /setPreviews\(old => openItem\(old, item\)\)/);
assert.match(chat, /setPreviews\(old => syncArtifacts\(old, items, session\?\.closedPreviews\)\)/);
assert.doesNotMatch(chat, /old\.some\(a => a\.id === item\.id\) \? old/);
assert.doesNotMatch(chat, /\[\.\.\.old\.filter\(a => !items\.some/);
const panel = readFileSync(resolve(root, 'src/components/ArtifactPanel.tsx'), 'utf8');
assert.match(panel, /\}, \[current\?\.id, version, revision\]\);/);
assert.match(panel, /const version = useMemo\(\(\) => current \? previewVersion\(current\) : '', \[current\]\)/);
// the reading view is reset by the file, not by its content
assert.match(panel, /setMode\('preview'\);[\s\S]*?\}, \[current\?\.id\]\);/);
const deliver = readFileSync(resolve(root, 'src/lib/deliverables.ts'), 'utf8');
assert.match(deliver, /version = a\.mtime !== undefined/);
});