authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-18 00:19:47-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 21:59:17-08:00
log867c122082c18d550965e7060dc603e0dcbca862
treebba5bd6597d2d4d0aeb6d6958d1ba095a1a8c9bd
parentcf8a2f03fc1c46b70faa565b2e00625cb8b58ebd

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

4 files changed, 189 insertions(+), 77 deletions(-)

apps/web/src/components/ChatView.tsx+32-15
......@@ -112,6 +112,7 @@ import {
112112 projectScriptIdFromCommand,
113113 setupProjectScript,
114114} from "~/projectScripts";
115import { Toggle } from "./ui/toggle";
115116
116117function formatMessageMeta(createdAt: string, duration: string | null): string {
117118 if (!duration) return formatTimestamp(createdAt);
......@@ -377,7 +378,8 @@ export default function ChatView() {
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() {
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() {
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() {
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({
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({
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({
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";
1414import { Button } from "./ui/button";
1515import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
1616import { cn } from "~/lib/utils";
17import { ToggleGroup, Toggle } from "./ui/toggle-group";
1718
1819type DiffRenderMode = "stacked" | "split";
1920
......@@ -406,68 +407,19 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
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 created+97
......@@ -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 created+46
......@@ -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 };