authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 15:43:24-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-02-19 15:43:24-08:00
log70a13c298f50be8c620020708d8b1db6ae80557e
treeb1cf20836e92b09979dca45a58d916cc162b188a
parent8cea075a1eb94b574434676122abc5f82e941f43
parent8a72a2a91d52c16f3f761115d1c49b21fa6cdc4e
signature Signed by PGP key B5690EEEBB952194

Merge pull request #78 from pingdotgg/codething/53bdee92

Retarget open diff panel to the visited thread

9 files changed, 272 insertions(+), 270 deletions(-)

apps/web/src/components/ChatView.tsx+46-28
...@@ -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, stripDiffSearchParams } from "../diffRouteSearch";
38import { useAppSettings } from "../appSettings";40import { useAppSettings } from "../appSettings";
39import { buildBootstrapInput } from "../historyBootstrap";41import { buildBootstrapInput } from "../historyBootstrap";
40import {42import {
...@@ -375,6 +377,8 @@ interface ChatViewProps {...@@ -375,6 +377,8 @@ interface ChatViewProps {
375377
376export default function ChatView({ threadId }: ChatViewProps) {378export default function ChatView({ threadId }: ChatViewProps) {
377 const { state, dispatch } = useStore();379 const { state, dispatch } = useStore();
380 const navigate = useNavigate();
381 const rawSearch = useSearch({ strict: false });
378 const api = useNativeApi();382 const api = useNativeApi();
379 const { resolvedTheme } = useTheme();383 const { resolvedTheme } = useTheme();
380 const { settings: appSettings } = useAppSettings();384 const { settings: appSettings } = useAppSettings();
...@@ -413,6 +417,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -413,6 +417,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
413 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());417 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());
414418
415 const activeThread = state.threads.find((t) => t.id === threadId);419 const activeThread = state.threads.find((t) => t.id === threadId);
420 const diffSearch = useMemo(
421 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
422 [rawSearch],
423 );
424 const diffOpen = diffSearch.diff === "1";
416 const activeThreadId = activeThread?.id ?? null;425 const activeThreadId = activeThread?.id ?? null;
417 const activeSessionId = activeThread?.session?.sessionId ?? null;426 const activeSessionId = activeThread?.session?.sessionId ?? null;
418 const activeThreadRuntimeId =427 const activeThreadRuntimeId =
...@@ -1962,32 +1971,29 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1962,32 +1971,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
1962 setExpandedImage(image);1971 setExpandedImage(image);
1963 }, []);1972 }, []);
1964 const onToggleDiff = useCallback(() => {1973 const onToggleDiff = useCallback(() => {
1965 if (state.diffOpen) {1974 void navigate({
1966 dispatch({ type: "CLOSE_DIFF" });1975 to: "/$threadId",
1967 return;1976 params: { threadId },
1968 }1977 search: (previous) => {
19691978 const rest = stripDiffSearchParams(previous);
1970 if (!activeThread) {1979 return diffOpen ? rest : { ...rest, diff: "1" };
1971 dispatch({ type: "TOGGLE_DIFF" });1980 },
1972 return;
1973 }
1974
1975 dispatch({
1976 type: "OPEN_DIFF",
1977 threadId: activeThread.id,
1978 });1981 });
1979 }, [activeThread, dispatch, state.diffOpen]);1982 }, [diffOpen, navigate, threadId]);
1980 const onOpenTurnDiff = useCallback(1983 const onOpenTurnDiff = useCallback(
1981 (turnId: string, filePath?: string) => {1984 (turnId: string, filePath?: string) => {
1982 if (!activeThread) return;1985 void navigate({
1983 dispatch({1986 to: "/$threadId",
1984 type: "OPEN_DIFF",1987 params: { threadId },
1985 threadId: activeThread.id,1988 search: (previous) => {
1986 turnId,1989 const rest = stripDiffSearchParams(previous);
1987 ...(filePath ? { filePath } : {}),1990 return filePath
1991 ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath }
1992 : { ...rest, diff: "1", diffTurnId: turnId };
1993 },
1988 });1994 });
1989 },1995 },
1990 [activeThread, dispatch],1996 [navigate, threadId],
1991 );1997 );
1992 const onRevertUserMessage = useCallback(1998 const onRevertUserMessage = useCallback(
1993 (messageId: string) => {1999 (messageId: string) => {
...@@ -2035,7 +2041,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2035,7 +2041,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2035 keybindings={keybindings}2041 keybindings={keybindings}
2036 api={api}2042 api={api}
2037 gitCwd={gitCwd}2043 gitCwd={gitCwd}
2038 diffOpen={state.diffOpen}2044 diffOpen={diffOpen}
2039 onRunProjectScript={(script) => {2045 onRunProjectScript={(script) => {
2040 void runProjectScript(script);2046 void runProjectScript(script);
2041 }}2047 }}
...@@ -2820,11 +2826,16 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2820,11 +2826,16 @@ const MessagesTimeline = memo(function MessagesTimeline({
2820 row.message.id,2826 row.message.id,
2821 );2827 );
2822 if (!turnSummary) return null;2828 if (!turnSummary) return null;
2823 const checkpointFilesState = turnCheckpointFilesByTurnId.get(turnSummary.turnId);2829 const checkpointFilesState = turnCheckpointFilesByTurnId.get(
2830 turnSummary.turnId,
2831 );
2824 const checkpointFiles = checkpointFilesState?.files ?? [];2832 const checkpointFiles = checkpointFilesState?.files ?? [];
2825 const summaryStat = checkpointFiles.reduce(2833 const summaryStat = checkpointFiles.reduce(
2826 (acc, file) => {2834 (acc, file) => {
2827 if (typeof file.additions !== "number" || typeof file.deletions !== "number") {2835 if (
2836 typeof file.additions !== "number" ||
2837 typeof file.deletions !== "number"
2838 ) {
2828 return acc;2839 return acc;
2829 }2840 }
2830 return {2841 return {
...@@ -2835,7 +2846,9 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2835,7 +2846,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
2835 { additions: 0, deletions: 0 },2846 { additions: 0, deletions: 0 },
2836 );2847 );
2837 const changedFileCountLabel =2848 const changedFileCountLabel =
2838 checkpointFilesState?.status === "ready" ? String(checkpointFiles.length) : "...";2849 checkpointFilesState?.status === "ready"
2850 ? String(checkpointFiles.length)
2851 : "...";
2839 return (2852 return (
2840 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">2853 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
2841 <div className="mb-1.5 flex items-center justify-between gap-2">2854 <div className="mb-1.5 flex items-center justify-between gap-2">
...@@ -2846,7 +2859,9 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2846,7 +2859,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
2846 <span className="mx-1">•</span>2859 <span className="mx-1">•</span>
2847 <span className="text-success">+{summaryStat.additions}</span>2860 <span className="text-success">+{summaryStat.additions}</span>
2848 <span className="mx-0.5 text-muted-foreground/70">/</span>2861 <span className="mx-0.5 text-muted-foreground/70">/</span>
2849 <span className="text-destructive">-{summaryStat.deletions}</span>2862 <span className="text-destructive">
2863 -{summaryStat.deletions}
2864 </span>
2850 </>2865 </>
2851 )}2866 )}
2852 </p>2867 </p>
...@@ -2854,12 +2869,15 @@ const MessagesTimeline = memo(function MessagesTimeline({...@@ -2854,12 +2869,15 @@ const MessagesTimeline = memo(function MessagesTimeline({
2854 type="button"2869 type="button"
2855 size="xs"2870 size="xs"
2856 variant="outline"2871 variant="outline"
2857 onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}2872 onClick={() =>
2873 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
2874 }
2858 >2875 >
2859 View diff2876 View diff
2860 </Button>2877 </Button>
2861 </div>2878 </div>
2862 {!checkpointFilesState || checkpointFilesState.status === "pending" ? (2879 {!checkpointFilesState ||
2880 checkpointFilesState.status === "pending" ? (
2863 <p className="text-[11px] text-muted-foreground/70">2881 <p className="text-[11px] text-muted-foreground/70">
2864 Waiting for checkpoint metadata...2882 Waiting for checkpoint metadata...
2865 </p>2883 </p>
apps/web/src/components/DiffPanel.tsx+29-50
...@@ -1,16 +1,12 @@...@@ -1,16 +1,12 @@
1import { parsePatchFiles } from "@pierre/diffs";1import { parsePatchFiles } from "@pierre/diffs";
2import {2import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
3 FileDiff,
4 type FileDiffMetadata,
5 Virtualizer,
6 WorkerPoolContextProvider,
7} from "@pierre/diffs/react";
8import { useQuery } from "@tanstack/react-query";3import { useQuery } from "@tanstack/react-query";
9import { useParams } from "@tanstack/react-router";4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
10import { Columns2Icon, Rows3Icon } from "lucide-react";5import { Columns2Icon, Rows3Icon } from "lucide-react";
11import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";6import { useEffect, useMemo, useRef, useState } from "react";
12import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";7import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
13import { cn } from "~/lib/utils";8import { cn } from "~/lib/utils";
9import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
14import { isElectron } from "../env";10import { isElectron } from "../env";
15import { useNativeApi } from "../hooks/useNativeApi";11import { useNativeApi } from "../hooks/useNativeApi";
16import { useTheme } from "../hooks/useTheme";12import { useTheme } from "../hooks/useTheme";
...@@ -76,38 +72,23 @@ interface DiffPanelProps {...@@ -76,38 +72,23 @@ interface DiffPanelProps {
76 mode?: "inline" | "sheet";72 mode?: "inline" | "sheet";
77}73}
7874
79export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {75export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
80 const workerPoolSize = useMemo(() => {
81 const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4);
82 return Math.max(2, Math.min(6, Math.floor(cores / 2)));
83 }, []);
84
85 return (
86 <WorkerPoolContextProvider
87 poolOptions={{
88 workerFactory: () =>
89 new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }),
90 poolSize: workerPoolSize,
91 totalASTLRUCacheSize: 240,
92 }}
93 highlighterOptions={{
94 tokenizeMaxLineLength: 1_000,
95 }}
96 >
97 {children}
98 </WorkerPoolContextProvider>
99 );
100}
10176
102export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {77export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
103 const api = useNativeApi();78 const api = useNativeApi();
79 const navigate = useNavigate();
104 const { resolvedTheme } = useTheme();80 const { resolvedTheme } = useTheme();
105 const { state, dispatch } = useStore();81 const { state } = useStore();
106 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");82 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
107 const patchViewportRef = useRef<HTMLDivElement>(null);83 const patchViewportRef = useRef<HTMLDivElement>(null);
108 const params = useParams({ strict: false });84 const params = useParams({ strict: false });
85 const rawSearch = useSearch({ strict: false });
109 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;86 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
110 const activeThreadId = state.diffThreadId ?? routeThreadId;87 const diffSearch = useMemo(
88 () => parseDiffRouteSearch(rawSearch as Record<string, unknown>),
89 [rawSearch],
90 );
91 const activeThreadId = routeThreadId;
111 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);92 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);
112 const activeThreadRuntimeId =93 const activeThreadRuntimeId =
113 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;94 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
...@@ -115,10 +96,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -115,10 +96,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
115 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =96 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
116 useTurnDiffSummaries(activeThread);97 useTurnDiffSummaries(activeThread);
11798
118 const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id);99 const selectedTurnId = diffSearch.diffTurnId ?? null;
119 const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null;100 const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null;
120 const selectedFilePath =
121 canApplyStoredTarget && selectedTurnId !== null ? state.diffFilePath : null;
122 const selectedTurn =101 const selectedTurn =
123 selectedTurnId === null102 selectedTurnId === null
124 ? undefined103 ? undefined
...@@ -218,17 +197,24 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -218,17 +197,24 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
218197
219 const selectTurn = (turnId: string) => {198 const selectTurn = (turnId: string) => {
220 if (!activeThread) return;199 if (!activeThread) return;
221 dispatch({200 void navigate({
222 type: "SET_DIFF_TARGET",201 to: "/$threadId",
223 threadId: activeThread.id,202 params: { threadId: activeThread.id },
224 turnId,203 search: (previous) => {
204 const rest = stripDiffSearchParams(previous);
205 return { ...rest, diff: "1", diffTurnId: turnId };
206 },
225 });207 });
226 };208 };
227 const selectWholeConversation = () => {209 const selectWholeConversation = () => {
228 if (!activeThread) return;210 if (!activeThread) return;
229 dispatch({211 void navigate({
230 type: "SET_DIFF_TARGET",212 to: "/$threadId",
231 threadId: activeThread.id,213 params: { threadId: activeThread.id },
214 search: (previous) => {
215 const rest = stripDiffSearchParams(previous);
216 return { ...rest, diff: "1" };
217 },
232 });218 });
233 };219 };
234220
...@@ -318,13 +304,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -318,13 +304,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
318 ) : (304 ) : (
319 <>305 <>
320 <div ref={patchViewportRef} className="min-h-0 min-w-0 flex-1 overflow-hidden">306 <div ref={patchViewportRef} className="min-h-0 min-w-0 flex-1 overflow-hidden">
321 {!canApplyStoredTarget && state.diffThreadId && (
322 <div className="px-3 pt-2">
323 <p className="mb-2 text-[11px] text-muted-foreground/65">
324 Showing diffs for the active thread.
325 </p>
326 </div>
327 )}
328 {checkpointDiffError && !renderablePatch && (307 {checkpointDiffError && !renderablePatch && (
329 <div className="px-3">308 <div className="px-3">
330 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>309 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>
apps/web/src/components/DiffWorkerPoolProvider.tsx created+26
...@@ -0,0 +1,26 @@
1import { WorkerPoolContextProvider } from "@pierre/diffs/react";
2import { useMemo, type ReactNode } from "react";
3
4export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
5 const workerPoolSize = useMemo(() => {
6 const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4);
7 return Math.max(2, Math.min(6, Math.floor(cores / 2)));
8 }, []);
9
10 return (
11 <WorkerPoolContextProvider
12 poolOptions={{
13 workerFactory: () =>
14 new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }),
15 poolSize: workerPoolSize,
16 totalASTLRUCacheSize: 240,
17 }}
18 highlighterOptions={{
19 tokenizeMaxLineLength: 1_000,
20 }}
21 >
22 {children}
23 </WorkerPoolContextProvider>
24 );
25}
26
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+32
...@@ -0,0 +1,32 @@
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 stripDiffSearchParams<T extends Record<string, unknown>>(
16 params: T,
17): Omit<T, "diff" | "diffTurnId" | "diffFilePath"> {
18 const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params;
19 return rest as Omit<T, "diff" | "diffTurnId" | "diffFilePath">;
20}
21
22export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch {
23 const diff = search.diff === "1" ? "1" : undefined;
24 const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined;
25 const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined;
26
27 return {
28 ...(diff ? { diff } : {}),
29 ...(diffTurnId ? { diffTurnId } : {}),
30 ...(diffFilePath ? { diffFilePath } : {}),
31 };
32}
apps/web/src/routes/_chat.$threadId.tsx+78-2
...@@ -1,14 +1,67 @@...@@ -1,14 +1,67 @@
1import { createFileRoute, useNavigate } from "@tanstack/react-router";1import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";2import { Activity, Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
33
4import ChatView from "../components/ChatView";4import ChatView from "../components/ChatView";
5import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
6import { useMediaQuery } from "../hooks/useMediaQuery";
5import { useStore } from "../store";7import { useStore } from "../store";
8import { Sheet, SheetPopup } from "../components/ui/sheet";
9
10const DiffPanel = lazy(() => import("../components/DiffPanel"));
11const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
12
13const DiffPanelWrapper = (props: {
14 children: ReactNode;
15 sheet: boolean;
16 diffOpen: boolean;
17 onCloseDiff: () => void;
18}) => {
19 if (props.sheet) {
20 return (
21 <Sheet
22 open={props.diffOpen}
23 onOpenChange={(open) => {
24 if (!open) {
25 props.onCloseDiff();
26 }
27 }}
28 >
29 <SheetPopup
30 side="right"
31 showCloseButton={false}
32 keepMounted
33 className="w-[min(88vw,820px)] max-w-[820px] p-0"
34 >
35 {props.children}
36 </SheetPopup>
37 </Sheet>
38 );
39 }
40
41 return (
42 <aside className={props.diffOpen ? undefined : "hidden"} aria-hidden={!props.diffOpen}>
43 {props.children}
44 </aside>
45 );
46};
647
7function ChatThreadRouteView() {48function ChatThreadRouteView() {
8 const { state } = useStore();49 const { state } = useStore();
9 const navigate = useNavigate();50 const navigate = useNavigate();
10 const { threadId } = Route.useParams();51 const { threadId } = Route.useParams();
52 const search = Route.useSearch();
11 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;53 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;
54 const diffOpen = search.diff === "1";
55 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
56 const closeDiff = useCallback(() => {
57 void navigate({
58 to: "/$threadId",
59 params: { threadId },
60 search: (previous) => {
61 return stripDiffSearchParams(previous);
62 },
63 });
64 }, [navigate, threadId]);
1265
13 useEffect(() => {66 useEffect(() => {
14 if (!threadId) {67 if (!threadId) {
...@@ -30,9 +83,32 @@ function ChatThreadRouteView() {...@@ -30,9 +83,32 @@ function ChatThreadRouteView() {
30 return null;83 return null;
31 }84 }
3285
33 return <ChatView threadId={threadId} />;86 const diffLoadingFallback =
87 !diffOpen || shouldUseDiffSheet ? (
88 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
89 Loading diff viewer...
90 </div>
91 ) : (
92 <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70">
93 Loading diff viewer...
94 </aside>
95 );
96
97 return (
98 <>
99 <ChatView threadId={threadId} />
100 <Activity mode={diffOpen ? "visible" : "hidden"}>
101 <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}>
102 <Suspense fallback={diffLoadingFallback}>
103 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />
104 </Suspense>
105 </DiffPanelWrapper>
106 </Activity>
107 </>
108 );
34}109}
35110
36export const Route = createFileRoute("/_chat/$threadId")({111export const Route = createFileRoute("/_chat/$threadId")({
112 validateSearch: (search) => parseDiffRouteSearch(search),
37 component: ChatThreadRouteView,113 component: ChatThreadRouteView,
38});114});
apps/web/src/routes/_chat.tsx+6-66
...@@ -1,53 +1,10 @@...@@ -1,53 +1,10 @@
1import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";1import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
2import { Activity, Suspense, lazy, type ReactNode, useEffect } from "react";2import { useEffect } from "react";
33
4import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
4import Sidebar from "../components/Sidebar";5import Sidebar from "../components/Sidebar";
5import { useMediaQuery } from "../hooks/useMediaQuery";
6import { useStore } from "../store";
7import { Sheet, SheetPopup } from "../components/ui/sheet";
8
9const DiffPanel = lazy(() => import("../components/DiffPanel"));
10const DiffWorkerPoolProvider = lazy(() =>
11 import("../components/DiffPanel").then((module) => ({
12 default: module.DiffWorkerPoolProvider,
13 })),
14);
15const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
16
17const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {
18 const { state, dispatch } = useStore();
19 if (props.sheet) {
20 return (
21 <Sheet
22 open={state.diffOpen}
23 onOpenChange={(open) => {
24 if (!open) {
25 dispatch({ type: "CLOSE_DIFF" });
26 }
27 }}
28 >
29 <SheetPopup
30 side="right"
31 showCloseButton={false}
32 keepMounted
33 className="w-[min(88vw,820px)] max-w-[820px] p-0"
34 >
35 {props.children}
36 </SheetPopup>
37 </Sheet>
38 );
39 }
40
41 return (
42 <aside className={state.diffOpen ? undefined : "hidden"} aria-hidden={!state.diffOpen}>
43 {props.children}
44 </aside>
45 );
46};
476
48function ChatRouteLayout() {7function ChatRouteLayout() {
49 const { state } = useStore();
50 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
51 const navigate = useNavigate();8 const navigate = useNavigate();
529
53 useEffect(() => {10 useEffect(() => {
...@@ -66,30 +23,12 @@ function ChatRouteLayout() {...@@ -66,30 +23,12 @@ function ChatRouteLayout() {
66 };23 };
67 }, [navigate]);24 }, [navigate]);
6825
69 const diffLoadingFallback =
70 !state.diffOpen || shouldUseDiffSheet ? (
71 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
72 Loading diff viewer...
73 </div>
74 ) : (
75 <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70">
76 Loading diff viewer...
77 </aside>
78 );
79
80 return (26 return (
81 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">27 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
82 <Sidebar />28 <Sidebar />
83 <Outlet />29 <DiffWorkerPoolProvider>
84 <Activity mode={state.diffOpen ? "visible" : "hidden"}>30 <Outlet />
85 <DiffPanelWrapper sheet={shouldUseDiffSheet}>31 </DiffWorkerPoolProvider>
86 <Suspense fallback={diffLoadingFallback}>
87 <DiffWorkerPoolProvider>
88 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />
89 </DiffWorkerPoolProvider>
90 </Suspense>
91 </DiffPanelWrapper>
92 </Activity>
93 </div>32 </div>
94 );33 );
95}34}
...@@ -97,3 +36,4 @@ function ChatRouteLayout() {...@@ -97,3 +36,4 @@ function ChatRouteLayout() {
97export const Route = createFileRoute("/_chat")({36export const Route = createFileRoute("/_chat")({
98 component: ChatRouteLayout,37 component: ChatRouteLayout,
99});38});
39
apps/web/src/store.test.ts-39
...@@ -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", () => {
apps/web/src/store.ts+2-85
...@@ -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;
...@@ -1093,15 +1014,11 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -1093,15 +1014,11 @@ export function reducer(state: AppState, action: Action): AppState {
1093 runtimeMode: action.mode,1014 runtimeMode: action.mode,
1094 };1015 };
10951016
1096 case "DELETE_THREAD": {1017 case "DELETE_THREAD":
1097 const threads = state.threads.filter((t) => t.id !== action.threadId);
1098 const diffState = resetDiffTargetIfMissing(state, threads);
1099 return {1018 return {
1100 ...state,1019 ...state,
1101 threads,1020 threads: state.threads.filter((t) => t.id !== action.threadId),
1102 ...diffState,
1103 };1021 };
1104 }
11051022
1106 default:1023 default:
1107 return state;1024 return state;