authorgravatar for crazywolf132@gmail.com0x1f99d <crazywolf132@gmail.com> 2026-03-13 14:47:29+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 20:47:29-07:00
logac0d1f52a087c63819b2b032ac4535d18d3c23af
tree3d4e795d6eb01d3ff96d5093e21d0e61b3f8e8a1
parentd0ae0adc9923e51f7027ee16afedb14b3ae7779c
signature Signed by PGP key B5690EEEBB952194

fix(web): defer diff worker startup until diff opens (#934)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

4 files changed, 148 insertions(+), 55 deletions(-)

apps/web/src/components/DiffPanel.tsx+13-31
......@@ -19,7 +19,6 @@ import { cn } from "~/lib/utils";
1919import { readNativeApi } from "../nativeApi";
2020import { resolvePathLinkTarget } from "../terminal-links";
2121import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
22import { isElectron } from "../env";
2322import { useTheme } from "../hooks/useTheme";
2423import { buildPatchCacheKey } from "../lib/diffRendering";
2524import { resolveDiffThemeName } from "../lib/diffRendering";
......@@ -27,6 +26,7 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
2726import { useStore } from "../store";
2827import { useAppSettings } from "../appSettings";
2928import { formatShortTimestamp } from "../timestampFormat";
29import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
3030import { ToggleGroup, Toggle } from "./ui/toggle-group";
3131
3232type DiffRenderMode = "stacked" | "split";
......@@ -152,7 +152,7 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
152152}
153153
154154interface DiffPanelProps {
155 mode?: "inline" | "sheet" | "sidebar";
155 mode?: DiffPanelMode;
156156}
157157
158158export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
......@@ -398,7 +398,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
398398 selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
399399 }, [selectedTurn?.turnId, selectedTurnId]);
400400
401 const shouldUseDragRegion = isElectron && mode !== "sheet";
402401 const headerRow = (
403402 <>
404403 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
......@@ -512,28 +511,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
512511 </ToggleGroup>
513512 </>
514513 );
515 const headerRowClassName = cn(
516 "flex items-center justify-between gap-2 px-4",
517 shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12",
518 );
519514
520515 return (
521 <div
522 className={cn(
523 "flex h-full min-w-0 flex-col bg-background",
524 mode === "inline"
525 ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
526 : "w-full",
527 )}
528 >
529 {shouldUseDragRegion ? (
530 <div className={headerRowClassName}>{headerRow}</div>
531 ) : (
532 <div className="border-b border-border">
533 <div className={headerRowClassName}>{headerRow}</div>
534 </div>
535 )}
536
516 <DiffPanelShell mode={mode} header={headerRow}>
537517 {!activeThread ? (
538518 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
539519 Select a thread to inspect turn diffs.
......@@ -558,15 +538,17 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
558538 </div>
559539 )}
560540 {!renderablePatch ? (
561 <div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70">
562 <p>
563 {isLoadingCheckpointDiff
564 ? "Loading checkpoint diff..."
565 : hasNoNetChanges
541 isLoadingCheckpointDiff ? (
542 <DiffPanelLoadingState label="Loading checkpoint diff..." />
543 ) : (
544 <div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70">
545 <p>
546 {hasNoNetChanges
566547 ? "No net changes in this selection."
567548 : "No patch available for this selection."}
568 </p>
569 </div>
549 </p>
550 </div>
551 )
570552 ) : renderablePatch.kind === "files" ? (
571553 <Virtualizer
572554 className="diff-render-surface h-full min-h-0 overflow-auto px-2 pb-2"
......@@ -622,6 +604,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
622604 </div>
623605 </>
624606 )}
625 </div>
607 </DiffPanelShell>
626608 );
627609}
apps/web/src/components/DiffPanelShell.tsx created+92
......@@ -0,0 +1,92 @@
1import type { ReactNode } from "react";
2
3import { isElectron } from "~/env";
4import { cn } from "~/lib/utils";
5
6import { Skeleton } from "./ui/skeleton";
7
8export type DiffPanelMode = "inline" | "sheet" | "sidebar";
9
10function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
11 const shouldUseDragRegion = isElectron && mode !== "sheet";
12 return cn(
13 "flex items-center justify-between gap-2 px-4",
14 shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12",
15 );
16}
17
18export function DiffPanelShell(props: {
19 mode: DiffPanelMode;
20 header: ReactNode;
21 children: ReactNode;
22}) {
23 const shouldUseDragRegion = isElectron && props.mode !== "sheet";
24
25 return (
26 <div
27 className={cn(
28 "flex h-full min-w-0 flex-col bg-background",
29 props.mode === "inline"
30 ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
31 : "w-full",
32 )}
33 >
34 {shouldUseDragRegion ? (
35 <div className={getDiffPanelHeaderRowClassName(props.mode)}>{props.header}</div>
36 ) : (
37 <div className="border-b border-border">
38 <div className={getDiffPanelHeaderRowClassName(props.mode)}>{props.header}</div>
39 </div>
40 )}
41 {props.children}
42 </div>
43 );
44}
45
46export function DiffPanelHeaderSkeleton() {
47 return (
48 <>
49 <div className="relative min-w-0 flex-1">
50 <Skeleton className="absolute left-0 top-1/2 size-6 -translate-y-1/2 rounded-md border border-border/50" />
51 <Skeleton className="absolute right-0 top-1/2 size-6 -translate-y-1/2 rounded-md border border-border/50" />
52 <div className="flex gap-1 overflow-hidden px-8 py-0.5">
53 <Skeleton className="h-6 w-16 shrink-0 rounded-md" />
54 <Skeleton className="h-6 w-24 shrink-0 rounded-md" />
55 <Skeleton className="h-6 w-24 shrink-0 rounded-md max-sm:hidden" />
56 </div>
57 </div>
58 <div className="flex shrink-0 gap-1">
59 <Skeleton className="size-7 rounded-md" />
60 <Skeleton className="size-7 rounded-md" />
61 </div>
62 </>
63 );
64}
65
66export function DiffPanelLoadingState(props: { label: string }) {
67 return (
68 <div className="flex min-h-0 flex-1 flex-col p-2">
69 <div
70 className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border/60 bg-card/25"
71 role="status"
72 aria-live="polite"
73 aria-label={props.label}
74 >
75 <div className="flex items-center gap-2 border-b border-border/50 px-3 py-2">
76 <Skeleton className="h-4 w-32 rounded-full" />
77 <Skeleton className="ml-auto h-4 w-20 rounded-full" />
78 </div>
79 <div className="flex min-h-0 flex-1 flex-col gap-4 px-3 py-4">
80 <div className="space-y-2">
81 <Skeleton className="h-3 w-full rounded-full" />
82 <Skeleton className="h-3 w-full rounded-full" />
83 <Skeleton className="h-3 w-10/12 rounded-full" />
84 <Skeleton className="h-3 w-11/12 rounded-full" />
85 <Skeleton className="h-3 w-9/12 rounded-full" />
86 </div>
87 <span className="sr-only">{props.label}</span>
88 </div>
89 </div>
90 </div>
91 );
92}
apps/web/src/routes/_chat.$threadId.tsx+42-20
......@@ -1,8 +1,15 @@
11import { ThreadId } from "@t3tools/contracts";
22import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router";
3import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
3import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react";
44
55import ChatView from "../components/ChatView";
6import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
7import {
8 DiffPanelHeaderSkeleton,
9 DiffPanelLoadingState,
10 DiffPanelShell,
11 type DiffPanelMode,
12} from "../components/DiffPanelShell";
613import { useComposerDraftStore } from "../composerDraftStore";
714import {
815 type DiffRouteSearch,
......@@ -47,19 +54,21 @@ const DiffPanelSheet = (props: {
4754 );
4855};
4956
50const DiffLoadingFallback = (props: { inline: boolean }) => {
51 if (props.inline) {
52 return (
53 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
54 Loading diff viewer...
55 </div>
56 );
57 }
57const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => {
58 return (
59 <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}>
60 <DiffPanelLoadingState label="Loading diff viewer..." />
61 </DiffPanelShell>
62 );
63};
5864
65const LazyDiffPanel = (props: { mode: DiffPanelMode }) => {
5966 return (
60 <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">
61 Loading diff viewer...
62 </aside>
67 <DiffWorkerPoolProvider>
68 <Suspense fallback={<DiffLoadingFallback mode={props.mode} />}>
69 <DiffPanel mode={props.mode} />
70 </Suspense>
71 </DiffWorkerPoolProvider>
6372 );
6473};
6574
......@@ -67,8 +76,9 @@ const DiffPanelInlineSidebar = (props: {
6776 diffOpen: boolean;
6877 onCloseDiff: () => void;
6978 onOpenDiff: () => void;
79 renderDiffContent: boolean;
7080}) => {
71 const { diffOpen, onCloseDiff, onOpenDiff } = props;
81 const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props;
7282 const onOpenChange = useCallback(
7383 (open: boolean) => {
7484 if (open) {
......@@ -143,9 +153,7 @@ const DiffPanelInlineSidebar = (props: {
143153 storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY,
144154 }}
145155 >
146 <Suspense fallback={<DiffLoadingFallback inline />}>
147 <DiffPanel mode="sidebar" />
148 </Suspense>
156 {renderDiffContent ? <LazyDiffPanel mode="sidebar" /> : null}
149157 <SidebarRail />
150158 </Sidebar>
151159 </SidebarProvider>
......@@ -166,6 +174,9 @@ function ChatThreadRouteView() {
166174 const routeThreadExists = threadExists || draftThreadExists;
167175 const diffOpen = search.diff === "1";
168176 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
177 // TanStack Router keeps active route components mounted across param-only navigations
178 // unless remountDeps are configured, so this stays warm across thread switches.
179 const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen);
169180 const closeDiff = useCallback(() => {
170181 void navigate({
171182 to: "/$threadId",
......@@ -184,6 +195,12 @@ function ChatThreadRouteView() {
184195 });
185196 }, [navigate, threadId]);
186197
198 useEffect(() => {
199 if (diffOpen) {
200 setHasOpenedDiff(true);
201 }
202 }, [diffOpen]);
203
187204 useEffect(() => {
188205 if (!threadsHydrated) {
189206 return;
......@@ -199,13 +216,20 @@ function ChatThreadRouteView() {
199216 return null;
200217 }
201218
219 const shouldRenderDiffContent = diffOpen || hasOpenedDiff;
220
202221 if (!shouldUseDiffSheet) {
203222 return (
204223 <>
205224 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
206225 <ChatView key={threadId} threadId={threadId} />
207226 </SidebarInset>
208 <DiffPanelInlineSidebar diffOpen={diffOpen} onCloseDiff={closeDiff} onOpenDiff={openDiff} />
227 <DiffPanelInlineSidebar
228 diffOpen={diffOpen}
229 onCloseDiff={closeDiff}
230 onOpenDiff={openDiff}
231 renderDiffContent={shouldRenderDiffContent}
232 />
209233 </>
210234 );
211235 }
......@@ -216,9 +240,7 @@ function ChatThreadRouteView() {
216240 <ChatView key={threadId} threadId={threadId} />
217241 </SidebarInset>
218242 <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}>
219 <Suspense fallback={<DiffLoadingFallback inline={false} />}>
220 <DiffPanel mode="sheet" />
221 </Suspense>
243 {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null}
222244 </DiffPanelSheet>
223245 </>
224246 );
apps/web/src/routes/_chat.tsx+1-4
......@@ -3,7 +3,6 @@ import { useQuery } from "@tanstack/react-query";
33import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
44import { useEffect } from "react";
55
6import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
76import ThreadSidebar from "../components/Sidebar";
87import { useHandleNewThread } from "../hooks/useHandleNewThread";
98import { isTerminalFocused } from "../lib/terminalFocus";
......@@ -120,9 +119,7 @@ function ChatRouteLayout() {
120119 >
121120 <ThreadSidebar />
122121 </Sidebar>
123 <DiffWorkerPoolProvider>
124 <Outlet />
125 </DiffWorkerPoolProvider>
122 <Outlet />
126123 </SidebarProvider>
127124 );
128125}