From 33511456659691a7d2f4ab1224341ac079ccd314 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 4 Apr 2026 13:42:55 -0500 Subject: [PATCH] fix(web): render diff content directly to fix empty diffs panel Replace the broken DiffFileSection wrapper (which used overflow-hidden on a section element, preventing FileDiff from rendering its content) with direct FileDiff rendering in plain divs matching the proven t3code implementation. Also replaces the Select dropdown with a scrollable turn chip strip for better UX. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/DiffPanel.tsx | 484 ++++++++++---------------- 1 file changed, 190 insertions(+), 294 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 3fca694c7..6cdbe76cd 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -3,8 +3,21 @@ import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/reac import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { ThreadId, type TurnId } from "@okcode/contracts"; -import { CheckIcon, ChevronDownIcon, Columns2Icon, Rows3Icon, TextWrapIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ChevronLeftIcon, + ChevronRightIcon, + Columns2Icon, + Rows3Icon, + TextWrapIcon, +} from "lucide-react"; +import { + type WheelEvent as ReactWheelEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; @@ -12,14 +25,6 @@ import { useCodeViewerStore } from "../codeViewerStore"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; -import { - acceptAllDiffFiles, - expandDiffFile, - reconcileDiffFileReviewState, - toggleDiffFileAccepted, - toggleDiffFileCollapsed, - type DiffFileReviewStateByPath, -} from "../lib/diffFileReviewState"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; @@ -27,9 +32,6 @@ import { useAppSettings } from "../appSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { useI18nContext } from "../i18n/I18nProvider"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { DiffStatLabel, hasNonZeroStat } from "./chat/DiffStatLabel"; -import { Button } from "./ui/button"; -import { Select, SelectButton, SelectItem, SelectPopup } from "./ui/select"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; @@ -154,113 +156,6 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; } -function summarizeFileDiffStats(fileDiff: FileDiffMetadata): { - additions: number; - deletions: number; -} { - return fileDiff.hunks.reduce( - (summary, hunk) => ({ - additions: summary.additions + hunk.additionLines, - deletions: summary.deletions + hunk.deletionLines, - }), - { additions: 0, deletions: 0 }, - ); -} - -function DiffFileSection(props: { - fileDiff: FileDiffMetadata; - filePath: string; - fileKey: string; - diffRenderMode: DiffRenderMode; - diffWordWrap: boolean; - resolvedTheme: "light" | "dark"; - collapsed: boolean; - accepted: boolean; - onOpenInEditor: (filePath: string) => void; - onToggleCollapsed: (filePath: string) => void; - onToggleAccepted: (filePath: string) => void; -}) { - const { - accepted, - collapsed, - diffRenderMode, - diffWordWrap, - fileDiff, - fileKey, - filePath, - onOpenInEditor, - onToggleAccepted, - onToggleCollapsed, - resolvedTheme, - } = props; - const stats = summarizeFileDiffStats(fileDiff); - - return ( -
-
- - - {hasNonZeroStat(stats) && ( - - - - )} - -
- {!collapsed && ( -
- -
- )} -
- ); -} - interface DiffPanelProps { mode?: DiffPanelMode; } @@ -275,10 +170,10 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); const patchViewportRef = useRef(null); + const turnStripRef = useRef(null); const previousDiffOpenRef = useRef(false); - const [reviewStateBySelectionKey, setReviewStateBySelectionKey] = useState< - Record - >({}); + const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); + const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), @@ -406,32 +301,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }), ); }, [renderablePatch]); - const patchReviewSelectionKey = useMemo(() => { - if (!activeThreadId || !selectedPatch) { - return null; - } - const scope = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation"; - return `${activeThreadId}:${scope}:${buildPatchCacheKey(selectedPatch, "diff-review")}`; - }, [activeThreadId, selectedPatch, selectedTurn]); - const renderableFilePaths = useMemo( - () => renderableFiles.map((fileDiff) => resolveFileDiffPath(fileDiff)), - [renderableFiles], - ); - const activeReviewState = useMemo(() => { - return patchReviewSelectionKey - ? (reviewStateBySelectionKey[patchReviewSelectionKey] ?? {}) - : {}; - }, [patchReviewSelectionKey, reviewStateBySelectionKey]); - const acceptedFileCount = useMemo( - () => - renderableFilePaths.reduce( - (count, filePath) => count + (activeReviewState[filePath]?.accepted ? 1 : 0), - 0, - ), - [activeReviewState, renderableFilePaths], - ); - const hasUnacceptedFiles = - renderableFilePaths.length > 0 && acceptedFileCount < renderableFilePaths.length; useEffect(() => { if (diffOpen && !previousDiffOpenRef.current) { @@ -440,45 +309,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { previousDiffOpenRef.current = diffOpen; }, [diffOpen, settings.diffWordWrap]); - useEffect(() => { - if (!patchReviewSelectionKey) { - return; - } - setReviewStateBySelectionKey((current) => { - const nextSelectionState = reconcileDiffFileReviewState( - renderableFilePaths, - current[patchReviewSelectionKey], - ); - if (current[patchReviewSelectionKey] === nextSelectionState) { - return current; - } - return { - ...current, - [patchReviewSelectionKey]: nextSelectionState, - }; - }); - }, [patchReviewSelectionKey, renderableFilePaths]); - - useEffect(() => { - if (!patchReviewSelectionKey || !selectedFilePath) { - return; - } - setReviewStateBySelectionKey((current) => { - const selectionState = current[patchReviewSelectionKey]; - if (!selectionState) { - return current; - } - const nextSelectionState = expandDiffFile(selectionState, selectedFilePath); - if (nextSelectionState === selectionState) { - return current; - } - return { - ...current, - [patchReviewSelectionKey]: nextSelectionState, - }; - }); - }, [patchReviewSelectionKey, selectedFilePath]); - useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { return; @@ -497,38 +327,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, [activeCwd, openFileInCodeViewer], ); - const updateActiveReviewState = useCallback( - (updater: (current: DiffFileReviewStateByPath) => DiffFileReviewStateByPath) => { - if (!patchReviewSelectionKey) { - return; - } - setReviewStateBySelectionKey((current) => ({ - ...current, - [patchReviewSelectionKey]: updater(current[patchReviewSelectionKey] ?? {}), - })); - }, - [patchReviewSelectionKey], - ); - const onToggleFileAccepted = useCallback( - (filePath: string) => { - updateActiveReviewState((current) => toggleDiffFileAccepted(current, filePath)); - }, - [updateActiveReviewState], - ); - const onAcceptAllFiles = useCallback(() => { - if (renderableFilePaths.length === 0) { - return; - } - updateActiveReviewState((current) => acceptAllDiffFiles(current, renderableFilePaths)); - }, [renderableFilePaths, updateActiveReviewState]); - const onToggleFileCollapsed = useCallback( - (filePath: string) => { - updateActiveReviewState((current) => toggleDiffFileCollapsed(current, filePath)); - }, - [updateActiveReviewState], - ); - - const latestSelectedTurnId = orderedTurnDiffSummaries[0]?.turnId ?? null; const selectTurn = (turnId: TurnId) => { if (!activeThread) return; @@ -552,75 +350,164 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, }); }; - const turnSelectValue = selectedTurnId ?? "all"; - const handleTurnSelectChange = useCallback( - (value: string | null) => { - if (value === "all" || value === null) { - selectWholeConversation(); - } else { - selectTurn(value as TurnId); - } - }, - [selectTurn, selectWholeConversation], - ); + const updateTurnStripScrollState = useCallback(() => { + const element = turnStripRef.current; + if (!element) { + setCanScrollTurnStripLeft(false); + setCanScrollTurnStripRight(false); + return; + } + + const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); + setCanScrollTurnStripLeft(element.scrollLeft > 4); + setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); + }, []); + const scrollTurnStripBy = useCallback((offset: number) => { + const element = turnStripRef.current; + if (!element) return; + element.scrollBy({ left: offset, behavior: "smooth" }); + }, []); + const onTurnStripWheel = useCallback((event: ReactWheelEvent) => { + const element = turnStripRef.current; + if (!element) return; + if (element.scrollWidth <= element.clientWidth + 1) return; + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; + + event.preventDefault(); + element.scrollBy({ left: event.deltaY, behavior: "auto" }); + }, []); + + useEffect(() => { + const element = turnStripRef.current; + if (!element) return; + + const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); + const onScroll = () => updateTurnStripScrollState(); + + element.addEventListener("scroll", onScroll, { passive: true }); + + const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); + resizeObserver.observe(element); + + return () => { + window.cancelAnimationFrame(frameId); + element.removeEventListener("scroll", onScroll); + resizeObserver.disconnect(); + }; + }, [updateTurnStripScrollState]); + + useEffect(() => { + const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); + + useEffect(() => { + const element = turnStripRef.current; + if (!element) return; + + const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); + selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); + }, [selectedTurn?.turnId, selectedTurnId]); const headerRow = ( <> -
- +
+ + + ))} +
- {renderablePatch?.kind === "files" ? ( - - ) : null} + data-diff-file-path={filePath} + className="diff-render-file mb-2 rounded-md first:mt-2 last:mb-0" + onClickCapture={(event) => { + const nativeEvent = event.nativeEvent as MouseEvent; + const composedPath = nativeEvent.composedPath?.() ?? []; + const clickedHeader = composedPath.some((node) => { + if (!(node instanceof Element)) return false; + return node.hasAttribute("data-title"); + }); + if (!clickedHeader) return; + openDiffFileInCodeViewer(filePath); + }} + > + +
); })}