diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 33149c5088a7f5082a821512302c953f4295a4a1..4b867209108223ed0e1ac0dcf4d9d504facdab99 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -2,8 +2,8 @@ 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 { Columns2Icon, Rows3Icon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -12,7 +12,6 @@ import { useNativeApi } from "../hooks/useNativeApi"; import { useTheme } from "../hooks/useTheme"; import { buildPatchCacheKey } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { formatTimestamp } from "../session-logic"; import { useStore } from "../store"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; @@ -68,6 +67,13 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; } +function formatTurnChipTimestamp(isoDate: string): string { + return new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }).format(new Date(isoDate)); +} + interface DiffPanelProps { mode?: "inline" | "sheet"; } @@ -81,6 +87,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const { state } = useStore(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const patchViewportRef = useRef(null); + const turnStripRef = useRef(null); + const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); + const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const params = useParams({ strict: false }); const rawSearch = useSearch({ strict: false }); const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; @@ -217,6 +226,64 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, }); }; + 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: WheelEvent) => { + 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; + + updateTurnStripScrollState(); + const onScroll = () => updateTurnStripScrollState(); + + element.addEventListener("scroll", onScroll, { passive: true }); + element.addEventListener("wheel", onTurnStripWheel, { passive: false }); + + const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); + resizeObserver.observe(element); + + return () => { + element.removeEventListener("scroll", onScroll); + element.removeEventListener("wheel", onTurnStripWheel); + resizeObserver.disconnect(); + }; + }, [updateTurnStripScrollState, onTurnStripWheel]); + + useEffect(() => { + updateTurnStripScrollState(); + }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); + + useEffect(() => { + const element = turnStripRef.current; + if (!element) return; + + const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); + selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); + }, [selectedTurn?.turnId, selectedTurnId]); const shouldUseDragRegion = isElectron && mode === "inline"; @@ -231,49 +298,98 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { >
-
- - {turnDiffSummaries.map((summary, index) => ( + +
- ))} + {turnDiffSummaries.map((summary, index) => ( + + ))} +
.scrollbar.vertical { width: 6px !important;