- 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 primitives
Unified · Split
| @@ -88,6 +88,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" · 87 unmodified lines | ||
| 88 | 88 | import { CursorIcon, Icon } from "./Icons"; |
| 89 | 89 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 90 | 90 | import { Badge } from "./ui/badge"; |
| 91 | +import { Toggle } from "./ui/toggle"; | |
| 91 | 92 | |
| 92 | 93 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 93 | 94 | if (!duration) return formatTimestamp(createdAt); |
| @@ -177,7 +178,8 @@ export default function ChatView() { · 83 unmodified lines | ||
| 177 | 178 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 178 | 179 | const activeThreadId = activeThread?.id ?? null; |
| 179 | 180 | const activeSessionId = activeThread?.session?.sessionId; |
| 180 | − const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 181 | + const activeThreadRuntimeId = | |
| 182 | + activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; | |
| 181 | 183 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 182 | 184 | const selectedModel = resolveModelSlug( |
| 183 | 185 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| @@ -299,7 +301,8 @@ export default function ChatView() { · 115 unmodified lines | ||
| 299 | 301 | } |
| 300 | 302 | |
| 301 | 303 | const turnSummariesNeedingDiff = turnDiffSummaries.filter( |
| 302 | − (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], | |
| 304 | + (summary) => | |
| 305 | + !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], | |
| 303 | 306 | ); |
| 304 | 307 | const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => { |
| 305 | 308 | const requestKey = `${activeThreadId}:${summary.turnId}`; |
| @@ -919,7 +922,15 @@ export default function ChatView() { · 613 unmodified lines | ||
| 919 | 922 | setIsConnecting(false); |
| 920 | 923 | } |
| 921 | 924 | }, |
| 922 | − [activeProject, activeThread, api, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel], | |
| 925 | + [ | |
| 926 | + activeProject, | |
| 927 | + activeThread, | |
| 928 | + api, | |
| 929 | + dispatch, | |
| 930 | + runtimeApprovalPolicy, | |
| 931 | + runtimeSandboxMode, | |
| 932 | + selectedModel, | |
| 933 | + ], | |
| 923 | 934 | ); |
| 924 | 935 | |
| 925 | 936 | useEffect(() => { |
| @@ -930,14 +941,16 @@ export default function ChatView() { · 4 unmodified lines | ||
| 930 | 941 | const selectedDiffTurnId = |
| 931 | 942 | state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; |
| 932 | 943 | const selectedDiffTurn = |
| 933 | − turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? turnDiffSummaries[0]; | |
| 944 | + turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? | |
| 945 | + turnDiffSummaries[0]; | |
| 934 | 946 | const selectedTurnMissingPatchBody = Boolean( |
| 935 | 947 | selectedDiffTurn && |
| 936 | − !selectedDiffTurn.unifiedDiff && | |
| 937 | − selectedDiffTurn.files.every((file) => !file.diff), | |
| 948 | + !selectedDiffTurn.unifiedDiff && | |
| 949 | + selectedDiffTurn.files.every((file) => !file.diff), | |
| 938 | 950 | ); |
| 939 | 951 | const hasPendingDiffHydration = |
| 940 | − turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || selectedTurnMissingPatchBody; | |
| 952 | + turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || | |
| 953 | + selectedTurnMissingPatchBody; | |
| 941 | 954 | if (!hasPendingDiffHydration) return; |
| 942 | 955 | |
| 943 | 956 | const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; |
| @@ -1610,15 +1623,15 @@ const ChatHeader = memo(function ChatHeader({ · 666 unmodified lines | ||
| 1610 | 1623 | <div className="flex items-center gap-3"> |
| 1611 | 1624 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} |
| 1612 | 1625 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} |
| 1613 | − <Button | |
| 1626 | + <Toggle | |
| 1627 | + pressed={diffOpen} | |
| 1628 | + onPressedChange={onToggleDiff} | |
| 1614 | 1629 | aria-label="Toggle diff panel" |
| 1615 | − size="icon-xs" | |
| 1616 | 1630 | variant="outline" |
| 1617 | − className={cn(diffOpen && "bg-accent text-accent-foreground")} | |
| 1618 | − onClick={onToggleDiff} | |
| 1631 | + size="xs" | |
| 1619 | 1632 | > |
| 1620 | − <DiffIcon /> | |
| 1621 | − </Button> | |
| 1633 | + <DiffIcon className="size-3" /> | |
| 1634 | + </Toggle> | |
| 1622 | 1635 | </div> |
| 1623 | 1636 | </> |
| 1624 | 1637 | ); |
| @@ -1750,7 +1763,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 125 unmodified lines | ||
| 1750 | 1763 | if (!hasMessages && !isWorking) { |
| 1751 | 1764 | return ( |
| 1752 | 1765 | <div className="flex h-full items-center justify-center"> |
| 1753 | − <p className="text-sm text-muted-foreground/30">Send a message to start the conversation.</p> | |
| 1766 | + <p className="text-sm text-muted-foreground/30"> | |
| 1767 | + Send a message to start the conversation. | |
| 1768 | + </p> | |
| 1754 | 1769 | </div> |
| 1755 | 1770 | ); |
| 1756 | 1771 | } |
| @@ -1918,7 +1933,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 161 unmodified lines | ||
| 1918 | 1933 | } |
| 1919 | 1934 | /> |
| 1920 | 1935 | {(() => { |
| 1921 | − const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id); | |
| 1936 | + const turnSummary = turnDiffSummaryByAssistantMessageId.get( | |
| 1937 | + timelineEntry.message.id, | |
| 1938 | + ); | |
| 1922 | 1939 | if (!turnSummary) return null; |
| 1923 | 1940 | const isCheckpointDiffLoading = |
| 1924 | 1941 | !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; |
| @@ -1964,7 +1981,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 39 unmodified lines | ||
| 1964 | 1981 | type="button" |
| 1965 | 1982 | size="xs" |
| 1966 | 1983 | variant="outline" |
| 1967 | − onClick={() => onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)} | |
| 1984 | + onClick={() => | |
| 1985 | + onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path) | |
| 1986 | + } | |
| 1968 | 1987 | > |
| 1969 | 1988 | View diff |
| 1970 | 1989 | </Button> |
| @@ -14,6 +14,7 @@ import { useStore } from "../store"; · 13 unmodified lines | ||
| 14 | 14 | import { Button } from "./ui/button"; |
| 15 | 15 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| 16 | 16 | import { cn } from "~/lib/utils"; |
| 17 | +import { ToggleGroup, Toggle } from "./ui/toggle-group"; | |
| 17 | 18 | |
| 18 | 19 | type DiffRenderMode = "stacked" | "split"; |
| 19 | 20 | |
| @@ -406,68 +407,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 386 unmodified lines | ||
| 406 | 407 | </button> |
| 407 | 408 | ))} |
| 408 | 409 | </div> |
| 409 | − <div className="flex items-center gap-1"> | |
| 410 | − <Popover> | |
| 411 | − <PopoverTrigger | |
| 412 | − openOnHover | |
| 413 | − render={ | |
| 414 | − <Button | |
| 415 | − aria-label="Stacked diff view" | |
| 416 | − aria-pressed={diffRenderMode === "stacked"} | |
| 417 | − size="icon-xs" | |
| 418 | − variant="outline" | |
| 419 | − className={cn(diffRenderMode === "stacked" && "bg-accent text-accent-foreground")} | |
| 420 | − onClick={() => setDiffRenderMode("stacked")} | |
| 421 | − /> | |
| 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> | |
| 410 | + <ToggleGroup | |
| 411 | + variant="outline" | |
| 412 | + size="xs" | |
| 413 | + value={[diffRenderMode]} | |
| 414 | + onValueChange={(value) => setDiffRenderMode(value[0] as DiffRenderMode)} | |
| 415 | + > | |
| 416 | + <Toggle aria-label="Stacked diff view" value="stacked"> | |
| 417 | + <Rows3Icon className="size-3" /> | |
| 418 | + </Toggle> | |
| 419 | + <Toggle aria-label="Split diff view" value="split"> | |
| 420 | + <Columns2Icon className="size-3" /> | |
| 421 | + </Toggle> | |
| 422 | + </ToggleGroup> | |
| 471 | 423 | </div> |
| 472 | 424 | |
| 473 | 425 | {!activeThread ? ( |
| @@ -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 }; | |
| @@ -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 }; | |