import { parsePatchFiles } from "@pierre/diffs";import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";import { useQuery } from "@tanstack/react-query";import { useNavigate, useParams, useSearch } from "@tanstack/react-router";import { ThreadId, type TurnId } from "@t3tools/contracts";import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState,} from "react";import { openInPreferredEditor } from "../editorPreferences";import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";import { cn } from "~/lib/utils";import { readNativeApi } from "../nativeApi";import { resolvePathLinkTarget } from "../terminal-links";import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";import { useTheme } from "../hooks/useTheme";import { buildPatchCacheKey } from "../lib/diffRendering";import { resolveDiffThemeName } from "../lib/diffRendering";import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";import { useStore } from "../store";import { useAppSettings } from "../appSettings";import { formatShortTimestamp } from "../timestampFormat";import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split";type DiffThemeType = "light" | "dark"; const DIFF_PANEL_UNSAFE_CSS = `[data-diffs-header],[data-diff],[data-file],[data-error-wrapper],[data-virtualizer-buffer] { --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-token-light-bg: transparent; --diffs-token-dark-bg: transparent; --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive)); --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); --diffs-bg-deletion-emphasis-override: color-mix( in srgb, var(--background) 80%, var(--destructive) ); background-color: var(--diffs-bg) !important;} [data-file-info] { background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; border-block-color: var(--border) !important; color: var(--foreground) !important;} [data-diffs-header] { position: sticky !important; top: 0; z-index: 4; background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; border-bottom: 1px solid var(--border) !important;} [data-title] { cursor: pointer; transition: color 120ms ease, text-decoration-color 120ms ease; text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 2px;} [data-title]:hover { color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; text-decoration-color: currentColor;}`; type RenderablePatch = | { kind: "files"; files: FileDiffMetadata[]; } | { kind: "raw"; text: string; reason: string; }; function getRenderablePatch( patch: string | undefined, cacheScope = "diff-panel",): RenderablePatch | null { if (!patch) return null; const normalizedPatch = patch.trim(); if (normalizedPatch.length === 0) return null; try { const parsedPatches = parsePatchFiles( normalizedPatch, buildPatchCacheKey(normalizedPatch, cacheScope), ); const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); if (files.length > 0) { return { kind: "files", files }; } return { kind: "raw", text: normalizedPatch, reason: "Unsupported diff format. Showing raw patch.", }; } catch { return { kind: "raw", text: normalizedPatch, reason: "Failed to parse patch. Showing raw patch.", }; }} function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { const raw = fileDiff.name ?? fileDiff.prevName ?? ""; if (raw.startsWith("a/") || raw.startsWith("b/")) { return raw.slice(2); } return raw;} function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;} interface DiffPanelProps { mode?: DiffPanelMode;} export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); const { resolvedTheme } = useTheme(); const { settings } = useAppSettings(); const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); const patchViewportRef = useRef<HTMLDivElement>(null); const turnStripRef = useRef<HTMLDivElement>(null); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const activeThreadId = routeThreadId; const activeThread = useStore((store) => activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const orderedTurnDiffSummaries = useMemo( () => [...turnDiffSummaries].toSorted((left, right) => { const leftTurnCount = left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; const rightTurnCount = right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; if (leftTurnCount !== rightTurnCount) { return rightTurnCount - leftTurnCount; } return right.completedAt.localeCompare(left.completedAt); }), [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); const selectedTurnId = diffSearch.diffTurnId ?? null; const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; const selectedTurn = selectedTurnId === null ? undefined : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? orderedTurnDiffSummaries[0]); const selectedCheckpointTurnCount = selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" ? { fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), toTurnCount: selectedCheckpointTurnCount, } : null, [selectedCheckpointTurnCount], ); const conversationCheckpointTurnCount = useMemo(() => { const turnCounts = orderedTurnDiffSummaries .map( (summary) => summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], ) .filter((value): value is number => typeof value === "number"); if (turnCounts.length === 0) { return undefined; } const latest = Math.max(...turnCounts); return latest > 0 ? latest : undefined; }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); const conversationCheckpointRange = useMemo( () => !selectedTurn && typeof conversationCheckpointTurnCount === "number" ? { fromTurnCount: 0, toTurnCount: conversationCheckpointTurnCount, } : null, [conversationCheckpointTurnCount, selectedTurn], ); const activeCheckpointRange = selectedTurn ? selectedCheckpointRange : conversationCheckpointRange; const conversationCacheScope = useMemo(() => { if (selectedTurn || orderedTurnDiffSummaries.length === 0) { return null; } return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; }, [orderedTurnDiffSummaries, selectedTurn]); const activeCheckpointDiffQuery = useQuery( checkpointDiffQueryOptions({ threadId: activeThreadId, fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, enabled: isGitRepo, }), ); const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiffQuery.data?.diff : undefined; const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiffQuery.data?.diff; const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; const checkpointDiffError = activeCheckpointDiffQuery.error instanceof Error ? activeCheckpointDiffQuery.error.message : activeCheckpointDiffQuery.error ? "Failed to load checkpoint diff." : null; const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo( () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), [resolvedTheme, selectedPatch], ); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { return []; } return renderablePatch.files.toSorted((left, right) => resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { numeric: true, sensitivity: "base", }), ); }, [renderablePatch]); useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { return; } const target = Array.from( patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"), ).find((element) => element.dataset.diffFilePath === selectedFilePath); target?.scrollIntoView({ block: "nearest" }); }, [selectedFilePath, renderableFiles]); const openDiffFileInEditor = useCallback( (filePath: string) => { const api = readNativeApi(); if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; void openInPreferredEditor(api, targetPath).catch((error) => { console.warn("Failed to open diff file in editor.", error); }); }, [activeCwd], ); const selectTurn = (turnId: TurnId) => { if (!activeThread) return; void navigate({ to: "/$threadId", params: { threadId: activeThread.id }, search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1", diffTurnId: turnId }; }, }); }; const selectWholeConversation = () => { if (!activeThread) return; void navigate({ to: "/$threadId", params: { threadId: activeThread.id }, search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1" }; }, }); }; const updateTurnStripScrollState = useCallback(() => { const element = turnStripRef.current; if (!element) { setCanScrollTurnStripLeft(false); setCanScrollTurnStripRight(false); return; } const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); setCanScrollTurnStripLeft(element.scrollLeft > 4); setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); }, []); const scrollTurnStripBy = useCallback((offset: number) => { const element = turnStripRef.current; if (!element) return; element.scrollBy({ left: offset, behavior: "smooth" }); }, []); const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => { const element = turnStripRef.current; if (!element) return; if (element.scrollWidth <= element.clientWidth + 1) return; if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; event.preventDefault(); element.scrollBy({ left: event.deltaY, behavior: "auto" }); }, []); useEffect(() => { const element = turnStripRef.current; if (!element) return; const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); const onScroll = () => updateTurnStripScrollState(); element.addEventListener("scroll", onScroll, { passive: true }); const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); resizeObserver.observe(element); return () => { window.cancelAnimationFrame(frameId); element.removeEventListener("scroll", onScroll); resizeObserver.disconnect(); }; }, [updateTurnStripScrollState]); useEffect(() => { const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); return () => { window.cancelAnimationFrame(frameId); }; }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); useEffect(() => { const element = turnStripRef.current; if (!element) return; const selectedChip = element.querySelector<HTMLElement>("[data-turn-chip-selected='true']"); selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); }, [selectedTurn?.turnId, selectedTurnId]); const headerRow = ( <> <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]"> {canScrollTurnStripLeft && ( <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-linear-to-r from-card to-transparent" /> )} {canScrollTurnStripRight && ( <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-linear-to-l from-card to-transparent" /> )} <button type="button" className={cn( "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", canScrollTurnStripLeft ? "border-border/70 hover:border-border hover:text-foreground" : "cursor-not-allowed border-border/40 text-muted-foreground/40", )} onClick={() => scrollTurnStripBy(-180)} disabled={!canScrollTurnStripLeft} aria-label="Scroll turn list left" > <ChevronLeftIcon className="size-3.5" /> </button> <button type="button" className={cn( "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", canScrollTurnStripRight ? "border-border/70 hover:border-border hover:text-foreground" : "cursor-not-allowed border-border/40 text-muted-foreground/40", )} onClick={() => scrollTurnStripBy(180)} disabled={!canScrollTurnStripRight} aria-label="Scroll turn list right" > <ChevronRightIcon className="size-3.5" /> </button> <div ref={turnStripRef} className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5" onWheel={onTurnStripWheel} > <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation} data-turn-chip-selected={selectedTurnId === null} > <div className={cn( "rounded-md border px-2 py-1 text-left transition-colors", selectedTurnId === null ? "border-border bg-accent text-accent-foreground" : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", )} > <div className="text-[10px] leading-tight font-medium">All turns</div> </div> </button> {orderedTurnDiffSummaries.map((summary) => ( <button key={summary.turnId} type="button" className="shrink-0 rounded-md" onClick={() => selectTurn(summary.turnId)} title={summary.turnId} data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} > <div className={cn( "rounded-md border px-2 py-1 text-left transition-colors", summary.turnId === selectedTurn?.turnId ? "border-border bg-accent text-accent-foreground" : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", )} > <div className="flex items-center gap-1"> <span className="text-[10px] leading-tight font-medium"> Turn{" "} {summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId] ?? "?"} </span> <span className="text-[9px] leading-tight opacity-70"> {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} </span> </div> </div> </button> ))} </div> </div> <ToggleGroup className="shrink-0 [-webkit-app-region:no-drag]" variant="outline" size="xs" value={[diffRenderMode]} onValueChange={(value) => { const next = value[0]; if (next === "stacked" || next === "split") {Lines 1–500 of 609. Load more
import { parsePatchFiles } from "@pierre/diffs"; import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { ThreadId, type TurnId } from "@t3tools/contracts"; import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { openInPreferredEditor } from "../editorPreferences"; import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; import { useAppSettings } from "../appSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; const DIFF_PANEL_UNSAFE_CSS = ` [data-diffs-header], [data-diff], [data-file], [data-error-wrapper], [data-virtualizer-buffer] { --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-token-light-bg: transparent; --diffs-token-dark-bg: transparent; --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success)); --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success)); --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive)); --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive)); --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); --diffs-bg-deletion-emphasis-override: color-mix( in srgb, var(--background) 80%, var(--destructive) ); background-color: var(--diffs-bg) !important; } [data-file-info] { background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; border-block-color: var(--border) !important; color: var(--foreground) !important; } [data-diffs-header] { position: sticky !important; top: 0; z-index: 4; background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; border-bottom: 1px solid var(--border) !important; } [data-title] { cursor: pointer; transition: color 120ms ease, text-decoration-color 120ms ease; text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 2px; } [data-title]:hover { color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; text-decoration-color: currentColor; } `; type RenderablePatch = | { kind: "files"; files: FileDiffMetadata[]; } | { kind: "raw"; text: string; reason: string; }; function getRenderablePatch( patch: string | undefined, cacheScope = "diff-panel", ): RenderablePatch | null { if (!patch) return null; const normalizedPatch = patch.trim(); if (normalizedPatch.length === 0) return null; try { const parsedPatches = parsePatchFiles( normalizedPatch, buildPatchCacheKey(normalizedPatch, cacheScope), ); const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); if (files.length > 0) { return { kind: "files", files }; } return { kind: "raw", text: normalizedPatch, reason: "Unsupported diff format. Showing raw patch.", }; } catch { return { kind: "raw", text: normalizedPatch, reason: "Failed to parse patch. Showing raw patch.", }; } } function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { const raw = fileDiff.name ?? fileDiff.prevName ?? ""; if (raw.startsWith("a/") || raw.startsWith("b/")) { return raw.slice(2); } return raw; } function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; } interface DiffPanelProps { mode?: DiffPanelMode; } export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); const { resolvedTheme } = useTheme(); const { settings } = useAppSettings(); const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); const patchViewportRef = useRef<HTMLDivElement>(null); const turnStripRef = useRef<HTMLDivElement>(null); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const activeThreadId = routeThreadId; const activeThread = useStore((store) => activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const orderedTurnDiffSummaries = useMemo( () => [...turnDiffSummaries].toSorted((left, right) => { const leftTurnCount = left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; const rightTurnCount = right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; if (leftTurnCount !== rightTurnCount) { return rightTurnCount - leftTurnCount; } return right.completedAt.localeCompare(left.completedAt); }), [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], ); const selectedTurnId = diffSearch.diffTurnId ?? null; const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; const selectedTurn = selectedTurnId === null ? undefined : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? orderedTurnDiffSummaries[0]); const selectedCheckpointTurnCount = selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); const selectedCheckpointRange = useMemo( () => typeof selectedCheckpointTurnCount === "number" ? { fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), toTurnCount: selectedCheckpointTurnCount, } : null, [selectedCheckpointTurnCount], ); const conversationCheckpointTurnCount = useMemo(() => { const turnCounts = orderedTurnDiffSummaries .map( (summary) => summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], ) .filter((value): value is number => typeof value === "number"); if (turnCounts.length === 0) { return undefined; } const latest = Math.max(...turnCounts); return latest > 0 ? latest : undefined; }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); const conversationCheckpointRange = useMemo( () => !selectedTurn && typeof conversationCheckpointTurnCount === "number" ? { fromTurnCount: 0, toTurnCount: conversationCheckpointTurnCount, } : null, [conversationCheckpointTurnCount, selectedTurn], ); const activeCheckpointRange = selectedTurn ? selectedCheckpointRange : conversationCheckpointRange; const conversationCacheScope = useMemo(() => { if (selectedTurn || orderedTurnDiffSummaries.length === 0) { return null; } return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; }, [orderedTurnDiffSummaries, selectedTurn]); const activeCheckpointDiffQuery = useQuery( checkpointDiffQueryOptions({ threadId: activeThreadId, fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, enabled: isGitRepo, }), ); const selectedTurnCheckpointDiff = selectedTurn ? activeCheckpointDiffQuery.data?.diff : undefined; const conversationCheckpointDiff = selectedTurn ? undefined : activeCheckpointDiffQuery.data?.diff; const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; const checkpointDiffError = activeCheckpointDiffQuery.error instanceof Error ? activeCheckpointDiffQuery.error.message : activeCheckpointDiffQuery.error ? "Failed to load checkpoint diff." : null; const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo( () => getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`), [resolvedTheme, selectedPatch], ); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { return []; } return renderablePatch.files.toSorted((left, right) => resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { numeric: true, sensitivity: "base", }), ); }, [renderablePatch]); useEffect(() => { if (!selectedFilePath || !patchViewportRef.current) { return; } const target = Array.from( patchViewportRef.current.querySelectorAll<HTMLElement>("[data-diff-file-path]"), ).find((element) => element.dataset.diffFilePath === selectedFilePath); target?.scrollIntoView({ block: "nearest" }); }, [selectedFilePath, renderableFiles]); const openDiffFileInEditor = useCallback( (filePath: string) => { const api = readNativeApi(); if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; void openInPreferredEditor(api, targetPath).catch((error) => { console.warn("Failed to open diff file in editor.", error); }); }, [activeCwd], ); const selectTurn = (turnId: TurnId) => { if (!activeThread) return; void navigate({ to: "/$threadId", params: { threadId: activeThread.id }, search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1", diffTurnId: turnId }; }, }); }; const selectWholeConversation = () => { if (!activeThread) return; void navigate({ to: "/$threadId", params: { threadId: activeThread.id }, search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1" }; }, }); }; const updateTurnStripScrollState = useCallback(() => { const element = turnStripRef.current; if (!element) { setCanScrollTurnStripLeft(false); setCanScrollTurnStripRight(false); return; } const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); setCanScrollTurnStripLeft(element.scrollLeft > 4); setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); }, []); const scrollTurnStripBy = useCallback((offset: number) => { const element = turnStripRef.current; if (!element) return; element.scrollBy({ left: offset, behavior: "smooth" }); }, []); const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => { const element = turnStripRef.current; if (!element) return; if (element.scrollWidth <= element.clientWidth + 1) return; if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; event.preventDefault(); element.scrollBy({ left: event.deltaY, behavior: "auto" }); }, []); useEffect(() => { const element = turnStripRef.current; if (!element) return; const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); const onScroll = () => updateTurnStripScrollState(); element.addEventListener("scroll", onScroll, { passive: true }); const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); resizeObserver.observe(element); return () => { window.cancelAnimationFrame(frameId); element.removeEventListener("scroll", onScroll); resizeObserver.disconnect(); }; }, [updateTurnStripScrollState]); useEffect(() => { const frameId = window.requestAnimationFrame(() => updateTurnStripScrollState()); return () => { window.cancelAnimationFrame(frameId); }; }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); useEffect(() => { const element = turnStripRef.current; if (!element) return; const selectedChip = element.querySelector<HTMLElement>("[data-turn-chip-selected='true']"); selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); }, [selectedTurn?.turnId, selectedTurnId]); const headerRow = ( <> <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]"> {canScrollTurnStripLeft && ( <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-linear-to-r from-card to-transparent" /> )} {canScrollTurnStripRight && ( <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-linear-to-l from-card to-transparent" /> )} <button type="button" className={cn( "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", canScrollTurnStripLeft ? "border-border/70 hover:border-border hover:text-foreground" : "cursor-not-allowed border-border/40 text-muted-foreground/40", )} onClick={() => scrollTurnStripBy(-180)} disabled={!canScrollTurnStripLeft} aria-label="Scroll turn list left" > <ChevronLeftIcon className="size-3.5" /> </button> <button type="button" className={cn( "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors", canScrollTurnStripRight ? "border-border/70 hover:border-border hover:text-foreground" : "cursor-not-allowed border-border/40 text-muted-foreground/40", )} onClick={() => scrollTurnStripBy(180)} disabled={!canScrollTurnStripRight} aria-label="Scroll turn list right" > <ChevronRightIcon className="size-3.5" /> </button> <div ref={turnStripRef} className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5" onWheel={onTurnStripWheel} > <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation} data-turn-chip-selected={selectedTurnId === null} > <div className={cn( "rounded-md border px-2 py-1 text-left transition-colors", selectedTurnId === null ? "border-border bg-accent text-accent-foreground" : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", )} > <div className="text-[10px] leading-tight font-medium">All turns</div> </div> </button> {orderedTurnDiffSummaries.map((summary) => ( <button key={summary.turnId} type="button" className="shrink-0 rounded-md" onClick={() => selectTurn(summary.turnId)} title={summary.turnId} data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} > <div className={cn( "rounded-md border px-2 py-1 text-left transition-colors", summary.turnId === selectedTurn?.turnId ? "border-border bg-accent text-accent-foreground" : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", )} > <div className="flex items-center gap-1"> <span className="text-[10px] leading-tight font-medium"> Turn{" "} {summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId] ?? "?"} </span> <span className="text-[9px] leading-tight opacity-70"> {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} </span> </div> </div> </button> ))} </div> </div> <ToggleGroup className="shrink-0 [-webkit-app-region:no-drag]" variant="outline" size="xs" value={[diffRenderMode]} onValueChange={(value) => { const next = value[0]; if (next === "stacked" || next === "split") {