From f3b773f1f0eca53ff6e5a70df4bcb5ae3eb1ffd4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 19 Feb 2026 01:14:53 -0800 Subject: [PATCH] Improve DiffPanel turn chip strip navigation - Add left/right controls, edge fades, and wheel-to-horizontal scrolling for turn chips - Auto-scroll selected turn chip into view and hide the chip strip scrollbar - Simplify chip metadata to show compact per-turn time --- apps/web/src/components/DiffPanel.tsx | 176 ++++++++++++++++++++++---- apps/web/src/index.css | 10 ++ 2 files changed, 159 insertions(+), 27 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a2678e03fcd39cd083a724f01645e5a492131f5e..394631c103cba88add74319ad4d586e0a36b6148 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -7,8 +7,16 @@ import { } from "@pierre/diffs/react"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "@tanstack/react-router"; -import { Columns2Icon, Rows3Icon } from "lucide-react"; -import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; +import { + type ReactNode, + type WheelEvent as ReactWheelEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { isElectron } from "../env"; @@ -16,7 +24,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"; @@ -72,6 +79,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"; } @@ -105,6 +119,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const { state, dispatch } = 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 routeThreadId = typeof params.threadId === "string" ? params.threadId : null; const activeThreadId = state.diffThreadId ?? routeThreadId; @@ -231,6 +248,61 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { threadId: activeThread.id, }); }; + 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) => { + 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 }); + + const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); + resizeObserver.observe(element); + + return () => { + element.removeEventListener("scroll", onScroll); + resizeObserver.disconnect(); + }; + }, [updateTurnStripScrollState]); + + 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"; @@ -245,49 +317,99 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { >
-
- - {turnDiffSummaries.map((summary, index) => ( + +
- ))} + {turnDiffSummaries.map((summary, index) => ( + + ))} +
.scrollbar.vertical { width: 6px !important; -- 2.54.0