diff --git a/apps/daemon/src/project-routes.ts b/apps/daemon/src/project-routes.ts index d7e2561db..48fbc8e7f 100644 --- a/apps/daemon/src/project-routes.ts +++ b/apps/daemon/src/project-routes.ts @@ -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 = ``; + +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; diff --git a/apps/daemon/tests/project-file-range.test.ts b/apps/daemon/tests/project-file-range.test.ts index 45b9d1199..8fcd8abb2 100644 --- a/apps/daemon/tests/project-file-range.test.ts +++ b/apps/daemon/tests/project-file-range.test.ts @@ -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('')); + await writeFile(path.join(dir, 'body.html'), Buffer.from('
Preview
')); + await writeFile( + path.join(dir, 'bridged.html'), + Buffer.from('
Preview
'), + ); }); afterAll(() => new Promise((resolve) => server.close(() => resolve()))); @@ -226,6 +231,32 @@ describe('GET /api/projects/:id/raw/* range request route', () => { expect(text).toBe(''); }); + it('injects the URL preview scroll bridge only when requested', async () => { + const plain = await fetch(rawUrl('page.html')); + expect(await plain.text()).toBe(''); + + 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('')); + }); + + 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); diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index ddc2d2b07..a1f0a982e 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -4519,7 +4519,7 @@ const [manualEditTargets, setManualEditTargets] = useState([ 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([ useEffect(() => { restorePreviewScrollPosition(); - }, [boardMode, manualEditMode, srcDoc, restorePreviewScrollPosition]); + }, [boardMode, drawOverlayOpen, manualEditMode, srcDoc, restorePreviewScrollPosition]); useEffect(() => { function onMessage(ev: MessageEvent) { @@ -6016,6 +6016,7 @@ const [manualEditTargets, setManualEditTargets] = useState([ setAgentToolsOpen(false); return; } + capturePreviewScrollPosition(); const activateDraw = () => { setCommentPanelOpen(false); setCommentCreateMode(false); diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index b9e62e426..f63c48973 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -441,7 +441,7 @@ describe('FileViewer SVG artifacts', () => { const { container } = render(); 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('.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&r=0"'); + expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll"'); expect(markup).toContain('sandbox="allow-scripts allow-downloads"'); }); @@ -774,7 +774,8 @@ describe('FileViewer SVG artifacts', () => { const { container } = render(); const getFrame = () => container.querySelector('[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( + , + ); + + 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(