From cc3e1db22a0a450396bf34650eebb7b8c56129de Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 19 Feb 2026 01:47:48 -0800 Subject: [PATCH] Drive diff panel state from route search params - Parse and validate `diff`, `diffTurnId`, and `diffFilePath` from chat route search - Switch ChatView/DiffPanel diff open/target actions to router navigation instead of store actions - Remove diff-specific state/actions from the app store and update reducer tests --- apps/web/src/components/ChatView.tsx | 60 ++++++++++------ apps/web/src/components/DiffPanel.tsx | 68 +++++++++--------- apps/web/src/diffRouteSearch.test.ts | 53 ++++++++++++++ apps/web/src/diffRouteSearch.ts | 26 +++++++ apps/web/src/routes/_chat.$threadId.tsx | 2 + apps/web/src/routes/_chat.tsx | 58 ++++++++++++---- apps/web/src/store.test.ts | 81 ---------------------- apps/web/src/store.ts | 91 +------------------------ 8 files changed, 203 insertions(+), 236 deletions(-) create mode 100644 apps/web/src/diffRouteSearch.test.ts create mode 100644 apps/web/src/diffRouteSearch.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9b5288a4fdfd08a45d380c5cad659688a955159d..fd94dfff9385b0eff8c2c86ae54da7d7845bfcd8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,6 +28,7 @@ import { } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; @@ -35,6 +36,7 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; +import { parseDiffRouteSearch } from "../diffRouteSearch"; import { buildBootstrapInput } from "../historyBootstrap"; import { type ComposerTriggerKind, @@ -374,6 +376,8 @@ interface ChatViewProps { export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); + const navigate = useNavigate(); + const rawSearch = useSearch({ strict: false }); const api = useNativeApi(); const { resolvedTheme } = useTheme(); const queryClient = useQueryClient(); @@ -411,6 +415,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const checkpointTurnCountSyncFingerprintRef = useRef(new Map()); const activeThread = state.threads.find((t) => t.id === threadId); + const diffSearch = useMemo( + () => parseDiffRouteSearch(rawSearch as Record), + [rawSearch], + ); + const diffOpen = diffSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId ?? null; const activeThreadRuntimeId = @@ -1954,32 +1963,39 @@ export default function ChatView({ threadId }: ChatViewProps) { setExpandedImage(image); }, []); const onToggleDiff = useCallback(() => { - if (state.diffOpen) { - dispatch({ type: "CLOSE_DIFF" }); - return; - } - - if (!activeThread) { - dispatch({ type: "TOGGLE_DIFF" }); - return; - } - - dispatch({ - type: "OPEN_DIFF", - threadId: activeThread.id, + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => { + const { + diff: _diff, + diffTurnId: _diffTurnId, + diffFilePath: _diffFilePath, + ...rest + } = previous; + return diffOpen ? rest : { ...rest, diff: "1" }; + }, }); - }, [activeThread, dispatch, state.diffOpen]); + }, [diffOpen, navigate, threadId]); const onOpenTurnDiff = useCallback( (turnId: string, filePath?: string) => { - if (!activeThread) return; - dispatch({ - type: "OPEN_DIFF", - threadId: activeThread.id, - turnId, - ...(filePath ? { filePath } : {}), + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => { + const { + diff: _diff, + diffTurnId: _diffTurnId, + diffFilePath: _diffFilePath, + ...rest + } = previous; + return filePath + ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } + : { ...rest, diff: "1", diffTurnId: turnId }; + }, }); }, - [activeThread, dispatch], + [navigate, threadId], ); const onRevertUserMessage = useCallback( (messageId: string) => { @@ -2027,7 +2043,7 @@ export default function ChatView({ threadId }: ChatViewProps) { keybindings={keybindings} api={api} gitCwd={gitCwd} - diffOpen={state.diffOpen} + diffOpen={diffOpen} onRunProjectScript={(script) => { void runProjectScript(script); }} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 43b19188671f31b4914797125cd4a413c9403a82..face19f291c743484177b4fd83a34e942aaf7788 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -6,11 +6,12 @@ import { WorkerPoolContextProvider, } from "@pierre/diffs/react"; import { useQuery } from "@tanstack/react-query"; -import { useParams } from "@tanstack/react-router"; +import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { Columns2Icon, Rows3Icon } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; +import { parseDiffRouteSearch } from "../diffRouteSearch"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { useTheme } from "../hooks/useTheme"; @@ -101,13 +102,19 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const api = useNativeApi(); + const navigate = useNavigate(); const { resolvedTheme } = useTheme(); - const { state, dispatch } = useStore(); + const { state } = useStore(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const patchViewportRef = useRef(null); const params = useParams({ strict: false }); + const rawSearch = useSearch({ strict: false }); const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; - const activeThreadId = routeThreadId ?? state.diffThreadId; + const diffSearch = useMemo( + () => parseDiffRouteSearch(rawSearch as Record), + [rawSearch], + ); + const activeThreadId = routeThreadId; const activeThread = state.threads.find((thread) => thread.id === activeThreadId); const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; @@ -115,10 +122,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); - const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); - const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; - const selectedFilePath = - canApplyStoredTarget && selectedTurnId !== null ? state.diffFilePath : null; + const selectedTurnId = diffSearch.diffTurnId ?? null; + const selectedFilePath = selectedTurnId !== null ? diffSearch.diffFilePath ?? null : null; const selectedTurn = selectedTurnId === null ? undefined @@ -216,30 +221,36 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { target?.scrollIntoView({ block: "nearest" }); }, [selectedFilePath, renderableFiles]); - useEffect(() => { - if (!state.diffOpen) return; - if (!routeThreadId) return; - if (state.diffThreadId === routeThreadId) return; - - dispatch({ - type: "SET_DIFF_TARGET", - threadId: routeThreadId, - }); - }, [dispatch, routeThreadId, state.diffOpen, state.diffThreadId]); - const selectTurn = (turnId: string) => { if (!activeThread) return; - dispatch({ - type: "SET_DIFF_TARGET", - threadId: activeThread.id, - turnId, + void navigate({ + to: "/$threadId", + params: { threadId: activeThread.id }, + search: (previous) => { + const { + diff: _diff, + diffTurnId: _diffTurnId, + diffFilePath: _diffFilePath, + ...rest + } = previous; + return { ...rest, diff: "1", diffTurnId: turnId }; + }, }); }; const selectWholeConversation = () => { if (!activeThread) return; - dispatch({ - type: "SET_DIFF_TARGET", - threadId: activeThread.id, + void navigate({ + to: "/$threadId", + params: { threadId: activeThread.id }, + search: (previous) => { + const { + diff: _diff, + diffTurnId: _diffTurnId, + diffFilePath: _diffFilePath, + ...rest + } = previous; + return { ...rest, diff: "1" }; + }, }); }; @@ -329,13 +340,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ) : ( <>
- {!canApplyStoredTarget && state.diffThreadId && ( -
-

- Showing diffs for the active thread. -

-
- )} {checkpointDiffError && !renderablePatch && (

{checkpointDiffError}

diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..becf9e335f0ff4111e8d5725f9cb0db56adf9f29 --- /dev/null +++ b/apps/web/src/diffRouteSearch.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { parseDiffRouteSearch } from "./diffRouteSearch"; + +describe("parseDiffRouteSearch", () => { + it("parses valid diff search 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("drops turn and file values when diff is closed", () => { + const parsed = parseDiffRouteSearch({ + diff: "0", + diffTurnId: "turn-1", + diffFilePath: "src/app.ts", + }); + + expect(parsed).toEqual({}); + }); + + it("drops file value when turn is not selected", () => { + const parsed = parseDiffRouteSearch({ + diff: "1", + diffFilePath: "src/app.ts", + }); + + expect(parsed).toEqual({ + diff: "1", + }); + }); + + it("normalizes whitespace-only values", () => { + const parsed = parseDiffRouteSearch({ + diff: "1", + diffTurnId: " ", + diffFilePath: " ", + }); + + expect(parsed).toEqual({ + diff: "1", + }); + }); +}); + diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b1ccded2427f9a07e213f16554b91b7c2d45cf1 --- /dev/null +++ b/apps/web/src/diffRouteSearch.ts @@ -0,0 +1,26 @@ +export interface DiffRouteSearch { + diff?: "1"; + diffTurnId?: string; + diffFilePath?: string; +} + +function normalizeSearchString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +export function parseDiffRouteSearch(search: Record): DiffRouteSearch { + const diff = search.diff === "1" ? "1" : undefined; + const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined; + const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; + + return { + ...(diff ? { diff } : {}), + ...(diffTurnId ? { diffTurnId } : {}), + ...(diffFilePath ? { diffFilePath } : {}), + }; +} + diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 066f657ea332294601206c030cfa1f4cc06edccc..8cca8c2e69d0dde8c85474d0589215cf00883b80 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -2,6 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import ChatView from "../components/ChatView"; +import { parseDiffRouteSearch } from "../diffRouteSearch"; import { useStore } from "../store"; function ChatThreadRouteView() { @@ -34,5 +35,6 @@ function ChatThreadRouteView() { } export const Route = createFileRoute("/_chat/$threadId")({ + validateSearch: (search) => parseDiffRouteSearch(search), component: ChatThreadRouteView, }); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 702e873b07fdf761c80121b9de3378e86257da4e..d225a4e33f353b6fb10d756f4788a35a06301a6d 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,9 +1,15 @@ -import { Outlet, createFileRoute } from "@tanstack/react-router"; -import { Activity, Suspense, lazy, type ReactNode } from "react"; +import { + Outlet, + createFileRoute, + useNavigate, + useParams, + useSearch, +} from "@tanstack/react-router"; +import { Activity, Suspense, lazy, type ReactNode, useCallback, useMemo } from "react"; import Sidebar from "../components/Sidebar"; +import { parseDiffRouteSearch } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { useStore } from "../store"; import { Sheet, SheetPopup } from "../components/ui/sheet"; const DiffPanel = lazy(() => import("../components/DiffPanel")); @@ -14,15 +20,19 @@ const DiffWorkerPoolProvider = lazy(() => ); const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; -const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => { - const { state, dispatch } = useStore(); +const DiffPanelWrapper = (props: { + children: ReactNode; + sheet: boolean; + diffOpen: boolean; + onCloseDiff: () => void; +}) => { if (props.sheet) { return ( { if (!open) { - dispatch({ type: "CLOSE_DIFF" }); + props.onCloseDiff(); } }} > @@ -39,18 +49,42 @@ const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => { } return ( -