| author | |
| committer | |
| log | ac0d1f52a087c63819b2b032ac4535d18d3c23af |
| tree | 3d4e795d6eb01d3ff96d5093e21d0e61b3f8e8a1 |
| parent | d0ae0adc9923e51f7027ee16afedb14b3ae7779c |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 19 | 19 | import { readNativeApi } from "../nativeApi"; |
| 20 | 20 | import { resolvePathLinkTarget } from "../terminal-links"; |
| 21 | 21 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 22 | import { isElectron } from "../env"; | |
| 23 | 22 | import { useTheme } from "../hooks/useTheme"; |
| 24 | 23 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 25 | 24 | import { resolveDiffThemeName } from "../lib/diffRendering"; |
| ... | ... | @@ -27,6 +26,7 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 27 | 26 | import { useStore } from "../store"; |
| 28 | 27 | import { useAppSettings } from "../appSettings"; |
| 29 | 28 | import { formatShortTimestamp } from "../timestampFormat"; |
| 29 | import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; | |
| 30 | 30 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 31 | 31 | |
| 32 | 32 | type DiffRenderMode = "stacked" | "split"; |
| ... | ... | @@ -152,7 +152,7 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { |
| 152 | 152 | } |
| 153 | 153 | |
| 154 | 154 | interface DiffPanelProps { |
| 155 | mode?: "inline" | "sheet" | "sidebar"; | |
| 155 | mode?: DiffPanelMode; | |
| 156 | 156 | } |
| 157 | 157 | |
| 158 | 158 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; |
| ... | ... | @@ -398,7 +398,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 398 | 398 | selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); |
| 399 | 399 | }, [selectedTurn?.turnId, selectedTurnId]); |
| 400 | 400 | |
| 401 | const shouldUseDragRegion = isElectron && mode !== "sheet"; | |
| 402 | 401 | const headerRow = ( |
| 403 | 402 | <> |
| 404 | 403 | <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]"> |
| ... | ... | @@ -512,28 +511,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 512 | 511 | </ToggleGroup> |
| 513 | 512 | </> |
| 514 | 513 | ); |
| 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 | ); | |
| 519 | 514 | |
| 520 | 515 | 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}> | |
| 537 | 517 | {!activeThread ? ( |
| 538 | 518 | <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70"> |
| 539 | 519 | Select a thread to inspect turn diffs. |
| ... | ... | @@ -558,15 +538,17 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 558 | 538 | </div> |
| 559 | 539 | )} |
| 560 | 540 | {!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 | |
| 566 | 547 | ? "No net changes in this selection." |
| 567 | 548 | : "No patch available for this selection."} |
| 568 | </p> | |
| 569 | </div> | |
| 549 | </p> | |
| 550 | </div> | |
| 551 | ) | |
| 570 | 552 | ) : renderablePatch.kind === "files" ? ( |
| 571 | 553 | <Virtualizer |
| 572 | 554 | 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) { |
| 622 | 604 | </div> |
| 623 | 605 | </> |
| 624 | 606 | )} |
| 625 | </div> | |
| 607 | </DiffPanelShell> | |
| 626 | 608 | ); |
| 627 | 609 | } |
apps/web/src/components/DiffPanelShell.tsx created+92| ... | ... | @@ -0,0 +1,92 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | ||
| 3 | import { isElectron } from "~/env"; | |
| 4 | import { cn } from "~/lib/utils"; | |
| 5 | ||
| 6 | import { Skeleton } from "./ui/skeleton"; | |
| 7 | ||
| 8 | export type DiffPanelMode = "inline" | "sheet" | "sidebar"; | |
| 9 | ||
| 10 | function 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 | ||
| 18 | export 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 | ||
| 46 | export 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 | ||
| 66 | export 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 @@ |
| 1 | 1 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | 2 | import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; |
| 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; | |
| 3 | import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react"; | |
| 4 | 4 | |
| 5 | 5 | import ChatView from "../components/ChatView"; |
| 6 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | |
| 7 | import { | |
| 8 | DiffPanelHeaderSkeleton, | |
| 9 | DiffPanelLoadingState, | |
| 10 | DiffPanelShell, | |
| 11 | type DiffPanelMode, | |
| 12 | } from "../components/DiffPanelShell"; | |
| 6 | 13 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 7 | 14 | import { |
| 8 | 15 | type DiffRouteSearch, |
| ... | ... | @@ -47,19 +54,21 @@ const DiffPanelSheet = (props: { |
| 47 | 54 | ); |
| 48 | 55 | }; |
| 49 | 56 | |
| 50 | const 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 | } | |
| 57 | const DiffLoadingFallback = (props: { mode: DiffPanelMode }) => { | |
| 58 | return ( | |
| 59 | <DiffPanelShell mode={props.mode} header={<DiffPanelHeaderSkeleton />}> | |
| 60 | <DiffPanelLoadingState label="Loading diff viewer..." /> | |
| 61 | </DiffPanelShell> | |
| 62 | ); | |
| 63 | }; | |
| 58 | 64 | |
| 65 | const LazyDiffPanel = (props: { mode: DiffPanelMode }) => { | |
| 59 | 66 | 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> | |
| 63 | 72 | ); |
| 64 | 73 | }; |
| 65 | 74 | |
| ... | ... | @@ -67,8 +76,9 @@ const DiffPanelInlineSidebar = (props: { |
| 67 | 76 | diffOpen: boolean; |
| 68 | 77 | onCloseDiff: () => void; |
| 69 | 78 | onOpenDiff: () => void; |
| 79 | renderDiffContent: boolean; | |
| 70 | 80 | }) => { |
| 71 | const { diffOpen, onCloseDiff, onOpenDiff } = props; | |
| 81 | const { diffOpen, onCloseDiff, onOpenDiff, renderDiffContent } = props; | |
| 72 | 82 | const onOpenChange = useCallback( |
| 73 | 83 | (open: boolean) => { |
| 74 | 84 | if (open) { |
| ... | ... | @@ -143,9 +153,7 @@ const DiffPanelInlineSidebar = (props: { |
| 143 | 153 | storageKey: DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY, |
| 144 | 154 | }} |
| 145 | 155 | > |
| 146 | <Suspense fallback={<DiffLoadingFallback inline />}> | |
| 147 | <DiffPanel mode="sidebar" /> | |
| 148 | </Suspense> | |
| 156 | {renderDiffContent ? <LazyDiffPanel mode="sidebar" /> : null} | |
| 149 | 157 | <SidebarRail /> |
| 150 | 158 | </Sidebar> |
| 151 | 159 | </SidebarProvider> |
| ... | ... | @@ -166,6 +174,9 @@ function ChatThreadRouteView() { |
| 166 | 174 | const routeThreadExists = threadExists || draftThreadExists; |
| 167 | 175 | const diffOpen = search.diff === "1"; |
| 168 | 176 | 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); | |
| 169 | 180 | const closeDiff = useCallback(() => { |
| 170 | 181 | void navigate({ |
| 171 | 182 | to: "/$threadId", |
| ... | ... | @@ -184,6 +195,12 @@ function ChatThreadRouteView() { |
| 184 | 195 | }); |
| 185 | 196 | }, [navigate, threadId]); |
| 186 | 197 | |
| 198 | useEffect(() => { | |
| 199 | if (diffOpen) { | |
| 200 | setHasOpenedDiff(true); | |
| 201 | } | |
| 202 | }, [diffOpen]); | |
| 203 | ||
| 187 | 204 | useEffect(() => { |
| 188 | 205 | if (!threadsHydrated) { |
| 189 | 206 | return; |
| ... | ... | @@ -199,13 +216,20 @@ function ChatThreadRouteView() { |
| 199 | 216 | return null; |
| 200 | 217 | } |
| 201 | 218 | |
| 219 | const shouldRenderDiffContent = diffOpen || hasOpenedDiff; | |
| 220 | ||
| 202 | 221 | if (!shouldUseDiffSheet) { |
| 203 | 222 | return ( |
| 204 | 223 | <> |
| 205 | 224 | <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground"> |
| 206 | 225 | <ChatView key={threadId} threadId={threadId} /> |
| 207 | 226 | </SidebarInset> |
| 208 | <DiffPanelInlineSidebar diffOpen={diffOpen} onCloseDiff={closeDiff} onOpenDiff={openDiff} /> | |
| 227 | <DiffPanelInlineSidebar | |
| 228 | diffOpen={diffOpen} | |
| 229 | onCloseDiff={closeDiff} | |
| 230 | onOpenDiff={openDiff} | |
| 231 | renderDiffContent={shouldRenderDiffContent} | |
| 232 | /> | |
| 209 | 233 | </> |
| 210 | 234 | ); |
| 211 | 235 | } |
| ... | ... | @@ -216,9 +240,7 @@ function ChatThreadRouteView() { |
| 216 | 240 | <ChatView key={threadId} threadId={threadId} /> |
| 217 | 241 | </SidebarInset> |
| 218 | 242 | <DiffPanelSheet diffOpen={diffOpen} onCloseDiff={closeDiff}> |
| 219 | <Suspense fallback={<DiffLoadingFallback inline={false} />}> | |
| 220 | <DiffPanel mode="sheet" /> | |
| 221 | </Suspense> | |
| 243 | {shouldRenderDiffContent ? <LazyDiffPanel mode="sheet" /> : null} | |
| 222 | 244 | </DiffPanelSheet> |
| 223 | 245 | </> |
| 224 | 246 | ); |
apps/web/src/routes/_chat.tsx+1-4| ... | ... | @@ -3,7 +3,6 @@ import { useQuery } from "@tanstack/react-query"; |
| 3 | 3 | import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | import { useEffect } from "react"; |
| 5 | 5 | |
| 6 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | |
| 7 | 6 | import ThreadSidebar from "../components/Sidebar"; |
| 8 | 7 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 9 | 8 | import { isTerminalFocused } from "../lib/terminalFocus"; |
| ... | ... | @@ -120,9 +119,7 @@ function ChatRouteLayout() { |
| 120 | 119 | > |
| 121 | 120 | <ThreadSidebar /> |
| 122 | 121 | </Sidebar> |
| 123 | <DiffWorkerPoolProvider> | |
| 124 | <Outlet /> | |
| 125 | </DiffWorkerPoolProvider> | |
| 122 | <Outlet /> | |
| 126 | 123 | </SidebarProvider> |
| 127 | 124 | ); |
| 128 | 125 | } |