From dd551b7563b8def35daef21f745f2639751d7cb8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 21:24:36 +0000 Subject: [PATCH 1/3] test(web): source file tab keeps its scroll position after switching files Fails on main: returning to a scrolled code file resets it to the top (#37348). Co-Authored-By: Claude Opus 5.5 --- .../files/FilePreviewPanel.browser.test.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index b36940287c2c7..cf408f34386ad 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -268,6 +268,38 @@ describe("source file focus", () => { }); }); +describe("source file scroll", () => { + it("returns a file tab to where it was scrolled after another file was shown", async () => { + using _settings = setup(); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + using _style = { [Symbol.dispose]: () => style.remove() }; + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); + const sourceFile = (relativePath: string, lines: number) => { + const contents = Array.from({ length: lines }, (_, i) => `const line${i} = ${i};`).join("\n"); + state.file = { relativePath, contents, byteLength: contents.length, truncated: false }; + return ( +
+ {filePreview(relativePath)} +
+ ); + }; + const scroller = () => document.querySelector(".file-preview-virtualizer"); + + await using screen = await render(sourceFile("a.ts", 2000)); + await vi.waitFor(() => expect(scroller()?.scrollHeight).toBeGreaterThan(5000)); + scroller()!.scrollTop = 4200; + await vi.waitFor(() => expect(scroller()?.scrollTop).toBe(4200)); + + await screen.rerender(sourceFile("b.ts", 1500)); + await vi.waitFor(() => expect(scroller()?.scrollTop).toBe(0)); + await screen.rerender(sourceFile("a.ts", 2000)); + + await vi.waitFor(() => expect(scroller()?.scrollTop).toBe(4200)); + }); +}); + describe("rendered Markdown task saves", () => { it("keeps a truncated read read-only without queuing a replacement", async () => { using _settings = setup(); From 274807c5f24571cae886f0d70a07eaec9a811dd8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 21:37:14 +0000 Subject: [PATCH 2/3] fix(web): file tabs keep their scroll position when you switch back Every file tab in a workspace shares one FilePreviewPanel, and the code view remounts per file, so returning to a tab showed it at the top. Record the code view's offset when a file's view unmounts and restore it on the next mount, unless a line reveal is pending for that file. Fixes pingdotgg/t3code#37348 Co-Authored-By: Claude Opus 5.5 --- .../src/components/files/FilePreviewPanel.tsx | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index f21183011f99c..4cc9464e6db9e 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -503,6 +503,10 @@ interface FileRevealState { cancelGuard: (() => void) | null; handledRequestId: number | null; latestRequestId: number | null; + /** The path's code view offset when it last unmounted, restored when it is shown again. */ + scrollTop: number; + scrollContainer: HTMLElement | null; + restoreFrameId: number | null; } function useFileLineReveal( @@ -521,6 +525,9 @@ function useFileLineReveal( cancelGuard: null, handledRequestId: null, latestRequestId: null, + scrollTop: 0, + scrollContainer: null, + restoreFrameId: null, }; if (!existingState) revealStatesByPath.set(relativePath, state); @@ -530,10 +537,18 @@ function useFileLineReveal( state.frameId = null; } state.cancelGuard?.(); + if (state.restoreFrameId !== null) { + cancelAnimationFrame(state.restoreFrameId); + state.restoreFrameId = null; + } }; + const scrollContainer = fileContainer.closest(".file-preview-virtualizer"); + if (phase === "unmount") { cancelPendingReveal(); + if (scrollContainer) state.scrollTop = scrollContainer.scrollTop; + state.scrollContainer = null; return; } @@ -550,12 +565,37 @@ function useFileLineReveal( state.handledRequestId = null; } + if (scrollContainer && state.scrollContainer !== scrollContainer) { + state.scrollContainer = scrollContainer; + const savedScrollTop = state.scrollTop; + // A pending line reveal owns the scroll position. + if ( + savedScrollTop > 0 && + (revealLine === null || state.handledRequestId === revealRequestId) + ) { + const restoreScroll = (attempt: number) => { + state.restoreFrameId = requestAnimationFrame(() => { + state.restoreFrameId = null; + if (!scrollContainer.isConnected || scrollContainer.scrollTop !== 0) return; + // Wait for the virtualizer to size the file so the offset is not clamped. + const fits = + scrollContainer.scrollHeight - scrollContainer.clientHeight >= savedScrollTop; + if (fits || attempt >= REVEAL_MAX_ATTEMPTS) { + scrollContainer.scrollTop = savedScrollTop; + return; + } + restoreScroll(attempt + 1); + }); + }; + restoreScroll(0); + } + } + if (revealLine === null) { fileContainer.style.minHeight = ""; return; } - const scrollContainer = fileContainer.closest(".file-preview-virtualizer"); if (!scrollContainer) return; fileContainer.style.minHeight = `${Math.ceil( Math.max(instance.height, scrollContainer.clientHeight), From e10c62a12807b4de2bb587be7eb65febc4a06ea5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 22:18:51 +0000 Subject: [PATCH 3/3] fix(web): a remount before the scroll restore no longer drops the saved offset The app renders under StrictMode, so a file view returning to its tab mounts, unmounts before its restore frame runs, and mounts again. That unmount recorded scrollTop 0 over the saved offset. Keep the saved offset when a view unmounts with its restore still pending, and render the scroll test under StrictMode so it covers this path. Co-Authored-By: Claude Opus 5.5 --- .../files/FilePreviewPanel.browser.test.tsx | 11 +++++++---- apps/web/src/components/files/FilePreviewPanel.tsx | 4 +++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx index cf408f34386ad..82fcd5fcbca3a 100644 --- a/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.browser.test.tsx @@ -1,7 +1,7 @@ import { DEFAULT_THEMES, preloadHighlighter } from "@pierre/diffs"; import { EnvironmentId, ThreadId, type ProjectReadFileResult } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; -import type { ReactNode } from "react"; +import { StrictMode, type ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; import { act, fakeTimers, render } from "../../../test/render"; @@ -279,10 +279,13 @@ describe("source file scroll", () => { const sourceFile = (relativePath: string, lines: number) => { const contents = Array.from({ length: lines }, (_, i) => `const line${i} = ${i};`).join("\n"); state.file = { relativePath, contents, byteLength: contents.length, truncated: false }; + // The app mounts under StrictMode, whose remount must not wipe the saved offset. return ( -
- {filePreview(relativePath)} -
+ +
+ {filePreview(relativePath)} +
+
); }; const scroller = () => document.querySelector(".file-preview-virtualizer"); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 4cc9464e6db9e..57b93a76353d2 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -546,8 +546,10 @@ function useFileLineReveal( const scrollContainer = fileContainer.closest(".file-preview-virtualizer"); if (phase === "unmount") { + // A view torn down before its restore ran never showed the saved offset, so keep it. + const restorePending = state.restoreFrameId !== null; cancelPendingReveal(); - if (scrollContainer) state.scrollTop = scrollContainer.scrollTop; + if (scrollContainer && !restorePending) state.scrollTop = scrollContainer.scrollTop; state.scrollContainer = null; return; }