authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 16:45:59-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 21:59:17-08:00
log453fb4992e9333be607322af1c2c2870ad93135d
tree582ded0550ffda3c46a5ed9635a6cc4f6c1e420a
parent5cfab38f315945b81e2225b7edca01fdff622bb8

Keep diff panel mounted and reuse worker pool across layouts

- Keep the diff viewer alive when hidden to avoid remount churn - Share diff worker pool setup via a dedicated provider - Add `keepMounted` support to `SheetPopup` for sheet-mode persistence

3 files changed, 197 insertions(+), 169 deletions(-)

apps/web/src/App.tsx+58-39
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";1import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
2import { Suspense, lazy, useEffect, useRef } from "react";2import { Activity, Suspense, lazy, useEffect, useRef, useState } from "react";
33
4import ChatView from "./components/ChatView";4import ChatView from "./components/ChatView";
5import Sidebar from "./components/Sidebar";5import Sidebar from "./components/Sidebar";
...@@ -15,8 +15,44 @@ import { Sheet, SheetPopup } from "./components/ui/sheet";...@@ -15,8 +15,44 @@ import { Sheet, SheetPopup } from "./components/ui/sheet";
15import { invalidateGitQueries } from "./lib/gitReactQuery";15import { invalidateGitQueries } from "./lib/gitReactQuery";
1616
17const DiffPanel = lazy(() => import("./components/DiffPanel"));17const DiffPanel = lazy(() => import("./components/DiffPanel"));
18const DiffWorkerPoolProvider = lazy(() =>
19 import("./components/DiffPanel").then((module) => ({
20 default: module.DiffWorkerPoolProvider,
21 })),
22);
18const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";23const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
1924
25const DiffPanelWrapper = (props: { children: React.ReactNode; sheet: boolean }) => {
26 const { state, dispatch } = useStore();
27 if (props.sheet) {
28 return (
29 <Sheet
30 open={state.diffOpen}
31 onOpenChange={(open) => {
32 if (!open) {
33 dispatch({ type: "CLOSE_DIFF" });
34 }
35 }}
36 >
37 <SheetPopup
38 side="right"
39 showCloseButton={false}
40 keepMounted
41 className="w-[min(88vw,820px)] max-w-[820px] p-0"
42 >
43 {props.children}
44 </SheetPopup>
45 </Sheet>
46 );
47 }
48
49 return (
50 <aside className={state.diffOpen ? undefined : "hidden"} aria-hidden={!state.diffOpen}>
51 {props.children}
52 </aside>
53 );
54};
55
20function EventRouter() {56function EventRouter() {
21 const api = useNativeApi();57 const api = useNativeApi();
22 const { dispatch } = useStore();58 const { dispatch } = useStore();
...@@ -181,9 +217,20 @@ function DesktopProjectBootstrap() {...@@ -181,9 +217,20 @@ function DesktopProjectBootstrap() {
181217
182function Layout() {218function Layout() {
183 const api = useNativeApi();219 const api = useNativeApi();
184 const { state, dispatch } = useStore();220 const { state } = useStore();
185 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);221 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
186222
223 const diffLoadingFallback =
224 !state.diffOpen || shouldUseDiffSheet ? (
225 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
226 Loading diff viewer...
227 </div>
228 ) : (
229 <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">
230 Loading diff viewer...
231 </aside>
232 );
233
187 if (!api) {234 if (!api) {
188 return (235 return (
189 <div className="flex h-screen flex-col bg-background text-foreground">236 <div className="flex h-screen flex-col bg-background text-foreground">
...@@ -201,43 +248,15 @@ function Layout() {...@@ -201,43 +248,15 @@ function Layout() {
201 <DesktopProjectBootstrap />248 <DesktopProjectBootstrap />
202 <Sidebar />249 <Sidebar />
203 <ChatView />250 <ChatView />
204 {state.diffOpen && !shouldUseDiffSheet && (251 <Activity mode={state.diffOpen ? "visible" : "hidden"}>
205 <Suspense252 <DiffPanelWrapper sheet={shouldUseDiffSheet}>
206 fallback={253 <Suspense fallback={diffLoadingFallback}>
207 <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">254 <DiffWorkerPoolProvider>
208 Loading diff viewer...255 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />
209 </aside>256 </DiffWorkerPoolProvider>
210 }257 </Suspense>
211 >258 </DiffPanelWrapper>
212 <DiffPanel mode="inline" />259 </Activity>
213 </Suspense>
214 )}
215 {state.diffOpen && shouldUseDiffSheet && (
216 <Sheet
217 open={state.diffOpen}
218 onOpenChange={(open) => {
219 if (!open) {
220 dispatch({ type: "CLOSE_DIFF" });
221 }
222 }}
223 >
224 <SheetPopup
225 side="right"
226 showCloseButton={false}
227 className="w-[min(88vw,820px)] max-w-[820px] p-0"
228 >
229 <Suspense
230 fallback={
231 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
232 Loading diff viewer...
233 </div>
234 }
235 >
236 <DiffPanel mode="sheet" />
237 </Suspense>
238 </SheetPopup>
239 </Sheet>
240 )}
241 </div>260 </div>
242 );261 );
243}262}
apps/web/src/components/DiffPanel.tsx+136-129
...@@ -2,7 +2,7 @@ import { parsePatchFiles } from "@pierre/diffs";...@@ -2,7 +2,7 @@ import { parsePatchFiles } from "@pierre/diffs";
2import { FileDiff, type FileDiffMetadata, WorkerPoolContextProvider } from "@pierre/diffs/react";2import { FileDiff, type FileDiffMetadata, WorkerPoolContextProvider } from "@pierre/diffs/react";
3import { useQuery, useQueryClient } from "@tanstack/react-query";3import { useQuery, useQueryClient } from "@tanstack/react-query";
4import { Columns2Icon, Rows3Icon } from "lucide-react";4import { Columns2Icon, Rows3Icon } from "lucide-react";
5import { useEffect, useMemo, useRef, useState } from "react";5import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
6import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery";6import { checkpointDiffQueryOptions, providerQueryKeys } from "~/lib/providerReactQuery";
7import { cn } from "~/lib/utils";7import { cn } from "~/lib/utils";
8import { isElectron } from "../env";8import { isElectron } from "../env";
...@@ -65,6 +65,29 @@ interface DiffPanelProps {...@@ -65,6 +65,29 @@ interface DiffPanelProps {
65 mode?: "inline" | "sheet";65 mode?: "inline" | "sheet";
66}66}
6767
68export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
69 const workerPoolSize = useMemo(() => {
70 const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4);
71 return Math.max(2, Math.min(6, Math.floor(cores / 2)));
72 }, []);
73
74 return (
75 <WorkerPoolContextProvider
76 poolOptions={{
77 workerFactory: () =>
78 new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }),
79 poolSize: workerPoolSize,
80 totalASTLRUCacheSize: 240,
81 }}
82 highlighterOptions={{
83 tokenizeMaxLineLength: 1_000,
84 }}
85 >
86 {children}
87 </WorkerPoolContextProvider>
88 );
89}
90
68export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {91export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
69 const api = useNativeApi();92 const api = useNativeApi();
70 const { resolvedTheme } = useTheme();93 const { resolvedTheme } = useTheme();
...@@ -289,151 +312,135 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -289,151 +312,135 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
289 };312 };
290313
291 const shouldUseDragRegion = isElectron && mode === "inline";314 const shouldUseDragRegion = isElectron && mode === "inline";
292 const workerPoolSize = useMemo(() => {
293 const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4);
294 return Math.max(2, Math.min(6, Math.floor(cores / 2)));
295 }, []);
296315
297 return (316 return (
298 <WorkerPoolContextProvider317 <aside
299 poolOptions={{318 className={cn(
300 workerFactory: () =>319 "flex h-full min-w-0 flex-col bg-card",
301 new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }),320 mode === "inline"
302 poolSize: workerPoolSize,321 ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
303 totalASTLRUCacheSize: 240,322 : "w-full",
304 }}323 )}
305 highlighterOptions={{
306 tokenizeMaxLineLength: 1_000,
307 }}
308 >324 >
309 <aside325 <div
310 className={cn(326 className={cn(
311 "flex h-full min-w-0 flex-col bg-card",327 "flex items-center justify-between border-b border-border px-4",
312 mode === "inline"328 shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",
313 ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
314 : "w-full",
315 )}329 )}
316 >330 >
317 <div331 <div className="flex gap-1.5 overflow-x-auto pb-0.5">
318 className={cn(332 <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation}>
319 "flex items-center justify-between border-b border-border px-4",333 <div
320 shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",334 className={cn(
321 )}335 "rounded-md border px-2 py-1 text-left transition-colors",
322 >336 selectedTurnId === null
323 <div className="flex gap-1.5 overflow-x-auto pb-0.5">337 ? "border-border bg-accent text-accent-foreground"
324 <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation}>338 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
339 )}
340 >
341 <div className="text-[10px] font-medium">All turns</div>
342 <div className="text-[8px] opacity-70">Conversation</div>
343 </div>
344 </button>
345 {turnDiffSummaries.map((summary, index) => (
346 <button
347 key={summary.turnId}
348 type="button"
349 className="shrink-0 rounded-md"
350 onClick={() => selectTurn(summary.turnId)}
351 title={summary.turnId}
352 >
325 <div353 <div
326 className={cn(354 className={cn(
327 "rounded-md border px-2 py-1 text-left transition-colors",355 "rounded-md border px-2 py-1 text-left transition-colors",
328 selectedTurnId === null356 summary.turnId === selectedTurn?.turnId
329 ? "border-border bg-accent text-accent-foreground"357 ? "border-border bg-accent text-accent-foreground"
330 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",358 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
331 )}359 )}
332 >360 >
333 <div className="text-[10px] font-medium">All turns</div>361 <div className="text-[10px] font-medium">
334 <div className="text-[8px] opacity-70">Conversation</div>362 Turn {turnDiffSummaries.length - index}
363 </div>
364 <div className="text-[8px] opacity-70">{formatTimestamp(summary.completedAt)}</div>
335 </div>365 </div>
336 </button>366 </button>
337 {turnDiffSummaries.map((summary, index) => (367 ))}
338 <button
339 key={summary.turnId}
340 type="button"
341 className="shrink-0 rounded-md"
342 onClick={() => selectTurn(summary.turnId)}
343 title={summary.turnId}
344 >
345 <div
346 className={cn(
347 "rounded-md border px-2 py-1 text-left transition-colors",
348 summary.turnId === selectedTurn?.turnId
349 ? "border-border bg-accent text-accent-foreground"
350 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
351 )}
352 >
353 <div className="text-[10px] font-medium">
354 Turn {turnDiffSummaries.length - index}
355 </div>
356 <div className="text-[8px] opacity-70">{formatTimestamp(summary.completedAt)}</div>
357 </div>
358 </button>
359 ))}
360 </div>
361 <ToggleGroup
362 variant="outline"
363 size="xs"
364 value={[diffRenderMode]}
365 onValueChange={(value) => {
366 const next = value[0];
367 if (next === "stacked" || next === "split") {
368 setDiffRenderMode(next);
369 }
370 }}
371 >
372 <Toggle aria-label="Stacked diff view" value="stacked">
373 <Rows3Icon className="size-3" />
374 </Toggle>
375 <Toggle aria-label="Split diff view" value="split">
376 <Columns2Icon className="size-3" />
377 </Toggle>
378 </ToggleGroup>
379 </div>368 </div>
369 <ToggleGroup
370 variant="outline"
371 size="xs"
372 value={[diffRenderMode]}
373 onValueChange={(value) => {
374 const next = value[0];
375 if (next === "stacked" || next === "split") {
376 setDiffRenderMode(next);
377 }
378 }}
379 >
380 <Toggle aria-label="Stacked diff view" value="stacked">
381 <Rows3Icon className="size-3" />
382 </Toggle>
383 <Toggle aria-label="Split diff view" value="split">
384 <Columns2Icon className="size-3" />
385 </Toggle>
386 </ToggleGroup>
387 </div>
380388
381 {!activeThread ? (389 {!activeThread ? (
382 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">390 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
383 Select a thread to inspect turn diffs.391 Select a thread to inspect turn diffs.
384 </div>392 </div>
385 ) : turnDiffSummaries.length === 0 ? (393 ) : turnDiffSummaries.length === 0 ? (
386 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">394 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
387 No completed turns yet.395 No completed turns yet.
396 </div>
397 ) : (
398 <>
399 <div ref={patchViewportRef} className="min-w-0 flex-1 overflow-auto px-3 py-2">
400 {!canApplyStoredTarget && state.diffThreadId && (
401 <p className="mb-2 text-[11px] text-muted-foreground/65">
402 Showing diffs for the active thread.
403 </p>
404 )}
405 {checkpointDiffError && !renderablePatch && (
406 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>
407 )}
408 {!renderablePatch ? (
409 <div className="flex h-full items-center justify-center text-xs text-muted-foreground/70">
410 {isLoadingCheckpointDiff
411 ? "Loading checkpoint diff..."
412 : "No patch available for this selection."}
413 </div>
414 ) : renderablePatch.kind === "files" ? (
415 <div className="space-y-3">
416 {renderableFiles.map((fileDiff) => (
417 <div
418 key={fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`}
419 data-diff-file-path={resolveFileDiffPath(fileDiff)}
420 className="rounded-md"
421 >
422 <FileDiff
423 fileDiff={fileDiff}
424 options={{
425 diffStyle: diffRenderMode === "split" ? "split" : "unified",
426 lineDiffType: "none",
427 themeType: resolvedTheme,
428 }}
429 />
430 </div>
431 ))}
432 </div>
433 ) : (
434 <div className="space-y-2">
435 <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p>
436 <pre className="max-h-[72vh] overflow-auto rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90">
437 {renderablePatch.text}
438 </pre>
439 </div>
440 )}
388 </div>441 </div>
389 ) : (442 </>
390 <>443 )}
391 <div ref={patchViewportRef} className="min-w-0 flex-1 overflow-auto px-3 py-2">444 </aside>
392 {!canApplyStoredTarget && state.diffThreadId && (
393 <p className="mb-2 text-[11px] text-muted-foreground/65">
394 Showing diffs for the active thread.
395 </p>
396 )}
397 {checkpointDiffError && !renderablePatch && (
398 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>
399 )}
400 {!renderablePatch ? (
401 <div className="flex h-full items-center justify-center text-xs text-muted-foreground/70">
402 {isLoadingCheckpointDiff
403 ? "Loading checkpoint diff..."
404 : "No patch available for this selection."}
405 </div>
406 ) : renderablePatch.kind === "files" ? (
407 <div className="space-y-3">
408 {renderableFiles.map((fileDiff) => (
409 <div
410 key={fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`}
411 data-diff-file-path={resolveFileDiffPath(fileDiff)}
412 className="rounded-md"
413 >
414 <FileDiff
415 fileDiff={fileDiff}
416 options={{
417 diffStyle: diffRenderMode === "split" ? "split" : "unified",
418 lineDiffType: "none",
419 themeType: resolvedTheme,
420 }}
421 />
422 </div>
423 ))}
424 </div>
425 ) : (
426 <div className="space-y-2">
427 <p className="text-[11px] text-muted-foreground/75">{renderablePatch.reason}</p>
428 <pre className="max-h-[72vh] overflow-auto rounded-md border border-border/70 bg-background/70 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground/90">
429 {renderablePatch.text}
430 </pre>
431 </div>
432 )}
433 </div>
434 </>
435 )}
436 </aside>
437 </WorkerPoolContextProvider>
438 );445 );
439}446}
apps/web/src/components/ui/sheet.tsx+3-1
...@@ -60,16 +60,18 @@ function SheetPopup({...@@ -60,16 +60,18 @@ function SheetPopup({
60 className,60 className,
61 children,61 children,
62 showCloseButton = true,62 showCloseButton = true,
63 keepMounted = false,
63 side = "right",64 side = "right",
64 variant = "default",65 variant = "default",
65 ...props66 ...props
66}: SheetPrimitive.Popup.Props & {67}: SheetPrimitive.Popup.Props & {
67 showCloseButton?: boolean;68 showCloseButton?: boolean;
69 keepMounted?: boolean;
68 side?: "right" | "left" | "top" | "bottom";70 side?: "right" | "left" | "top" | "bottom";
69 variant?: "default" | "inset";71 variant?: "default" | "inset";
70}) {72}) {
71 return (73 return (
72 <SheetPortal>74 <SheetPortal keepMounted={keepMounted}>
73 <SheetBackdrop />75 <SheetBackdrop />
74 <SheetViewport side={side} variant={variant}>76 <SheetViewport side={side} variant={variant}>
75 <SheetPrimitive.Popup77 <SheetPrimitive.Popup