authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 01:47:48-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 01:47:48-08:00
logcc3e1db22a0a450396bf34650eebb7b8c56129de
tree32be0cf97f4373d0ae18844ebf65f5b112de9b34
parentb253d1f8ca8f107933034ed76abc2cb20dbaa52b

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

8 files changed, 203 insertions(+), 236 deletions(-)

apps/web/src/components/ChatView.tsx+38-22
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28} from "react";28} from "react";
29import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";29import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
30import { useDebouncedValue } from "@tanstack/react-pacer";30import { useDebouncedValue } from "@tanstack/react-pacer";
31import { useNavigate, useSearch } from "@tanstack/react-router";
31import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";32import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
32import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";33import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
33import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";34import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
...@@ -35,6 +36,7 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";...@@ -35,6 +36,7 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
35import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";36import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3637
37import { isElectron } from "../env";38import { isElectron } from "../env";
39import { parseDiffRouteSearch } from "../diffRouteSearch";
38import { buildBootstrapInput } from "../historyBootstrap";40import { buildBootstrapInput } from "../historyBootstrap";
39import {41import {
40 type ComposerTriggerKind,42 type ComposerTriggerKind,
...@@ -374,6 +376,8 @@ interface ChatViewProps {...@@ -374,6 +376,8 @@ interface ChatViewProps {
374376
375export default function ChatView({ threadId }: ChatViewProps) {377export default function ChatView({ threadId }: ChatViewProps) {
376 const { state, dispatch } = useStore();378 const { state, dispatch } = useStore();
379 const navigate = useNavigate();
380 const rawSearch = useSearch({ strict: false });
377 const api = useNativeApi();381 const api = useNativeApi();
378 const { resolvedTheme } = useTheme();382 const { resolvedTheme } = useTheme();
379 const queryClient = useQueryClient();383 const queryClient = useQueryClient();
...@@ -411,6 +415,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -411,6 +415,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
411 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());415 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());
412416
413 const activeThread = state.threads.find((t) => t.id === threadId);417 const activeThread = state.threads.find((t) => t.id === threadId);
418 const diffSearch = useMemo(
419 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
420 [rawSearch],
421 );
422 const diffOpen = diffSearch.diff === "1";
414 const activeThreadId = activeThread?.id ?? null;423 const activeThreadId = activeThread?.id ?? null;
415 const activeSessionId = activeThread?.session?.sessionId ?? null;424 const activeSessionId = activeThread?.session?.sessionId ?? null;
416 const activeThreadRuntimeId =425 const activeThreadRuntimeId =
...@@ -1954,32 +1963,39 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1954,32 +1963,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
1954 setExpandedImage(image);1963 setExpandedImage(image);
1955 }, []);1964 }, []);
1956 const onToggleDiff = useCallback(() => {1965 const onToggleDiff = useCallback(() => {
1957 if (state.diffOpen) {1966 void navigate({
1958 dispatch({ type: "CLOSE_DIFF" });1967 to: "/$threadId",
1959 return;1968 params: { threadId },
1960 }1969 search: (previous) => {
19611970 const {
1962 if (!activeThread) {1971 diff: _diff,
1963 dispatch({ type: "TOGGLE_DIFF" });1972 diffTurnId: _diffTurnId,
1964 return;1973 diffFilePath: _diffFilePath,
1965 }1974 ...rest
19661975 } = previous;
1967 dispatch({1976 return diffOpen ? rest : { ...rest, diff: "1" };
1968 type: "OPEN_DIFF",1977 },
1969 threadId: activeThread.id,
1970 });1978 });
1971 }, [activeThread, dispatch, state.diffOpen]);1979 }, [diffOpen, navigate, threadId]);
1972 const onOpenTurnDiff = useCallback(1980 const onOpenTurnDiff = useCallback(
1973 (turnId: string, filePath?: string) => {1981 (turnId: string, filePath?: string) => {
1974 if (!activeThread) return;1982 void navigate({
1975 dispatch({1983 to: "/$threadId",
1976 type: "OPEN_DIFF",1984 params: { threadId },
1977 threadId: activeThread.id,1985 search: (previous) => {
1978 turnId,1986 const {
1979 ...(filePath ? { filePath } : {}),1987 diff: _diff,
1988 diffTurnId: _diffTurnId,
1989 diffFilePath: _diffFilePath,
1990 ...rest
1991 } = previous;
1992 return filePath
1993 ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath }
1994 : { ...rest, diff: "1", diffTurnId: turnId };
1995 },
1980 });1996 });
1981 },1997 },
1982 [activeThread, dispatch],1998 [navigate, threadId],
1983 );1999 );
1984 const onRevertUserMessage = useCallback(2000 const onRevertUserMessage = useCallback(
1985 (messageId: string) => {2001 (messageId: string) => {
...@@ -2027,7 +2043,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2027,7 +2043,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2027 keybindings={keybindings}2043 keybindings={keybindings}
2028 api={api}2044 api={api}
2029 gitCwd={gitCwd}2045 gitCwd={gitCwd}
2030 diffOpen={state.diffOpen}2046 diffOpen={diffOpen}
2031 onRunProjectScript={(script) => {2047 onRunProjectScript={(script) => {
2032 void runProjectScript(script);2048 void runProjectScript(script);
2033 }}2049 }}
apps/web/src/components/DiffPanel.tsx+36-32
...@@ -6,11 +6,12 @@ import {...@@ -6,11 +6,12 @@ import {
6 WorkerPoolContextProvider,6 WorkerPoolContextProvider,
7} from "@pierre/diffs/react";7} from "@pierre/diffs/react";
8import { useQuery } from "@tanstack/react-query";8import { useQuery } from "@tanstack/react-query";
9import { useParams } from "@tanstack/react-router";9import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
10import { Columns2Icon, Rows3Icon } from "lucide-react";10import { Columns2Icon, Rows3Icon } from "lucide-react";
11import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";11import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
12import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";12import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
13import { cn } from "~/lib/utils";13import { cn } from "~/lib/utils";
14import { parseDiffRouteSearch } from "../diffRouteSearch";
14import { isElectron } from "../env";15import { isElectron } from "../env";
15import { useNativeApi } from "../hooks/useNativeApi";16import { useNativeApi } from "../hooks/useNativeApi";
16import { useTheme } from "../hooks/useTheme";17import { useTheme } from "../hooks/useTheme";
...@@ -101,13 +102,19 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {...@@ -101,13 +102,19 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
101102
102export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {103export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
103 const api = useNativeApi();104 const api = useNativeApi();
105 const navigate = useNavigate();
104 const { resolvedTheme } = useTheme();106 const { resolvedTheme } = useTheme();
105 const { state, dispatch } = useStore();107 const { state } = useStore();
106 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");108 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
107 const patchViewportRef = useRef<HTMLDivElement>(null);109 const patchViewportRef = useRef<HTMLDivElement>(null);
108 const params = useParams({ strict: false });110 const params = useParams({ strict: false });
111 const rawSearch = useSearch({ strict: false });
109 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;112 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
110 const activeThreadId = routeThreadId ?? state.diffThreadId;113 const diffSearch = useMemo(
114 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
115 [rawSearch],
116 );
117 const activeThreadId = routeThreadId;
111 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);118 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);
112 const activeThreadRuntimeId =119 const activeThreadRuntimeId =
113 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;120 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
...@@ -115,10 +122,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -115,10 +122,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
115 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =122 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
116 useTurnDiffSummaries(activeThread);123 useTurnDiffSummaries(activeThread);
117124
118 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);125 const selectedTurnId = diffSearch.diffTurnId ?? null;
119 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;126 const selectedFilePath = selectedTurnId !== null ? diffSearch.diffFilePath ?? null : null;
120 const selectedFilePath =
121 canApplyStoredTarget && selectedTurnId !== null ? state.diffFilePath : null;
122 const selectedTurn =127 const selectedTurn =
123 selectedTurnId === null128 selectedTurnId === null
124 ? undefined129 ? undefined
...@@ -216,30 +221,36 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -216,30 +221,36 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
216 target?.scrollIntoView({ block: "nearest" });221 target?.scrollIntoView({ block: "nearest" });
217 }, [selectedFilePath, renderableFiles]);222 }, [selectedFilePath, renderableFiles]);
218223
219 useEffect(() => {
220 if (!state.diffOpen) return;
221 if (!routeThreadId) return;
222 if (state.diffThreadId === routeThreadId) return;
223
224 dispatch({
225 type: "SET_DIFF_TARGET",
226 threadId: routeThreadId,
227 });
228 }, [dispatch, routeThreadId, state.diffOpen, state.diffThreadId]);
229
230 const selectTurn = (turnId: string) => {224 const selectTurn = (turnId: string) => {
231 if (!activeThread) return;225 if (!activeThread) return;
232 dispatch({226 void navigate({
233 type: "SET_DIFF_TARGET",227 to: "/$threadId",
234 threadId: activeThread.id,228 params: { threadId: activeThread.id },
235 turnId,229 search: (previous) => {
230 const {
231 diff: _diff,
232 diffTurnId: _diffTurnId,
233 diffFilePath: _diffFilePath,
234 ...rest
235 } = previous;
236 return { ...rest, diff: "1", diffTurnId: turnId };
237 },
236 });238 });
237 };239 };
238 const selectWholeConversation = () => {240 const selectWholeConversation = () => {
239 if (!activeThread) return;241 if (!activeThread) return;
240 dispatch({242 void navigate({
241 type: "SET_DIFF_TARGET",243 to: "/$threadId",
242 threadId: activeThread.id,244 params: { threadId: activeThread.id },
245 search: (previous) => {
246 const {
247 diff: _diff,
248 diffTurnId: _diffTurnId,
249 diffFilePath: _diffFilePath,
250 ...rest
251 } = previous;
252 return { ...rest, diff: "1" };
253 },
243 });254 });
244 };255 };
245256
...@@ -329,13 +340,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -329,13 +340,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
329 ) : (340 ) : (
330 <>341 <>
331 <div ref={patchViewportRef} className="min-h-0 min-w-0 flex-1 overflow-hidden">342 <div ref={patchViewportRef} className="min-h-0 min-w-0 flex-1 overflow-hidden">
332 {!canApplyStoredTarget && state.diffThreadId && (
333 <div className="px-3 pt-2">
334 <p className="mb-2 text-[11px] text-muted-foreground/65">
335 Showing diffs for the active thread.
336 </p>
337 </div>
338 )}
339 {checkpointDiffError && !renderablePatch && (343 {checkpointDiffError && !renderablePatch && (
340 <div className="px-3">344 <div className="px-3">
341 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>345 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>
apps/web/src/diffRouteSearch.test.ts created+53
...@@ -0,0 +1,53 @@
1import { describe, expect, it } from "vitest";
2
3import { parseDiffRouteSearch } from "./diffRouteSearch";
4
5describe("parseDiffRouteSearch", () => {
6 it("parses valid diff search values", () => {
7 const parsed = parseDiffRouteSearch({
8 diff: "1",
9 diffTurnId: "turn-1",
10 diffFilePath: "src/app.ts",
11 });
12
13 expect(parsed).toEqual({
14 diff: "1",
15 diffTurnId: "turn-1",
16 diffFilePath: "src/app.ts",
17 });
18 });
19
20 it("drops turn and file values when diff is closed", () => {
21 const parsed = parseDiffRouteSearch({
22 diff: "0",
23 diffTurnId: "turn-1",
24 diffFilePath: "src/app.ts",
25 });
26
27 expect(parsed).toEqual({});
28 });
29
30 it("drops file value when turn is not selected", () => {
31 const parsed = parseDiffRouteSearch({
32 diff: "1",
33 diffFilePath: "src/app.ts",
34 });
35
36 expect(parsed).toEqual({
37 diff: "1",
38 });
39 });
40
41 it("normalizes whitespace-only values", () => {
42 const parsed = parseDiffRouteSearch({
43 diff: "1",
44 diffTurnId: " ",
45 diffFilePath: " ",
46 });
47
48 expect(parsed).toEqual({
49 diff: "1",
50 });
51 });
52});
53
apps/web/src/diffRouteSearch.ts created+26
...@@ -0,0 +1,26 @@
1export interface DiffRouteSearch {
2 diff?: "1";
3 diffTurnId?: string;
4 diffFilePath?: string;
5}
6
7function normalizeSearchString(value: unknown): string | undefined {
8 if (typeof value !== "string") {
9 return undefined;
10 }
11 const normalized = value.trim();
12 return normalized.length > 0 ? normalized : undefined;
13}
14
15export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch {
16 const diff = search.diff === "1" ? "1" : undefined;
17 const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined;
18 const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined;
19
20 return {
21 ...(diff ? { diff } : {}),
22 ...(diffTurnId ? { diffTurnId } : {}),
23 ...(diffFilePath ? { diffFilePath } : {}),
24 };
25}
26
apps/web/src/routes/_chat.$threadId.tsx+2
...@@ -2,6 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";...@@ -2,6 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";2import { useEffect } from "react";
33
4import ChatView from "../components/ChatView";4import ChatView from "../components/ChatView";
5import { parseDiffRouteSearch } from "../diffRouteSearch";
5import { useStore } from "../store";6import { useStore } from "../store";
67
7function ChatThreadRouteView() {8function ChatThreadRouteView() {
...@@ -34,5 +35,6 @@ function ChatThreadRouteView() {...@@ -34,5 +35,6 @@ function ChatThreadRouteView() {
34}35}
3536
36export const Route = createFileRoute("/_chat/$threadId")({37export const Route = createFileRoute("/_chat/$threadId")({
38 validateSearch: (search) => parseDiffRouteSearch(search),
37 component: ChatThreadRouteView,39 component: ChatThreadRouteView,
38});40});
apps/web/src/routes/_chat.tsx+46-12
...@@ -1,9 +1,15 @@...@@ -1,9 +1,15 @@
1import { Outlet, createFileRoute } from "@tanstack/react-router";1import {
2import { Activity, Suspense, lazy, type ReactNode } from "react";2 Outlet,
3 createFileRoute,
4 useNavigate,
5 useParams,
6 useSearch,
7} from "@tanstack/react-router";
8import { Activity, Suspense, lazy, type ReactNode, useCallback, useMemo } from "react";
39
4import Sidebar from "../components/Sidebar";10import Sidebar from "../components/Sidebar";
11import { parseDiffRouteSearch } from "../diffRouteSearch";
5import { useMediaQuery } from "../hooks/useMediaQuery";12import { useMediaQuery } from "../hooks/useMediaQuery";
6import { useStore } from "../store";
7import { Sheet, SheetPopup } from "../components/ui/sheet";13import { Sheet, SheetPopup } from "../components/ui/sheet";
814
9const DiffPanel = lazy(() => import("../components/DiffPanel"));15const DiffPanel = lazy(() => import("../components/DiffPanel"));
...@@ -14,15 +20,19 @@ const DiffWorkerPoolProvider = lazy(() =>...@@ -14,15 +20,19 @@ const DiffWorkerPoolProvider = lazy(() =>
14);20);
15const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";21const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
1622
17const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {23const DiffPanelWrapper = (props: {
18 const { state, dispatch } = useStore();24 children: ReactNode;
25 sheet: boolean;
26 diffOpen: boolean;
27 onCloseDiff: () => void;
28}) => {
19 if (props.sheet) {29 if (props.sheet) {
20 return (30 return (
21 <Sheet31 <Sheet
22 open={state.diffOpen}32 open={props.diffOpen}
23 onOpenChange={(open) => {33 onOpenChange={(open) => {
24 if (!open) {34 if (!open) {
25 dispatch({ type: "CLOSE_DIFF" });35 props.onCloseDiff();
26 }36 }
27 }}37 }}
28 >38 >
...@@ -39,18 +49,42 @@ const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {...@@ -39,18 +49,42 @@ const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {
39 }49 }
4050
41 return (51 return (
42 <aside className={state.diffOpen ? undefined : "hidden"} aria-hidden={!state.diffOpen}>52 <aside className={props.diffOpen ? undefined : "hidden"} aria-hidden={!props.diffOpen}>
43 {props.children}53 {props.children}
44 </aside>54 </aside>
45 );55 );
46};56};
4757
48function ChatRouteLayout() {58function ChatRouteLayout() {
49 const { state } = useStore();59 const navigate = useNavigate();
60 const params = useParams({ strict: false });
61 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
62 const rawSearch = useSearch({ strict: false });
63 const diffSearch = useMemo(
64 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
65 [rawSearch],
66 );
67 const diffOpen = routeThreadId !== null && diffSearch.diff === "1";
50 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);68 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
69 const closeDiff = useCallback(() => {
70 if (!routeThreadId) return;
71 void navigate({
72 to: "/$threadId",
73 params: { threadId: routeThreadId },
74 search: (previous) => {
75 const {
76 diff: _diff,
77 diffTurnId: _diffTurnId,
78 diffFilePath: _diffFilePath,
79 ...rest
80 } = previous;
81 return rest;
82 },
83 });
84 }, [navigate, routeThreadId]);
5185
52 const diffLoadingFallback =86 const diffLoadingFallback =
53 !state.diffOpen || shouldUseDiffSheet ? (87 !diffOpen || shouldUseDiffSheet ? (
54 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">88 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
55 Loading diff viewer...89 Loading diff viewer...
56 </div>90 </div>
...@@ -64,8 +98,8 @@ function ChatRouteLayout() {...@@ -64,8 +98,8 @@ function ChatRouteLayout() {
64 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">98 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
65 <Sidebar />99 <Sidebar />
66 <Outlet />100 <Outlet />
67 <Activity mode={state.diffOpen ? "visible" : "hidden"}>101 <Activity mode={diffOpen ? "visible" : "hidden"}>
68 <DiffPanelWrapper sheet={shouldUseDiffSheet}>102 <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}>
69 <Suspense fallback={diffLoadingFallback}>103 <Suspense fallback={diffLoadingFallback}>
70 <DiffWorkerPoolProvider>104 <DiffWorkerPoolProvider>
71 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />105 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />
apps/web/src/store.test.ts-81
...@@ -81,10 +81,6 @@ function makeState(thread: Thread): AppState {...@@ -81,10 +81,6 @@ function makeState(thread: Thread): AppState {
81 threads: [thread],81 threads: [thread],
82 threadsHydrated: true,82 threadsHydrated: true,
83 runtimeMode: "full-access",83 runtimeMode: "full-access",
84 diffOpen: false,
85 diffThreadId: null,
86 diffTurnId: null,
87 diffFilePath: null,
88 };84 };
89}85}
9086
...@@ -149,21 +145,6 @@ describe("store reducer thread continuity", () => {...@@ -149,21 +145,6 @@ describe("store reducer thread continuity", () => {
149 expect(next.threads[0]?.terminalOpen).toBe(true);145 expect(next.threads[0]?.terminalOpen).toBe(true);
150 });146 });
151147
152 it("opens diff panel with an explicit turn/file target", () => {
153 const state = makeState(makeThread());
154 const next = reducer(state, {
155 type: "OPEN_DIFF",
156 threadId: "thread-local-1",
157 turnId: "turn-1",
158 filePath: "src/app.ts",
159 });
160
161 expect(next.diffOpen).toBe(true);
162 expect(next.diffThreadId).toBe("thread-local-1");
163 expect(next.diffTurnId).toBe("turn-1");
164 expect(next.diffFilePath).toBe("src/app.ts");
165 });
166
167 it("sets terminal open state per thread", () => {148 it("sets terminal open state per thread", () => {
168 const state = makeState(makeThread({ terminalOpen: true }));149 const state = makeState(makeThread({ terminalOpen: true }));
169 const next = reducer(state, {150 const next = reducer(state, {
...@@ -618,10 +599,6 @@ describe("store reducer thread continuity", () => {...@@ -618,10 +599,6 @@ describe("store reducer thread continuity", () => {
618 ],599 ],
619 threadsHydrated: true,600 threadsHydrated: true,
620 runtimeMode: "full-access",601 runtimeMode: "full-access",
621 diffOpen: true,
622 diffThreadId: "thread-b",
623 diffTurnId: "turn-b",
624 diffFilePath: "src/removed.ts",
625 };602 };
626603
627 const next = reducer(state, {604 const next = reducer(state, {
...@@ -645,10 +622,6 @@ describe("store reducer thread continuity", () => {...@@ -645,10 +622,6 @@ describe("store reducer thread continuity", () => {
645 expect(next.threads).toHaveLength(1);622 expect(next.threads).toHaveLength(1);
646 expect(next.threads[0]?.id).toBe("thread-a");623 expect(next.threads[0]?.id).toBe("thread-a");
647 expect(next.threads[0]?.projectId).toBe("project-new-a");624 expect(next.threads[0]?.projectId).toBe("project-new-a");
648 expect(next.diffOpen).toBe(false);
649 expect(next.diffThreadId).toBeNull();
650 expect(next.diffTurnId).toBeNull();
651 expect(next.diffFilePath).toBeNull();
652 });625 });
653626
654 it("treats empty scripts from sync as authoritative", () => {627 it("treats empty scripts from sync as authoritative", () => {
...@@ -674,10 +647,6 @@ describe("store reducer thread continuity", () => {...@@ -674,10 +647,6 @@ describe("store reducer thread continuity", () => {
674 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],647 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],
675 threadsHydrated: true,648 threadsHydrated: true,
676 runtimeMode: "full-access",649 runtimeMode: "full-access",
677 diffOpen: false,
678 diffThreadId: null,
679 diffTurnId: null,
680 diffFilePath: null,
681 };650 };
682651
683 const next = reducer(state, {652 const next = reducer(state, {
...@@ -766,10 +735,6 @@ describe("store reducer thread continuity", () => {...@@ -766,10 +735,6 @@ describe("store reducer thread continuity", () => {
766 ],735 ],
767 threadsHydrated: true,736 threadsHydrated: true,
768 runtimeMode: "full-access",737 runtimeMode: "full-access",
769 diffOpen: true,
770 diffThreadId: "thread-a",
771 diffTurnId: "turn-a",
772 diffFilePath: "src/a.ts",
773 };738 };
774739
775 const next = reducer(state, {740 const next = reducer(state, {
...@@ -781,10 +746,6 @@ describe("store reducer thread continuity", () => {...@@ -781,10 +746,6 @@ describe("store reducer thread continuity", () => {
781 expect(next.projects[0]?.id).toBe("project-2");746 expect(next.projects[0]?.id).toBe("project-2");
782 expect(next.threads).toHaveLength(1);747 expect(next.threads).toHaveLength(1);
783 expect(next.threads[0]?.id).toBe("thread-b");748 expect(next.threads[0]?.id).toBe("thread-b");
784 expect(next.diffOpen).toBe(false);
785 expect(next.diffThreadId).toBeNull();
786 expect(next.diffTurnId).toBeNull();
787 expect(next.diffFilePath).toBeNull();
788 });749 });
789750
790 it("marks completion as seen immediately for the active thread", () => {751 it("marks completion as seen immediately for the active thread", () => {
...@@ -833,48 +794,6 @@ describe("store reducer thread continuity", () => {...@@ -833,48 +794,6 @@ describe("store reducer thread continuity", () => {
833 expect(next.threads[0]?.lastVisitedAt).toBe(nextVisitedAt);794 expect(next.threads[0]?.lastVisitedAt).toBe(nextVisitedAt);
834 });795 });
835796
836 it("retargets open diff state to the visited thread", () => {
837 const state: AppState = {
838 ...makeState(
839 makeThread({
840 id: "thread-local-1",
841 latestTurnCompletedAt: "2026-02-08T10:00:10.000Z",
842 lastVisitedAt: "2026-02-08T10:00:00.000Z",
843 }),
844 ),
845 threads: [
846 makeThread({
847 id: "thread-local-1",
848 latestTurnCompletedAt: "2026-02-08T10:00:10.000Z",
849 lastVisitedAt: "2026-02-08T10:00:00.000Z",
850 }),
851 makeThread({
852 id: "thread-local-2",
853 session: makeSession({ sessionId: "sess-2" }),
854 latestTurnCompletedAt: "2026-02-08T09:00:10.000Z",
855 lastVisitedAt: "2026-02-08T09:00:00.000Z",
856 }),
857 ],
858 diffOpen: true,
859 diffThreadId: "thread-local-1",
860 diffTurnId: "turn-1",
861 diffFilePath: "src/old.ts",
862 };
863
864 const nextVisitedAt = "2026-02-08T10:00:20.000Z";
865 const next = reducer(state, {
866 type: "MARK_THREAD_VISITED",
867 threadId: "thread-local-2",
868 visitedAt: nextVisitedAt,
869 });
870
871 expect(next.threads[1]?.lastVisitedAt).toBe(nextVisitedAt);
872 expect(next.diffOpen).toBe(true);
873 expect(next.diffThreadId).toBe("thread-local-2");
874 expect(next.diffTurnId).toBeNull();
875 expect(next.diffFilePath).toBeNull();
876 });
877
878 it("reverts thread state to a checkpoint snapshot", () => {797 it("reverts thread state to a checkpoint snapshot", () => {
879 const state = makeState(798 const state = makeState(
880 makeThread({799 makeThread({
apps/web/src/store.ts+2-89
...@@ -48,20 +48,6 @@ type Action =...@@ -48,20 +48,6 @@ type Action =
48 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }48 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
49 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }49 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
50 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }50 | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string }
51 | { type: "TOGGLE_DIFF" }
52 | {
53 type: "OPEN_DIFF";
54 threadId: string;
55 turnId?: string;
56 filePath?: string;
57 }
58 | {
59 type: "SET_DIFF_TARGET";
60 threadId: string;
61 turnId?: string;
62 filePath?: string;
63 }
64 | { type: "CLOSE_DIFF" }
65 | {51 | {
66 type: "APPLY_EVENT";52 type: "APPLY_EVENT";
67 event: ProviderEvent;53 event: ProviderEvent;
...@@ -110,10 +96,6 @@ export interface AppState {...@@ -110,10 +96,6 @@ export interface AppState {
110 threads: Thread[];96 threads: Thread[];
111 threadsHydrated: boolean;97 threadsHydrated: boolean;
112 runtimeMode: RuntimeMode;98 runtimeMode: RuntimeMode;
113 diffOpen: boolean;
114 diffThreadId: string | null;
115 diffTurnId: string | null;
116 diffFilePath: string | null;
117}99}
118100
119const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";101const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
...@@ -133,10 +115,6 @@ const initialState: AppState = {...@@ -133,10 +115,6 @@ const initialState: AppState = {
133 threads: [],115 threads: [],
134 threadsHydrated: false,116 threadsHydrated: false,
135 runtimeMode: DEFAULT_RUNTIME_MODE,117 runtimeMode: DEFAULT_RUNTIME_MODE,
136 diffOpen: false,
137 diffThreadId: null,
138 diffTurnId: null,
139 diffFilePath: null,
140};118};
141119
142// ── Helpers ──────────────────────────────────────────────────────────120// ── Helpers ──────────────────────────────────────────────────────────
...@@ -160,10 +138,6 @@ function readPersistedState(): AppState {...@@ -160,10 +138,6 @@ function readPersistedState(): AppState {
160 ...hydrated,138 ...hydrated,
161 threads,139 threads,
162 threadsHydrated: threads.length > 0,140 threadsHydrated: threads.length > 0,
163 diffOpen: false,
164 diffThreadId: null,
165 diffTurnId: null,
166 diffFilePath: null,
167 };141 };
168 } catch {142 } catch {
169 return initialState;143 return initialState;
...@@ -191,32 +165,6 @@ function updateThread(...@@ -191,32 +165,6 @@ function updateThread(
191 return threads.map((t) => (t.id === threadId ? updater(t) : t));165 return threads.map((t) => (t.id === threadId ? updater(t) : t));
192}166}
193167
194function resetDiffTargetIfMissing(state: AppState, threads: Thread[]) {
195 if (!state.diffThreadId) {
196 return {
197 diffOpen: state.diffOpen,
198 diffThreadId: state.diffThreadId,
199 diffTurnId: state.diffTurnId,
200 diffFilePath: state.diffFilePath,
201 };
202 }
203 const hasThread = threads.some((thread) => thread.id === state.diffThreadId);
204 if (hasThread) {
205 return {
206 diffOpen: state.diffOpen,
207 diffThreadId: state.diffThreadId,
208 diffTurnId: state.diffTurnId,
209 diffFilePath: state.diffFilePath,
210 };
211 }
212 return {
213 diffOpen: false,
214 diffThreadId: null,
215 diffTurnId: null,
216 diffFilePath: null,
217 };
218}
219
220function mergeTurnDiffSummaries(168function mergeTurnDiffSummaries(
221 existing: Thread["turnDiffSummaries"],169 existing: Thread["turnDiffSummaries"],
222 next: Thread["turnDiffSummaries"],170 next: Thread["turnDiffSummaries"],
...@@ -615,13 +563,11 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -615,13 +563,11 @@ export function reducer(state: AppState, action: Action): AppState {
615 });563 });
616 })564 })
617 .filter((thread): thread is Thread => thread !== null);565 .filter((thread): thread is Thread => thread !== null);
618 const diffState = resetDiffTargetIfMissing(state, nextThreads);
619566
620 return {567 return {
621 ...state,568 ...state,
622 projects: nextProjects,569 projects: nextProjects,
623 threads: nextThreads,570 threads: nextThreads,
624 ...diffState,
625 };571 };
626 }572 }
627573
...@@ -640,13 +586,11 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -640,13 +586,11 @@ export function reducer(state: AppState, action: Action): AppState {
640 }586 }
641587
642 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);588 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
643 const diffState = resetDiffTargetIfMissing(state, threads);
644589
645 return {590 return {
646 ...state,591 ...state,
647 projects,592 projects,
648 threads,593 threads,
649 ...diffState,
650 };594 };
651 }595 }
652596
...@@ -830,29 +774,6 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -830,29 +774,6 @@ export function reducer(state: AppState, action: Action): AppState {
830 ),774 ),
831 };775 };
832776
833 case "TOGGLE_DIFF":
834 return { ...state, diffOpen: !state.diffOpen };
835
836 case "OPEN_DIFF":
837 return {
838 ...state,
839 diffOpen: true,
840 diffThreadId: action.threadId,
841 diffTurnId: action.turnId ?? null,
842 diffFilePath: action.filePath ?? null,
843 };
844
845 case "SET_DIFF_TARGET":
846 return {
847 ...state,
848 diffThreadId: action.threadId,
849 diffTurnId: action.turnId ?? null,
850 diffFilePath: action.filePath ?? null,
851 };
852
853 case "CLOSE_DIFF":
854 return { ...state, diffOpen: false };
855
856 case "APPLY_TERMINAL_EVENT":777 case "APPLY_TERMINAL_EVENT":
857 if (!state.threads.some((thread) => thread.id === action.event.threadId)) {778 if (!state.threads.some((thread) => thread.id === action.event.threadId)) {
858 return state;779 return state;
...@@ -1078,16 +999,12 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -1078,16 +999,12 @@ export function reducer(state: AppState, action: Action): AppState {
1078999
1079 case "MARK_THREAD_VISITED": {1000 case "MARK_THREAD_VISITED": {
1080 const visitedAt = action.visitedAt ?? new Date().toISOString();1001 const visitedAt = action.visitedAt ?? new Date().toISOString();
1081 const shouldRetargetOpenDiff = state.diffOpen && state.diffThreadId !== action.threadId;
1082 return {1002 return {
1083 ...state,1003 ...state,
1084 threads: updateThread(state.threads, action.threadId, (t) => ({1004 threads: updateThread(state.threads, action.threadId, (t) => ({
1085 ...t,1005 ...t,
1086 lastVisitedAt: visitedAt,1006 lastVisitedAt: visitedAt,
1087 })),1007 })),
1088 diffThreadId: shouldRetargetOpenDiff ? action.threadId : state.diffThreadId,
1089 diffTurnId: shouldRetargetOpenDiff ? null : state.diffTurnId,
1090 diffFilePath: shouldRetargetOpenDiff ? null : state.diffFilePath,
1091 };1008 };
1092 }1009 }
10931010
...@@ -1097,15 +1014,11 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -1097,15 +1014,11 @@ export function reducer(state: AppState, action: Action): AppState {
1097 runtimeMode: action.mode,1014 runtimeMode: action.mode,
1098 };1015 };
10991016
1100 case "DELETE_THREAD": {1017 case "DELETE_THREAD":
1101 const threads = state.threads.filter((t) => t.id !== action.threadId);
1102 const diffState = resetDiffTargetIfMissing(state, threads);
1103 return {1018 return {
1104 ...state,1019 ...state,
1105 threads,1020 threads: state.threads.filter((t) => t.id !== action.threadId),
1106 ...diffState,
1107 };1021 };
1108 }
11091022
1110 default:1023 default:
1111 return state;1024 return state;