| ... | @@ -7,8 +7,16 @@ import { | ... | @@ -7,8 +7,16 @@ import { |
| 7 | } from "@pierre/diffs/react"; | 7 | } from "@pierre/diffs/react"; |
| 8 | import { useQuery } from "@tanstack/react-query"; | 8 | import { useQuery } from "@tanstack/react-query"; |
| 9 | import { useParams } from "@tanstack/react-router"; | 9 | import { useParams } from "@tanstack/react-router"; |
| 10 | import { Columns2Icon, Rows3Icon } from "lucide-react"; | 10 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 11 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "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 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 20 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 13 | import { cn } from "~/lib/utils"; | 21 | import { cn } from "~/lib/utils"; |
| 14 | import { isElectron } from "../env"; | 22 | import { isElectron } from "../env"; |
| ... | @@ -16,7 +24,6 @@ import { useNativeApi } from "../hooks/useNativeApi"; | ... | @@ -16,7 +24,6 @@ import { useNativeApi } from "../hooks/useNativeApi"; |
| 16 | import { useTheme } from "../hooks/useTheme"; | 24 | import { useTheme } from "../hooks/useTheme"; |
| 17 | import { buildPatchCacheKey } from "../lib/diffRendering"; | 25 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 18 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 26 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 19 | import { formatTimestamp } from "../session-logic"; | | |
| 20 | import { useStore } from "../store"; | 27 | import { useStore } from "../store"; |
| 21 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; | 28 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 22 | | 29 | |
| ... | @@ -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 | } |
| 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 | interface DiffPanelProps { | 89 | interface 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]); |
| 234 | | 306 | |
| 235 | const shouldUseDragRegion = isElectron && mode === "inline"; | 307 | const shouldUseDragRegion = isElectron && mode === "inline"; |
| 236 | | 308 | |
| ... | @@ -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 | <div | 318 | <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 | <div | 326 | <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 === null | 329 | <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 | <button | 364 | <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 | <div | 370 | <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?.turnId | 373 | 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 | <ToggleGroup | 411 | <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]} |