| author | |
| committer | |
| log | 867c122082c18d550965e7060dc603e0dcbca862 |
| tree | bba5bd6597d2d4d0aeb6d6958d1ba095a1a8c9bd |
| parent | cf8a2f03fc1c46b70faa565b2e00625cb8b58ebd |
- add reusable `ui/toggle` and `ui/toggle-group` components built on Base UI
- switch diff view mode controls and chat header diff toggle to use the new toggle primitives4 files changed, 189 insertions(+), 77 deletions(-)
apps/web/src/components/ChatView.tsx+32-15| ... | @@ -112,6 +112,7 @@ import { | ... | @@ -112,6 +112,7 @@ import { |
| 112 | projectScriptIdFromCommand, | 112 | projectScriptIdFromCommand, |
| 113 | setupProjectScript, | 113 | setupProjectScript, |
| 114 | } from "~/projectScripts"; | 114 | } from "~/projectScripts"; |
| 115 | import { Toggle } from "./ui/toggle"; | ||
| 115 | 116 | ||
| 116 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 117 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 117 | if (!duration) return formatTimestamp(createdAt); | 118 | if (!duration) return formatTimestamp(createdAt); |
| ... | @@ -377,7 +378,8 @@ export default function ChatView() { | ... | @@ -377,7 +378,8 @@ export default function ChatView() { |
| 377 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | 378 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 378 | const activeThreadId = activeThread?.id ?? null; | 379 | const activeThreadId = activeThread?.id ?? null; |
| 379 | const activeSessionId = activeThread?.session?.sessionId; | 380 | const activeSessionId = activeThread?.session?.sessionId; |
| 380 | const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | 381 | const activeThreadRuntimeId = |
| 382 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | ||
| 381 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | 383 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 382 | const selectedModel = resolveModelSlug( | 384 | const selectedModel = resolveModelSlug( |
| 383 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, | 385 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| ... | @@ -499,7 +501,8 @@ export default function ChatView() { | ... | @@ -499,7 +501,8 @@ export default function ChatView() { |
| 499 | } | 501 | } |
| 500 | 502 | ||
| 501 | const turnSummariesNeedingDiff = turnDiffSummaries.filter( | 503 | const turnSummariesNeedingDiff = turnDiffSummaries.filter( |
| 502 | (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], | 504 | (summary) => |
| 505 | !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], | ||
| 503 | ); | 506 | ); |
| 504 | const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => { | 507 | const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => { |
| 505 | const requestKey = `${activeThreadId}:${summary.turnId}`; | 508 | const requestKey = `${activeThreadId}:${summary.turnId}`; |
| ... | @@ -1463,7 +1466,15 @@ export default function ChatView() { | ... | @@ -1463,7 +1466,15 @@ export default function ChatView() { |
| 1463 | setIsConnecting(false); | 1466 | setIsConnecting(false); |
| 1464 | } | 1467 | } |
| 1465 | }, | 1468 | }, |
| 1466 | [activeProject, activeThread, api, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel], | 1469 | [ |
| 1470 | activeProject, | ||
| 1471 | activeThread, | ||
| 1472 | api, | ||
| 1473 | dispatch, | ||
| 1474 | runtimeApprovalPolicy, | ||
| 1475 | runtimeSandboxMode, | ||
| 1476 | selectedModel, | ||
| 1477 | ], | ||
| 1467 | ); | 1478 | ); |
| 1468 | 1479 | ||
| 1469 | useEffect(() => { | 1480 | useEffect(() => { |
| ... | @@ -1474,14 +1485,16 @@ export default function ChatView() { | ... | @@ -1474,14 +1485,16 @@ export default function ChatView() { |
| 1474 | const selectedDiffTurnId = | 1485 | const selectedDiffTurnId = |
| 1475 | state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; | 1486 | state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; |
| 1476 | const selectedDiffTurn = | 1487 | const selectedDiffTurn = |
| 1477 | turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? turnDiffSummaries[0]; | 1488 | turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? |
| 1489 | turnDiffSummaries[0]; | ||
| 1478 | const selectedTurnMissingPatchBody = Boolean( | 1490 | const selectedTurnMissingPatchBody = Boolean( |
| 1479 | selectedDiffTurn && | 1491 | selectedDiffTurn && |
| 1480 | !selectedDiffTurn.unifiedDiff && | 1492 | !selectedDiffTurn.unifiedDiff && |
| 1481 | selectedDiffTurn.files.every((file) => !file.diff), | 1493 | selectedDiffTurn.files.every((file) => !file.diff), |
| 1482 | ); | 1494 | ); |
| 1483 | const hasPendingDiffHydration = | 1495 | const hasPendingDiffHydration = |
| 1484 | turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || selectedTurnMissingPatchBody; | 1496 | turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || |
| 1497 | selectedTurnMissingPatchBody; | ||
| 1485 | if (!hasPendingDiffHydration) return; | 1498 | if (!hasPendingDiffHydration) return; |
| 1486 | 1499 | ||
| 1487 | const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; | 1500 | const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; |
| ... | @@ -2310,15 +2323,15 @@ const ChatHeader = memo(function ChatHeader({ | ... | @@ -2310,15 +2323,15 @@ const ChatHeader = memo(function ChatHeader({ |
| 2310 | )} | 2323 | )} |
| 2311 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} | 2324 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} |
| 2312 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} | 2325 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} |
| 2313 | <Button | 2326 | <Toggle |
| 2327 | pressed={diffOpen} | ||
| 2328 | onPressedChange={onToggleDiff} | ||
| 2314 | aria-label="Toggle diff panel" | 2329 | aria-label="Toggle diff panel" |
| 2315 | size="icon-xs" | ||
| 2316 | variant="outline" | 2330 | variant="outline" |
| 2317 | className={cn(diffOpen && "bg-accent text-accent-foreground")} | 2331 | size="xs" |
| 2318 | onClick={onToggleDiff} | ||
| 2319 | > | 2332 | > |
| 2320 | <DiffIcon /> | 2333 | <DiffIcon className="size-3" /> |
| 2321 | </Button> | 2334 | </Toggle> |
| 2322 | </div> | 2335 | </div> |
| 2323 | </> | 2336 | </> |
| 2324 | ); | 2337 | ); |
| ... | @@ -2620,7 +2633,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2620,7 +2633,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2620 | } | 2633 | } |
| 2621 | /> | 2634 | /> |
| 2622 | {(() => { | 2635 | {(() => { |
| 2623 | const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id); | 2636 | const turnSummary = turnDiffSummaryByAssistantMessageId.get( |
| 2637 | timelineEntry.message.id, | ||
| 2638 | ); | ||
| 2624 | if (!turnSummary) return null; | 2639 | if (!turnSummary) return null; |
| 2625 | const isCheckpointDiffLoading = | 2640 | const isCheckpointDiffLoading = |
| 2626 | !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; | 2641 | !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; |
| ... | @@ -2666,7 +2681,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2666,7 +2681,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2666 | type="button" | 2681 | type="button" |
| 2667 | size="xs" | 2682 | size="xs" |
| 2668 | variant="outline" | 2683 | variant="outline" |
| 2669 | onClick={() => onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)} | 2684 | onClick={() => |
| 2685 | onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path) | ||
| 2686 | } | ||
| 2670 | > | 2687 | > |
| 2671 | View diff | 2688 | View diff |
| 2672 | </Button> | 2689 | </Button> |
apps/web/src/components/DiffPanel.tsx+14-62| ... | @@ -14,6 +14,7 @@ import { useStore } from "../store"; | ... | @@ -14,6 +14,7 @@ import { useStore } from "../store"; |
| 14 | import { Button } from "./ui/button"; | 14 | import { Button } from "./ui/button"; |
| 15 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | 15 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| 16 | import { cn } from "~/lib/utils"; | 16 | import { cn } from "~/lib/utils"; |
| 17 | import { ToggleGroup, Toggle } from "./ui/toggle-group"; | ||
| 17 | 18 | ||
| 18 | type DiffRenderMode = "stacked" | "split"; | 19 | type DiffRenderMode = "stacked" | "split"; |
| 19 | 20 | ||
| ... | @@ -406,68 +407,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -406,68 +407,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 406 | </button> | 407 | </button> |
| 407 | ))} | 408 | ))} |
| 408 | </div> | 409 | </div> |
| 409 | <div className="flex items-center gap-1"> | 410 | <ToggleGroup |
| 410 | <Popover> | 411 | variant="outline" |
| 411 | <PopoverTrigger | 412 | size="xs" |
| 412 | openOnHover | 413 | value={[diffRenderMode]} |
| 413 | render={ | 414 | onValueChange={(value) => setDiffRenderMode(value[0] as DiffRenderMode)} |
| 414 | <Button | 415 | > |
| 415 | aria-label="Stacked diff view" | 416 | <Toggle aria-label="Stacked diff view" value="stacked"> |
| 416 | aria-pressed={diffRenderMode === "stacked"} | 417 | <Rows3Icon className="size-3" /> |
| 417 | size="icon-xs" | 418 | </Toggle> |
| 418 | variant="outline" | 419 | <Toggle aria-label="Split diff view" value="split"> |
| 419 | className={cn(diffRenderMode === "stacked" && "bg-accent text-accent-foreground")} | 420 | <Columns2Icon className="size-3" /> |
| 420 | onClick={() => setDiffRenderMode("stacked")} | 421 | </Toggle> |
| 421 | /> | 422 | </ToggleGroup> |
| 422 | } | ||
| 423 | > | ||
| 424 | <Rows3Icon /> | ||
| 425 | </PopoverTrigger> | ||
| 426 | <PopoverPopup | ||
| 427 | tooltipStyle | ||
| 428 | side="bottom" | ||
| 429 | sideOffset={6} | ||
| 430 | align="center" | ||
| 431 | className="pointer-events-none select-none" | ||
| 432 | > | ||
| 433 | Stacked | ||
| 434 | </PopoverPopup> | ||
| 435 | </Popover> | ||
| 436 | <Popover> | ||
| 437 | <PopoverTrigger | ||
| 438 | openOnHover | ||
| 439 | render={ | ||
| 440 | <Button | ||
| 441 | aria-label="Split diff view" | ||
| 442 | aria-pressed={diffRenderMode === "split"} | ||
| 443 | size="icon-xs" | ||
| 444 | variant="outline" | ||
| 445 | className={cn(diffRenderMode === "split" && "bg-accent text-accent-foreground")} | ||
| 446 | onClick={() => setDiffRenderMode("split")} | ||
| 447 | /> | ||
| 448 | } | ||
| 449 | > | ||
| 450 | <Columns2Icon /> | ||
| 451 | </PopoverTrigger> | ||
| 452 | <PopoverPopup | ||
| 453 | tooltipStyle | ||
| 454 | side="bottom" | ||
| 455 | sideOffset={6} | ||
| 456 | align="center" | ||
| 457 | className="pointer-events-none select-none" | ||
| 458 | > | ||
| 459 | Split | ||
| 460 | </PopoverPopup> | ||
| 461 | </Popover> | ||
| 462 | <Button | ||
| 463 | type="button" | ||
| 464 | size="icon-xs" | ||
| 465 | variant="ghost" | ||
| 466 | onClick={() => dispatch({ type: "CLOSE_DIFF" })} | ||
| 467 | > | ||
| 468 | <XIcon /> | ||
| 469 | </Button> | ||
| 470 | </div> | ||
| 471 | </div> | 423 | </div> |
| 472 | 424 | ||
| 473 | {!activeThread ? ( | 425 | {!activeThread ? ( |
apps/web/src/components/ui/toggle-group.tsx created+97| ... | @@ -0,0 +1,97 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import type { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; | ||
| 4 | import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"; | ||
| 5 | import type { VariantProps } from "class-variance-authority"; | ||
| 6 | import * as React from "react"; | ||
| 7 | |||
| 8 | import { cn } from "~/lib/utils"; | ||
| 9 | import { Separator } from "~/components/ui/separator"; | ||
| 10 | import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle"; | ||
| 11 | |||
| 12 | const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({ | ||
| 13 | size: "default", | ||
| 14 | variant: "default", | ||
| 15 | }); | ||
| 16 | |||
| 17 | function ToggleGroup({ | ||
| 18 | className, | ||
| 19 | variant = "default", | ||
| 20 | size = "default", | ||
| 21 | orientation = "horizontal", | ||
| 22 | children, | ||
| 23 | ...props | ||
| 24 | }: ToggleGroupPrimitive.Props & VariantProps<typeof toggleVariants>) { | ||
| 25 | return ( | ||
| 26 | <ToggleGroupPrimitive | ||
| 27 | className={cn( | ||
| 28 | "flex w-fit *:focus-visible:z-10 dark:*:[[data-slot=separator]:has(+[data-slot=toggle]:hover)]:before:bg-input/64 dark:*:[[data-slot=separator]:has(+[data-slot=toggle][data-pressed])]:before:bg-input dark:*:[[data-slot=toggle]:hover+[data-slot=separator]]:before:bg-input/64 dark:*:[[data-slot=toggle][data-pressed]+[data-slot=separator]]:before:bg-input", | ||
| 29 | orientation === "horizontal" | ||
| 30 | ? "*:pointer-coarse:after:min-w-auto" | ||
| 31 | : "*:pointer-coarse:after:min-h-auto", | ||
| 32 | variant === "default" | ||
| 33 | ? "gap-0.5" | ||
| 34 | : orientation === "horizontal" | ||
| 35 | ? "*:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none" | ||
| 36 | : "*:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block", | ||
| 37 | className, | ||
| 38 | )} | ||
| 39 | data-size={size} | ||
| 40 | data-slot="toggle-group" | ||
| 41 | data-variant={variant} | ||
| 42 | orientation={orientation} | ||
| 43 | {...props} | ||
| 44 | > | ||
| 45 | <ToggleGroupContext.Provider value={{ size, variant }}> | ||
| 46 | {children} | ||
| 47 | </ToggleGroupContext.Provider> | ||
| 48 | </ToggleGroupPrimitive> | ||
| 49 | ); | ||
| 50 | } | ||
| 51 | |||
| 52 | function Toggle({ | ||
| 53 | className, | ||
| 54 | children, | ||
| 55 | variant, | ||
| 56 | size, | ||
| 57 | ...props | ||
| 58 | }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) { | ||
| 59 | const context = React.useContext(ToggleGroupContext); | ||
| 60 | |||
| 61 | const resolvedVariant = context.variant || variant; | ||
| 62 | const resolvedSize = context.size || size; | ||
| 63 | |||
| 64 | return ( | ||
| 65 | <ToggleComponent | ||
| 66 | className={className} | ||
| 67 | data-size={resolvedSize} | ||
| 68 | data-variant={resolvedVariant} | ||
| 69 | size={resolvedSize} | ||
| 70 | variant={resolvedVariant} | ||
| 71 | {...props} | ||
| 72 | > | ||
| 73 | {children} | ||
| 74 | </ToggleComponent> | ||
| 75 | ); | ||
| 76 | } | ||
| 77 | |||
| 78 | function ToggleGroupSeparator({ | ||
| 79 | className, | ||
| 80 | orientation = "vertical", | ||
| 81 | ...props | ||
| 82 | }: { | ||
| 83 | className?: string; | ||
| 84 | } & React.ComponentProps<typeof Separator>) { | ||
| 85 | return ( | ||
| 86 | <Separator | ||
| 87 | className={cn( | ||
| 88 | "pointer-events-none relative bg-input before:absolute before:inset-0 dark:before:bg-input/32", | ||
| 89 | className, | ||
| 90 | )} | ||
| 91 | orientation={orientation} | ||
| 92 | {...props} | ||
| 93 | /> | ||
| 94 | ); | ||
| 95 | } | ||
| 96 | |||
| 97 | export { ToggleGroup, Toggle, Toggle as ToggleGroupItem, ToggleGroupSeparator }; | ||
apps/web/src/components/ui/toggle.tsx created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"; | ||
| 4 | import { cva, type VariantProps } from "class-variance-authority"; | ||
| 5 | |||
| 6 | import { cn } from "~/lib/utils"; | ||
| 7 | |||
| 8 | const toggleVariants = cva( | ||
| 9 | "[&_svg]:-mx-0.5 relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-medium text-base text-foreground outline-none transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-pressed:bg-input/64 data-pressed:text-accent-foreground sm:text-sm [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", | ||
| 10 | { | ||
| 11 | defaultVariants: { | ||
| 12 | size: "default", | ||
| 13 | variant: "default", | ||
| 14 | }, | ||
| 15 | variants: { | ||
| 16 | size: { | ||
| 17 | default: "h-9 min-w-9 px-[calc(--spacing(2)-1px)] sm:h-8 sm:min-w-8", | ||
| 18 | lg: "h-10 min-w-10 px-[calc(--spacing(2.5)-1px)] sm:h-9 sm:min-w-9", | ||
| 19 | sm: "h-8 min-w-8 px-[calc(--spacing(1.5)-1px)] sm:h-7 sm:min-w-7", | ||
| 20 | xs: "h-7 min-w-7 px-[calc(--spacing(1)-1px)] sm:h-6 sm:min-w-6 rounded-md", | ||
| 21 | }, | ||
| 22 | variant: { | ||
| 23 | default: "border-transparent", | ||
| 24 | outline: | ||
| 25 | "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:data-pressed:bg-input dark:hover:bg-input/64 dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] dark:not-disabled:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] [:disabled,:active,[data-pressed]]:shadow-none", | ||
| 26 | }, | ||
| 27 | }, | ||
| 28 | }, | ||
| 29 | ); | ||
| 30 | |||
| 31 | function Toggle({ | ||
| 32 | className, | ||
| 33 | variant, | ||
| 34 | size, | ||
| 35 | ...props | ||
| 36 | }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) { | ||
| 37 | return ( | ||
| 38 | <TogglePrimitive | ||
| 39 | className={cn(toggleVariants({ className, size, variant }))} | ||
| 40 | data-slot="toggle" | ||
| 41 | {...props} | ||
| 42 | /> | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 46 | export { Toggle, toggleVariants }; | ||