authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 16:09:52-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-02-19 16:09:52-08:00
log749a2076d8ad713eef9ef575ff40437abd3adcd8
treedd4df39132f84ab40cb0f0ce9bacc581ead45983
parent70a13c298f50be8c620020708d8b1db6ae80557e
parentf039878edd64cac3cd7b3e5d95d41e228544b3aa
signature Signed by PGP key B5690EEEBB952194

Merge pull request #77 from pingdotgg/codething/c71d8ea1

Improve DiffPanel turn chip strip navigation and scrolling UX

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

apps/web/src/components/DiffPanel.tsx+143-27
......@@ -2,8 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs";
22import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
33import { useQuery } from "@tanstack/react-query";
44import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
5import { Columns2Icon, Rows3Icon } from "lucide-react";
6import { useEffect, useMemo, useRef, useState } from "react";
5import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
6import { useCallback, useEffect, useMemo, useRef, useState } from "react";
77import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
88import { cn } from "~/lib/utils";
99import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
......@@ -12,7 +12,6 @@ import { useNativeApi } from "../hooks/useNativeApi";
1212import { useTheme } from "../hooks/useTheme";
1313import { buildPatchCacheKey } from "../lib/diffRendering";
1414import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
15import { formatTimestamp } from "../session-logic";
1615import { useStore } from "../store";
1716import { ToggleGroup, Toggle } from "./ui/toggle-group";
1817
......@@ -68,6 +67,13 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
6867 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
6968}
7069
70function formatTurnChipTimestamp(isoDate: string): string {
71 return new Intl.DateTimeFormat(undefined, {
72 hour: "numeric",
73 minute: "2-digit",
74 }).format(new Date(isoDate));
75}
76
7177interface DiffPanelProps {
7278 mode?: "inline" | "sheet";
7379}
......@@ -81,6 +87,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
8187 const { state } = useStore();
8288 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
8389 const patchViewportRef = useRef<HTMLDivElement>(null);
90 const turnStripRef = useRef<HTMLDivElement>(null);
91 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
92 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
8493 const params = useParams({ strict: false });
8594 const rawSearch = useSearch({ strict: false });
8695 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
......@@ -217,6 +226,64 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
217226 },
218227 });
219228 };
229 const updateTurnStripScrollState = useCallback(() => {
230 const element = turnStripRef.current;
231 if (!element) {
232 setCanScrollTurnStripLeft(false);
233 setCanScrollTurnStripRight(false);
234 return;
235 }
236
237 const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
238 setCanScrollTurnStripLeft(element.scrollLeft > 4);
239 setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4);
240 }, []);
241 const scrollTurnStripBy = useCallback((offset: number) => {
242 const element = turnStripRef.current;
243 if (!element) return;
244 element.scrollBy({ left: offset, behavior: "smooth" });
245 }, []);
246 const onTurnStripWheel = useCallback((event: WheelEvent) => {
247 const element = turnStripRef.current;
248 if (!element) return;
249 if (element.scrollWidth <= element.clientWidth + 1) return;
250 if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
251
252 event.preventDefault();
253 element.scrollBy({ left: event.deltaY, behavior: "auto" });
254 }, []);
255
256 useEffect(() => {
257 const element = turnStripRef.current;
258 if (!element) return;
259
260 updateTurnStripScrollState();
261 const onScroll = () => updateTurnStripScrollState();
262
263 element.addEventListener("scroll", onScroll, { passive: true });
264 element.addEventListener("wheel", onTurnStripWheel, { passive: false });
265
266 const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState());
267 resizeObserver.observe(element);
268
269 return () => {
270 element.removeEventListener("scroll", onScroll);
271 element.removeEventListener("wheel", onTurnStripWheel);
272 resizeObserver.disconnect();
273 };
274 }, [updateTurnStripScrollState, onTurnStripWheel]);
275
276 useEffect(() => {
277 updateTurnStripScrollState();
278 }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]);
279
280 useEffect(() => {
281 const element = turnStripRef.current;
282 if (!element) return;
283
284 const selectedChip = element.querySelector<HTMLElement>("[data-turn-chip-selected='true']");
285 selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
286 }, [selectedTurn?.turnId, selectedTurnId]);
220287
221288 const shouldUseDragRegion = isElectron && mode === "inline";
222289
......@@ -231,49 +298,98 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
231298 >
232299 <div
233300 className={cn(
234 "flex items-center justify-between border-b border-border px-4",
301 "flex items-center justify-between gap-2 border-b border-border px-4",
235302 shouldUseDragRegion ? "drag-region h-[52px]" : "py-3",
236303 )}
237304 >
238 <div className="flex gap-1.5 overflow-x-auto pb-0.5">
239 <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation}>
240 <div
241 className={cn(
242 "rounded-md border px-2 py-1 text-left transition-colors",
243 selectedTurnId === null
244 ? "border-border bg-accent text-accent-foreground"
245 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
246 )}
247 >
248 <div className="text-[10px] font-medium">All turns</div>
249 <div className="text-[8px] opacity-70">Conversation</div>
250 </div>
305 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
306 {canScrollTurnStripLeft && (
307 <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-gradient-to-r from-card to-transparent" />
308 )}
309 {canScrollTurnStripRight && (
310 <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-gradient-to-l from-card to-transparent" />
311 )}
312 <button
313 type="button"
314 className={cn(
315 "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",
316 canScrollTurnStripLeft
317 ? "border-border/70 hover:border-border hover:text-foreground"
318 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
319 )}
320 onClick={() => scrollTurnStripBy(-180)}
321 disabled={!canScrollTurnStripLeft}
322 aria-label="Scroll turn list left"
323 >
324 <ChevronLeftIcon className="size-3.5" />
251325 </button>
252 {turnDiffSummaries.map((summary, index) => (
326 <button
327 type="button"
328 className={cn(
329 "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",
330 canScrollTurnStripRight
331 ? "border-border/70 hover:border-border hover:text-foreground"
332 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
333 )}
334 onClick={() => scrollTurnStripBy(180)}
335 disabled={!canScrollTurnStripRight}
336 aria-label="Scroll turn list right"
337 >
338 <ChevronRightIcon className="size-3.5" />
339 </button>
340 <div
341 ref={turnStripRef}
342 className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
343 >
253344 <button
254 key={summary.turnId}
255345 type="button"
256346 className="shrink-0 rounded-md"
257 onClick={() => selectTurn(summary.turnId)}
258 title={summary.turnId}
347 onClick={selectWholeConversation}
348 data-turn-chip-selected={selectedTurnId === null}
259349 >
260350 <div
261351 className={cn(
262352 "rounded-md border px-2 py-1 text-left transition-colors",
263 summary.turnId === selectedTurn?.turnId
353 selectedTurnId === null
264354 ? "border-border bg-accent text-accent-foreground"
265355 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
266356 )}
267357 >
268 <div className="text-[10px] font-medium">
269 Turn {turnDiffSummaries.length - index}
270 </div>
271 <div className="text-[8px] opacity-70">{formatTimestamp(summary.completedAt)}</div>
358 <div className="text-[10px] leading-tight font-medium">All turns</div>
272359 </div>
273360 </button>
274 ))}
361 {turnDiffSummaries.map((summary, index) => (
362 <button
363 key={summary.turnId}
364 type="button"
365 className="shrink-0 rounded-md"
366 onClick={() => selectTurn(summary.turnId)}
367 title={summary.turnId}
368 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}
369 >
370 <div
371 className={cn(
372 "rounded-md border px-2 py-1 text-left transition-colors",
373 summary.turnId === selectedTurn?.turnId
374 ? "border-border bg-accent text-accent-foreground"
375 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
376 )}
377 >
378 <div className="flex items-center gap-1">
379 <span className="text-[10px] leading-tight font-medium">
380 Turn {turnDiffSummaries.length - index}
381 </span>
382 <span className="text-[9px] leading-tight opacity-70">
383 {formatTurnChipTimestamp(summary.completedAt)}
384 </span>
385 </div>
386 </div>
387 </button>
388 ))}
389 </div>
275390 </div>
276391 <ToggleGroup
392 className="shrink-0 [-webkit-app-region:no-drag]"
277393 variant="outline"
278394 size="xs"
279395 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;