authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 01:14:53-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 01:14:53-08:00
logf3b773f1f0eca53ff6e5a70df4bcb5ae3eb1ffd4
treecec6bc0a1353aaa78975d378b1b0b000c46a5212
parent564474f92711bbd496376b8abe4ee7500c2a8d5f

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

2 files changed, 159 insertions(+), 27 deletions(-)

apps/web/src/components/DiffPanel.tsx+149-27
...@@ -7,8 +7,16 @@ import {...@@ -7,8 +7,16 @@ import {
7} from "@pierre/diffs/react";7} from "@pierre/diffs/react";
8import { useQuery } from "@tanstack/react-query";8import { useQuery } from "@tanstack/react-query";
9import { useParams } from "@tanstack/react-router";9import { useParams } from "@tanstack/react-router";
10import { Columns2Icon, Rows3Icon } from "lucide-react";10import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
11import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";11import {
12 type ReactNode,
13 type WheelEvent as ReactWheelEvent,
14 useCallback,
15 useEffect,
16 useMemo,
17 useRef,
18 useState,
19} from "react";
12import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";20import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
13import { cn } from "~/lib/utils";21import { cn } from "~/lib/utils";
14import { isElectron } from "../env";22import { isElectron } from "../env";
...@@ -16,7 +24,6 @@ import { useNativeApi } from "../hooks/useNativeApi";...@@ -16,7 +24,6 @@ import { useNativeApi } from "../hooks/useNativeApi";
16import { useTheme } from "../hooks/useTheme";24import { useTheme } from "../hooks/useTheme";
17import { buildPatchCacheKey } from "../lib/diffRendering";25import { buildPatchCacheKey } from "../lib/diffRendering";
18import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";26import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
19import { formatTimestamp } from "../session-logic";
20import { useStore } from "../store";27import { useStore } from "../store";
21import { ToggleGroup, Toggle } from "./ui/toggle-group";28import { ToggleGroup, Toggle } from "./ui/toggle-group";
2229
...@@ -72,6 +79,13 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {...@@ -72,6 +79,13 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
72 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;79 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
73}80}
7481
82function formatTurnChipTimestamp(isoDate: string): string {
83 return new Intl.DateTimeFormat(undefined, {
84 hour: "numeric",
85 minute: "2-digit",
86 }).format(new Date(isoDate));
87}
88
75interface DiffPanelProps {89interface DiffPanelProps {
76 mode?: "inline" | "sheet";90 mode?: "inline" | "sheet";
77}91}
...@@ -105,6 +119,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -105,6 +119,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
105 const { state, dispatch } = useStore();119 const { state, dispatch } = useStore();
106 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");120 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
107 const patchViewportRef = useRef<HTMLDivElement>(null);121 const patchViewportRef = useRef<HTMLDivElement>(null);
122 const turnStripRef = useRef<HTMLDivElement>(null);
123 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
124 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
108 const params = useParams({ strict: false });125 const params = useParams({ strict: false });
109 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;126 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
110 const activeThreadId = state.diffThreadId ?? routeThreadId;127 const activeThreadId = state.diffThreadId ?? routeThreadId;
...@@ -231,6 +248,61 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -231,6 +248,61 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
231 threadId: activeThread.id,248 threadId: activeThread.id,
232 });249 });
233 };250 };
251 const updateTurnStripScrollState = useCallback(() => {
252 const element = turnStripRef.current;
253 if (!element) {
254 setCanScrollTurnStripLeft(false);
255 setCanScrollTurnStripRight(false);
256 return;
257 }
258
259 const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
260 setCanScrollTurnStripLeft(element.scrollLeft > 4);
261 setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4);
262 }, []);
263 const scrollTurnStripBy = useCallback((offset: number) => {
264 const element = turnStripRef.current;
265 if (!element) return;
266 element.scrollBy({ left: offset, behavior: "smooth" });
267 }, []);
268 const onTurnStripWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
269 const element = turnStripRef.current;
270 if (!element) return;
271 if (element.scrollWidth <= element.clientWidth + 1) return;
272 if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
273
274 event.preventDefault();
275 element.scrollBy({ left: event.deltaY, behavior: "auto" });
276 }, []);
277
278 useEffect(() => {
279 const element = turnStripRef.current;
280 if (!element) return;
281
282 updateTurnStripScrollState();
283 const onScroll = () => updateTurnStripScrollState();
284 element.addEventListener("scroll", onScroll, { passive: true });
285
286 const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
287 resizeObserver.observe(element);
288
289 return () => {
290 element.removeEventListener("scroll", onScroll);
291 resizeObserver.disconnect();
292 };
293 }, [updateTurnStripScrollState]);
294
295 useEffect(() => {
296 updateTurnStripScrollState();
297 }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);
298
299 useEffect(() => {
300 const element = turnStripRef.current;
301 if (!element) return;
302
303 const selectedChip = element.querySelector<HTMLElement>("[data-turn-chip-selected='true']");
304 selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
305 }, [selectedTurn?.turnId, selectedTurnId]);
234306
235 const shouldUseDragRegion = isElectron && mode === "inline";307 const shouldUseDragRegion = isElectron && mode === "inline";
236308
...@@ -245,49 +317,99 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -245,49 +317,99 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
245 >317 >
246 <div318 <div
247 className={cn(319 className={cn(
248 "flex items-center justify-between border-b border-border px-4",320 "flex items-center justify-between gap-2 border-b border-border px-4",
249 shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",321 shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",
250 )}322 )}
251 >323 >
252 <div className="flex gap-1.5 overflow-x-auto pb-0.5">324 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
253 <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation}>325 {canScrollTurnStripLeft && (
254 <div326 <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-gradient-to-r from-card to-transparent" />
255 className={cn(327 )}
256 "rounded-md border px-2 py-1 text-left transition-colors",328 {canScrollTurnStripRight && (
257 selectedTurnId === null329 <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-gradient-to-l from-card to-transparent" />
258 ? "border-border bg-accent text-accent-foreground"330 )}
259 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",331 <button
260 )}332 type="button"
261 >333 className={cn(
262 <div className="text-[10px] font-medium">All turns</div>334 "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",
263 <div className="text-[8px] opacity-70">Conversation</div>335 canScrollTurnStripLeft
264 </div>336 ? "border-border/70 hover:border-border hover:text-foreground"
337 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
338 )}
339 onClick={() => scrollTurnStripBy(-180)}
340 disabled={!canScrollTurnStripLeft}
341 aria-label="Scroll turn list left"
342 >
343 <ChevronLeftIcon className="size-3.5" />
344 </button>
345 <button
346 type="button"
347 className={cn(
348 "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",
349 canScrollTurnStripRight
350 ? "border-border/70 hover:border-border hover:text-foreground"
351 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
352 )}
353 onClick={() => scrollTurnStripBy(180)}
354 disabled={!canScrollTurnStripRight}
355 aria-label="Scroll turn list right"
356 >
357 <ChevronRightIcon className="size-3.5" />
265 </button>358 </button>
266 {turnDiffSummaries.map((summary, index) => (359 <div
360 ref={turnStripRef}
361 className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
362 onWheel={onTurnStripWheel}
363 >
267 <button364 <button
268 key={summary.turnId}
269 type="button"365 type="button"
270 className="shrink-0 rounded-md"366 className="shrink-0 rounded-md"
271 onClick={() => selectTurn(summary.turnId)}367 onClick={selectWholeConversation}
272 title={summary.turnId}368 data-turn-chip-selected={selectedTurnId === null}
273 >369 >
274 <div370 <div
275 className={cn(371 className={cn(
276 "rounded-md border px-2 py-1 text-left transition-colors",372 "rounded-md border px-2 py-1 text-left transition-colors",
277 summary.turnId === selectedTurn?.turnId373 selectedTurnId === null
278 ? "border-border bg-accent text-accent-foreground"374 ? "border-border bg-accent text-accent-foreground"
279 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",375 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
280 )}376 )}
281 >377 >
282 <div className="text-[10px] font-medium">378 <div className="text-[10px] leading-tight font-medium">All turns</div>
283 Turn {turnDiffSummaries.length - index}
284 </div>
285 <div className="text-[8px] opacity-70">{formatTimestamp(summary.completedAt)}</div>
286 </div>379 </div>
287 </button>380 </button>
288 ))}381 {turnDiffSummaries.map((summary, index) => (
382 <button
383 key={summary.turnId}
384 type="button"
385 className="shrink-0 rounded-md"
386 onClick={() => selectTurn(summary.turnId)}
387 title={summary.turnId}
388 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}
389 >
390 <div
391 className={cn(
392 "rounded-md border px-2 py-1 text-left transition-colors",
393 summary.turnId === selectedTurn?.turnId
394 ? "border-border bg-accent text-accent-foreground"
395 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
396 )}
397 >
398 <div className="flex items-center gap-1">
399 <span className="text-[10px] leading-tight font-medium">
400 Turn {turnDiffSummaries.length - index}
401 </span>
402 <span className="text-[9px] leading-tight opacity-70">
403 {formatTurnChipTimestamp(summary.completedAt)}
404 </span>
405 </div>
406 </div>
407 </button>
408 ))}
409 </div>
289 </div>410 </div>
290 <ToggleGroup411 <ToggleGroup
412 className="shrink-0 [-webkit-app-region:no-drag]"
291 variant="outline"413 variant="outline"
292 size="xs"414 size="xs"
293 value={[diffRenderMode]}415 value={[diffRenderMode]}
apps/web/src/index.css+10
...@@ -190,6 +190,16 @@ input {...@@ -190,6 +190,16 @@ input {
190 background: rgba(255, 255, 255, 0.18);190 background: rgba(255, 255, 255, 0.18);
191}191}
192192
193.turn-chip-strip {
194 scrollbar-width: none;
195 -ms-overflow-style: none;
196 overscroll-behavior-x: contain;
197}
198
199.turn-chip-strip::-webkit-scrollbar {
200 display: none;
201}
202
193/* Terminal drawer scrollbar parity with chat */203/* Terminal drawer scrollbar parity with chat */
194.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical {204.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical {
195 width: 6px !important;205 width: 6px !important;