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 {
2828} from "react";
2929import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
3030import { useDebouncedValue } from "@tanstack/react-pacer";
31import { useNavigate, useSearch } from "@tanstack/react-router";
3132import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
3233import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
3334import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
......@@ -35,6 +36,7 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3536import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3637
3738import { isElectron } from "../env";
39import { parseDiffRouteSearch } from "../diffRouteSearch";
3840import { buildBootstrapInput } from "../historyBootstrap";
3941import {
4042 type ComposerTriggerKind,
......@@ -374,6 +376,8 @@ interface ChatViewProps {
374376
375377export default function ChatView({ threadId }: ChatViewProps) {
376378 const { state, dispatch } = useStore();
379 const navigate = useNavigate();
380 const rawSearch = useSearch({ strict: false });
377381 const api = useNativeApi();
378382 const { resolvedTheme } = useTheme();
379383 const queryClient = useQueryClient();
......@@ -411,6 +415,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
411415 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());
412416
413417 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";
414423 const activeThreadId = activeThread?.id ?? null;
415424 const activeSessionId = activeThread?.session?.sessionId ?? null;
416425 const activeThreadRuntimeId =
......@@ -1954,32 +1963,39 @@ export default function ChatView({ threadId }: ChatViewProps) {
19541963 setExpandedImage(image);
19551964 }, []);
19561965 const onToggleDiff = useCallback(() => {
1957 if (state.diffOpen) {
1958 dispatch({ type: "CLOSE_DIFF" });
1959 return;
1960 }
1961
1962 if (!activeThread) {
1963 dispatch({ type: "TOGGLE_DIFF" });
1964 return;
1965 }
1966
1967 dispatch({
1968 type: "OPEN_DIFF",
1969 threadId: activeThread.id,
1966 void navigate({
1967 to: "/$threadId",
1968 params: { threadId },
1969 search: (previous) => {
1970 const {
1971 diff: _diff,
1972 diffTurnId: _diffTurnId,
1973 diffFilePath: _diffFilePath,
1974 ...rest
1975 } = previous;
1976 return diffOpen ? rest : { ...rest, diff: "1" };
1977 },
19701978 });
1971 }, [activeThread, dispatch, state.diffOpen]);
1979 }, [diffOpen, navigate, threadId]);
19721980 const onOpenTurnDiff = useCallback(
19731981 (turnId: string, filePath?: string) => {
1974 if (!activeThread) return;
1975 dispatch({
1976 type: "OPEN_DIFF",
1977 threadId: activeThread.id,
1978 turnId,
1979 ...(filePath ? { filePath } : {}),
1982 void navigate({
1983 to: "/$threadId",
1984 params: { threadId },
1985 search: (previous) => {
1986 const {
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 },
19801996 });
19811997 },
1982 [activeThread, dispatch],
1998 [navigate, threadId],
19831999 );
19842000 const onRevertUserMessage = useCallback(
19852001 (messageId: string) => {
......@@ -2027,7 +2043,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20272043 keybindings={keybindings}
20282044 api={api}
20292045 gitCwd={gitCwd}
2030 diffOpen={state.diffOpen}
2046 diffOpen={diffOpen}
20312047 onRunProjectScript={(script) => {
20322048 void runProjectScript(script);
20332049 }}
apps/web/src/components/DiffPanel.tsx+36-32
......@@ -6,11 +6,12 @@ import {
66 WorkerPoolContextProvider,
77} from "@pierre/diffs/react";
88import { useQuery } from "@tanstack/react-query";
9import { useParams } from "@tanstack/react-router";
9import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
1010import { Columns2Icon, Rows3Icon } from "lucide-react";
1111import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
1212import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
1313import { cn } from "~/lib/utils";
14import { parseDiffRouteSearch } from "../diffRouteSearch";
1415import { isElectron } from "../env";
1516import { useNativeApi } from "../hooks/useNativeApi";
1617import { useTheme } from "../hooks/useTheme";
......@@ -101,13 +102,19 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
101102
102103export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
103104 const api = useNativeApi();
105 const navigate = useNavigate();
104106 const { resolvedTheme } = useTheme();
105 const { state, dispatch } = useStore();
107 const { state } = useStore();
106108 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
107109 const patchViewportRef = useRef<HTMLDivElement>(null);
108110 const params = useParams({ strict: false });
111 const rawSearch = useSearch({ strict: false });
109112 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;
111118 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);
112119 const activeThreadRuntimeId =
113120 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
......@@ -115,10 +122,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
115122 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
116123 useTurnDiffSummaries(activeThread);
117124
118 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);
119 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;
120 const selectedFilePath =
121 canApplyStoredTarget && selectedTurnId !== null ? state.diffFilePath : null;
125 const selectedTurnId = diffSearch.diffTurnId ?? null;
126 const selectedFilePath = selectedTurnId !== null ? diffSearch.diffFilePath ?? null : null;
122127 const selectedTurn =
123128 selectedTurnId === null
124129 ? undefined
......@@ -216,30 +221,36 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
216221 target?.scrollIntoView({ block: "nearest" });
217222 }, [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
230224 const selectTurn = (turnId: string) => {
231225 if (!activeThread) return;
232 dispatch({
233 type: "SET_DIFF_TARGET",
234 threadId: activeThread.id,
235 turnId,
226 void navigate({
227 to: "/$threadId",
228 params: { threadId: activeThread.id },
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 },
236238 });
237239 };
238240 const selectWholeConversation = () => {
239241 if (!activeThread) return;
240 dispatch({
241 type: "SET_DIFF_TARGET",
242 threadId: activeThread.id,
242 void navigate({
243 to: "/$threadId",
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 },
243254 });
244255 };
245256
......@@ -329,13 +340,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
329340 ) : (
330341 <>
331342 <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 )}
339343 {checkpointDiffError && !renderablePatch && (
340344 <div className="px-3">
341345 <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";
22import { useEffect } from "react";
33
44import ChatView from "../components/ChatView";
5import { parseDiffRouteSearch } from "../diffRouteSearch";
56import { useStore } from "../store";
67
78function ChatThreadRouteView() {
......@@ -34,5 +35,6 @@ function ChatThreadRouteView() {
3435}
3536
3637export const Route = createFileRoute("/_chat/$threadId")({
38 validateSearch: (search) => parseDiffRouteSearch(search),
3739 component: ChatThreadRouteView,
3840});
apps/web/src/routes/_chat.tsx+46-12
......@@ -1,9 +1,15 @@
1import { Outlet, createFileRoute } from "@tanstack/react-router";
2import { Activity, Suspense, lazy, type ReactNode } from "react";
1import {
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
410import Sidebar from "../components/Sidebar";
11import { parseDiffRouteSearch } from "../diffRouteSearch";
512import { useMediaQuery } from "../hooks/useMediaQuery";
6import { useStore } from "../store";
713import { Sheet, SheetPopup } from "../components/ui/sheet";
814
915const DiffPanel = lazy(() => import("../components/DiffPanel"));
......@@ -14,15 +20,19 @@ const DiffWorkerPoolProvider = lazy(() =>
1420);
1521const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
1622
17const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {
18 const { state, dispatch } = useStore();
23const DiffPanelWrapper = (props: {
24 children: ReactNode;
25 sheet: boolean;
26 diffOpen: boolean;
27 onCloseDiff: () => void;
28}) => {
1929 if (props.sheet) {
2030 return (
2131 <Sheet
22 open={state.diffOpen}
32 open={props.diffOpen}
2333 onOpenChange={(open) => {
2434 if (!open) {
25 dispatch({ type: "CLOSE_DIFF" });
35 props.onCloseDiff();
2636 }
2737 }}
2838 >
......@@ -39,18 +49,42 @@ const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {
3949 }
4050
4151 return (
42 <aside className={state.diffOpen ? undefined : "hidden"} aria-hidden={!state.diffOpen}>
52 <aside className={props.diffOpen ? undefined : "hidden"} aria-hidden={!props.diffOpen}>
4353 {props.children}
4454 </aside>
4555 );
4656};
4757
4858function 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";
5068 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
5286 const diffLoadingFallback =
53 !state.diffOpen || shouldUseDiffSheet ? (
87 !diffOpen || shouldUseDiffSheet ? (
5488 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
5589 Loading diff viewer...
5690 </div>
......@@ -64,8 +98,8 @@ function ChatRouteLayout() {
6498 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
6599 <Sidebar />
66100 <Outlet />
67 <Activity mode={state.diffOpen ? "visible" : "hidden"}>
68 <DiffPanelWrapper sheet={shouldUseDiffSheet}>
101 <Activity mode={diffOpen ? "visible" : "hidden"}>
102 <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}>
69103 <Suspense fallback={diffLoadingFallback}>
70104 <DiffWorkerPoolProvider>
71105 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />
apps/web/src/store.test.ts-81
......@@ -81,10 +81,6 @@ function makeState(thread: Thread): AppState {
8181 threads: [thread],
8282 threadsHydrated: true,
8383 runtimeMode: "full-access",
84 diffOpen: false,
85 diffThreadId: null,
86 diffTurnId: null,
87 diffFilePath: null,
8884 };
8985}
9086
......@@ -149,21 +145,6 @@ describe("store reducer thread continuity", () => {
149145 expect(next.threads[0]?.terminalOpen).toBe(true);
150146 });
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
167148 it("sets terminal open state per thread", () => {
168149 const state = makeState(makeThread({ terminalOpen: true }));
169150 const next = reducer(state, {
......@@ -618,10 +599,6 @@ describe("store reducer thread continuity", () => {
618599 ],
619600 threadsHydrated: true,
620601 runtimeMode: "full-access",
621 diffOpen: true,
622 diffThreadId: "thread-b",
623 diffTurnId: "turn-b",
624 diffFilePath: "src/removed.ts",
625602 };
626603
627604 const next = reducer(state, {
......@@ -645,10 +622,6 @@ describe("store reducer thread continuity", () => {
645622 expect(next.threads).toHaveLength(1);
646623 expect(next.threads[0]?.id).toBe("thread-a");
647624 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();
652625 });
653626
654627 it("treats empty scripts from sync as authoritative", () => {
......@@ -674,10 +647,6 @@ describe("store reducer thread continuity", () => {
674647 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],
675648 threadsHydrated: true,
676649 runtimeMode: "full-access",
677 diffOpen: false,
678 diffThreadId: null,
679 diffTurnId: null,
680 diffFilePath: null,
681650 };
682651
683652 const next = reducer(state, {
......@@ -766,10 +735,6 @@ describe("store reducer thread continuity", () => {
766735 ],
767736 threadsHydrated: true,
768737 runtimeMode: "full-access",
769 diffOpen: true,
770 diffThreadId: "thread-a",
771 diffTurnId: "turn-a",
772 diffFilePath: "src/a.ts",
773738 };
774739
775740 const next = reducer(state, {
......@@ -781,10 +746,6 @@ describe("store reducer thread continuity", () => {
781746 expect(next.projects[0]?.id).toBe("project-2");
782747 expect(next.threads).toHaveLength(1);
783748 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();
788749 });
789750
790751 it("marks completion as seen immediately for the active thread", () => {
......@@ -833,48 +794,6 @@ describe("store reducer thread continuity", () => {
833794 expect(next.threads[0]?.lastVisitedAt).toBe(nextVisitedAt);
834795 });
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
878797 it("reverts thread state to a checkpoint snapshot", () => {
879798 const state = makeState(
880799 makeThread({
apps/web/src/store.ts+2-89
......@@ -48,20 +48,6 @@ type Action =
4848 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
4949 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
5050 | { 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" }
6551 | {
6652 type: "APPLY_EVENT";
6753 event: ProviderEvent;
......@@ -110,10 +96,6 @@ export interface AppState {
11096 threads: Thread[];
11197 threadsHydrated: boolean;
11298 runtimeMode: RuntimeMode;
113 diffOpen: boolean;
114 diffThreadId: string | null;
115 diffTurnId: string | null;
116 diffFilePath: string | null;
11799}
118100
119101const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
......@@ -133,10 +115,6 @@ const initialState: AppState = {
133115 threads: [],
134116 threadsHydrated: false,
135117 runtimeMode: DEFAULT_RUNTIME_MODE,
136 diffOpen: false,
137 diffThreadId: null,
138 diffTurnId: null,
139 diffFilePath: null,
140118};
141119
142120// ── Helpers ──────────────────────────────────────────────────────────
......@@ -160,10 +138,6 @@ function readPersistedState(): AppState {
160138 ...hydrated,
161139 threads,
162140 threadsHydrated: threads.length > 0,
163 diffOpen: false,
164 diffThreadId: null,
165 diffTurnId: null,
166 diffFilePath: null,
167141 };
168142 } catch {
169143 return initialState;
......@@ -191,32 +165,6 @@ function updateThread(
191165 return threads.map((t) => (t.id === threadId ? updater(t) : t));
192166}
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
220168function mergeTurnDiffSummaries(
221169 existing: Thread["turnDiffSummaries"],
222170 next: Thread["turnDiffSummaries"],
......@@ -615,13 +563,11 @@ export function reducer(state: AppState, action: Action): AppState {
615563 });
616564 })
617565 .filter((thread): thread is Thread => thread !== null);
618 const diffState = resetDiffTargetIfMissing(state, nextThreads);
619566
620567 return {
621568 ...state,
622569 projects: nextProjects,
623570 threads: nextThreads,
624 ...diffState,
625571 };
626572 }
627573
......@@ -640,13 +586,11 @@ export function reducer(state: AppState, action: Action): AppState {
640586 }
641587
642588 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
643 const diffState = resetDiffTargetIfMissing(state, threads);
644589
645590 return {
646591 ...state,
647592 projects,
648593 threads,
649 ...diffState,
650594 };
651595 }
652596
......@@ -830,29 +774,6 @@ export function reducer(state: AppState, action: Action): AppState {
830774 ),
831775 };
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
856777 case "APPLY_TERMINAL_EVENT":
857778 if (!state.threads.some((thread) => thread.id === action.event.threadId)) {
858779 return state;
......@@ -1078,16 +999,12 @@ export function reducer(state: AppState, action: Action): AppState {
1078999
10791000 case "MARK_THREAD_VISITED": {
10801001 const visitedAt = action.visitedAt ?? new Date().toISOString();
1081 const shouldRetargetOpenDiff = state.diffOpen && state.diffThreadId !== action.threadId;
10821002 return {
10831003 ...state,
10841004 threads: updateThread(state.threads, action.threadId, (t) => ({
10851005 ...t,
10861006 lastVisitedAt: visitedAt,
10871007 })),
1088 diffThreadId: shouldRetargetOpenDiff ? action.threadId : state.diffThreadId,
1089 diffTurnId: shouldRetargetOpenDiff ? null : state.diffTurnId,
1090 diffFilePath: shouldRetargetOpenDiff ? null : state.diffFilePath,
10911008 };
10921009 }
10931010
......@@ -1097,15 +1014,11 @@ export function reducer(state: AppState, action: Action): AppState {
10971014 runtimeMode: action.mode,
10981015 };
10991016
1100 case "DELETE_THREAD": {
1101 const threads = state.threads.filter((t) => t.id !== action.threadId);
1102 const diffState = resetDiffTargetIfMissing(state, threads);
1017 case "DELETE_THREAD":
11031018 return {
11041019 ...state,
1105 threads,
1106 ...diffState,
1020 threads: state.threads.filter((t) => t.id !== action.threadId),
11071021 };
1108 }
11091022
11101023 default:
11111024 return state;