| 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 | 112 | projectScriptIdFromCommand, |
| 113 | 113 | setupProjectScript, |
| 114 | 114 | } from "~/projectScripts"; |
| 115 | import { Toggle } from "./ui/toggle"; | |
| 115 | 116 | |
| 116 | 117 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 117 | 118 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -377,7 +378,8 @@ export default function ChatView() { |
| 377 | 378 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 378 | 379 | const activeThreadId = activeThread?.id ?? null; |
| 379 | 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 | 383 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| 382 | 384 | const selectedModel = resolveModelSlug( |
| 383 | 385 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| ... | ... | @@ -499,7 +501,8 @@ export default function ChatView() { |
| 499 | 501 | } |
| 500 | 502 | |
| 501 | 503 | const turnSummariesNeedingDiff = turnDiffSummaries.filter( |
| 502 | (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], | |
| 504 | (summary) => | |
| 505 | !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId], | |
| 503 | 506 | ); |
| 504 | 507 | const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => { |
| 505 | 508 | const requestKey = `${activeThreadId}:${summary.turnId}`; |
| ... | ... | @@ -1463,7 +1466,15 @@ export default function ChatView() { |
| 1463 | 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 | 1480 | useEffect(() => { |
| ... | ... | @@ -1474,14 +1485,16 @@ export default function ChatView() { |
| 1474 | 1485 | const selectedDiffTurnId = |
| 1475 | 1486 | state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null; |
| 1476 | 1487 | const selectedDiffTurn = |
| 1477 | turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? turnDiffSummaries[0]; | |
| 1488 | turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? | |
| 1489 | turnDiffSummaries[0]; | |
| 1478 | 1490 | const selectedTurnMissingPatchBody = Boolean( |
| 1479 | 1491 | selectedDiffTurn && |
| 1480 | !selectedDiffTurn.unifiedDiff && | |
| 1481 | selectedDiffTurn.files.every((file) => !file.diff), | |
| 1492 | !selectedDiffTurn.unifiedDiff && | |
| 1493 | selectedDiffTurn.files.every((file) => !file.diff), | |
| 1482 | 1494 | ); |
| 1483 | 1495 | const hasPendingDiffHydration = |
| 1484 | turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || selectedTurnMissingPatchBody; | |
| 1496 | turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || | |
| 1497 | selectedTurnMissingPatchBody; | |
| 1485 | 1498 | if (!hasPendingDiffHydration) return; |
| 1486 | 1499 | |
| 1487 | 1500 | const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; |
| ... | ... | @@ -2310,15 +2323,15 @@ const ChatHeader = memo(function ChatHeader({ |
| 2310 | 2323 | )} |
| 2311 | 2324 | {activeProjectName && <OpenInPicker keybindings={keybindings} />} |
| 2312 | 2325 | {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />} |
| 2313 | <Button | |
| 2326 | <Toggle | |
| 2327 | pressed={diffOpen} | |
| 2328 | onPressedChange={onToggleDiff} | |
| 2314 | 2329 | aria-label="Toggle diff panel" |
| 2315 | size="icon-xs" | |
| 2316 | 2330 | variant="outline" |
| 2317 | className={cn(diffOpen && "bg-accent text-accent-foreground")} | |
| 2318 | onClick={onToggleDiff} | |
| 2331 | size="xs" | |
| 2319 | 2332 | > |
| 2320 | <DiffIcon /> | |
| 2321 | </Button> | |
| 2333 | <DiffIcon className="size-3" /> | |
| 2334 | </Toggle> | |
| 2322 | 2335 | </div> |
| 2323 | 2336 | </> |
| 2324 | 2337 | ); |
| ... | ... | @@ -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 | 2639 | if (!turnSummary) return null; |
| 2625 | 2640 | const isCheckpointDiffLoading = |
| 2626 | 2641 | !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; |
| ... | ... | @@ -2666,7 +2681,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2666 | 2681 | type="button" |
| 2667 | 2682 | size="xs" |
| 2668 | 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 | 2688 | View diff |
| 2672 | 2689 | </Button> |
apps/web/src/components/DiffPanel.tsx+14-62| ... | ... | @@ -14,6 +14,7 @@ import { useStore } from "../store"; |
| 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) { |
| 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 ? ( |
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 }; |