From ed0534f68c77db95d8d8cb77ab1f5b8d791f9b6d Mon Sep 17 00:00:00 2001 From: NickAwrist Date: Sat, 3 Oct 2026 16:29:01 -0700 Subject: [PATCH 1/8] fix(web): expand clicked file in sidebar diff Reveal and expand files selected from chat in turn diffs. Preserve other files and configured expansion defaults, and wait for patch availability before recording a reveal request. Models: Claude Opus 5.5 and GPT-6.1-Sol Harnesses: Claude Code and Codex --- apps/web/src/components/DiffPanel.tsx | 36 +++++++++++++-------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f7a54e8ad07a..31f269172a3c 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -565,14 +565,6 @@ export default function DiffPanel({ return getDiffLineStat(renderableFiles); }, [renderableFiles, selectedGitSource, selectedTurn]); const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]); - const selectedDiffFileKey = selectedFilePath - ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null) - : null; - - useEffect(() => { - if (!selectedDiffFileKey || !codeView?.getInstance()) return; - codeView.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" }); - }, [codeView, codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]); const treeRevealScope = useMemo( () => ({ collapseScopeKey, diffSelection }), @@ -589,7 +581,7 @@ export default function DiffPanel({ (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, ); const file = renderableFileEntries[index]; - if (!file) return; + if (!file) return false; setCollapsedDiffFiles((current) => { const next = new Set( current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, @@ -601,6 +593,7 @@ export default function DiffPanel({ requestFile(index); } requestTreeReveal(file.fileKey); + return true; }, [ renderableFileEntries, @@ -613,18 +606,23 @@ export default function DiffPanel({ ], ); - const externalRevealRef = useRef<{ cache: string; key: string } | null>(null); + const externalRevealRef = useRef<{ scope: string | null; key: string } | null>(null); useEffect(() => { - if (!lazySource || !selectedFilePath) return; + if (!selectedFilePath) return; + const scope = collapseScopeKey ?? filePatchScope; const key = `${selectedFilePath}:${selectedFileRevealRequestId}`; - if ( - externalRevealRef.current?.cache === filePatchScope && - externalRevealRef.current.key === key - ) - return; - externalRevealRef.current = { cache: filePatchScope, key }; - revealDiffFile(selectedFilePath); - }, [lazySource, selectedFilePath, selectedFileRevealRequestId, filePatchScope, revealDiffFile]); + if (externalRevealRef.current?.scope === scope && externalRevealRef.current.key === key) return; + const revealed = revealDiffFile(selectedFilePath); + if (revealed) { + externalRevealRef.current = { scope, key }; + } + }, [ + collapseScopeKey, + filePatchScope, + selectedFilePath, + selectedFileRevealRequestId, + revealDiffFile, + ]); const openDiffFile = useCallback( (filePath: string) => { From 99d7a72d464fc8d9e3ddb6559bdea47c1fac1709 Mon Sep 17 00:00:00 2001 From: NickAwrist Date: Fri, 16 Oct 2026 17:31:59 -0700 Subject: [PATCH 2/8] test(web): cover selected diff file reveals Exercise turn and lazy diffs with collapsed defaults, delayed patches, sibling expansion state, and scope changes using the existing jsdom and atom registry setup. --- apps/web/src/components/DiffPanel.test.tsx | 280 +++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 apps/web/src/components/DiffPanel.test.tsx diff --git a/apps/web/src/components/DiffPanel.test.tsx b/apps/web/src/components/DiffPanel.test.tsx new file mode 100644 index 000000000000..c542cb51c975 --- /dev/null +++ b/apps/web/src/components/DiffPanel.test.tsx @@ -0,0 +1,280 @@ +// @vitest-environment jsdom + +import { RegistryContext } from "@effect/atom-react"; +import type { CodeViewScrollTarget } from "@pierre/diffs"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + RunId, + ThreadId, + type ReviewDiffPreviewResult, + type ReviewDiffPreviewSource, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import * as DateTime from "effect/DateTime"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { + act, + useImperativeHandle, + useLayoutEffect, + useRef, + type ComponentProps, + type ReactNode, + type Ref, +} from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + patchAvailable: true, + scroll: vi.fn<(target: CodeViewScrollTarget, collapsed: boolean | undefined) => void>(), + requestPatch: vi.fn<(path: string) => void>(), +})); + +vi.mock("@tanstack/react-router", () => ({ useParams: () => threadRef })); +vi.mock("../state/entities", () => ({ + useThreadShell: () => ({ ...threadRef, worktreePath: "/repo" }), + useThreadProjection: () => null, + useProject: () => null, +})); +vi.mock("../state/filesystem", () => ({ useFilesystemReadAccess: () => ({ canReadFiles: true }) })); +vi.mock("../state/server", () => ({ serverEnvironment: { configValueAtom: () => configAtom } })); +vi.mock("../state/vcs", () => ({ + vcsEnvironment: { status: () => null, listRefs: () => null }, +})); +vi.mock("../state/review", () => ({ + reviewEnvironment: { + diffPreview: () => previewAtom, + diffFilePatch: ({ input }: { input: { request: { file: { path: string } } } }) => { + state.requestPatch(input.request.file.path); + return patchAtoms(input.request.file.path); + }, + }, +})); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: () => ({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true }), + useUpdateClientSettings: () => vi.fn(), +})); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../hooks/useLocalStorage", () => ({ useLocalStorage: () => [true, vi.fn()] })); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); +vi.mock("../fileContextMenu", () => ({ useFileContextMenuHandler: () => vi.fn() })); +vi.mock("../hooks/useTurnDiffSummaries", () => ({ + useTurnDiffSummaries: () => ({ + turnDiffSummaries: turns, + inferredCheckpointTurnCountByRunId: {}, + }), +})); +vi.mock("../lib/checkpointDiffState", () => ({ + useCheckpointDiff: () => ({ + data: state.patchAvailable ? { diff: patch } : null, + isPending: !state.patchAvailable, + }), +})); +vi.mock("./ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ render, children }: { render?: ReactNode; children?: ReactNode }) => + render ?? children, + TooltipPopup: () => null, +})); +vi.mock("./diffs/DiffFileTree", () => ({ + DiffFileTree: ({ + entries, + onSelectFile, + }: ComponentProps) => + entries.map(({ path }) => ( + + )), +})); + +// Keep the panel's real state, patch loading, and reveal effects. Replace only the +// virtual viewer with headers and a scroll handle that observes committed rows. +vi.mock("./diffs/AnnotatableCodeView", () => ({ + AnnotatableCodeView: ({ files, viewerRef, renderHeaderPrefix }: ViewerProps) => { + const committedFiles = useRef(files); + useLayoutEffect(() => { + committedFiles.current = files; + }, [files]); + useImperativeHandle( + viewerRef, + () => ({ + getInstance: () => committedFiles, + scrollTo: (target: CodeViewScrollTarget) => { + const file = + target.type === "item" + ? committedFiles.current.find((file) => file.fileKey === target.id) + : undefined; + state.scroll(target, file?.collapsed); + }, + }), + [], + ); + return files.map((file) => ( +
+ {renderHeaderPrefix?.(file.fileDiff, file.fileKey, file.collapsed)} + {!file.collapsed &&
{file.filePath} changes
} +
+ )); + }, +})); + +import DiffPanel from "./DiffPanel"; +import { useDiffPanelStore } from "../diffPanelStore"; +import type { AnnotatableCodeView } from "./diffs/AnnotatableCodeView"; + +type ViewerProps = Pick< + ComponentProps, + "files" | "renderHeaderPrefix" +> & { + viewerRef: Ref<{ getInstance(): object; scrollTo(target: CodeViewScrollTarget): void }>; +}; +const threadRef = scopeThreadRef(EnvironmentId.make("environment"), ThreadId.make("thread")); +const turns = [1, 2].map((ordinal) => ({ + runId: RunId.make(`turn-${ordinal}`), + checkpointTurnCount: ordinal, + completedAt: "2026-10-16T00:00:00Z", +})); +const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"]; +function filePatch(path: string) { + return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-before\n+after\n`; +} +const patch = paths.map(filePatch).join(""); +const source: ReviewDiffPreviewSource = { + id: "branch", + kind: "branch-range", + title: "Changes", + baseRef: "main", + headRef: "HEAD", + diff: patch, + diffHash: "branch-diff", + truncated: true, + files: paths.map((path) => ({ path, previousPath: null, additions: 1, deletions: 1 })), +}; +const configAtom = Atom.make(null); +const previewAtom = Atom.make( + AsyncResult.success({ + cwd: "/repo", + generatedAt: DateTime.makeUnsafe("2026-10-16T00:00:00Z"), + sources: [source, { ...source, id: "working", kind: "working-tree" }], + }), +); +const patchAtoms = Atom.family((path: string) => + Atom.make>( + paths.indexOf(path) < 4 ? AsyncResult.success(loadedPatch(path)) : AsyncResult.initial(), + ), +); +let registry: AtomRegistry.AtomRegistry; +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + state.patchAvailable = true; + state.scroll.mockClear(); + state.requestPatch.mockClear(); + useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); + registry = AtomRegistry.make(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + registry.dispose(); + vi.unstubAllGlobals(); +}); +function loadedPatch(path: string): ReviewDiffPreviewSource { + return { + ...source, + diff: filePatch(path), + files: source.files?.filter((file) => file.path === path) ?? [], + truncated: false, + }; +} +function completePatch(path: string) { + registry.set(patchAtoms(path), AsyncResult.success(loadedPatch(path))); +} +async function render() { + await act(async () => + root.render( + + + , + ), + ); +} +async function click(label: string) { + const button = [...container.querySelectorAll("button")].find( + (button) => button.getAttribute("aria-label") === label || button.textContent === label, + ); + expect(button, label).toBeDefined(); + await act(async () => button!.click()); +} +function expectExpanded(expected: string[]) { + expect(container.querySelectorAll("section").length).toBeGreaterThan(0); + expect([...container.querySelectorAll("section pre")].map((pre) => pre.textContent)).toEqual( + expected.map((path) => `${path} changes`), + ); +} +function expectScrolled(path: string) { + expect(state.scroll).toHaveBeenLastCalledWith( + { type: "item", id: expect.any(String), align: "start" }, + false, + ); + const target = state.scroll.mock.lastCall?.[0]; + expect(target?.type === "item" && target.id).toContain(path); +} + +it("reveals selected turn files after the patch arrives, preserving siblings and new-diff defaults", async () => { + state.patchAvailable = false; + useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "b.ts"); + await render(); + expect(state.scroll).not.toHaveBeenCalled(); + state.patchAvailable = true; + await render(); + expectExpanded(["b.ts"]); + expectScrolled("b.ts"); + await click("Expand a.ts"); + await act(async () => + useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "e.ts"), + ); + expectExpanded(["a.ts", "b.ts", "e.ts"]); + expectScrolled("e.ts"); + await click("Collapse b.ts"); + state.scroll.mockClear(); + await act(async () => + useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "e.ts"), + ); + expectExpanded(["a.ts", "e.ts"]); + expectScrolled("e.ts"); + expect(state.scroll).toHaveBeenCalledTimes(1); + state.scroll.mockClear(); + await act(async () => useDiffPanelStore.getState().selectTurn(threadRef, turns[1]!.runId)); + expectExpanded([]); + expect(state.scroll).not.toHaveBeenCalled(); + expect(state.requestPatch).not.toHaveBeenCalled(); +}); + +it("loads and reveals a lazy file without changing siblings or the next diff's collapsed default", async () => { + await render(); + expectExpanded([]); + expect(state.requestPatch).not.toHaveBeenCalledWith("e.ts"); + await click("Expand a.ts"); + await click("Reveal b.ts"); + expectExpanded(["a.ts", "b.ts"]); + expectScrolled("b.ts"); + await click("Collapse b.ts"); + state.scroll.mockClear(); + await click("Reveal e.ts"); + expect(state.requestPatch).toHaveBeenCalledWith("e.ts"); + expect(state.scroll).not.toHaveBeenCalled(); + await act(async () => completePatch("e.ts")); + expectExpanded(["a.ts", "e.ts"]); + expectScrolled("e.ts"); + await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "unstaged")); + expectExpanded([]); +}); From f98c547ace0a21a3de1504c9716b1f3e8e0f8ab4 Mon Sep 17 00:00:00 2001 From: NickAwrist Date: Sat, 17 Oct 2026 05:02:14 -0700 Subject: [PATCH 3/8] fix(web): clear diff reveal cache when file selection ends --- apps/web/src/components/DiffPanel.test.tsx | 19 +++++++++++++++++++ apps/web/src/components/DiffPanel.tsx | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/DiffPanel.test.tsx b/apps/web/src/components/DiffPanel.test.tsx index c542cb51c975..2330c29f9dbe 100644 --- a/apps/web/src/components/DiffPanel.test.tsx +++ b/apps/web/src/components/DiffPanel.test.tsx @@ -259,6 +259,25 @@ it("reveals selected turn files after the patch arrives, preserving siblings and expect(state.requestPatch).not.toHaveBeenCalled(); }); +it("reveals the same turn file again after visiting a Git scope", async () => { + useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "b.ts"); + await render(); + expectExpanded(["b.ts"]); + expectScrolled("b.ts"); + + await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "branch")); + expectExpanded([]); + await click("Expand a.ts"); + state.scroll.mockClear(); + + await act(async () => + useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "b.ts"), + ); + expectExpanded(["b.ts"]); + expectScrolled("b.ts"); + expect(state.scroll).toHaveBeenCalledTimes(1); +}); + it("loads and reveals a lazy file without changing siblings or the next diff's collapsed default", async () => { await render(); expectExpanded([]); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 31f269172a3c..dc54aca7cbcf 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -608,7 +608,10 @@ export default function DiffPanel({ const externalRevealRef = useRef<{ scope: string | null; key: string } | null>(null); useEffect(() => { - if (!selectedFilePath) return; + if (!selectedFilePath) { + externalRevealRef.current = null; + return; + } const scope = collapseScopeKey ?? filePatchScope; const key = `${selectedFilePath}:${selectedFileRevealRequestId}`; if (externalRevealRef.current?.scope === scope && externalRevealRef.current.key === key) return; From 910590922fca217cfda8f83fd71cfd653c55d986 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 04:59:46 +0000 Subject: [PATCH 4/8] test(web): cover diff file reveal in the browser --- .../src/components/DiffPanel.browser.test.tsx | 223 +++++++++++++ apps/web/src/components/DiffPanel.test.tsx | 299 ------------------ 2 files changed, 223 insertions(+), 299 deletions(-) create mode 100644 apps/web/src/components/DiffPanel.browser.test.tsx delete mode 100644 apps/web/src/components/DiffPanel.test.tsx diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx new file mode 100644 index 000000000000..dba0d359a1ed --- /dev/null +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -0,0 +1,223 @@ +import "~/index.css"; + +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + RunId, + ThreadId, + type ReviewDiffPreviewResult, + type ReviewDiffPreviewSource, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import * as DateTime from "effect/DateTime"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +vi.mock("../hooks/useThreadRouteTarget", () => ({ useThreadRouteRef: () => threadRef })); +vi.mock("../state/entities", () => ({ + useQuietThreadShell: () => thread, + useThreadCheckpointSummaries: () => turns, + useProject: () => null, +})); +vi.mock("../state/filesystem", () => ({ useFilesystemReadAccess: () => ({ canReadFiles: true }) })); +vi.mock("../state/server", () => ({ serverEnvironment: { configValueAtom: () => configAtom } })); +vi.mock("../state/vcs", () => ({ vcsEnvironment: { status: () => null, listRefs: () => null } })); +vi.mock("../state/review", () => ({ + reviewEnvironment: { + diffPreview: () => previewAtom, + diffFilePatch: ({ input }: { input: { request: { file: { path: string } } } }) => + patchAtoms(input.request.file.path), + }, +})); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: () => ({ + ...DEFAULT_CLIENT_SETTINGS, + diffFilesCollapsed: useAtomValue(collapsedAtom), + }), + updateClientSettings: vi.fn(), +})); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); +vi.mock("../fileContextMenu", () => ({ useFileContextMenuHandler: () => vi.fn() })); +vi.mock("../lib/checkpointDiffState", () => ({ + useCheckpointDiff: () => { + const diff = useAtomValue(checkpointAtom); + return { data: diff === null ? null : { diff }, isPending: diff === null, error: null }; + }, +})); + +import DiffPanel from "./DiffPanel"; +import { useDiffPanelStore } from "../diffPanelStore"; + +const threadRef = scopeThreadRef(EnvironmentId.make("environment"), ThreadId.make("thread")); +const thread = { ...threadRef, worktreePath: "/repo" }; +const turns = [1, 2].map((ordinal) => ({ + runId: RunId.make(`turn-${ordinal}`), + checkpointTurnCount: ordinal, + completedAt: "2026-10-16T00:00:00Z", +})); +const paths = ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]; +function filePatch(path: string) { + const count = path === "e.txt" ? 30 : 4; + const lines = Array.from({ length: count }, (_, index) => `+after ${index}`).join("\n"); + return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1,${count} @@\n-before\n${lines}\n`; +} +const source: ReviewDiffPreviewSource = { + id: "branch", + kind: "branch-range", + title: "Changes", + baseRef: "main", + headRef: "HEAD", + diff: "", + diffHash: "branch-diff", + truncated: true, + files: paths.map((path) => ({ + path, + previousPath: null, + additions: path === "e.txt" ? 30 : 4, + deletions: 1, + })), +}; +const configAtom = Atom.make(null); +const collapsedAtom = Atom.make(true); +const checkpointAtom = Atom.make(null); +const previewAtom = Atom.make( + AsyncResult.success({ + cwd: "/repo", + generatedAt: DateTime.makeUnsafe("2026-10-16T00:00:00Z"), + sources: [source, { ...source, id: "working", kind: "working-tree" }], + }), +); +function loadedPatch(path: string): ReviewDiffPreviewSource { + return { + ...source, + diff: filePatch(path), + files: source.files?.filter((file) => file.path === path) ?? [], + truncated: false, + }; +} +const patchAtoms = Atom.family((path: string) => + Atom.make>( + path === "e.txt" ? AsyncResult.initial() : AsyncResult.success(loadedPatch(path)), + ), +); +let registry: AtomRegistry.AtomRegistry; +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + localStorage.setItem("t3code.diffFileTreeOpen", "true"); + useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); + registry = AtomRegistry.make(); + container = document.createElement("div"); + container.style.cssText = "width: 1000px; height: 300px"; + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + registry.dispose(); + localStorage.clear(); + vi.unstubAllGlobals(); +}); +async function render() { + await act(async () => + root.render( + + + , + ), + ); +} +const toggle = (path: string, expanded: boolean) => + page.getByRole("button", { name: `${expanded ? "Collapse" : "Expand"} ${path}`, exact: true }); +const viewport = () => container.querySelector(".diff-render-surface")!; +async function selectTurn(path?: string, turn = turns[0]!) { + await act(async () => useDiffPanelStore.getState().selectTurn(threadRef, turn.runId, path)); +} +async function expectRevealed(path: string) { + await expect.element(toggle(path, true)).toBeInTheDocument(); + await expect + .poll(() => { + const bounds = viewport().getBoundingClientRect(); + const header = toggle(path, true).element().getBoundingClientRect(); + return viewport().scrollTop > 0 && header.top >= bounds.top && header.bottom <= bounds.bottom; + }) + .toBe(true); +} +async function scrollToStart() { + await act(async () => { + viewport().scrollTop = 0; + viewport().dispatchEvent(new Event("scroll")); + await new Promise(requestAnimationFrame); + }); +} +async function selectTreeFile(path: string) { + const tree = container.querySelector("file-tree-container")!; + const row = tree.shadowRoot!.querySelector(`[data-item-path="${path}"]`)!; + await act(async () => + row.dispatchEvent( + new MouseEvent("click", { bubbles: true, composed: true, button: 0, detail: 1 }), + ), + ); +} + +it("reveals a delayed turn selection, preserves siblings, and repeats after scope changes", async () => { + await selectTurn("e.txt"); + await render(); + await expect + .element(page.getByRole("status", { name: "Loading checkpoint diff..." })) + .toBeInTheDocument(); + await act(async () => registry.set(checkpointAtom, paths.map(filePatch).join(""))); + await expectRevealed("e.txt"); + await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "branch")); + await expect.element(toggle("a.txt", false)).toBeInTheDocument(); + await selectTurn("e.txt"); + await expectRevealed("e.txt"); + await scrollToStart(); + await toggle("a.txt", false).click(); + await selectTurn("b.txt"); + await expect.element(toggle("b.txt", true)).toBeInTheDocument(); + await toggle("b.txt", true).click(); + await selectTurn("e.txt"); + await expectRevealed("e.txt"); + await scrollToStart(); + await expect.element(toggle("a.txt", true)).toBeInTheDocument(); + await expect.element(toggle("b.txt", false)).toBeInTheDocument(); + await expect.element(toggle("c.txt", false)).toBeInTheDocument(); + await selectTurn("e.txt"); + await expectRevealed("e.txt"); + + await selectTurn(undefined, turns[1]!); + await expect.element(toggle("a.txt", false)).toBeInTheDocument(); + await expect.element(toggle("e.txt", false)).toBeInTheDocument(); +}); + +it("waits for a lazy Git patch while preserving sibling choices and expanded defaults", async () => { + registry.set(collapsedAtom, false); + await render(); + await toggle("b.txt", true).click(); + await selectTreeFile("b.txt"); + await expect.element(toggle("b.txt", true)).toBeInTheDocument(); + await toggle("b.txt", true).click(); + await scrollToStart(); + await selectTreeFile("e.txt"); + expect(viewport().scrollTop).toBe(0); + await expect.element(toggle("e.txt", true)).not.toBeInTheDocument(); + await act(async () => + registry.set(patchAtoms("e.txt"), AsyncResult.success(loadedPatch("e.txt"))), + ); + await expectRevealed("e.txt"); + await scrollToStart(); + await expect.element(toggle("a.txt", true)).toBeInTheDocument(); + await expect.element(toggle("b.txt", false)).toBeInTheDocument(); + await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "unstaged")); + await expect.element(toggle("b.txt", true)).toBeInTheDocument(); +}); diff --git a/apps/web/src/components/DiffPanel.test.tsx b/apps/web/src/components/DiffPanel.test.tsx deleted file mode 100644 index 2330c29f9dbe..000000000000 --- a/apps/web/src/components/DiffPanel.test.tsx +++ /dev/null @@ -1,299 +0,0 @@ -// @vitest-environment jsdom - -import { RegistryContext } from "@effect/atom-react"; -import type { CodeViewScrollTarget } from "@pierre/diffs"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { - EnvironmentId, - RunId, - ThreadId, - type ReviewDiffPreviewResult, - type ReviewDiffPreviewSource, -} from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import * as DateTime from "effect/DateTime"; -import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; -import { - act, - useImperativeHandle, - useLayoutEffect, - useRef, - type ComponentProps, - type ReactNode, - type Ref, -} from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; - -const state = vi.hoisted(() => ({ - patchAvailable: true, - scroll: vi.fn<(target: CodeViewScrollTarget, collapsed: boolean | undefined) => void>(), - requestPatch: vi.fn<(path: string) => void>(), -})); - -vi.mock("@tanstack/react-router", () => ({ useParams: () => threadRef })); -vi.mock("../state/entities", () => ({ - useThreadShell: () => ({ ...threadRef, worktreePath: "/repo" }), - useThreadProjection: () => null, - useProject: () => null, -})); -vi.mock("../state/filesystem", () => ({ useFilesystemReadAccess: () => ({ canReadFiles: true }) })); -vi.mock("../state/server", () => ({ serverEnvironment: { configValueAtom: () => configAtom } })); -vi.mock("../state/vcs", () => ({ - vcsEnvironment: { status: () => null, listRefs: () => null }, -})); -vi.mock("../state/review", () => ({ - reviewEnvironment: { - diffPreview: () => previewAtom, - diffFilePatch: ({ input }: { input: { request: { file: { path: string } } } }) => { - state.requestPatch(input.request.file.path); - return patchAtoms(input.request.file.path); - }, - }, -})); -vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("../hooks/useSettings", () => ({ - useClientSettings: () => ({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true }), - useUpdateClientSettings: () => vi.fn(), -})); -vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); -vi.mock("../hooks/useLocalStorage", () => ({ useLocalStorage: () => [true, vi.fn()] })); -vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); -vi.mock("../fileContextMenu", () => ({ useFileContextMenuHandler: () => vi.fn() })); -vi.mock("../hooks/useTurnDiffSummaries", () => ({ - useTurnDiffSummaries: () => ({ - turnDiffSummaries: turns, - inferredCheckpointTurnCountByRunId: {}, - }), -})); -vi.mock("../lib/checkpointDiffState", () => ({ - useCheckpointDiff: () => ({ - data: state.patchAvailable ? { diff: patch } : null, - isPending: !state.patchAvailable, - }), -})); -vi.mock("./ui/tooltip", () => ({ - Tooltip: ({ children }: { children: ReactNode }) => children, - TooltipTrigger: ({ render, children }: { render?: ReactNode; children?: ReactNode }) => - render ?? children, - TooltipPopup: () => null, -})); -vi.mock("./diffs/DiffFileTree", () => ({ - DiffFileTree: ({ - entries, - onSelectFile, - }: ComponentProps) => - entries.map(({ path }) => ( - - )), -})); - -// Keep the panel's real state, patch loading, and reveal effects. Replace only the -// virtual viewer with headers and a scroll handle that observes committed rows. -vi.mock("./diffs/AnnotatableCodeView", () => ({ - AnnotatableCodeView: ({ files, viewerRef, renderHeaderPrefix }: ViewerProps) => { - const committedFiles = useRef(files); - useLayoutEffect(() => { - committedFiles.current = files; - }, [files]); - useImperativeHandle( - viewerRef, - () => ({ - getInstance: () => committedFiles, - scrollTo: (target: CodeViewScrollTarget) => { - const file = - target.type === "item" - ? committedFiles.current.find((file) => file.fileKey === target.id) - : undefined; - state.scroll(target, file?.collapsed); - }, - }), - [], - ); - return files.map((file) => ( -
- {renderHeaderPrefix?.(file.fileDiff, file.fileKey, file.collapsed)} - {!file.collapsed &&
{file.filePath} changes
} -
- )); - }, -})); - -import DiffPanel from "./DiffPanel"; -import { useDiffPanelStore } from "../diffPanelStore"; -import type { AnnotatableCodeView } from "./diffs/AnnotatableCodeView"; - -type ViewerProps = Pick< - ComponentProps, - "files" | "renderHeaderPrefix" -> & { - viewerRef: Ref<{ getInstance(): object; scrollTo(target: CodeViewScrollTarget): void }>; -}; -const threadRef = scopeThreadRef(EnvironmentId.make("environment"), ThreadId.make("thread")); -const turns = [1, 2].map((ordinal) => ({ - runId: RunId.make(`turn-${ordinal}`), - checkpointTurnCount: ordinal, - completedAt: "2026-10-16T00:00:00Z", -})); -const paths = ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"]; -function filePatch(path: string) { - return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-before\n+after\n`; -} -const patch = paths.map(filePatch).join(""); -const source: ReviewDiffPreviewSource = { - id: "branch", - kind: "branch-range", - title: "Changes", - baseRef: "main", - headRef: "HEAD", - diff: patch, - diffHash: "branch-diff", - truncated: true, - files: paths.map((path) => ({ path, previousPath: null, additions: 1, deletions: 1 })), -}; -const configAtom = Atom.make(null); -const previewAtom = Atom.make( - AsyncResult.success({ - cwd: "/repo", - generatedAt: DateTime.makeUnsafe("2026-10-16T00:00:00Z"), - sources: [source, { ...source, id: "working", kind: "working-tree" }], - }), -); -const patchAtoms = Atom.family((path: string) => - Atom.make>( - paths.indexOf(path) < 4 ? AsyncResult.success(loadedPatch(path)) : AsyncResult.initial(), - ), -); -let registry: AtomRegistry.AtomRegistry; -let container: HTMLDivElement; -let root: Root; - -beforeEach(() => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - state.patchAvailable = true; - state.scroll.mockClear(); - state.requestPatch.mockClear(); - useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); - registry = AtomRegistry.make(); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); - container.remove(); - registry.dispose(); - vi.unstubAllGlobals(); -}); -function loadedPatch(path: string): ReviewDiffPreviewSource { - return { - ...source, - diff: filePatch(path), - files: source.files?.filter((file) => file.path === path) ?? [], - truncated: false, - }; -} -function completePatch(path: string) { - registry.set(patchAtoms(path), AsyncResult.success(loadedPatch(path))); -} -async function render() { - await act(async () => - root.render( - - - , - ), - ); -} -async function click(label: string) { - const button = [...container.querySelectorAll("button")].find( - (button) => button.getAttribute("aria-label") === label || button.textContent === label, - ); - expect(button, label).toBeDefined(); - await act(async () => button!.click()); -} -function expectExpanded(expected: string[]) { - expect(container.querySelectorAll("section").length).toBeGreaterThan(0); - expect([...container.querySelectorAll("section pre")].map((pre) => pre.textContent)).toEqual( - expected.map((path) => `${path} changes`), - ); -} -function expectScrolled(path: string) { - expect(state.scroll).toHaveBeenLastCalledWith( - { type: "item", id: expect.any(String), align: "start" }, - false, - ); - const target = state.scroll.mock.lastCall?.[0]; - expect(target?.type === "item" && target.id).toContain(path); -} - -it("reveals selected turn files after the patch arrives, preserving siblings and new-diff defaults", async () => { - state.patchAvailable = false; - useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "b.ts"); - await render(); - expect(state.scroll).not.toHaveBeenCalled(); - state.patchAvailable = true; - await render(); - expectExpanded(["b.ts"]); - expectScrolled("b.ts"); - await click("Expand a.ts"); - await act(async () => - useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "e.ts"), - ); - expectExpanded(["a.ts", "b.ts", "e.ts"]); - expectScrolled("e.ts"); - await click("Collapse b.ts"); - state.scroll.mockClear(); - await act(async () => - useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "e.ts"), - ); - expectExpanded(["a.ts", "e.ts"]); - expectScrolled("e.ts"); - expect(state.scroll).toHaveBeenCalledTimes(1); - state.scroll.mockClear(); - await act(async () => useDiffPanelStore.getState().selectTurn(threadRef, turns[1]!.runId)); - expectExpanded([]); - expect(state.scroll).not.toHaveBeenCalled(); - expect(state.requestPatch).not.toHaveBeenCalled(); -}); - -it("reveals the same turn file again after visiting a Git scope", async () => { - useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "b.ts"); - await render(); - expectExpanded(["b.ts"]); - expectScrolled("b.ts"); - - await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "branch")); - expectExpanded([]); - await click("Expand a.ts"); - state.scroll.mockClear(); - - await act(async () => - useDiffPanelStore.getState().selectTurn(threadRef, turns[0]!.runId, "b.ts"), - ); - expectExpanded(["b.ts"]); - expectScrolled("b.ts"); - expect(state.scroll).toHaveBeenCalledTimes(1); -}); - -it("loads and reveals a lazy file without changing siblings or the next diff's collapsed default", async () => { - await render(); - expectExpanded([]); - expect(state.requestPatch).not.toHaveBeenCalledWith("e.ts"); - await click("Expand a.ts"); - await click("Reveal b.ts"); - expectExpanded(["a.ts", "b.ts"]); - expectScrolled("b.ts"); - await click("Collapse b.ts"); - state.scroll.mockClear(); - await click("Reveal e.ts"); - expect(state.requestPatch).toHaveBeenCalledWith("e.ts"); - expect(state.scroll).not.toHaveBeenCalled(); - await act(async () => completePatch("e.ts")); - expectExpanded(["a.ts", "e.ts"]); - expectScrolled("e.ts"); - await act(async () => useDiffPanelStore.getState().selectGitScope(threadRef, "unstaged")); - expectExpanded([]); -}); From 6a4594161f57a79f695f8d351f834607be2f13d8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:02:46 +0000 Subject: [PATCH 5/8] test(web): complete route mock for browser regression --- apps/web/src/components/DiffPanel.browser.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index dba0d359a1ed..b0eb2ea9f3b5 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -17,7 +17,10 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; -vi.mock("../hooks/useThreadRouteTarget", () => ({ useThreadRouteRef: () => threadRef })); +vi.mock("../hooks/useThreadRouteTarget", () => ({ + useThreadRouteRef: () => threadRef, + useThreadRouteTarget: () => ({ ...threadRef, kind: "thread" }), +})); vi.mock("../state/entities", () => ({ useQuietThreadShell: () => thread, useThreadCheckpointSummaries: () => turns, From cfd6863aaf8f905725f7753857998538132e6367 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:04:55 +0000 Subject: [PATCH 6/8] fix(web): expand selected turn files before revealing them --- apps/web/src/components/DiffPanel.tsx | 44 ++++++++++++++------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index b4083eac66b1..6c7ef1f79827 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -516,16 +516,6 @@ export default function DiffPanel({ const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys); const diffLineStat = sourceLineStat ?? getDiffLineStat(renderableFiles); const fileTreeEntries = diffFileTreeEntries(renderableFiles); - const selectedDiffFileKey = selectedFilePath - ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null) - : null; - - useEffect(() => { - if (!selectedDiffFileKey || !codeView?.getInstance()) return; - codeView.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" }); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A remounted viewer or a repeated reveal request scrolls to the same file again. - }, [codeView, codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]); - const treeRevealScope = { collapseScopeKey, diffSelection }; const requestTreeReveal = useCodeViewFileReveal(codeView, treeRevealScope, codeViewFileKeys); const revealDiffFile = (filePath: string) => { @@ -533,7 +523,7 @@ export default function DiffPanel({ (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, ); const file = renderableFileEntries[index]; - if (!file) return; + if (!file) return false; setCollapsedDiffFiles((current) => { const next = new Set( current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys, @@ -545,20 +535,32 @@ export default function DiffPanel({ requestFile(index); } requestTreeReveal(file.fileKey); + return true; }; - const externalRevealRef = useRef<{ cache: string; key: string } | null>(null); + const externalRevealRef = useRef(null); useEffect(() => { - if (!lazySource || !selectedFilePath) return; - const key = `${selectedFilePath}:${selectedFileRevealRequestId}`; - if ( - externalRevealRef.current?.cache === filePatchScope && - externalRevealRef.current.key === key - ) + if (!selectedFilePath) { + externalRevealRef.current = null; return; - externalRevealRef.current = { cache: filePatchScope, key }; - revealDiffFile(selectedFilePath); - }, [lazySource, selectedFilePath, selectedFileRevealRequestId, filePatchScope, revealDiffFile]); + } + const key = JSON.stringify([ + collapseScopeKey, + filePatchScope, + selectedFilePath, + selectedFileRevealRequestId, + ]); + if (externalRevealRef.current === key) return; + if (revealDiffFile(selectedFilePath)) { + externalRevealRef.current = key; + } + }, [ + collapseScopeKey, + filePatchScope, + selectedFilePath, + selectedFileRevealRequestId, + revealDiffFile, + ]); const openDiffFile = (filePath: string) => { openDiffFilePrimaryAction({ From 6352f85d042b3ab07477be810b97125a6c66d8c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 05:10:40 +0000 Subject: [PATCH 7/8] fix(web): keep diff refreshes from repeating file selections --- apps/web/src/components/DiffPanel.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 6c7ef1f79827..abef3b734db6 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -544,23 +544,12 @@ export default function DiffPanel({ externalRevealRef.current = null; return; } - const key = JSON.stringify([ - collapseScopeKey, - filePatchScope, - selectedFilePath, - selectedFileRevealRequestId, - ]); + const key = JSON.stringify([collapseScopeKey, selectedFilePath, selectedFileRevealRequestId]); if (externalRevealRef.current === key) return; if (revealDiffFile(selectedFilePath)) { externalRevealRef.current = key; } - }, [ - collapseScopeKey, - filePatchScope, - selectedFilePath, - selectedFileRevealRequestId, - revealDiffFile, - ]); + }, [collapseScopeKey, selectedFilePath, selectedFileRevealRequestId, revealDiffFile]); const openDiffFile = (filePath: string) => { openDiffFilePrimaryAction({ From 026c33d96912bdd528b1cb15d2b196c111018b61 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 04:35:41 +0000 Subject: [PATCH 8/8] test(web): provide diff panel visibility in reveal tests --- apps/web/src/components/DiffPanel.browser.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index b0eb2ea9f3b5..6acb3d76ba0d 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -134,7 +134,12 @@ async function render() { await act(async () => root.render( - + , ), );