mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 14:21:06 -10:00
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:
1 parent
792b626980
commit
68bead1082
7 files changed
+257
-18
No files matched your search
@@ -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.' };
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user