fix(web): preserve preview scroll across tools (#3313)

* fix(web): preserve preview scroll across tools

Capture URL-loaded preview scroll state before tool handoff and restore it through an opt-in raw HTML bridge to avoid jumping back to the top.

Agent-Model: gpt-5

Agent-Family: openai

Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197

Agent-Step: 0.0.6

* test(daemon): cover scroll bridge injection paths

Agent-Model: gpt-5
Agent-Family: openai
Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197
Agent-Step: 0.0.6

---------

Co-authored-by: Codex <gpt-5@openai.com>
This commit is contained in:
xinsngx 2026-05-30 11:53:50 +08:00 committed by GitHub
parent 778010bcf9
commit c88a83cd5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 195 additions and 12 deletions

View file

@ -21,6 +21,106 @@ import { auditDesignSystemPackage } from './tools-connectors-cli.js';
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {}
const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
(function(){
if (window.__odUrlScrollBridge) return;
window.__odUrlScrollBridge = true;
var pending = false;
function scrollElement(){
return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement;
}
function num(value){
var next = Number(value || 0);
return Number.isFinite(next) ? next : 0;
}
function post(){
var el = scrollElement();
if (!el) return;
var frame = document.scrollingElement || document.documentElement;
window.parent.postMessage({
type: 'od:preview-scroll',
canvasLeft: Math.round(el.scrollLeft || 0),
canvasTop: Math.round(el.scrollTop || 0),
frameLeft: Math.round(frame.scrollLeft || 0),
frameTop: Math.round(frame.scrollTop || 0)
}, '*');
}
function schedule(){
if (pending) return;
pending = true;
window.requestAnimationFrame(function(){
pending = false;
post();
});
}
function scrollTo(el, left, top){
if (!el) return;
if (typeof el.scrollTo === 'function') el.scrollTo(num(left), num(top));
else {
el.scrollLeft = num(left);
el.scrollTop = num(top);
}
}
function scrollBy(el, left, top){
if (!el) return;
var dx = num(left);
var dy = num(top);
if (!dx && !dy) return;
if (typeof el.scrollBy === 'function') el.scrollBy({ left: dx, top: dy, behavior: 'auto' });
else {
el.scrollLeft = (el.scrollLeft || 0) + dx;
el.scrollTop = (el.scrollTop || 0) + dy;
}
}
function requestRestore(){
window.parent.postMessage({ type: 'od:preview-scroll-request' }, '*');
}
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data || !data.type) return;
if (data.type === 'od:preview-scroll-restore') {
scrollTo(document.scrollingElement || document.documentElement, data.frameLeft, data.frameTop);
scrollTo(scrollElement(), data.canvasLeft, data.canvasTop);
setTimeout(post, 0);
return;
}
if (data.type === 'od:preview-scroll-by') {
scrollBy(scrollElement(), data.left, data.top);
schedule();
}
});
window.addEventListener('scroll', schedule, true);
document.addEventListener('scroll', schedule, true);
window.addEventListener('resize', schedule);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function(){
requestRestore();
schedule();
});
} else {
setTimeout(function(){
requestRestore();
schedule();
}, 0);
}
})();
</script>`;
function wantsUrlPreviewScrollBridge(value: unknown): boolean {
if (Array.isArray(value)) return value.some(wantsUrlPreviewScrollBridge);
if (typeof value !== 'string') return false;
return value === 'scroll' || value === '1' || value === 'true';
}
function injectUrlPreviewScrollBridge(html: string): string {
if (html.includes('data-od-url-scroll-bridge')) return html;
const bodyCloseIndex = html.search(/<\/body\s*>/i);
if (bodyCloseIndex >= 0) {
return `${html.slice(0, bodyCloseIndex)}${URL_PREVIEW_SCROLL_BRIDGE}${html.slice(bodyCloseIndex)}`;
}
return `${html}${URL_PREVIEW_SCROLL_BRIDGE}`;
}
export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
@ -959,6 +1059,13 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
}
const file = await readProjectFile(PROJECTS_DIR, projectId, relPath, project?.metadata);
if (
wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) &&
/^text\/html(?:;|$)/i.test(file.mime)
) {
res.type(file.mime).send(injectUrlPreviewScrollBridge(file.buffer.toString('utf8')));
return;
}
res.type(file.mime).send(file.buffer);
} catch (err: any) {
const status = err && err.code === 'ENOENT' ? 404 : 400;

View file

@ -165,6 +165,11 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
await writeFile(path.join(dir, 'clip.mp4'), Buffer.alloc(FILE_SIZE, 0x42));
await writeFile(path.join(dir, 'audio.mp3'), Buffer.alloc(FILE_SIZE, 0x43));
await writeFile(path.join(dir, 'page.html'), Buffer.from('<html/>'));
await writeFile(path.join(dir, 'body.html'), Buffer.from('<html><body><main>Preview</main></body></html>'));
await writeFile(
path.join(dir, 'bridged.html'),
Buffer.from('<html><body><script data-od-url-scroll-bridge></script><main>Preview</main></body></html>'),
);
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
@ -226,6 +231,32 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
expect(text).toBe('<html/>');
});
it('injects the URL preview scroll bridge only when requested', async () => {
const plain = await fetch(rawUrl('page.html'));
expect(await plain.text()).toBe('<html/>');
const bridged = await fetch(`${rawUrl('page.html')}?odPreviewBridge=scroll`);
expect(bridged.status).toBe(200);
const html = await bridged.text();
expect(html).toContain('data-od-url-scroll-bridge');
expect(html).toContain("type: 'od:preview-scroll'");
});
it('injects the URL preview scroll bridge before the closing body tag', async () => {
const bridged = await fetch(`${rawUrl('body.html')}?odPreviewBridge=scroll`);
expect(bridged.status).toBe(200);
const html = await bridged.text();
expect(html.indexOf('data-od-url-scroll-bridge')).toBeGreaterThan(-1);
expect(html.indexOf('data-od-url-scroll-bridge')).toBeLessThan(html.indexOf('</body>'));
});
it('does not inject the URL preview scroll bridge twice', async () => {
const bridged = await fetch(`${rawUrl('bridged.html')}?odPreviewBridge=scroll`);
expect(bridged.status).toBe(200);
const html = await bridged.text();
expect(html.match(/data-od-url-scroll-bridge/g)?.length).toBe(1);
});
it('returns 404 for a missing file', async () => {
const res = await fetch(rawUrl('missing.mp4'));
expect(res.status).toBe(404);

View file

@ -4519,7 +4519,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
needsFocusGuard,
});
const basePreviewSrcUrl = useMemo(
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}`,
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewBridge=scroll`,
[projectId, file.name, file.mtime, reloadKey],
);
const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl);
@ -4692,7 +4692,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
useEffect(() => {
restorePreviewScrollPosition();
}, [boardMode, manualEditMode, srcDoc, restorePreviewScrollPosition]);
}, [boardMode, drawOverlayOpen, manualEditMode, srcDoc, restorePreviewScrollPosition]);
useEffect(() => {
function onMessage(ev: MessageEvent) {
@ -6016,6 +6016,7 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
setAgentToolsOpen(false);
return;
}
capturePreviewScrollPosition();
const activateDraw = () => {
setCommentPanelOpen(false);
setCommentCreateMode(false);

View file

@ -441,7 +441,7 @@ describe('FileViewer SVG artifacts', () => {
const { container } = render(<Shell />);
const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0');
expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll');
fireEvent.click(screen.getByRole('button', { name: 'Leave project' }));
@ -449,7 +449,7 @@ describe('FileViewer SVG artifacts', () => {
expect(screen.getByTestId('home-view')).toBeTruthy();
const parkedFrame = container.querySelector<HTMLIFrameElement>('.iframe-keep-alive-pool iframe');
expect(parkedFrame).toBe(firstFrame);
expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0');
expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll');
fireEvent.click(screen.getByRole('button', { name: 'Return project' }));
@ -596,7 +596,7 @@ describe('FileViewer SVG artifacts', () => {
expect(markup).toContain('data-od-render-mode="url-load"');
expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"');
expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="false"');
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&amp;r=0"');
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&amp;r=0&amp;odPreviewBridge=scroll"');
expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
});
@ -774,7 +774,8 @@ describe('FileViewer SVG artifacts', () => {
const { container } = render(<Switcher />);
const getFrame = () => container.querySelector<HTMLIFrameElement>('[data-testid="artifact-preview-frame"]');
expect(getFrame()?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0');
const initialFrame = getFrame();
expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewBridge=scroll');
const observationsBeforeSwitch = observedCommittedSrcs.length;
fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
@ -782,9 +783,9 @@ describe('FileViewer SVG artifacts', () => {
const nextFrame = getFrame();
expect(nextFrame).toBeTruthy();
expect(observedCommittedSrcs[observationsBeforeSwitch]).toBe(
'/api/projects/project-1/raw/second.html?v=1710000000&r=0',
'/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll',
);
expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0');
expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll');
});
it('allows downloads in the in-tab HTML presentation iframe', async () => {
@ -1782,11 +1783,54 @@ describe('FileViewer tweaks toolbar', () => {
expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toBe(frame.srcdoc);
});
it('keeps Draw queue available while disabling direct send during a running task', async () => {
const annotationSpy = vi.fn((event: Event) => {
const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail;
detail.ack?.({ ok: true });
it('preserves URL-loaded preview scroll when opening Draw', async () => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal('cancelAnimationFrame', vi.fn());
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const urlFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(urlFrame.getAttribute('data-od-render-mode')).toBe('url-load');
expect(urlFrame.getAttribute('src')).toContain('odPreviewBridge=scroll');
const srcDocFrame = screen.getByTestId('artifact-preview-frame-srcdoc') as HTMLIFrameElement;
const postSpy = vi.spyOn(srcDocFrame.contentWindow!, 'postMessage');
window.dispatchEvent(new MessageEvent('message', {
source: urlFrame.contentWindow,
data: {
type: 'od:preview-scroll',
frameLeft: 4,
frameTop: 640,
canvasLeft: 0,
canvasTop: 640,
},
}));
clickAgentTool('draw-overlay-toggle');
await waitFor(() => {
expect(postSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'od:preview-scroll-restore',
frameLeft: 4,
frameTop: 640,
canvasTop: 640,
}),
'*',
);
});
});
it('lets Draw direct send emit a queued annotation while a task is running', async () => {
const annotationSpy = vi.fn();
window.addEventListener(ANNOTATION_EVENT, annotationSpy);
render(