History

Adopt toggle controls for diff panel and header button

- 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

Julius Marminge committed 8 months ago

Commit 197da251 parent 4a686f84 files · +192 −78Browse files

Unified · Split

4 files
  1. apps/web/src/components/ChatView.tsx +35 −16
  2. apps/web/src/components/DiffPanel.tsx +14 −62
  3. apps/web/src/components/ui/toggle-group.tsx +97 −0
  4. apps/web/src/components/ui/toggle.tsx +46 −0
apps/web/src/components/ChatView.tsx+35−16
@@ -88,6 +88,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" · 87 unmodified lines
88import { CursorIcon, Icon } from "./Icons";88import { CursorIcon, Icon } from "./Icons";
89import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";89import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
90import { Badge } from "./ui/badge";90import { Badge } from "./ui/badge";
91import { Toggle } from "./ui/toggle";
91 92
92function formatMessageMeta(createdAt: string, duration: string | null): string {93function formatMessageMeta(createdAt: string, duration: string | null): string {
93 if (!duration) return formatTimestamp(createdAt);94 if (!duration) return formatTimestamp(createdAt);
@@ -177,7 +178,8 @@ export default function ChatView() { · 83 unmodified lines
177 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);178 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
178 const activeThreadId = activeThread?.id ?? null;179 const activeThreadId = activeThread?.id ?? null;
179 const activeSessionId = activeThread?.session?.sessionId;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 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);183 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
182 const selectedModel = resolveModelSlug(184 const selectedModel = resolveModelSlug(
183 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,185 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
@@ -299,7 +301,8 @@ export default function ChatView() { · 115 unmodified lines
299 }301 }
300 302
301 const turnSummariesNeedingDiff = turnDiffSummaries.filter(303 const turnSummariesNeedingDiff = turnDiffSummaries.filter(
302 (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],304 (summary) =>
305 !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],
303 );306 );
304 const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => {307 const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => {
305 const requestKey = `${activeThreadId}:${summary.turnId}`;308 const requestKey = `${activeThreadId}:${summary.turnId}`;
@@ -919,7 +922,15 @@ export default function ChatView() { · 613 unmodified lines
919 setIsConnecting(false);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 useEffect(() => {936 useEffect(() => {
@@ -930,14 +941,16 @@ export default function ChatView() { · 4 unmodified lines
930 const selectedDiffTurnId =941 const selectedDiffTurnId =
931 state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null;942 state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null;
932 const selectedDiffTurn =943 const selectedDiffTurn =
933 turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? turnDiffSummaries[0];944 turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ??
945 turnDiffSummaries[0];
934 const selectedTurnMissingPatchBody = Boolean(946 const selectedTurnMissingPatchBody = Boolean(
935 selectedDiffTurn &&947 selectedDiffTurn &&
936 !selectedDiffTurn.unifiedDiff &&948 !selectedDiffTurn.unifiedDiff &&
937 selectedDiffTurn.files.every((file) => !file.diff),949 selectedDiffTurn.files.every((file) => !file.diff),
938 );950 );
939 const hasPendingDiffHydration =951 const hasPendingDiffHydration =
940 turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || selectedTurnMissingPatchBody;952 turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) ||
953 selectedTurnMissingPatchBody;
941 if (!hasPendingDiffHydration) return;954 if (!hasPendingDiffHydration) return;
942 955
943 const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`;956 const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`;
@@ -1610,15 +1623,15 @@ const ChatHeader = memo(function ChatHeader({ · 666 unmodified lines
1610 <div className="flex items-center gap-3">1623 <div className="flex items-center gap-3">
1611 {activeProjectName && <OpenInPicker keybindings={keybindings} />}1624 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
1612 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}1625 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
1613 <Button1626 <Toggle
1627 pressed={diffOpen}
1628 onPressedChange={onToggleDiff}
1614 aria-label="Toggle diff panel"1629 aria-label="Toggle diff panel"
1615 size="icon-xs"
1616 variant="outline"1630 variant="outline"
1617 className={cn(diffOpen && "bg-accent text-accent-foreground")}1631 size="xs"
1618 onClick={onToggleDiff}
1619 >1632 >
1620 <DiffIcon />1633 <DiffIcon className="size-3" />
1621 </Button>1634 </Toggle>
1622 </div>1635 </div>
1623 </>1636 </>
1624 );1637 );
@@ -1750,7 +1763,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 125 unmodified lines
1750 if (!hasMessages && !isWorking) {1763 if (!hasMessages && !isWorking) {
1751 return (1764 return (
1752 <div className="flex h-full items-center justify-center">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 </div>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 if (!turnSummary) return null;1939 if (!turnSummary) return null;
1923 const isCheckpointDiffLoading =1940 const isCheckpointDiffLoading =
1924 !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0;1941 !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0;
@@ -1964,7 +1981,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 39 unmodified lines
1964 type="button"1981 type="button"
1965 size="xs"1982 size="xs"
1966 variant="outline"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 View diff1988 View diff
1970 </Button>1989 </Button>
apps/web/src/components/DiffPanel.tsx+14−62
@@ -14,6 +14,7 @@ import { useStore } from "../store"; · 13 unmodified lines
14import { Button } from "./ui/button";14import { Button } from "./ui/button";
15import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";15import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
16import { cn } from "~/lib/utils";16import { cn } from "~/lib/utils";
17import { ToggleGroup, Toggle } from "./ui/toggle-group";
17 18
18type DiffRenderMode = "stacked" | "split";19type DiffRenderMode = "stacked" | "split";
19 20
@@ -406,68 +407,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 386 unmodified lines
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 <PopoverTrigger412 size="xs"
412 openOnHover413 value={[diffRenderMode]}
413 render={414 onValueChange={(value) => setDiffRenderMode(value[0] as DiffRenderMode)}
414 <Button415 >
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+97−0
@@ -0,0 +1,97 @@
1"use client";
2
3import type { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
4import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
5import type { VariantProps } from "class-variance-authority";
6import * as React from "react";
7
8import { cn } from "~/lib/utils";
9import { Separator } from "~/components/ui/separator";
10import { Toggle as ToggleComponent, type toggleVariants } from "~/components/ui/toggle";
11
12const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
13 size: "default",
14 variant: "default",
15});
16
17function 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
52function 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
78function 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
97export { ToggleGroup, Toggle, Toggle as ToggleGroupItem, ToggleGroupSeparator };
apps/web/src/components/ui/toggle.tsx+46−0
@@ -0,0 +1,46 @@
1"use client";
2
3import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
4import { cva, type VariantProps } from "class-variance-authority";
5
6import { cn } from "~/lib/utils";
7
8const 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
31function 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
46export { Toggle, toggleVariants };