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
8888 import { CursorIcon, Icon } from "./Icons";
8989 import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
9090 import { Badge } from "./ui/badge";
91+import { Toggle } from "./ui/toggle";
9192
9293 function formatMessageMeta(createdAt: string, duration: string | null): string {
9394 if (!duration) return formatTimestamp(createdAt);
@@ -177,7 +178,8 @@ export default function ChatView() { · 83 unmodified lines
177178 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
178179 const activeThreadId = activeThread?.id ?? null;
179180 const activeSessionId = activeThread?.session?.sessionId;
180− const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
181+ const activeThreadRuntimeId =
182+ activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
181183 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
182184 const selectedModel = resolveModelSlug(
183185 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
@@ -299,7 +301,8 @@ export default function ChatView() { · 115 unmodified lines
299301 }
300302
301303 const turnSummariesNeedingDiff = turnDiffSummaries.filter(
302− (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],
304+ (summary) =>
305+ !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],
303306 );
304307 const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => {
305308 const requestKey = `${activeThreadId}:${summary.turnId}`;
@@ -919,7 +922,15 @@ export default function ChatView() { · 613 unmodified lines
919922 setIsConnecting(false);
920923 }
921924 },
922− [activeProject, activeThread, api, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel],
925+ [
926+ activeProject,
927+ activeThread,
928+ api,
929+ dispatch,
930+ runtimeApprovalPolicy,
931+ runtimeSandboxMode,
932+ selectedModel,
933+ ],
923934 );
924935
925936 useEffect(() => {
@@ -930,14 +941,16 @@ export default function ChatView() { · 4 unmodified lines
930941 const selectedDiffTurnId =
931942 state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null;
932943 const selectedDiffTurn =
933− turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? turnDiffSummaries[0];
944+ turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ??
945+ turnDiffSummaries[0];
934946 const selectedTurnMissingPatchBody = Boolean(
935947 selectedDiffTurn &&
936− !selectedDiffTurn.unifiedDiff &&
937− selectedDiffTurn.files.every((file) => !file.diff),
948+ !selectedDiffTurn.unifiedDiff &&
949+ selectedDiffTurn.files.every((file) => !file.diff),
938950 );
939951 const hasPendingDiffHydration =
940− turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || selectedTurnMissingPatchBody;
952+ turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) ||
953+ selectedTurnMissingPatchBody;
941954 if (!hasPendingDiffHydration) return;
942955
943956 const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`;
@@ -1610,15 +1623,15 @@ const ChatHeader = memo(function ChatHeader({ · 666 unmodified lines
16101623 <div className="flex items-center gap-3">
16111624 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
16121625 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
1613− <Button
1626+ <Toggle
1627+ pressed={diffOpen}
1628+ onPressedChange={onToggleDiff}
16141629 aria-label="Toggle diff panel"
1615− size="icon-xs"
16161630 variant="outline"
1617− className={cn(diffOpen && "bg-accent text-accent-foreground")}
1618− onClick={onToggleDiff}
1631+ size="xs"
16191632 >
1620− <DiffIcon />
1621− </Button>
1633+ <DiffIcon className="size-3" />
1634+ </Toggle>
16221635 </div>
16231636 </>
16241637 );
@@ -1750,7 +1763,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 125 unmodified lines
17501763 if (!hasMessages && !isWorking) {
17511764 return (
17521765 <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>
17541769 </div>
17551770 );
17561771 }
@@ -1918,7 +1933,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 161 unmodified lines
19181933 }
19191934 />
19201935 {(() => {
1921− const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id);
1936+ const turnSummary = turnDiffSummaryByAssistantMessageId.get(
1937+ timelineEntry.message.id,
1938+ );
19221939 if (!turnSummary) return null;
19231940 const isCheckpointDiffLoading =
19241941 !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0;
@@ -1964,7 +1981,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 39 unmodified lines
19641981 type="button"
19651982 size="xs"
19661983 variant="outline"
1967− onClick={() => onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)}
1984+ onClick={() =>
1985+ onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)
1986+ }
19681987 >
19691988 View diff
19701989 </Button>
apps/web/src/components/DiffPanel.tsx+14−62
@@ -14,6 +14,7 @@ import { useStore } from "../store"; · 13 unmodified lines
1414 import { Button } from "./ui/button";
1515 import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
1616 import { cn } from "~/lib/utils";
17+import { ToggleGroup, Toggle } from "./ui/toggle-group";
1718
1819 type DiffRenderMode = "stacked" | "split";
1920
@@ -406,68 +407,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 386 unmodified lines
406407 </button>
407408 ))}
408409 </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>
471423 </div>
472424
473425 {!activeThread ? (
apps/web/src/components/ui/toggle-group.tsx+97−0
@@ -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+46−0
@@ -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 };