| ... | @@ -2,8 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; | ... | @@ -2,8 +2,8 @@ import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; | 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; |
| 3 | import { useQuery } from "@tanstack/react-query"; | 3 | import { useQuery } from "@tanstack/react-query"; |
| 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | import { Columns2Icon, Rows3Icon } from "lucide-react"; | 5 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 6 | import { useEffect, useMemo, useRef, useState } from "react"; | 6 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 7 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 7 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 8 | import { cn } from "~/lib/utils"; | 8 | import { cn } from "~/lib/utils"; |
| 9 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 9 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| ... | @@ -12,7 +12,6 @@ import { useNativeApi } from "../hooks/useNativeApi"; | ... | @@ -12,7 +12,6 @@ import { useNativeApi } from "../hooks/useNativeApi"; |
| 12 | import { useTheme } from "../hooks/useTheme"; | 12 | import { useTheme } from "../hooks/useTheme"; |
| 13 | import { buildPatchCacheKey } from "../lib/diffRendering"; | 13 | import { buildPatchCacheKey } from "../lib/diffRendering"; |
| 14 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; | 14 | import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; |
| 15 | import { formatTimestamp } from "../session-logic"; | | |
| 16 | import { useStore } from "../store"; | 15 | import { useStore } from "../store"; |
| 17 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; | 16 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; |
| 18 | | 17 | |
| ... | @@ -68,6 +67,13 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { | ... | @@ -68,6 +67,13 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { |
| 68 | return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; | 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 | interface DiffPanelProps { | 77 | interface DiffPanelProps { |
| 72 | mode?: "inline" | "sheet"; | 78 | mode?: "inline" | "sheet"; |
| 73 | } | 79 | } |
| ... | @@ -81,6 +87,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -81,6 +87,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 81 | const { state } = useStore(); | 87 | const { state } = useStore(); |
| 82 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); | 88 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 83 | const patchViewportRef = useRef<HTMLDivElement>(null); | 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 | const params = useParams({ strict: false }); | 93 | const params = useParams({ strict: false }); |
| 85 | const rawSearch = useSearch({ strict: false }); | 94 | const rawSearch = useSearch({ strict: false }); |
| 86 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; | 95 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; |
| ... | @@ -217,6 +226,64 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -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 | const shouldUseDragRegion = isElectron && mode === "inline"; | 288 | const shouldUseDragRegion = isElectron && mode === "inline"; |
| 222 | | 289 | |
| ... | @@ -231,49 +298,98 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -231,49 +298,98 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 231 | > | 298 | > |
| 232 | <div | 299 | <div |
| 233 | className={cn( | 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 | shouldUseDragRegion ? "drag-region h-[52px]" : "py-3", | 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"> | 305 | <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]"> |
| 239 | <button type="button" className="shrink-0 rounded-md" onClick={selectWholeConversation}> | 306 | {canScrollTurnStripLeft && ( |
| 240 | <div | 307 | <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-gradient-to-r from-card to-transparent" /> |
| 241 | className={cn( | 308 | )} |
| 242 | "rounded-md border px-2 py-1 text-left transition-colors", | 309 | {canScrollTurnStripRight && ( |
| 243 | selectedTurnId === null | 310 | <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-gradient-to-l from-card to-transparent" /> |
| 244 | ? "border-border bg-accent text-accent-foreground" | 311 | )} |
| 245 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | 312 | <button |
| 246 | )} | 313 | type="button" |
| 247 | > | 314 | className={cn( |
| 248 | <div className="text-[10px] font-medium">All turns</div> | 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", |
| 249 | <div className="text-[8px] opacity-70">Conversation</div> | 316 | canScrollTurnStripLeft |
| 250 | </div> | 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 | </button> | 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 | <button | 344 | <button |
| 254 | key={summary.turnId} | | |
| 255 | type="button" | 345 | type="button" |
| 256 | className="shrink-0 rounded-md" | 346 | className="shrink-0 rounded-md" |
| 257 | onClick={() => selectTurn(summary.turnId)} | 347 | onClick={selectWholeConversation} |
| 258 | title={summary.turnId} | 348 | data-turn-chip-selected={selectedTurnId === null} |
| 259 | > | 349 | > |
| 260 | <div | 350 | <div |
| 261 | className={cn( | 351 | className={cn( |
| 262 | "rounded-md border px-2 py-1 text-left transition-colors", | 352 | "rounded-md border px-2 py-1 text-left transition-colors", |
| 263 | summary.turnId === selectedTurn?.turnId | 353 | selectedTurnId === null |
| 264 | ? "border-border bg-accent text-accent-foreground" | 354 | ? "border-border bg-accent text-accent-foreground" |
| 265 | : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80", | 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"> | 358 | <div className="text-[10px] leading-tight font-medium">All turns</div> |
| 269 | Turn {turnDiffSummaries.length - index} | | |
| 270 | </div> | | |
| 271 | <div className="text-[8px] opacity-70">{formatTimestamp(summary.completedAt)}</div> | | |
| 272 | </div> | 359 | </div> |
| 273 | </button> | 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 | </div> | 390 | </div> |
| 276 | <ToggleGroup | 391 | <ToggleGroup |
| | 392 | className="shrink-0 [-webkit-app-region:no-drag]" |
| 277 | variant="outline" | 393 | variant="outline" |
| 278 | size="xs" | 394 | size="xs" |
| 279 | value={[diffRenderMode]} | 395 | value={[diffRenderMode]} |