| ... | ... | @@ -2,8 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; |
| 3 | 3 | import { useQuery } from "@tanstack/react-query"; |
| 4 | 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | | import { Columns2Icon, Rows3Icon } from "lucide-react"; |
| 6 | | import { useEffect, useMemo, useRef, useState } from "react"; |
| 5 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 7 | 7 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 8 | 8 | import { cn } from "~/lib/utils"; |
| 9 | 9 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| ... | ... | @@ -12,7 +12,6 @@ import { useNativeApi } from "../hooks/useNativeApi"; |
| 12 | 12 | import { useTheme } from "../hooks/useTheme"; |
| 13 | 13 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 14 | 14 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 15 | | import { formatTimestamp } from "../session-logic"; |
| 16 | 15 | import { useStore } from "../store"; |
| 17 | 16 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 18 | 17 | |
| ... | ... | @@ -68,6 +67,13 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { |
| 68 | 67 | return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; |
| 69 | 68 | } |
| 70 | 69 | |
| 70 | function formatTurnChipTimestamp(isoDate: string): string { |
| 71 | return new Intl.DateTimeFormat(undefined, { |
| 72 | hour: "numeric", |
| 73 | minute: "2-digit", |
| 74 | }).format(new Date(isoDate)); |
| 75 | } |
| 76 | |
| 71 | 77 | interface DiffPanelProps { |
| 72 | 78 | mode?: "inline" | "sheet"; |
| 73 | 79 | } |
| ... | ... | @@ -81,6 +87,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 81 | 87 | const { state } = useStore(); |
| 82 | 88 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 83 | 89 | const patchViewportRef = useRef<HTMLDivElement>(null); |
| 90 | const turnStripRef = useRef<HTMLDivElement>(null); |
| 91 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); |
| 92 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); |
| 84 | 93 | const params = useParams({ strict: false }); |
| 85 | 94 | const rawSearch = useSearch({ strict: false }); |
| 86 | 95 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; |
| ... | ... | @@ -217,6 +226,64 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 217 | 226 | }, |
| 218 | 227 | }); |
| 219 | 228 | }; |
| 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]); |
| 220 | 287 | |
| 221 | 288 | const shouldUseDragRegion = isElectron && mode === "inline"; |
| 222 | 289 | |
| ... | ... | @@ -231,49 +298,98 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 231 | 298 | > |
| 232 | 299 | <div |
| 233 | 300 | 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", |
| 235 | 302 | shouldUseDragRegion ? "drag-region h-[52px]" : "py-3", |
| 236 | 303 | )} |
| 237 | 304 | > |
| 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" /> |
| 251 | 325 | </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 | > |
| 253 | 344 | <button |
| 254 | | key={summary.turnId} |
| 255 | 345 | type="button" |
| 256 | 346 | className="shrink-0 rounded-md" |
| 257 | | onClick={() => selectTurn(summary.turnId)} |
| 258 | | title={summary.turnId} |
| 347 | onClick={selectWholeConversation} |
| 348 | data-turn-chip-selected={selectedTurnId === null} |
| 259 | 349 | > |
| 260 | 350 | <div |
| 261 | 351 | className={cn( |
| 262 | 352 | "rounded-md border px-2 py-1 text-left transition-colors", |
| 263 | | summary.turnId === selectedTurn?.turnId |
| 353 | selectedTurnId === null |
| 264 | 354 | ? "border-border bg-accent text-accent-foreground" |
| 265 | 355 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", |
| 266 | 356 | )} |
| 267 | 357 | > |
| 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> |
| 272 | 359 | </div> |
| 273 | 360 | </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> |
| 275 | 390 | </div> |
| 276 | 391 | <ToggleGroup |
| 392 | className="shrink-0 [-webkit-app-region:no-drag]" |
| 277 | 393 | variant="outline" |
| 278 | 394 | size="xs" |
| 279 | 395 | value={[diffRenderMode]} |