| ... | ... | @@ -7,8 +7,16 @@ import { |
| 7 | 7 | } from "@pierre/diffs/react"; |
| 8 | 8 | import { useQuery } from "@tanstack/react-query"; |
| 9 | 9 | import { useParams } from "@tanstack/react-router"; |
| 10 | | import { Columns2Icon, Rows3Icon } from "lucide-react"; |
| 11 | | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; |
| 10 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 11 | import { |
| 12 | type ReactNode, |
| 13 | type WheelEvent as ReactWheelEvent, |
| 14 | useCallback, |
| 15 | useEffect, |
| 16 | useMemo, |
| 17 | useRef, |
| 18 | useState, |
| 19 | } from "react"; |
| 12 | 20 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 13 | 21 | import { cn } from "~/lib/utils"; |
| 14 | 22 | import { isElectron } from "../env"; |
| ... | ... | @@ -16,7 +24,6 @@ import { useNativeApi } from "../hooks/useNativeApi"; |
| 16 | 24 | import { useTheme } from "../hooks/useTheme"; |
| 17 | 25 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 18 | 26 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 19 | | import { formatTimestamp } from "../session-logic"; |
| 20 | 27 | import { useStore } from "../store"; |
| 21 | 28 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 22 | 29 | |
| ... | ... | @@ -72,6 +79,13 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { |
| 72 | 79 | return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; |
| 73 | 80 | } |
| 74 | 81 | |
| 82 | function formatTurnChipTimestamp(isoDate: string): string { |
| 83 | return new Intl.DateTimeFormat(undefined, { |
| 84 | hour: "numeric", |
| 85 | minute: "2-digit", |
| 86 | }).format(new Date(isoDate)); |
| 87 | } |
| 88 | |
| 75 | 89 | interface DiffPanelProps { |
| 76 | 90 | mode?: "inline" | "sheet"; |
| 77 | 91 | } |
| ... | ... | @@ -105,6 +119,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 105 | 119 | const { state, dispatch } = useStore(); |
| 106 | 120 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 107 | 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 | 125 | const params = useParams({ strict: false }); |
| 109 | 126 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; |
| 110 | 127 | const activeThreadId = state.diffThreadId ?? routeThreadId; |
| ... | ... | @@ -231,6 +248,61 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 231 | 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]); |
| 234 | 306 | |
| 235 | 307 | const shouldUseDragRegion = isElectron && mode === "inline"; |
| 236 | 308 | |
| ... | ... | @@ -245,49 +317,99 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 245 | 317 | > |
| 246 | 318 | <div |
| 247 | 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 | 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"> |
| 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" /> |
| 265 | 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 | 364 | <button |
| 268 | | key={summary.turnId} |
| 269 | 365 | type="button" |
| 270 | 366 | className="shrink-0 rounded-md" |
| 271 | | onClick={() => selectTurn(summary.turnId)} |
| 272 | | title={summary.turnId} |
| 367 | onClick={selectWholeConversation} |
| 368 | data-turn-chip-selected={selectedTurnId === null} |
| 273 | 369 | > |
| 274 | 370 | <div |
| 275 | 371 | className={cn( |
| 276 | 372 | "rounded-md border px-2 py-1 text-left transition-colors", |
| 277 | | summary.turnId === selectedTurn?.turnId |
| 373 | selectedTurnId === null |
| 278 | 374 | ? "border-border bg-accent text-accent-foreground" |
| 279 | 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"> |
| 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> |
| 286 | 379 | </div> |
| 287 | 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 | 410 | </div> |
| 290 | 411 | <ToggleGroup |
| 412 | className="shrink-0 [-webkit-app-region:no-drag]" |
| 291 | 413 | variant="outline" |
| 292 | 414 | size="xs" |
| 293 | 415 | value={[diffRenderMode]} |