From 1dece16200eaa4bf480f9b78ba57c70be0bb2411 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 | 300 +++++++++++++++-- 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 | 313 ++++++++++++++++++ apps/web/src/uiStateStore.ts | 248 +++++++++++++- 7 files changed, 917 insertions(+), 138 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40cd1b4210..6a05a4eae3 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, @@ -620,10 +620,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( @@ -794,13 +790,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)); @@ -1498,25 +1497,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 && @@ -3237,10 +3225,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: { @@ -3250,12 +3239,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 e6dbb57cc7..11954f7668 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -2,11 +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, Rows3Icon, TextWrapIcon, @@ -31,6 +34,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"; @@ -38,7 +42,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 = ` @@ -105,6 +108,88 @@ const DIFF_PANEL_UNSAFE_CSS = ` } `; +function summarizeFileDiffStats(fileDiff: FileDiffMetadata): { + additions: number; + deletions: number; +} { + let additions = 0; + let deletions = 0; + for (const hunk of fileDiff.hunks) { + additions += hunk.additionLines; + deletions += hunk.deletionLines; + } + return { additions, deletions }; +} + +/** + * Inline copies of the change-type symbol icons that the @pierre/diffs library + * renders inside its shadow DOM (see node_modules/@pierre/diffs/dist/sprite.js). + * We render them ourselves on the collapsed header because the library's + * sprite is scoped to each FileDiff's shadow root and cannot be ``d from + * outside. Path data is verbatim so the collapsed and expanded views show the + * exact same glyph next to the file path. + */ +function ChangeTypeIcon({ + type, + className, +}: { + type: FileDiffMetadata["type"]; + className?: string; +}) { + const baseClassName = "size-4 shrink-0"; + switch (type) { + case "new": + return ( + + ); + case "deleted": + return ( + + ); + case "rename-pure": + case "rename-changed": + return ( + + ); + case "change": + default: + return ( + + ); + } +} + type RenderablePatch = | { kind: "files"; @@ -164,17 +249,96 @@ interface DiffPanelProps { mode?: DiffPanelMode; } +/** + * Renders a compact header for a collapsed file diff. + * + * We swap the entire FileDiff out for this small element when the user + * collapses a file rather than trying to hide the diff body via CSS. The + * library's Virtualizer reserves vertical space based on hunk metadata + * (explicit `
` heights and per-row min-heights + * inside the `pre`), so a CSS-only hide leaves a tall empty box and triggers + * ResizeObserver thrashing as the buffers and content fight to be measured. + * Rendering a fixed-height stand-in lets the Virtualizer measure a stable + * height and lays the panel out cleanly. + */ +function CollapsedFileHeader({ + fileDiff, + filePath, + collapseButtonClassName, + onToggle, +}: { + fileDiff: FileDiffMetadata; + filePath: string; + collapseButtonClassName: string; + onToggle: () => void; +}) { + const { additions, deletions } = useMemo( + () => summarizeFileDiffStats(fileDiff), + [fileDiff], + ); + const showDeletions = deletions > 0 || additions === 0; + const showAdditions = additions > 0 || deletions === 0; + return ( +
+ + + {fileDiff.prevName && fileDiff.prevName !== fileDiff.name ? ( + + {resolveFileDiffPath({ ...fileDiff, name: fileDiff.prevName })} + {" → "} + + ) : null} + + {filePath} + +
+ {showDeletions ? ( + -{deletions} + ) : null} + {showAdditions ? +{additions} : null} +
+
+ ); +} + export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; 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 diffRenderMode = useUiStateStore((store) => store.diffRenderMode); + const setDiffRenderMode = useUiStateStore((store) => store.setDiffRenderMode); + const diffWordWrap = useUiStateStore((store) => store.diffWordWrap); + const setDiffWordWrap = useUiStateStore((store) => store.setDiffWordWrap); + 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({ @@ -182,11 +346,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 @@ -314,12 +500,13 @@ 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 in the panel is preserved across remounts + // (e.g. the expand/collapse remount that swaps panel parents). useEffect(() => { - if (diffOpen && !previousDiffOpenRef.current) { - setDiffWordWrap(settings.diffWordWrap); - } - previousDiffOpenRef.current = diffOpen; - }, [diffOpen, settings.diffWordWrap]); + hydrateDiffSettings({ diffWordWrap: settings.diffWordWrap }); + }, [hydrateDiffSettings, settings.diffWordWrap]); useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { @@ -350,7 +537,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 }; }, }); }; @@ -360,11 +547,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) { @@ -551,6 +741,24 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { > + {mode !== "sheet" ? ( + { + toggleDiffFullWidth(); + }} + > + {diffFullWidth ? ( + + ) : ( + + )} + + ) : null}
); @@ -604,14 +812,31 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const filePath = resolveFileDiffPath(fileDiff); const fileKey = buildFileDiffRenderKey(fileDiff); const themedFileKey = `${fileKey}:${resolvedTheme}`; + const collapsed = isFileCollapsed(filePath); + const collapseButtonClassName = cn( + "relative mr-1 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors", + "hover:bg-accent hover:text-accent-foreground", + // Extend the hit area ~8px beyond the visible button on + // all sides so taps register even when slightly off-target. + "after:absolute after:-inset-2 after:content-['']", + ); 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"); @@ -620,17 +845,42 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { openDiffFileInEditor(filePath); }} > - + {collapsed ? ( + toggleFileCollapsed(filePath)} + /> + ) : ( + ( + + )} + /> + )}
); })} diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts index ef00874bd2..daa43de180 100644 --- a/apps/web/src/diffRouteSearch.test.ts +++ b/apps/web/src/diffRouteSearch.test.ts @@ -3,72 +3,46 @@ import { describe, expect, it } from "vitest"; import { parseDiffRouteSearch } from "./diffRouteSearch"; describe("parseDiffRouteSearch", () => { - it("parses valid diff search values", () => { + it("parses turn and file selection values", () => { const parsed = parseDiffRouteSearch({ - diff: "1", diffTurnId: "turn-1", diffFilePath: "src/app.ts", }); expect(parsed).toEqual({ - diff: "1", diffTurnId: "turn-1", diffFilePath: "src/app.ts", }); }); - it("treats numeric and boolean diff toggles as open", () => { - expect( - parseDiffRouteSearch({ - diff: 1, - diffTurnId: "turn-1", - }), - ).toEqual({ + it("ignores legacy 'diff' open/closed flag", () => { + // The diff panel open/closed state moved to the UI store. Old URLs that + // still carry ?diff=1 must not surface as anything in the parsed search. + const parsed = parseDiffRouteSearch({ diff: "1", diffTurnId: "turn-1", }); - expect( - parseDiffRouteSearch({ - diff: true, - diffTurnId: "turn-1", - }), - ).toEqual({ - diff: "1", + expect(parsed).toEqual({ diffTurnId: "turn-1", }); - }); - - it("drops turn and file values when diff is closed", () => { - const parsed = parseDiffRouteSearch({ - diff: "0", - diffTurnId: "turn-1", - diffFilePath: "src/app.ts", - }); - - expect(parsed).toEqual({}); + expect("diff" in parsed).toBe(false); }); it("drops file value when turn is not selected", () => { const parsed = parseDiffRouteSearch({ - diff: "1", diffFilePath: "src/app.ts", }); - expect(parsed).toEqual({ - diff: "1", - }); + expect(parsed).toEqual({}); }); it("normalizes whitespace-only values", () => { const parsed = parseDiffRouteSearch({ - diff: "1", diffTurnId: " ", diffFilePath: " ", }); - expect(parsed).toEqual({ - diff: "1", - }); + expect(parsed).toEqual({}); }); }); diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts index d9b072f28e..9dc2bd1394 100644 --- a/apps/web/src/diffRouteSearch.ts +++ b/apps/web/src/diffRouteSearch.ts @@ -1,15 +1,10 @@ import { TurnId } from "@t3tools/contracts"; export interface DiffRouteSearch { - diff?: "1" | undefined; diffTurnId?: TurnId | undefined; diffFilePath?: string | undefined; } -function isDiffOpenValue(value: unknown): boolean { - return value === "1" || value === 1 || value === true; -} - function normalizeSearchString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -21,18 +16,19 @@ function normalizeSearchString(value: unknown): string | undefined { export function stripDiffSearchParams>( params: T, ): Omit { + // "diff" is intentionally still listed here so any legacy URLs with the old + // open/closed flag are stripped on first interaction; the panel state now + // lives in the UI store, scoped per thread. const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; return rest as Omit; } export function parseDiffRouteSearch(search: Record): DiffRouteSearch { - const diff = isDiffOpenValue(search.diff) ? "1" : undefined; - const diffTurnIdRaw = diff ? normalizeSearchString(search.diffTurnId) : undefined; + const diffTurnIdRaw = normalizeSearchString(search.diffTurnId); const diffTurnId = diffTurnIdRaw ? TurnId.make(diffTurnIdRaw) : undefined; - const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; + const diffFilePath = diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; return { - ...(diff ? { diff } : {}), ...(diffTurnId ? { diffTurnId } : {}), ...(diffFilePath ? { diffFilePath } : {}), }; diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index dc1abed8a0..05d09e4f9a 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,4 +1,5 @@ -import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { scopedThreadKey } from "@t3tools/client-runtime"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; import ChatView from "../components/ChatView"; @@ -11,18 +12,16 @@ import { type DiffPanelMode, } from "../components/DiffPanelShell"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { - type DiffRouteSearch, - parseDiffRouteSearch, - stripDiffSearchParams, -} from "../diffRouteSearch"; +import { parseDiffRouteSearch } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; import { createThreadSelectorByRef } from "../storeSelectors"; -import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; +import { resolveThreadRouteRef } from "../threadRoutes"; +import { useUiStateStore } from "../uiStateStore"; import { RightPanelSheet } from "../components/RightPanelSheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; +import { cn } from "~/lib/utils"; const DiffPanel = lazy(() => import("../components/DiffPanel")); const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; @@ -143,7 +142,6 @@ function ChatThreadRouteView() { const threadRef = Route.useParams({ select: (params) => resolveThreadRouteRef(params), }); - const search = Route.useSearch(); const bootstrapComplete = useStore( (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, ); @@ -167,52 +165,51 @@ function ChatThreadRouteView() { const routeThreadExists = threadExists || draftThreadExists; const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; - const diffOpen = search.diff === "1"; + const activeThreadKey = useMemo( + () => (threadRef ? scopedThreadKey(threadRef) : null), + [threadRef], + ); + const diffOpen = useUiStateStore((store) => + activeThreadKey ? store.threadDiffOpenById[activeThreadKey] === true : false, + ); + const threadDiffFullWidth = useUiStateStore((store) => + activeThreadKey ? store.threadDiffFullWidthById[activeThreadKey] === true : false, + ); + const setThreadDiffOpen = useUiStateStore((store) => store.setThreadDiffOpen); + const diffFullWidth = diffOpen && threadDiffFullWidth; const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ - threadKey: currentThreadKey, + threadKey: activeThreadKey, hasOpenedDiff: diffOpen, })); const hasOpenedDiff = - diffPanelMountState.threadKey === currentThreadKey + diffPanelMountState.threadKey === activeThreadKey ? diffPanelMountState.hasOpenedDiff : diffOpen; const markDiffOpened = useCallback(() => { setDiffPanelMountState((previous) => { - if (previous.threadKey === currentThreadKey && previous.hasOpenedDiff) { + if (previous.threadKey === activeThreadKey && previous.hasOpenedDiff) { return previous; } return { - threadKey: currentThreadKey, + threadKey: activeThreadKey, hasOpenedDiff: true, }; }); - }, [currentThreadKey]); + }, [activeThreadKey]); const closeDiff = useCallback(() => { - if (!threadRef) { + if (!activeThreadKey) { return; } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: { diff: undefined }, - }); - }, [navigate, threadRef]); + setThreadDiffOpen(activeThreadKey, false); + }, [activeThreadKey, setThreadDiffOpen]); const openDiff = useCallback(() => { - if (!threadRef) { + if (!activeThreadKey) { return; } markDiffOpened(); - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - search: (previous) => { - const rest = stripDiffSearchParams(previous); - return { ...rest, diff: "1" }; - }, - }); - }, [markDiffOpened, navigate, threadRef]); + setThreadDiffOpen(activeThreadKey, true); + }, [activeThreadKey, markDiffOpened, setThreadDiffOpen]); useEffect(() => { if (!threadRef || !bootstrapComplete) { @@ -240,7 +237,12 @@ function ChatThreadRouteView() { if (!shouldUseDiffSheet) { return ( <> - + - + {diffFullWidth ? ( + + {shouldRenderDiffContent ? : null} + + ) : ( + + )} ); } @@ -278,8 +286,5 @@ function ChatThreadRouteView() { export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ validateSearch: (search) => parseDiffRouteSearch(search), - search: { - middlewares: [retainSearchParams(["diff"])], - }, component: ChatThreadRouteView, }); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index ca5ed2b4b1..3d81ddafed 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearThreadUi, + hydrateDiffSettings, hydratePersistedProjectState, markThreadVisited, markThreadUnread, @@ -10,8 +11,13 @@ import { type PersistedUiState, persistState, reorderProjects, + setDiffFileCollapsed, + setDiffRenderMode, + setDiffWordWrap, setProjectExpanded, setThreadChangedFilesExpanded, + setThreadDiffFullWidth, + setThreadDiffOpen, syncProjects, syncThreads, type UiState, @@ -23,6 +29,12 @@ function makeUiState(overrides: Partial = {}): UiState { projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, + threadDiffFullWidthById: {}, + threadDiffOpenById: {}, + threadDiffFileCollapsedById: {}, + diffRenderMode: "stacked", + diffWordWrap: false, + diffSettingsHydrated: false, ...overrides, }; } @@ -338,6 +350,18 @@ describe("uiStateStore pure functions", () => { "turn-2": false, }, }, + threadDiffFullWidthById: { + [thread1]: true, + [thread2]: true, + }, + threadDiffOpenById: { + [thread1]: true, + [thread2]: true, + }, + threadDiffFileCollapsedById: { + [thread1]: { "src/a.ts": true }, + [thread2]: { "src/b.ts": true }, + }, }); const next = syncThreads(initialState, [{ key: thread1 }]); @@ -350,6 +374,15 @@ describe("uiStateStore pure functions", () => { "turn-1": false, }, }); + expect(next.threadDiffFullWidthById).toEqual({ + [thread1]: true, + }); + expect(next.threadDiffOpenById).toEqual({ + [thread1]: true, + }); + expect(next.threadDiffFileCollapsedById).toEqual({ + [thread1]: { "src/a.ts": true }, + }); }); it("syncThreads seeds visit state for unseen snapshot threads", () => { @@ -394,12 +427,24 @@ describe("uiStateStore pure functions", () => { "turn-1": false, }, }, + threadDiffFullWidthById: { + [thread1]: true, + }, + threadDiffOpenById: { + [thread1]: true, + }, + threadDiffFileCollapsedById: { + [thread1]: { "src/a.ts": true }, + }, }); const next = clearThreadUi(initialState, thread1); expect(next.threadLastVisitedAtById).toEqual({}); expect(next.threadChangedFilesExpandedById).toEqual({}); + expect(next.threadDiffFullWidthById).toEqual({}); + expect(next.threadDiffOpenById).toEqual({}); + expect(next.threadDiffFileCollapsedById).toEqual({}); }); it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { @@ -429,6 +474,218 @@ describe("uiStateStore pure functions", () => { expect(next.threadChangedFilesExpandedById).toEqual({}); }); + + it("setThreadDiffFullWidth stores full-width preference per thread", () => { + const threadKey = "env-1:thread-1"; + const initialState = makeUiState(); + + const next = setThreadDiffFullWidth(initialState, threadKey, true); + + expect(next.threadDiffFullWidthById).toEqual({ + [threadKey]: true, + }); + }); + + it("setThreadDiffFullWidth removes the thread entry when collapsing back to split", () => { + const threadKey = "env-1:thread-1"; + const initialState = makeUiState({ + threadDiffFullWidthById: { + [threadKey]: true, + }, + }); + + const next = setThreadDiffFullWidth(initialState, threadKey, false); + + expect(next.threadDiffFullWidthById).toEqual({}); + }); + + it("setThreadDiffFullWidth is a no-op when value is unchanged", () => { + const threadKey = "env-1:thread-1"; + const initialState = makeUiState({ + threadDiffFullWidthById: { + [threadKey]: true, + }, + }); + + const next = setThreadDiffFullWidth(initialState, threadKey, true); + + expect(next).toBe(initialState); + }); + + it("setThreadDiffFullWidth keeps each thread's preference independent", () => { + const threadA = "env-1:thread-A"; + const threadB = "env-1:thread-B"; + + let state = makeUiState(); + state = setThreadDiffFullWidth(state, threadA, true); + state = setThreadDiffFullWidth(state, threadB, true); + state = setThreadDiffFullWidth(state, threadA, false); + + expect(state.threadDiffFullWidthById).toEqual({ + [threadB]: true, + }); + }); + + it("setDiffRenderMode updates the render mode", () => { + const initialState = makeUiState(); + + const next = setDiffRenderMode(initialState, "split"); + + expect(next.diffRenderMode).toBe("split"); + }); + + it("setDiffRenderMode is a no-op when value is unchanged", () => { + const initialState = makeUiState({ diffRenderMode: "split" }); + + const next = setDiffRenderMode(initialState, "split"); + + expect(next).toBe(initialState); + }); + + it("setDiffWordWrap updates the wrap value and marks settings hydrated", () => { + const initialState = makeUiState(); + + const next = setDiffWordWrap(initialState, true); + + expect(next.diffWordWrap).toBe(true); + expect(next.diffSettingsHydrated).toBe(true); + }); + + it("setDiffWordWrap still marks hydrated when toggling matches initial value", () => { + // The initial value is false; calling setDiffWordWrap(false) before any + // hydration must still mark hydrated so a later settings-default sync + // does not re-override the user's explicit "off" choice. + const initialState = makeUiState(); + + const next = setDiffWordWrap(initialState, false); + + expect(next.diffWordWrap).toBe(false); + expect(next.diffSettingsHydrated).toBe(true); + }); + + it("hydrateDiffSettings seeds wrap from settings on first call", () => { + const initialState = makeUiState(); + + const next = hydrateDiffSettings(initialState, { diffWordWrap: true }); + + expect(next.diffWordWrap).toBe(true); + expect(next.diffSettingsHydrated).toBe(true); + }); + + it("hydrateDiffSettings is a no-op once user has toggled wrap", () => { + let state = makeUiState(); + state = setDiffWordWrap(state, false); + + const next = hydrateDiffSettings(state, { diffWordWrap: true }); + + expect(next).toBe(state); + expect(next.diffWordWrap).toBe(false); + }); + + it("setThreadDiffOpen stores open state per thread", () => { + const threadKey = "env-1:thread-1"; + const initialState = makeUiState(); + + const next = setThreadDiffOpen(initialState, threadKey, true); + + expect(next.threadDiffOpenById).toEqual({ + [threadKey]: true, + }); + }); + + it("setThreadDiffOpen removes the thread entry when closing", () => { + const threadKey = "env-1:thread-1"; + const initialState = makeUiState({ + threadDiffOpenById: { + [threadKey]: true, + }, + }); + + const next = setThreadDiffOpen(initialState, threadKey, false); + + expect(next.threadDiffOpenById).toEqual({}); + }); + + it("setThreadDiffOpen is a no-op when value is unchanged", () => { + const threadKey = "env-1:thread-1"; + const initialState = makeUiState({ + threadDiffOpenById: { + [threadKey]: true, + }, + }); + + const next = setThreadDiffOpen(initialState, threadKey, true); + + expect(next).toBe(initialState); + }); + + it("setThreadDiffOpen keeps each thread's open state independent", () => { + const threadA = "env-1:thread-A"; + const threadB = "env-1:thread-B"; + + let state = makeUiState(); + state = setThreadDiffOpen(state, threadA, true); + state = setThreadDiffOpen(state, threadB, true); + state = setThreadDiffOpen(state, threadA, false); + + expect(state.threadDiffOpenById).toEqual({ + [threadB]: true, + }); + }); + + it("setDiffFileCollapsed marks a single file collapsed for a thread", () => { + const threadKey = "env-1:thread-1"; + const initialState = makeUiState(); + + const next = setDiffFileCollapsed(initialState, threadKey, "src/app.ts", true); + + expect(next.threadDiffFileCollapsedById).toEqual({ + [threadKey]: { "src/app.ts": true }, + }); + }); + + it("setDiffFileCollapsed removes the file entry on expand and prunes empty thread maps", () => { + const threadKey = "env-1:thread-1"; + const initialState = makeUiState({ + threadDiffFileCollapsedById: { + [threadKey]: { "src/app.ts": true }, + }, + }); + + const next = setDiffFileCollapsed(initialState, threadKey, "src/app.ts", false); + + expect(next.threadDiffFileCollapsedById).toEqual({}); + }); + + it("setDiffFileCollapsed leaves other thread entries untouched", () => { + const threadA = "env-1:thread-A"; + const threadB = "env-1:thread-B"; + const initialState = makeUiState({ + threadDiffFileCollapsedById: { + [threadA]: { "src/a.ts": true }, + [threadB]: { "src/b.ts": true }, + }, + }); + + const next = setDiffFileCollapsed(initialState, threadA, "src/a.ts", false); + + expect(next.threadDiffFileCollapsedById).toEqual({ + [threadB]: { "src/b.ts": true }, + }); + }); + + it("setDiffFileCollapsed is a no-op when value is unchanged", () => { + const threadKey = "env-1:thread-1"; + const initialState = makeUiState({ + threadDiffFileCollapsedById: { + [threadKey]: { "src/app.ts": true }, + }, + }); + + const next = setDiffFileCollapsed(initialState, threadKey, "src/app.ts", true); + + expect(next).toBe(initialState); + }); }); describe("uiStateStore persistence round-trip", () => { @@ -554,6 +811,62 @@ describe("uiStateStore persistence round-trip", () => { expect(rehydrated.projectOrder).toEqual([projectC.key, projectA.key, projectB.key]); }); + it("persists per-thread diff full-width preferences", () => { + const threadKey = "env-1:thread-1"; + const otherThreadKey = "env-1:thread-2"; + + let state = makeUiState(); + state = setThreadDiffFullWidth(state, threadKey, true); + // Setting and unsetting another thread should not leak into persisted state. + state = setThreadDiffFullWidth(state, otherThreadKey, true); + state = setThreadDiffFullWidth(state, otherThreadKey, false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + expect(persisted.threadDiffFullWidthById).toEqual({ + [threadKey]: true, + }); + }); + + it("persists per-thread diff open state", () => { + const threadA = "env-1:thread-A"; + const threadB = "env-1:thread-B"; + + let state = makeUiState(); + state = setThreadDiffOpen(state, threadA, true); + state = setThreadDiffOpen(state, threadB, true); + state = setThreadDiffOpen(state, threadB, false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + expect(persisted.threadDiffOpenById).toEqual({ + [threadA]: true, + }); + }); + + it("persists per-thread per-file collapse state and prunes empty entries", () => { + const threadA = "env-1:thread-A"; + const threadB = "env-1:thread-B"; + + let state = makeUiState(); + state = setDiffFileCollapsed(state, threadA, "src/a.ts", true); + state = setDiffFileCollapsed(state, threadA, "src/b.ts", true); + state = setDiffFileCollapsed(state, threadB, "src/c.ts", true); + state = setDiffFileCollapsed(state, threadB, "src/c.ts", false); + persistState(state); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + expect(persisted.threadDiffFileCollapsedById).toEqual({ + [threadA]: { "src/a.ts": true, "src/b.ts": true }, + }); + }); + it("preserves expand state across restart when project's logical key changes", () => { // After restart, in-memory previousExpandedById is empty, so the // previousLogicalKey-to-state bridge in syncProjects cannot help. The diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 8bd65ffc56..f4a7e49694 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -20,6 +20,9 @@ export interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; threadChangedFilesExpandedById?: Record>; + threadDiffFullWidthById?: Record; + threadDiffOpenById?: Record; + threadDiffFileCollapsedById?: Record>; } export interface UiProjectState { @@ -30,9 +33,25 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; threadChangedFilesExpandedById: Record>; + threadDiffFullWidthById: Record; + threadDiffOpenById: Record; + // Per-thread per-file: stores `true` when the user has collapsed that file + // in the diff panel. Absence means the file is expanded (default). + threadDiffFileCollapsedById: Record>; } -export interface UiState extends UiProjectState, UiThreadState {} +export type DiffRenderMode = "stacked" | "split"; + +export interface UiDiffViewState { + diffRenderMode: DiffRenderMode; + diffWordWrap: boolean; + // Tracks whether diffWordWrap has been seeded from the user's persistent + // settings preference yet this session. Once hydrated, runtime settings + // changes do not override the in-session toggle. + diffSettingsHydrated: boolean; +} + +export interface UiState extends UiProjectState, UiThreadState, UiDiffViewState {} export interface SyncProjectInput { /** Physical project key (env + cwd). Used for manual sort order. */ @@ -52,6 +71,12 @@ const initialState: UiState = { projectOrder: [], threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, + threadDiffFullWidthById: {}, + threadDiffOpenById: {}, + threadDiffFileCollapsedById: {}, + diffRenderMode: "stacked", + diffWordWrap: false, + diffSettingsHydrated: false, }; const persistedCollapsedProjectCwds = new Set(); @@ -91,12 +116,56 @@ function readPersistedState(): UiState { threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( parsed.threadChangedFilesExpandedById, ), + threadDiffFullWidthById: sanitizePersistedThreadBooleanMap(parsed.threadDiffFullWidthById), + threadDiffOpenById: sanitizePersistedThreadBooleanMap(parsed.threadDiffOpenById), + threadDiffFileCollapsedById: sanitizePersistedThreadDiffFileCollapsed( + parsed.threadDiffFileCollapsedById, + ), }; } catch { return initialState; } } +function sanitizePersistedThreadDiffFileCollapsed( + value: PersistedUiState["threadDiffFileCollapsedById"], +): Record> { + if (!value || typeof value !== "object") { + return {}; + } + const next: Record> = {}; + for (const [threadId, files] of Object.entries(value)) { + if (!threadId || !files || typeof files !== "object") { + continue; + } + const nextFiles: Record = {}; + for (const [filePath, collapsed] of Object.entries(files)) { + if (filePath && collapsed === true) { + nextFiles[filePath] = true; + } + } + if (Object.keys(nextFiles).length > 0) { + next[threadId] = nextFiles; + } + } + return next; +} + +function sanitizePersistedThreadBooleanMap( + value: Record | undefined, +): Record { + if (!value || typeof value !== "object") { + return {}; + } + const next: Record = {}; + for (const [threadId, flag] of Object.entries(value)) { + if (threadId && flag === true) { + next[threadId] = true; + } + } + return next; +} + function sanitizePersistedThreadChangedFilesExpanded( value: PersistedUiState["threadChangedFilesExpandedById"], ): Record> { @@ -173,6 +242,20 @@ export function persistState(state: UiState): void { return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; }), ); + const threadDiffFullWidthById = Object.fromEntries( + Object.entries(state.threadDiffFullWidthById).filter(([, fullWidth]) => fullWidth === true), + ); + const threadDiffOpenById = Object.fromEntries( + Object.entries(state.threadDiffOpenById).filter(([, open]) => open === true), + ); + const threadDiffFileCollapsedById = Object.fromEntries( + Object.entries(state.threadDiffFileCollapsedById).flatMap(([threadId, files]) => { + const nextFiles = Object.fromEntries( + Object.entries(files).filter(([, collapsed]) => collapsed === true), + ); + return Object.keys(nextFiles).length > 0 ? [[threadId, nextFiles]] : []; + }), + ); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ @@ -180,6 +263,9 @@ export function persistState(state: UiState): void { expandedProjectCwds, projectOrderCwds, threadChangedFilesExpandedById, + threadDiffFullWidthById, + threadDiffOpenById, + threadDiffFileCollapsedById, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -404,11 +490,32 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) retainedThreadIds.has(threadId), ), ); + const nextThreadDiffFullWidthById = Object.fromEntries( + Object.entries(state.threadDiffFullWidthById).filter(([threadId]) => + retainedThreadIds.has(threadId), + ), + ); + const nextThreadDiffOpenById = Object.fromEntries( + Object.entries(state.threadDiffOpenById).filter(([threadId]) => + retainedThreadIds.has(threadId), + ), + ); + const nextThreadDiffFileCollapsedById = Object.fromEntries( + Object.entries(state.threadDiffFileCollapsedById).filter(([threadId]) => + retainedThreadIds.has(threadId), + ), + ); if ( recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && nestedBooleanRecordsEqual( state.threadChangedFilesExpandedById, nextThreadChangedFilesExpandedById, + ) && + recordsEqual(state.threadDiffFullWidthById, nextThreadDiffFullWidthById) && + recordsEqual(state.threadDiffOpenById, nextThreadDiffOpenById) && + nestedBooleanRecordsEqual( + state.threadDiffFileCollapsedById, + nextThreadDiffFileCollapsedById, ) ) { return state; @@ -417,6 +524,9 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, + threadDiffFullWidthById: nextThreadDiffFullWidthById, + threadDiffOpenById: nextThreadDiffOpenById, + threadDiffFileCollapsedById: nextThreadDiffFileCollapsedById, }; } @@ -469,17 +579,35 @@ export function markThreadUnread( export function clearThreadUi(state: UiState, threadId: string): UiState { const hasVisitedState = threadId in state.threadLastVisitedAtById; const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById; - if (!hasVisitedState && !hasChangedFilesState) { + const hasDiffFullWidthState = threadId in state.threadDiffFullWidthById; + const hasDiffOpenState = threadId in state.threadDiffOpenById; + const hasDiffFileCollapsedState = threadId in state.threadDiffFileCollapsedById; + if ( + !hasVisitedState && + !hasChangedFilesState && + !hasDiffFullWidthState && + !hasDiffOpenState && + !hasDiffFileCollapsedState + ) { return state; } const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; + const nextThreadDiffFullWidthById = { ...state.threadDiffFullWidthById }; + const nextThreadDiffOpenById = { ...state.threadDiffOpenById }; + const nextThreadDiffFileCollapsedById = { ...state.threadDiffFileCollapsedById }; delete nextThreadLastVisitedAtById[threadId]; delete nextThreadChangedFilesExpandedById[threadId]; + delete nextThreadDiffFullWidthById[threadId]; + delete nextThreadDiffOpenById[threadId]; + delete nextThreadDiffFileCollapsedById[threadId]; return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, + threadDiffFullWidthById: nextThreadDiffFullWidthById, + threadDiffOpenById: nextThreadDiffOpenById, + threadDiffFileCollapsedById: nextThreadDiffFileCollapsedById, }; } @@ -532,6 +660,108 @@ export function setThreadChangedFilesExpanded( }; } +export function setDiffRenderMode(state: UiState, mode: DiffRenderMode): UiState { + if (state.diffRenderMode === mode) { + return state; + } + return { ...state, diffRenderMode: mode }; +} + +export function setDiffWordWrap(state: UiState, wrap: boolean): UiState { + if (state.diffWordWrap === wrap && state.diffSettingsHydrated) { + return state; + } + return { ...state, diffWordWrap: wrap, diffSettingsHydrated: true }; +} + +export function hydrateDiffSettings( + state: UiState, + defaults: { diffWordWrap: boolean }, +): UiState { + if (state.diffSettingsHydrated) { + return state; + } + return { + ...state, + diffWordWrap: defaults.diffWordWrap, + diffSettingsHydrated: true, + }; +} + +export function setThreadDiffFullWidth( + state: UiState, + threadKey: string, + fullWidth: boolean, +): UiState { + const current = state.threadDiffFullWidthById[threadKey] === true; + if (current === fullWidth) { + return state; + } + const next = { ...state.threadDiffFullWidthById }; + if (fullWidth) { + next[threadKey] = true; + } else { + delete next[threadKey]; + } + return { + ...state, + threadDiffFullWidthById: next, + }; +} + +export function setDiffFileCollapsed( + state: UiState, + threadKey: string, + filePath: string, + collapsed: boolean, +): UiState { + const currentThreadFiles = state.threadDiffFileCollapsedById[threadKey] ?? {}; + const current = currentThreadFiles[filePath] === true; + if (current === collapsed) { + return state; + } + + const nextThreadFiles = { ...currentThreadFiles }; + if (collapsed) { + nextThreadFiles[filePath] = true; + } else { + delete nextThreadFiles[filePath]; + } + + const nextById = { ...state.threadDiffFileCollapsedById }; + if (Object.keys(nextThreadFiles).length === 0) { + delete nextById[threadKey]; + } else { + nextById[threadKey] = nextThreadFiles; + } + + return { + ...state, + threadDiffFileCollapsedById: nextById, + }; +} + +export function setThreadDiffOpen( + state: UiState, + threadKey: string, + open: boolean, +): UiState { + const current = state.threadDiffOpenById[threadKey] === true; + if (current === open) { + return state; + } + const next = { ...state.threadDiffOpenById }; + if (open) { + next[threadKey] = true; + } else { + delete next[threadKey]; + } + return { + ...state, + threadDiffOpenById: next, + }; +} + export function toggleProject(state: UiState, projectId: string): UiState { const expanded = state.projectExpandedById[projectId] ?? true; return { @@ -606,6 +836,12 @@ interface UiStateStore extends UiState { markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: string) => void; setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; + setThreadDiffFullWidth: (threadKey: string, fullWidth: boolean) => void; + setThreadDiffOpen: (threadKey: string, open: boolean) => void; + setDiffFileCollapsed: (threadKey: string, filePath: string, collapsed: boolean) => void; + setDiffRenderMode: (mode: DiffRenderMode) => void; + setDiffWordWrap: (wrap: boolean) => void; + hydrateDiffSettings: (defaults: { diffWordWrap: boolean }) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: ( @@ -625,6 +861,14 @@ export const useUiStateStore = create((set) => ({ clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), setThreadChangedFilesExpanded: (threadId, turnId, expanded) => set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), + setThreadDiffFullWidth: (threadKey, fullWidth) => + set((state) => setThreadDiffFullWidth(state, threadKey, fullWidth)), + setThreadDiffOpen: (threadKey, open) => set((state) => setThreadDiffOpen(state, threadKey, open)), + setDiffFileCollapsed: (threadKey, filePath, collapsed) => + set((state) => setDiffFileCollapsed(state, threadKey, filePath, collapsed)), + setDiffRenderMode: (mode) => set((state) => setDiffRenderMode(state, mode)), + setDiffWordWrap: (wrap) => set((state) => setDiffWordWrap(state, wrap)), + hydrateDiffSettings: (defaults) => set((state) => hydrateDiffSettings(state, defaults)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)),