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 3a5cd891 parent ed253384 files · +189 −77Browse files

Unified · Split

4 files
  1. apps/web/src/components/ChatView.tsx +32 −15
  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+32−15
@@ -112,6 +112,7 @@ import { · 111 unmodified lines
112112 projectScriptIdFromCommand,
113113 setupProjectScript,
114114 } from "~/projectScripts";
115+import { Toggle } from "./ui/toggle";
115116
116117 function formatMessageMeta(createdAt: string, duration: string | null): string {
117118 if (!duration) return formatTimestamp(createdAt);
@@ -377,7 +378,8 @@ export default function ChatView() { · 259 unmodified lines
377378 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
378379 const activeThreadId = activeThread?.id ?? null;
379380 const activeSessionId = activeThread?.session?.sessionId;
380− const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
381+ const activeThreadRuntimeId =
382+ activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
381383 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
382384 const selectedModel = resolveModelSlug(
383385 activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
@@ -499,7 +501,8 @@ export default function ChatView() { · 115 unmodified lines
499501 }
500502
501503 const turnSummariesNeedingDiff = turnDiffSummaries.filter(
502− (summary) => !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],
504+ (summary) =>
505+ !summary.checkpointDiffLoaded && inferredCheckpointTurnCountByTurnId[summary.turnId],
503506 );
504507 const requestedSummaries = turnSummariesNeedingDiff.filter((summary) => {
505508 const requestKey = `${activeThreadId}:${summary.turnId}`;
@@ -1463,7 +1466,15 @@ export default function ChatView() { · 957 unmodified lines
14631466 setIsConnecting(false);
14641467 }
14651468 },
1466− [activeProject, activeThread, api, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel],
1469+ [
1470+ activeProject,
1471+ activeThread,
1472+ api,
1473+ dispatch,
1474+ runtimeApprovalPolicy,
1475+ runtimeSandboxMode,
1476+ selectedModel,
1477+ ],
14671478 );
14681479
14691480 useEffect(() => {
@@ -1474,14 +1485,16 @@ export default function ChatView() { · 4 unmodified lines
14741485 const selectedDiffTurnId =
14751486 state.diffOpen && state.diffThreadId === activeThreadId ? state.diffTurnId : null;
14761487 const selectedDiffTurn =
1477− turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ?? turnDiffSummaries[0];
1488+ turnDiffSummaries.find((summary) => summary.turnId === selectedDiffTurnId) ??
1489+ turnDiffSummaries[0];
14781490 const selectedTurnMissingPatchBody = Boolean(
14791491 selectedDiffTurn &&
1480− !selectedDiffTurn.unifiedDiff &&
1481− selectedDiffTurn.files.every((file) => !file.diff),
1492+ !selectedDiffTurn.unifiedDiff &&
1493+ selectedDiffTurn.files.every((file) => !file.diff),
14821494 );
14831495 const hasPendingDiffHydration =
1484− turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) || selectedTurnMissingPatchBody;
1496+ turnDiffSummaries.some((summary) => !summary.checkpointDiffLoaded) ||
1497+ selectedTurnMissingPatchBody;
14851498 if (!hasPendingDiffHydration) return;
14861499
14871500 const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`;
@@ -2310,15 +2323,15 @@ const ChatHeader = memo(function ChatHeader({ · 822 unmodified lines
23102323 )}
23112324 {activeProjectName && <OpenInPicker keybindings={keybindings} />}
23122325 {activeProjectName && <GitActionsControl api={api} gitCwd={gitCwd} />}
2313− <Button
2326+ <Toggle
2327+ pressed={diffOpen}
2328+ onPressedChange={onToggleDiff}
23142329 aria-label="Toggle diff panel"
2315− size="icon-xs"
23162330 variant="outline"
2317− className={cn(diffOpen && "bg-accent text-accent-foreground")}
2318− onClick={onToggleDiff}
2331+ size="xs"
23192332 >
2320− <DiffIcon />
2321− </Button>
2333+ <DiffIcon className="size-3" />
2334+ </Toggle>
23222335 </div>
23232336 </>
23242337 );
@@ -2620,7 +2633,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 295 unmodified lines
26202633 }
26212634 />
26222635 {(() => {
2623− const turnSummary = turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id);
2636+ const turnSummary = turnDiffSummaryByAssistantMessageId.get(
2637+ timelineEntry.message.id,
2638+ );
26242639 if (!turnSummary) return null;
26252640 const isCheckpointDiffLoading =
26262641 !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0;
@@ -2666,7 +2681,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ · 39 unmodified lines
26662681 type="button"
26672682 size="xs"
26682683 variant="outline"
2669− onClick={() => onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)}
2684+ onClick={() =>
2685+ onOpenTurnDiff(turnSummary.turnId, turnSummary.files[0]?.path)
2686+ }
26702687 >
26712688 View diff
26722689 </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 };

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t