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 {
77} from "@pierre/diffs/react";
88import { useQuery } from "@tanstack/react-query";
99import { useParams } from "@tanstack/react-router";
10import { Columns2Icon, Rows3Icon } from "lucide-react";
11import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
10import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
11import {
12 type ReactNode,
13 type WheelEvent as ReactWheelEvent,
14 useCallback,
15 useEffect,
16 useMemo,
17 useRef,
18 useState,
19} from "react";
1220import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
1321import { cn } from "~/lib/utils";
1422import { isElectron } from "../env";
......@@ -16,7 +24,6 @@ import { useNativeApi } from "../hooks/useNativeApi";
1624import { useTheme } from "../hooks/useTheme";
1725import { buildPatchCacheKey } from "../lib/diffRendering";
1826import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
19import { formatTimestamp } from "../session-logic";
2027import { useStore } from "../store";
2128import { ToggleGroup, Toggle } from "./ui/toggle-group";
2229
......@@ -72,6 +79,13 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
7279 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
7380}
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
7589interface DiffPanelProps {
7690 mode?: "inline" | "sheet";
7791}
......@@ -105,6 +119,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
105119 const { state, dispatch } = useStore();
106120 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
107121 const patchViewportRef = useRef<HTMLDivElement>(null);
122 const turnStripRef = useRef<HTMLDivElement>(null);
123 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
124 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
108125 const params = useParams({ strict: false });
109126 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
110127 const activeThreadId = state.diffThreadId ?? routeThreadId;
......@@ -231,6 +248,61 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
231248 threadId: activeThread.id,
232249 });
233250 };
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
235307 const shouldUseDragRegion = isElectron && mode === "inline";
236308
......@@ -245,49 +317,99 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
245317 >
246318 <div
247319 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",
249321 shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",
250322 )}
251323 >
252 <div className="flex gap-1.5 overflow-x-auto pb-0.5">
253 <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation}>
254 <div
255 className={cn(
256 "rounded-md border px-2 py-1 text-left transition-colors",
257 selectedTurnId === null
258 ? "border-border bg-accent text-accent-foreground"
259 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
260 )}
261 >
262 <div className="text-[10px] font-medium">All turns</div>
263 <div className="text-[8px] opacity-70">Conversation</div>
264 </div>
324 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
325 {canScrollTurnStripLeft && (
326 <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-gradient-to-r from-card to-transparent" />
327 )}
328 {canScrollTurnStripRight && (
329 <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-gradient-to-l from-card to-transparent" />
330 )}
331 <button
332 type="button"
333 className={cn(
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",
335 canScrollTurnStripLeft
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" />
265358 </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 >
267364 <button
268 key={summary.turnId}
269365 type="button"
270366 className="shrink-0 rounded-md"
271 onClick={() => selectTurn(summary.turnId)}
272 title={summary.turnId}
367 onClick={selectWholeConversation}
368 data-turn-chip-selected={selectedTurnId === null}
273369 >
274370 <div
275371 className={cn(
276372 "rounded-md border px-2 py-1 text-left transition-colors",
277 summary.turnId === selectedTurn?.turnId
373 selectedTurnId === null
278374 ? "border-border bg-accent text-accent-foreground"
279375 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
280376 )}
281377 >
282 <div className="text-[10px] font-medium">
283 Turn {turnDiffSummaries.length - index}
284 </div>
285 <div className="text-[8px] opacity-70">{formatTimestamp(summary.completedAt)}</div>
378 <div className="text-[10px] leading-tight font-medium">All turns</div>
286379 </div>
287380 </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>
289410 </div>
290411 <ToggleGroup
412 className="shrink-0 [-webkit-app-region:no-drag]"
291413 variant="outline"
292414 size="xs"
293415 value={[diffRenderMode]}
apps/web/src/index.css+10
......@@ -190,6 +190,16 @@ input {
190190 background: rgba(255, 255, 255, 0.18);
191191}
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
193203/* Terminal drawer scrollbar parity with chat */
194204.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical {
195205 width: 6px !important;