From 3a11c8d49496673437e2bd705a5c6aa873b54616 Mon Sep 17 00:00:00 2001 From: "afzal.hossain" Date: Fri, 1 May 2026 15:09:51 +0200 Subject: [PATCH] Diff panel: per-thread state, full-width toggle, and per-file collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move diff-panel UI state from URL search params and component-local useState into the Zustand UI store. State that is meaningful per thread is keyed by scopedThreadKey so two threads can have different diff panels open / expanded / collapsed independently. Wider state (render mode, line wrap) lives in the store globally so it survives the remount that expand/collapse causes. Per-thread (persisted to localStorage): - diff panel open/closed (replaces ?diff=1 in URL) - full-width vs split-pane (new toolbar toggle) - per-file collapse (new chevron on each file header) Global session (in store, not persisted to disk): - render mode (split/stacked) - line wrap, hydrated once from settings.diffWordWrap The per-file collapse swaps the FileDiff out for a small CollapsedFileHeader rather than hiding content via CSS — the library's Virtualizer reserves vertical space from hunk metadata, so a CSS hide leaves a tall empty box and triggers ResizeObserver thrashing during scroll. The collapsed header inlines the same change-type SVG paths the library renders inside its shadow DOM so the new/modified/deleted/renamed icon is identical between collapsed and expanded views. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/components/ChatView.tsx | 47 ++- apps/web/src/components/DiffPanel.tsx | 125 ++++--- apps/web/src/diffRouteSearch.test.ts | 44 +-- apps/web/src/diffRouteSearch.ts | 14 +- .../routes/_chat.$environmentId.$threadId.tsx | 89 ++--- apps/web/src/uiStateStore.test.ts | 332 ++++++++++++++++++ apps/web/src/uiStateStore.ts | 260 +++++++++++++- 7 files changed, 754 insertions(+), 157 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ef221e262..7fb7c1f1b5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -36,14 +36,14 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje import { truncate } from "@t3tools/shared/String"; import { Debouncer } from "@tanstack/react-pacer"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; -import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { stripDiffSearchParams } from "../diffRouteSearch"; import { collapseExpandedComposerCursor, parseStandaloneComposerSlashCommand, @@ -634,10 +634,6 @@ export default function ChatView(props: ChatViewProps) { const timestampFormat = settings.timestampFormat; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); - const rawSearch = useSearch({ - strict: false, - select: (params) => parseDiffRouteSearch(params), - }); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -808,13 +804,16 @@ export default function ChatView(props: ChatViewProps) { composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; - const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeThreadRef = useMemo( () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const diffOpen = useUiStateStore((store) => + activeThreadKey ? store.threadDiffOpenById[activeThreadKey] === true : false, + ); + const setThreadDiffOpen = useUiStateStore((store) => store.setThreadDiffOpen); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); @@ -1698,25 +1697,14 @@ export default function ChatView(props: ChatViewProps) { [keybindings, nonTerminalShortcutLabelOptions], ); const onToggleDiff = useCallback(() => { - if (!isServerThread) { + if (!isServerThread || !activeThreadKey) { return; } if (!diffOpen) { onDiffPanelOpen?.(); } - void navigate({ - to: "/$environmentId/$threadId", - params: { - environmentId, - threadId, - }, - replace: true, - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; - }, - }); - }, [diffOpen, environmentId, isServerThread, navigate, onDiffPanelOpen, threadId]); + setThreadDiffOpen(activeThreadKey, !diffOpen); + }, [activeThreadKey, diffOpen, isServerThread, onDiffPanelOpen, setThreadDiffOpen]); const envLocked = Boolean( activeThread && @@ -3456,10 +3444,11 @@ export default function ChatView(props: ChatViewProps) { }, []); const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { - if (!isServerThread) { + if (!isServerThread || !activeThreadKey) { return; } onDiffPanelOpen?.(); + setThreadDiffOpen(activeThreadKey, true); void navigate({ to: "/$environmentId/$threadId", params: { @@ -3469,12 +3458,20 @@ export default function ChatView(props: ChatViewProps) { search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath - ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } - : { ...rest, diff: "1", diffTurnId: turnId }; + ? { ...rest, diffTurnId: turnId, diffFilePath: filePath } + : { ...rest, diffTurnId: turnId }; }, }); }, - [environmentId, isServerThread, navigate, onDiffPanelOpen, threadId], + [ + activeThreadKey, + environmentId, + isServerThread, + navigate, + onDiffPanelOpen, + setThreadDiffOpen, + threadId, + ], ); // Both the Map and the revert handler are read from refs at call-time so // the callback reference is fully stable and never busts context identity. diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f178a69fb4..bbed5cfb1f 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -2,12 +2,14 @@ import { parsePatchFiles } from "@pierre/diffs"; import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import type { TurnId } from "@t3tools/contracts"; import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, + ChevronsLeftRightIcon, + ChevronsRightLeftIcon, Columns2Icon, PilcrowIcon, Rows3Icon, @@ -33,6 +35,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { selectProjectByRef, useStore } from "../store"; +import { useUiStateStore } from "../uiStateStore"; import { createThreadSelectorByRef } from "../storeSelectors"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { useSettings } from "../hooks/useSettings"; @@ -40,7 +43,6 @@ import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; -type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; const DIFF_PANEL_UNSAFE_CSS = ` @@ -187,15 +189,15 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); const { resolvedTheme } = useTheme(); const settings = useSettings(); - const [diffRenderMode, setDiffRenderMode] = useState("stacked"); - const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); - const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); - const [collapsedDiffFileKeys, setCollapsedDiffFileKeys] = useState>( - () => new Set(), - ); + const diffRenderMode = useUiStateStore((store) => store.diffRenderMode); + const setDiffRenderMode = useUiStateStore((store) => store.setDiffRenderMode); + const diffWordWrap = useUiStateStore((store) => store.diffWordWrap); + const setDiffWordWrap = useUiStateStore((store) => store.setDiffWordWrap); + const diffIgnoreWhitespace = useUiStateStore((store) => store.diffIgnoreWhitespace); + const setDiffIgnoreWhitespace = useUiStateStore((store) => store.setDiffIgnoreWhitespace); + const hydrateDiffSettings = useUiStateStore((store) => store.hydrateDiffSettings); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); - const previousDiffOpenRef = useRef(false); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const routeThreadRef = useParams({ @@ -203,11 +205,33 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { select: (params) => resolveThreadRouteRef(params), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); - const diffOpen = diffSearch.diff === "1"; const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useStore( useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); + const activeThreadKey = useMemo( + () => (routeThreadRef ? scopedThreadKey(routeThreadRef) : null), + [routeThreadRef], + ); + const diffFullWidth = useUiStateStore((store) => + activeThreadKey ? store.threadDiffFullWidthById[activeThreadKey] === true : false, + ); + const setThreadDiffFullWidth = useUiStateStore((store) => store.setThreadDiffFullWidth); + const collapsedFilePaths = useUiStateStore((store) => + activeThreadKey ? store.threadDiffFileCollapsedById[activeThreadKey] : undefined, + ); + const setDiffFileCollapsed = useUiStateStore((store) => store.setDiffFileCollapsed); + const isFileCollapsed = useCallback( + (filePath: string) => collapsedFilePaths?.[filePath] === true, + [collapsedFilePaths], + ); + const toggleFileCollapsed = useCallback( + (filePath: string) => { + if (!activeThreadKey) return; + setDiffFileCollapsed(activeThreadKey, filePath, !isFileCollapsed(filePath)); + }, + [activeThreadKey, isFileCollapsed, setDiffFileCollapsed], + ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => activeThread && activeProjectId @@ -336,26 +360,17 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ); }, [renderablePatch]); + // Seed the global diff-view store from the user's persistent settings the + // first time a DiffPanel mounts in this session. Subsequent calls are + // no-ops, so toggling wrap / ignore-whitespace in the panel is preserved + // across remounts (e.g. the expand/collapse remount that swaps panel + // parents). useEffect(() => { - if (renderableFiles.length === 0) { - setCollapsedDiffFileKeys((current) => (current.size === 0 ? current : new Set())); - return; - } - - const visibleFileKeys = new Set(renderableFiles.map(buildFileDiffRenderKey)); - setCollapsedDiffFileKeys((current) => { - const next = new Set([...current].filter((fileKey) => visibleFileKeys.has(fileKey))); - return next.size === current.size ? current : next; + hydrateDiffSettings({ + diffWordWrap: settings.diffWordWrap, + diffIgnoreWhitespace: settings.diffIgnoreWhitespace, }); - }, [renderableFiles]); - - useEffect(() => { - if (diffOpen && !previousDiffOpenRef.current) { - setDiffWordWrap(settings.diffWordWrap); - setDiffIgnoreWhitespace(settings.diffIgnoreWhitespace); - } - previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffIgnoreWhitespace, settings.diffWordWrap]); + }, [hydrateDiffSettings, settings.diffWordWrap, settings.diffIgnoreWhitespace]); useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { @@ -378,17 +393,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, [activeCwd], ); - const toggleDiffFileCollapsed = useCallback((fileKey: string) => { - setCollapsedDiffFileKeys((current) => { - const next = new Set(current); - if (next.has(fileKey)) { - next.delete(fileKey); - } else { - next.add(fileKey); - } - return next; - }); - }, []); const selectTurn = (turnId: TurnId) => { if (!activeThread) return; @@ -397,7 +401,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1", diffTurnId: turnId }; + return { ...rest, diffTurnId: turnId }; }, }); }; @@ -407,11 +411,14 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; + return stripDiffSearchParams(previous); }, }); }; + const toggleDiffFullWidth = () => { + if (!activeThreadKey) return; + setThreadDiffFullWidth(activeThreadKey, !diffFullWidth); + }; const updateTurnStripScrollState = useCallback(() => { const element = turnStripRef.current; if (!element) { @@ -610,6 +617,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { > + {mode !== "sheet" ? ( + { + toggleDiffFullWidth(); + }} + > + {diffFullWidth ? ( + + ) : ( + + )} + + ) : null} ); @@ -663,15 +690,24 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const filePath = resolveFileDiffPath(fileDiff); const fileKey = buildFileDiffRenderKey(fileDiff); const themedFileKey = `${fileKey}:${resolvedTheme}`; - const collapsed = collapsedDiffFileKeys.has(fileKey); + const collapsed = isFileCollapsed(filePath); return (
{ const nativeEvent = event.nativeEvent as MouseEvent; const composedPath = nativeEvent.composedPath?.() ?? []; + // A click that originated from (or passed through) the + // collapse button must not also trigger the open-in-editor + // behavior bound to clicks on the file title. + const clickedCollapseButton = composedPath.some((node) => { + if (!(node instanceof Element)) return false; + return node.hasAttribute("data-diff-file-collapse-button"); + }); + if (clickedCollapseButton) return; const clickedHeader = composedPath.some((node) => { if (!(node instanceof Element)) return false; return node.hasAttribute("data-title"); @@ -685,6 +721,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { renderHeaderPrefix={() => (