From 5f9bfd6813e7fb7d18f939aae2e80a95832896f2 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 23 Oct 2025 00:40:09 -0400 Subject: [PATCH 01/30] =?UTF-8?q?=F0=9F=A4=96=20Adopt=20shadcn/ui=20compon?= =?UTF-8?q?ents=20-=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add shadcn/ui components via CLI (tooltip, toggle-group, dialog, input, select) - Install lucide-react dependency for shadcn components - Replace custom ToggleGroup wrapper with direct shadcn usage (3 files) - ChatInput: Exec/Plan mode toggle - CostsTab: Session/Last Request toggle - Delete old ToggleGroup wrapper and stories - Add TooltipProvider to App.tsx root - Migrate ChatInput tooltips to shadcn (3 instances) - Add Component Guidelines to AGENTS.md Next steps: Migrate remaining Tooltip usages (26 files), Modal → Dialog (8 files), form elements _Generated with `cmux`_ --- bun.lock | 4 + docs/AGENTS.md | 22 ++ package.json | 2 + src/App.tsx | 9 +- src/components/ChatInput.tsx | 58 ++-- src/components/RightSidebar/CostsTab.tsx | 29 +- src/components/ToggleGroup.stories.tsx | 335 ----------------------- src/components/ToggleGroup.tsx | 40 --- src/components/ui/dialog.tsx | 102 +++++++ src/components/ui/input.tsx | 22 ++ src/components/ui/select.tsx | 151 ++++++++++ src/components/ui/toggle-group.tsx | 57 ++++ src/components/ui/toggle.tsx | 44 +++ src/components/ui/tooltip.tsx | 28 ++ 14 files changed, 494 insertions(+), 409 deletions(-) delete mode 100644 src/components/ToggleGroup.stories.tsx delete mode 100644 src/components/ToggleGroup.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/toggle-group.tsx create mode 100644 src/components/ui/toggle.tsx create mode 100644 src/components/ui/tooltip.tsx diff --git a/bun.lock b/bun.lock index 525904f5c..6d6b98062 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "ai": "^5.0.72", @@ -26,6 +27,7 @@ "express": "^5.1.0", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", + "lucide-react": "^0.546.0", "markdown-it": "^14.1.0", "minimist": "^1.2.8", "source-map-support": "^0.5.21", @@ -2090,6 +2092,8 @@ "lru-cache": ["lru-cache@11.2.2", "", {}, "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="], + "lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], diff --git a/docs/AGENTS.md b/docs/AGENTS.md index aff8e8e22..2d39e6c80 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -255,6 +255,28 @@ If IPC is hard to test, fix the test infrastructure or IPC layer, don't work aro - Use CSS variables (e.g., `var(--color-plan-mode)`) instead of hardcoded colors - Fonts are centralized as CSS variables in `src/styles/fonts.tsx` +## Component Guidelines + +**Always use shadcn/ui components from `@/components/ui/*` for standard UI patterns.** + +- **Adding components**: `bunx --bun shadcn@latest add ` +- **Common components**: button, input, select, dialog, tooltip, toggle-group, checkbox, switch, etc. +- **Documentation**: https://ui.shadcn.com/docs/components +- **Build custom only when**: The component has app-specific behavior with no shadcn equivalent + +Example: +```bash +# Add a new shadcn component +bunx --bun shadcn@latest add badge + +# Import in your code +import { Badge } from "@/components/ui/badge" +``` + +Shadcn components automatically respect our CSS variables via the `@/lib/utils` cn() helper and Tailwind config. + + + ## TypeScript Best Practices - **Avoid `as any` in all contexts** - Never use `as any` casts. Instead: diff --git a/package.json b/package.json index c327bec83..35b5c8a15 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "ai": "^5.0.72", @@ -66,6 +67,7 @@ "express": "^5.1.0", "jsonc-parser": "^3.3.1", "lru-cache": "^11.2.2", + "lucide-react": "^0.546.0", "markdown-it": "^14.1.0", "minimist": "^1.2.8", "source-map-support": "^0.5.21", diff --git a/src/App.tsx b/src/App.tsx index 972ce225f..908973ebd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { TooltipProvider } from "./components/ui/tooltip"; import "./styles/globals.css"; import type { ProjectConfig } from "./config"; import type { WorkspaceSelection } from "./components/ProjectSidebar"; @@ -748,9 +749,11 @@ function AppInner() { function App() { return ( - - - + + + + + ); } diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 47fa65aa2..b39258925 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -17,14 +17,15 @@ import { prepareCompactionMessage, type CommandHandlerContext, } from "@/utils/chatCommands"; -import { ToggleGroup } from "./ToggleGroup"; +import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; import { CUSTOM_EVENTS } from "@/constants/events"; import type { UIMode } from "@/types/mode"; import { getSlashCommandSuggestions, type SlashSuggestion, } from "@/utils/slashCommands/suggestions"; -import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import { HelpIndicator } from "./Tooltip"; import { matchesKeybind, formatKeybind, KEYBINDS, isEditableElement } from "@/utils/ui/keybinds"; import { ModelSelector, type ModelSelectorRef } from "./ModelSelector"; import { useModelLRU } from "@/hooks/useModelLRU"; @@ -757,9 +758,11 @@ export const ChatInput: React.FC = ({ onComplete={() => inputRef.current?.focus()} /> - - ? - + + + ? + + Click to edit or use{" "} {formatKeybind(KEYBINDS.OPEN_MODEL_SELECTOR)}
@@ -774,13 +777,16 @@ export const ChatInput: React.FC = ({ /model provider:model-name
(e.g., /model anthropic:claude-sonnet-4-5) -
-
+ +
-
value && setMode(value as UIMode)} className={cn( "flex gap-0 bg-toggle-bg rounded", "[&>button:first-of-type]:rounded-l [&>button:last-of-type]:rounded-r", @@ -790,19 +796,27 @@ export const ChatInput: React.FC = ({ "[&>button:last-of-type]:bg-plan-mode [&>button:last-of-type]:text-white [&>button:last-of-type]:hover:bg-plan-mode-hover" )} > - - options={[ - { value: "exec", label: "Exec", activeClassName: "bg-exec-mode text-white" }, - { value: "plan", label: "Plan", activeClassName: "bg-plan-mode text-white" }, - ]} - value={mode} - onChange={setMode} - /> -
+ + Exec + + + Plan + + - - ? - + + + ? + + Exec Mode: AI edits files and execute commands

@@ -810,8 +824,8 @@ export const ChatInput: React.FC = ({

Toggle with: {formatKeybind(KEYBINDS.TOGGLE_MODE)} -
-
+ +
diff --git a/src/components/RightSidebar/CostsTab.tsx b/src/components/RightSidebar/CostsTab.tsx index c4699e365..fd189a4d0 100644 --- a/src/components/RightSidebar/CostsTab.tsx +++ b/src/components/RightSidebar/CostsTab.tsx @@ -3,7 +3,7 @@ import { useWorkspaceUsage, useWorkspaceConsumers } from "@/stores/WorkspaceStor import { getModelStats } from "@/utils/tokens/modelStats"; import { sumUsageHistory } from "@/utils/tokens/usageAggregator"; import { usePersistedState } from "@/hooks/usePersistedState"; -import { ToggleGroup, type ToggleOption } from "../ToggleGroup"; +import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group"; import { use1MContext } from "@/hooks/use1MContext"; import { supports1MContext } from "@/utils/ai/models"; import { TOKEN_COMPONENT_COLORS } from "@/utils/tokens/tokenMeterUtils"; @@ -46,11 +46,6 @@ const calculateElevatedCost = (tokens: number, standardRate: number, isInput: bo type ViewMode = "last-request" | "session"; -const VIEW_MODE_OPTIONS: Array> = [ - { value: "session", label: "Session" }, - { value: "last-request", label: "Last Request" }, -]; - interface CostsTabProps { workspaceId: string; } @@ -352,10 +347,26 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { Cost + onValueChange={(value) => value && setViewMode(value as ViewMode)} + className="flex gap-0 bg-toggle-bg rounded" + > + + Session + + + Last Request + + {formatCostWithDollar(totalCost)} diff --git a/src/components/ToggleGroup.stories.tsx b/src/components/ToggleGroup.stories.tsx deleted file mode 100644 index 61b5f8de7..000000000 --- a/src/components/ToggleGroup.stories.tsx +++ /dev/null @@ -1,335 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { action } from "@storybook/addon-actions"; -import { expect, userEvent, within, waitFor } from "@storybook/test"; -import { useArgs } from "@storybook/preview-api"; -import { ToggleGroup, type ToggleOption } from "./ToggleGroup"; -import { useState } from "react"; -import { cn } from "@/lib/utils"; - -const meta = { - title: "Components/ToggleGroup", - component: ToggleGroup, - parameters: { - layout: "centered", - controls: { - exclude: ["onChange"], - }, - }, - argTypes: { - options: { - control: "object", - description: "Array of options", - }, - value: { - control: "text", - description: "Currently selected value", - }, - }, - args: { - onChange: action("value-changed"), - }, - tags: ["autodocs"], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const TwoOptions: Story = { - args: { - options: [ - { value: "light", label: "Light" }, - { value: "dark", label: "Dark" }, - ], - value: "dark", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( - updateArgs({ value: newValue })} - /> - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Find all buttons - const lightButton = canvas.getByRole("button", { name: /light/i }); - const darkButton = canvas.getByRole("button", { name: /dark/i }); - - // Initial state - dark should be active - await expect(darkButton).toHaveAttribute("aria-pressed", "true"); - await expect(lightButton).toHaveAttribute("aria-pressed", "false"); - - // Click light button - await userEvent.click(lightButton); - - // Verify state changed using waitFor - await waitFor(() => { - void expect(lightButton).toHaveAttribute("aria-pressed", "true"); - void expect(darkButton).toHaveAttribute("aria-pressed", "false"); - }); - - // Click dark button to toggle back - await userEvent.click(darkButton); - - // Verify state changed back using waitFor - await waitFor(() => { - void expect(darkButton).toHaveAttribute("aria-pressed", "true"); - void expect(lightButton).toHaveAttribute("aria-pressed", "false"); - }); - }, -}; - -export const ThreeOptions: Story = { - args: { - options: [ - { value: "small", label: "Small" }, - { value: "medium", label: "Medium" }, - { value: "large", label: "Large" }, - ], - value: "medium", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( - updateArgs({ value: newValue })} - /> - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Find all buttons - const smallButton = canvas.getByRole("button", { name: /small/i }); - const mediumButton = canvas.getByRole("button", { name: /medium/i }); - const largeButton = canvas.getByRole("button", { name: /large/i }); - - // Initial state - medium should be active, others inactive - await expect(mediumButton).toHaveAttribute("aria-pressed", "true"); - await expect(smallButton).toHaveAttribute("aria-pressed", "false"); - await expect(largeButton).toHaveAttribute("aria-pressed", "false"); - - // Click small button - await userEvent.click(smallButton); - - // Verify only small is active using waitFor - await waitFor(() => { - void expect(smallButton).toHaveAttribute("aria-pressed", "true"); - void expect(mediumButton).toHaveAttribute("aria-pressed", "false"); - void expect(largeButton).toHaveAttribute("aria-pressed", "false"); - }); - - // Click large button - await userEvent.click(largeButton); - - // Verify only large is active using waitFor - await waitFor(() => { - void expect(largeButton).toHaveAttribute("aria-pressed", "true"); - void expect(smallButton).toHaveAttribute("aria-pressed", "false"); - void expect(mediumButton).toHaveAttribute("aria-pressed", "false"); - }); - }, -}; - -export const ManyOptions: Story = { - args: { - options: [ - { value: "mon", label: "Mon" }, - { value: "tue", label: "Tue" }, - { value: "wed", label: "Wed" }, - { value: "thu", label: "Thu" }, - { value: "fri", label: "Fri" }, - { value: "sat", label: "Sat" }, - { value: "sun", label: "Sun" }, - ], - value: "mon", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( - updateArgs({ value: newValue })} - /> - ); - }, -}; - -const StyledModeToggle = ({ - mode, - children, -}: { - mode: "exec" | "plan"; - children: React.ReactNode; -}) => ( -
- {children} -
-); - -export const PermissionModes: Story = { - args: { - options: [ - { value: "exec", label: "Exec" }, - { value: "plan", label: "Plan" }, - ], - value: "exec", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( -
- - updateArgs({ value: newValue })} - /> - -
- Exec (purple): AI edits files and executes commands -
- Plan (blue): AI only provides plans without executing -
-
- ); - }, -}; - -export const ViewModes: Story = { - args: { - options: [ - { value: "grid", label: "Grid View" }, - { value: "list", label: "List View" }, - ], - value: "grid", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( - updateArgs({ value: newValue })} - /> - ); - }, -}; - -export const WithStateDisplay: Story = { - args: { - options: [ - { value: "enabled", label: "Enabled" }, - { value: "disabled", label: "Disabled" }, - ], - value: "enabled", - }, - render: function Render(args) { - const [{ value }, updateArgs] = useArgs(); - - return ( -
- updateArgs({ value: newValue })} - /> -
- Current selection: {value} -
-
- ); - }, -}; - -export const MultipleGroups: Story = { - parameters: { - controls: { disable: true }, - }, - args: { - options: [], - value: "", - }, - render: function Render() { - const [theme, setTheme] = useState<"light" | "dark">("dark"); - const [size, setSize] = useState<"small" | "medium" | "large">("medium"); - const [layout, setLayout] = useState<"compact" | "comfortable" | "spacious">("comfortable"); - - const themeOptions: Array> = [ - { value: "light", label: "Light" }, - { value: "dark", label: "Dark" }, - ]; - - const sizeOptions: Array> = [ - { value: "small", label: "S" }, - { value: "medium", label: "M" }, - { value: "large", label: "L" }, - ]; - - const layoutOptions: Array> = [ - { value: "compact", label: "Compact" }, - { value: "comfortable", label: "Comfortable" }, - { value: "spacious", label: "Spacious" }, - ]; - - const handleThemeChange = (newValue: "light" | "dark") => { - action("theme-changed")(newValue); - setTheme(newValue); - }; - - const handleSizeChange = (newValue: "small" | "medium" | "large") => { - action("size-changed")(newValue); - setSize(newValue); - }; - - const handleLayoutChange = (newValue: "compact" | "comfortable" | "spacious") => { - action("layout-changed")(newValue); - setLayout(newValue); - }; - - return ( -
-
-
Theme
- -
- -
-
Size
- -
- -
-
Layout
- -
-
- ); - }, -}; diff --git a/src/components/ToggleGroup.tsx b/src/components/ToggleGroup.tsx deleted file mode 100644 index 6388a2a65..000000000 --- a/src/components/ToggleGroup.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { cn } from "@/lib/utils"; - -export interface ToggleOption { - value: T; - label: string; - activeClassName?: string; -} - -interface ToggleGroupProps { - options: Array>; - value: T; - onChange: (value: T) => void; -} - -export function ToggleGroup({ options, value, onChange }: ToggleGroupProps) { - return ( -
- {options.map((option) => { - const isActive = value === option.value; - return ( - - ); - })} -
- ); -} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 000000000..28c435e90 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,102 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 000000000..bc891a3a8 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = "Input"; + +export { Input }; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 000000000..9d7798f31 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,151 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx new file mode 100644 index 000000000..9833ad5cc --- /dev/null +++ b/src/components/ui/toggle-group.tsx @@ -0,0 +1,57 @@ +"use client"; + +import * as React from "react"; +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; +import { type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { toggleVariants } from "@/components/ui/toggle"; + +const ToggleGroupContext = React.createContext>({ + size: "default", + variant: "default", +}); + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + {children} + +)); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext); + + return ( + + {children} + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +export { ToggleGroup, ToggleGroupItem }; diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 000000000..938db2b2e --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import * as TogglePrimitive from "@radix-ui/react-toggle"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +function Toggle({ + className, + variant, + size, + ...props +}: React.ComponentProps & VariantProps) { + return ( + + ); +} + +export { Toggle, toggleVariants }; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 000000000..675f3ad0e --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@/lib/utils"; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; From 91d1b39c512bf4186b458237de9753c5ea578736 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 23 Oct 2025 00:48:13 -0400 Subject: [PATCH 02/30] =?UTF-8?q?=F0=9F=A4=96=20Auto-fix=20ESLint=20warnin?= =?UTF-8?q?gs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ChatInput.tsx | 4 ++-- src/components/RightSidebar/CostsTab.tsx | 6 +++--- src/components/ui/dialog.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index b39258925..ee05d51ad 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -799,14 +799,14 @@ export const ChatInput: React.FC = ({ Exec Plan diff --git a/src/components/RightSidebar/CostsTab.tsx b/src/components/RightSidebar/CostsTab.tsx index fd189a4d0..f374ed4a4 100644 --- a/src/components/RightSidebar/CostsTab.tsx +++ b/src/components/RightSidebar/CostsTab.tsx @@ -350,19 +350,19 @@ const CostsTabComponent: React.FC = ({ workspaceId }) => { type="single" value={viewMode} onValueChange={(value) => value && setViewMode(value as ViewMode)} - className="flex gap-0 bg-toggle-bg rounded" + className="bg-toggle-bg flex gap-0 rounded" > Session Last Request diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 28c435e90..130f8707b 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -42,7 +42,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close From 71455440169593062a03a19fa3c9cc0844a26c29 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 23 Oct 2025 00:50:08 -0400 Subject: [PATCH 03/30] =?UTF-8?q?=F0=9F=A4=96=20Fix=20nullish=20coalescing?= =?UTF-8?q?=20in=20toggle-group?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/toggle-group.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index 9833ad5cc..0df7f30de 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -40,8 +40,8 @@ const ToggleGroupItem = React.forwardRef< ref={ref} className={cn( toggleVariants({ - variant: context.variant || variant, - size: context.size || size, + variant: context.variant ?? variant, + size: context.size ?? size, }), className )} From ee22483b35452245399d6c92627ef407f6a66ae6 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 23 Oct 2025 00:50:13 -0400 Subject: [PATCH 04/30] =?UTF-8?q?=F0=9F=A4=96=20Format=20docs/AGENTS.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AGENTS.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 2d39e6c80..27f809242 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -265,18 +265,17 @@ If IPC is hard to test, fix the test infrastructure or IPC layer, don't work aro - **Build custom only when**: The component has app-specific behavior with no shadcn equivalent Example: + ```bash # Add a new shadcn component bunx --bun shadcn@latest add badge -# Import in your code +# Import in your code import { Badge } from "@/components/ui/badge" ``` Shadcn components automatically respect our CSS variables via the `@/lib/utils` cn() helper and Tailwind config. - - ## TypeScript Best Practices - **Avoid `as any` in all contexts** - Never use `as any` casts. Instead: From 526498f7d31cbcab9b88516f1733b57823e8cfaf Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 23 Oct 2025 00:57:56 -0400 Subject: [PATCH 05/30] =?UTF-8?q?=F0=9F=A4=96=20Migrate=20all=20Tooltip=20?= =?UTF-8?q?components=20to=20shadcn/ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces custom TooltipWrapper/Tooltip with shadcn Tooltip/TooltipTrigger/TooltipContent. Files migrated (26 total, ~150 instances): - Tool components (5): FileReadToolCall, TodoToolCall, BashToolCall, FileEditToolCall, ProposePlanToolCall - UI components (8): ChatInput, NewWorkspaceModal, Context1MCheckbox, StatusIndicator, WorkspaceListItem, KebabMenu, MessageWindow, VimTextArea - Complex components (4): TitleBar (4 instances), ThinkingSlider (4), RightSidebar (4), ProjectSidebar (10) - Code review (4): RefreshButton, HunkViewer, ReviewPanel, DiffRenderer - Others: ModelDisplay, ConsumerBreakdown Changes: - Pattern: `...` → `...` - Props: `align/position` → `side`, `inline` removed, `width="wide"` → `className="max-w-md"` - Extracted HelpIndicator to standalone component for reuse - Deleted Tooltip.tsx (255 lines) and Tooltip.stories.tsx All static checks passing. Generated with `cmux` --- src/components/AIView.tsx | 28 +- src/components/ChatInput.tsx | 2 +- src/components/Context1MCheckbox.tsx | 16 +- src/components/HelpIndicator.tsx | 22 ++ src/components/KebabMenu.tsx | 10 +- src/components/Messages/MessageWindow.tsx | 20 +- src/components/Messages/ModelDisplay.tsx | 14 +- src/components/NewWorkspaceModal.tsx | 18 +- src/components/ProjectSidebar.tsx | 130 ++++----- src/components/RightSidebar.tsx | 86 +++--- .../RightSidebar/CodeReview/HunkViewer.tsx | 50 ++-- .../RightSidebar/CodeReview/RefreshButton.tsx | 54 ++-- .../RightSidebar/CodeReview/ReviewPanel.tsx | 78 +++--- .../RightSidebar/ConsumerBreakdown.tsx | 15 +- .../RightSidebar/VerticalTokenMeter.tsx | 24 +- src/components/StatusIndicator.tsx | 12 +- src/components/ThinkingSlider.tsx | 116 ++++---- src/components/TitleBar.tsx | 60 +++-- src/components/Tooltip.stories.tsx | 195 -------------- src/components/Tooltip.tsx | 255 ------------------ src/components/VimTextArea.tsx | 15 +- src/components/WorkspaceListItem.tsx | 34 +-- src/components/shared/DiffRenderer.tsx | 56 ++-- src/components/tools/BashToolCall.tsx | 12 +- src/components/tools/FileEditToolCall.tsx | 12 +- src/components/tools/FileReadToolCall.tsx | 12 +- src/components/tools/ProposePlanToolCall.tsx | 84 +++--- src/components/tools/TodoToolCall.tsx | 12 +- 28 files changed, 530 insertions(+), 912 deletions(-) create mode 100644 src/components/HelpIndicator.tsx delete mode 100644 src/components/Tooltip.stories.tsx delete mode 100644 src/components/Tooltip.tsx diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 84bc8dd3f..f9fdda02e 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -26,7 +26,7 @@ import { getModelName } from "@/utils/ai/models"; import { GitStatusIndicator } from "./GitStatusIndicator"; import { useGitStatus } from "@/stores/GitStatusStore"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import type { DisplayedMessage } from "@/types/message"; import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds"; @@ -348,19 +348,21 @@ const AIViewInner: React.FC = ({ {namedWorkspacePath} - - - + + + + + Open in terminal ({formatKeybind(KEYBINDS.OPEN_TERMINAL)}) - - + +
diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index ee05d51ad..596605133 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -25,7 +25,7 @@ import { type SlashSuggestion, } from "@/utils/slashCommands/suggestions"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; -import { HelpIndicator } from "./Tooltip"; +import { HelpIndicator } from "./HelpIndicator"; import { matchesKeybind, formatKeybind, KEYBINDS, isEditableElement } from "@/utils/ui/keybinds"; import { ModelSelector, type ModelSelectorRef } from "./ModelSelector"; import { useModelLRU } from "@/hooks/useModelLRU"; diff --git a/src/components/Context1MCheckbox.tsx b/src/components/Context1MCheckbox.tsx index ea474c208..b43251cf4 100644 --- a/src/components/Context1MCheckbox.tsx +++ b/src/components/Context1MCheckbox.tsx @@ -1,7 +1,7 @@ import React from "react"; import { use1MContext } from "@/hooks/use1MContext"; import { supports1MContext } from "@/utils/ai/models"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; interface Context1MCheckboxProps { modelString: string; @@ -26,12 +26,16 @@ export const Context1MCheckbox: React.FC = ({ modelStrin /> 1M Context - - ? - + + + + ? + + + Enable 1M token context window (beta feature for Claude Sonnet 4/4.5) - - + + ); }; diff --git a/src/components/HelpIndicator.tsx b/src/components/HelpIndicator.tsx new file mode 100644 index 000000000..1d9958ecf --- /dev/null +++ b/src/components/HelpIndicator.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { cn } from "@/lib/utils"; + +/** + * HelpIndicator - Small circular help indicator (typically "?") + * Used with tooltips to show additional information + */ +export const HelpIndicator: React.FC<{ className?: string; children?: React.ReactNode }> = ({ + className, + children, +}) => ( + + {children} + +); diff --git a/src/components/KebabMenu.tsx b/src/components/KebabMenu.tsx index 808441863..7d804400c 100644 --- a/src/components/KebabMenu.tsx +++ b/src/components/KebabMenu.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; export interface KebabMenuItem { @@ -88,10 +88,10 @@ export const KebabMenu: React.FC = ({ items, className }) => { return ( <>
- - {button} - More actions - + + {button} + More actions +
{isOpen && diff --git a/src/components/Messages/MessageWindow.tsx b/src/components/Messages/MessageWindow.tsx index 85b752de3..af3b0fee0 100644 --- a/src/components/Messages/MessageWindow.tsx +++ b/src/components/Messages/MessageWindow.tsx @@ -3,7 +3,7 @@ import React, { useState, useMemo } from "react"; import type { CmuxMessage, DisplayedMessage } from "@/types/message"; import { HeaderButton } from "../tools/shared/ToolPrimitives"; import { formatTimestamp } from "@/utils/ui/dateTime"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { KebabMenu, type KebabMenuItem } from "../KebabMenu"; export interface ButtonConfig { @@ -88,14 +88,16 @@ export const MessageWindow: React.FC = ({ {rightLabel} {buttons.map((button, index) => button.tooltip ? ( - - - {button.tooltip} - + + + + + {button.tooltip} + ) : ( = ({ modelString, showToo } return ( - - {content} - - {modelString} - - + + + {content} + + {modelString} + ); }; diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx index 893a37573..e6dc34858 100644 --- a/src/components/NewWorkspaceModal.tsx +++ b/src/components/NewWorkspaceModal.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useId, useState } from "react"; import { Modal, ModalInfo, ModalActions, CancelButton, PrimaryButton } from "./Modal"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { formatNewCommand } from "@/utils/chatCommands"; interface NewWorkspaceModalProps { @@ -102,11 +102,13 @@ const NewWorkspaceModal: React.FC = ({
void handleSubmit(event)}>
{projects.size === 0 ? ( @@ -498,48 +498,48 @@ const ProjectSidebarInner: React.FC = ({
{projectName}
- -
- {abbreviatePath(projectPath)} -
- - {projectPath} - -
-
- - - - Manage secrets - - - - - - Remove project + + +
+ {abbreviatePath(projectPath)} +
+
+ {projectPath}
-
+ + + + + + Manage secrets + + + + + + Remove project + {isExpanded && ( @@ -626,18 +626,20 @@ const ProjectSidebarInner: React.FC = ({ )} - - - + + + + + {collapsed ? "Expand sidebar" : "Collapse sidebar"} ( {formatKeybind(KEYBINDS.TOGGLE_SIDEBAR)}) - - + + {secretsModalState && ( = ({ role="tablist" aria-label="Metadata views" > - - - - {formatKeybind(KEYBINDS.COSTS_TAB)} - - - - - - {formatKeybind(KEYBINDS.REVIEW_TAB)} - - + + + + + {formatKeybind(KEYBINDS.COSTS_TAB)} + + + + + + {formatKeybind(KEYBINDS.REVIEW_TAB)} +
( >
{isRead && ( - - - ✓ - - - Marked as read - - + + + + ✓ + + + Marked as read + )}
( ({lineCount} {lineCount === 1 ? "line" : "lines"}) {onToggleRead && ( - - - + + + + + Mark as read ({formatKeybind(KEYBINDS.TOGGLE_HUNK_READ)}) - - + + )}
diff --git a/src/components/RightSidebar/CodeReview/RefreshButton.tsx b/src/components/RightSidebar/CodeReview/RefreshButton.tsx index a7f6a2ecd..cf5b2c21d 100644 --- a/src/components/RightSidebar/CodeReview/RefreshButton.tsx +++ b/src/components/RightSidebar/CodeReview/RefreshButton.tsx @@ -3,7 +3,7 @@ */ import React, { useState, useRef, useEffect } from "react"; -import { TooltipWrapper, Tooltip } from "@/components/Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; import { cn } from "@/lib/utils"; @@ -48,36 +48,38 @@ export const RefreshButton: React.FC = ({ onClick, isLoading }, []); return ( - - - + + + + + + {animationState !== "idle" ? "Refreshing..." : `Refresh diff (${formatKeybind(KEYBINDS.REFRESH_REVIEW)})`} - - + + ); }; diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index 861588b8f..e0eeac987 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -30,7 +30,7 @@ import { usePersistedState } from "@/hooks/usePersistedState"; import { useReviewState } from "@/hooks/useReviewState"; import { parseDiff, extractAllHunks } from "@/utils/git/diffParser"; import { getReviewSearchStateKey } from "@/constants/storage"; -import { Tooltip, TooltipWrapper } from "@/components/Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { parseNumstat, buildFileTree, extractNewPath } from "@/utils/git/numstatParser"; import type { DiffHunk, ReviewFilters as ReviewFiltersType } from "@/types/review"; import type { FileTreeNode } from "@/utils/git/numstatParser"; @@ -573,46 +573,50 @@ export const ReviewPanel: React.FC = ({ onChange={(e) => setSearchState({ ...searchState, input: e.target.value })} className="text-foreground placeholder:text-text-dim focus:bg-separator flex h-full flex-1 items-center border-none bg-transparent px-2.5 py-1.5 font-sans text-xs leading-[1.4] outline-none" /> - - - + + + + + {searchState.useRegex ? "Using regex search" : "Using substring search"} - - - - - + + + + + + + {searchState.matchCase ? "Match case (case-sensitive)" : "Ignore case (case-insensitive)"} - - + +
diff --git a/src/components/RightSidebar/ConsumerBreakdown.tsx b/src/components/RightSidebar/ConsumerBreakdown.tsx index 41f638998..f61302fd8 100644 --- a/src/components/RightSidebar/ConsumerBreakdown.tsx +++ b/src/components/RightSidebar/ConsumerBreakdown.tsx @@ -1,6 +1,7 @@ import React from "react"; import type { WorkspaceConsumersState } from "@/stores/WorkspaceStore"; -import { TooltipWrapper, Tooltip, HelpIndicator } from "../Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { HelpIndicator } from "@/components/HelpIndicator"; // Format token display - show k for thousands with 1 decimal const formatTokens = (tokens: number) => @@ -42,13 +43,15 @@ const ConsumerBreakdownComponent: React.FC = ({ consumer {consumer.name} {consumer.name === "web_search" && ( - - ? - + + + ? + + Web search results are encrypted and decrypted server-side. This estimate is approximate. - - + + )} diff --git a/src/components/RightSidebar/VerticalTokenMeter.tsx b/src/components/RightSidebar/VerticalTokenMeter.tsx index 83785e391..5a1c2388a 100644 --- a/src/components/RightSidebar/VerticalTokenMeter.tsx +++ b/src/components/RightSidebar/VerticalTokenMeter.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { TokenMeter } from "./TokenMeter"; import { type TokenMeterData, formatTokens, getSegmentLabel } from "@/utils/tokens/tokenMeterUtils"; @@ -36,14 +36,16 @@ const VerticalTokenMeterComponent: React.FC<{ data: TokenMeterData }> = ({ data className="flex w-full flex-1 flex-col items-center [&>*]:flex [&>*]:flex-1 [&>*]:flex-col" data-bar-wrapper="expand" > - - - + + + + +
= ({ data 💡 Expand your viewport to see full details
-
-
+ +
= ({ // If title provided, wrap with proper Tooltip component if (title) { return ( - - {indicator} - - {title} - - + + {indicator} + {title} + ); } diff --git a/src/components/ThinkingSlider.tsx b/src/components/ThinkingSlider.tsx index d3b4447f9..ff5a3310a 100644 --- a/src/components/ThinkingSlider.tsx +++ b/src/components/ThinkingSlider.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useId } from "react"; import type { ThinkingLevel, ThinkingLevelOn } from "@/types/thinking"; import { useThinkingLevel } from "@/hooks/useThinkingLevel"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; import { getThinkingPolicyForModel } from "@/utils/thinking/policy"; import { updatePersistedState } from "@/hooks/usePersistedState"; @@ -94,20 +94,22 @@ export const ThinkingSliderComponent: React.FC = ({ modelS const textStyle = getTextStyle(value); return ( - -
- - - {fixedLevel} - -
- {tooltipMessage} -
+ + +
+ + + {fixedLevel} + +
+
+ {tooltipMessage} +
); } @@ -126,46 +128,48 @@ export const ThinkingSliderComponent: React.FC = ({ modelS }; return ( - -
- - - handleThinkingLevelChange(valueToThinkingLevel(parseInt(e.target.value))) - } - onMouseEnter={() => setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - id={sliderId} - role="slider" - aria-valuemin={0} - aria-valuemax={3} - aria-valuenow={value} - aria-valuetext={thinkingLevel} - className="thinking-slider" - style={ - { - "--track-shadow": sliderStyles.trackShadow, - "--thumb-shadow": sliderStyles.thumbShadow, - "--thumb-bg": sliderStyles.thumbBg, - } as React.CSSProperties - } - /> - - {thinkingLevel} - -
- {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to toggle -
+ + +
+ + + handleThinkingLevelChange(valueToThinkingLevel(parseInt(e.target.value))) + } + onMouseEnter={() => setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + id={sliderId} + role="slider" + aria-valuemin={0} + aria-valuemax={3} + aria-valuenow={value} + aria-valuetext={thinkingLevel} + className="thinking-slider" + style={ + { + "--track-shadow": sliderStyles.trackShadow, + "--thumb-shadow": sliderStyles.thumbShadow, + "--thumb-bg": sliderStyles.thumbBg, + } as React.CSSProperties + } + /> + + {thinkingLevel} + +
+
+ {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to toggle +
); }; diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 80313ee96..9270f23f2 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { VERSION } from "@/version"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import type { UpdateStatus } from "@/types/ipc"; import { isTelemetryEnabled } from "@/telemetry"; @@ -217,39 +217,41 @@ export function TitleBar() {
{showUpdateIndicator && ( - -
- - {indicatorStatus === "disabled" - ? "⊘" - : indicatorStatus === "downloading" - ? "⟳" - : "↓"} - -
- - {getUpdateTooltip()} - -
+ + +
+ + {indicatorStatus === "disabled" + ? "⊘" + : indicatorStatus === "downloading" + ? "⟳" + : "↓"} + +
+
+ {getUpdateTooltip()} +
)}
cmux {gitDescribe ?? "(dev)"}
- -
{buildDate}
- Built at {extendedTimestamp} -
+ + +
{buildDate}
+
+ Built at {extendedTimestamp} +
); } diff --git a/src/components/Tooltip.stories.tsx b/src/components/Tooltip.stories.tsx deleted file mode 100644 index 20106be13..000000000 --- a/src/components/Tooltip.stories.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { expect, userEvent, within, waitFor } from "@storybook/test"; -import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip"; - -const meta = { - title: "Components/Tooltip", - component: Tooltip, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const BasicTooltip: Story = { - args: { children: "This is a helpful tooltip" }, - render: () => ( - - - This is a helpful tooltip - - ), - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Find the button to hover - const button = canvas.getByRole("button", { name: /hover me/i }); - - // Initially tooltip should not be in the document - let tooltip = document.body.querySelector(".tooltip"); - void expect(tooltip).not.toBeInTheDocument(); - - // Hover over the button - await userEvent.hover(button); - - // Wait for tooltip to appear in document.body (portal) - await waitFor( - () => { - tooltip = document.body.querySelector(".tooltip"); - void expect(tooltip).toBeInTheDocument(); - void expect(tooltip).toHaveTextContent("This is a helpful tooltip"); - }, - { timeout: 2000 } - ); - - // Unhover to hide tooltip - await userEvent.unhover(button); - - // Wait for tooltip to disappear - await waitFor( - () => { - tooltip = document.body.querySelector(".tooltip"); - void expect(tooltip).not.toBeInTheDocument(); - }, - { timeout: 2000 } - ); - }, -}; - -export const TooltipPositions: Story = { - args: { children: "Tooltip content" }, - render: () => ( -
- - - Tooltip appears above - - - - - Tooltip appears below - -
- ), -}; - -export const TooltipAlignments: Story = { - args: { children: "Tooltip content" }, - render: () => ( -
- - - Left-aligned tooltip - - - - - Center-aligned tooltip - - - - - Right-aligned tooltip - -
- ), -}; - -export const WideTooltip: Story = { - args: { children: "Tooltip content" }, - render: () => ( - - - - This is a wider tooltip that can contain more detailed information. It will wrap text - automatically and has a maximum width of 300px. - - - ), -}; - -export const WithHelpIndicator: Story = { - args: { children: "Tooltip content" }, - render: () => ( -
- Need help? - - ? - - Click here to open the help documentation. You can also press Cmd+Shift+H to quickly - access help. - - -
- ), -}; - -export const InlineTooltip: Story = { - args: { children: "Tooltip content" }, - render: () => ( -
- This is some text with an{" "} - - - inline tooltip - - Additional context appears here - {" "} - embedded in the sentence. -
- ), -}; - -export const KeyboardShortcut: Story = { - args: { children: "Tooltip content" }, - render: () => ( - - - - Save File ⌘S - - - ), -}; - -export const LongContent: Story = { - args: { children: "Tooltip content" }, - render: () => ( - - - - Getting Started: -
- 1. Create a new workspace -
- 2. Select your preferred model -
- 3. Start chatting with the AI -
-
- Press Cmd+K to open the command palette. -
-
- ), -}; diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx deleted file mode 100644 index d164f0e2b..000000000 --- a/src/components/Tooltip.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React, { useState, useRef, useLayoutEffect, createContext, useContext } from "react"; -import { createPortal } from "react-dom"; -import { cn } from "@/lib/utils"; - -// Context for passing hover state and trigger ref from wrapper to tooltip -interface TooltipContextValue { - isHovered: boolean; - setIsHovered: (value: boolean) => void; - triggerRef: React.RefObject | null; -} - -const TooltipContext = createContext({ - isHovered: false, - // eslint-disable-next-line @typescript-eslint/no-empty-function - setIsHovered: () => {}, - triggerRef: null, -}); - -// TooltipWrapper - React component that tracks hover state -interface TooltipWrapperProps { - inline?: boolean; - children: React.ReactNode; -} - -export const TooltipWrapper: React.FC = ({ inline = false, children }) => { - const [isHovered, setIsHovered] = useState(false); - const triggerRef = useRef(null); - const leaveTimerRef = useRef(null); - - const handleMouseEnter = () => { - if (leaveTimerRef.current) { - clearTimeout(leaveTimerRef.current); - leaveTimerRef.current = null; - } - setIsHovered(true); - }; - - const handleMouseLeave = () => { - // Delay hiding to allow moving mouse to tooltip - leaveTimerRef.current = setTimeout(() => { - setIsHovered(false); - }, 100); - }; - - return ( - - - {children} - - - ); -}; - -// Tooltip - Portal-based component with collision detection -interface TooltipProps { - align?: "left" | "center" | "right"; - width?: "auto" | "wide"; - position?: "top" | "bottom"; - children: React.ReactNode; - className?: string; - interactive?: boolean; -} - -export const Tooltip: React.FC = ({ - align = "left", - width = "auto", - position = "top", - children, - className = "tooltip", - interactive = false, -}) => { - const { isHovered, setIsHovered, triggerRef } = useContext(TooltipContext); - const tooltipRef = useRef(null); - const leaveTimerRef = useRef(null); - const [tooltipState, setTooltipState] = useState<{ - style: React.CSSProperties; - arrowStyle: React.CSSProperties; - isPositioned: boolean; - }>({ - style: {}, - arrowStyle: {}, - isPositioned: false, - }); - - // Use useLayoutEffect to measure and position synchronously before paint - useLayoutEffect(() => { - if (!isHovered || !triggerRef?.current || !tooltipRef.current) { - // Reset when hidden - setTooltipState({ style: {}, arrowStyle: {}, isPositioned: false }); - return; - } - - // Measure and position immediately in useLayoutEffect - // This runs synchronously before browser paint, preventing flash - const measure = () => { - if (!triggerRef?.current || !tooltipRef.current) return; - - const trigger = triggerRef.current.getBoundingClientRect(); - const tooltip = tooltipRef.current.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let top: number; - let left: number; - let finalPosition = position; - const gap = 8; // Gap between trigger and tooltip - - // Vertical positioning with collision detection - if (position === "bottom") { - top = trigger.bottom + gap; - // Check if tooltip would overflow bottom of viewport - if (top + tooltip.height > viewportHeight) { - // Flip to top - finalPosition = "top"; - top = trigger.top - tooltip.height - gap; - } - } else { - // position === "top" - top = trigger.top - tooltip.height - gap; - // Check if tooltip would overflow top of viewport - if (top < 0) { - // Flip to bottom - finalPosition = "bottom"; - top = trigger.bottom + gap; - } - } - - // Horizontal positioning based on align - if (align === "left") { - left = trigger.left; - } else if (align === "right") { - left = trigger.right - tooltip.width; - } else { - // center - left = trigger.left + trigger.width / 2 - tooltip.width / 2; - } - - // Horizontal collision detection - const minLeft = 8; // Min distance from viewport edge - const maxLeft = viewportWidth - tooltip.width - 8; - const originalLeft = left; - left = Math.max(minLeft, Math.min(maxLeft, left)); - - // Calculate arrow position - stays aligned with trigger even if tooltip shifts - let arrowLeft: number; - if (align === "center") { - arrowLeft = trigger.left + trigger.width / 2 - left; - } else if (align === "right") { - arrowLeft = tooltip.width - 15; // 10px from right + 5px arrow width - } else { - // left - arrowLeft = Math.max(10, Math.min(originalLeft - left + 10, tooltip.width - 15)); - } - - // Update all state atomically to prevent flashing - setTooltipState({ - style: { - position: "fixed", - top: `${top}px`, - left: `${left}px`, - visibility: "visible", - opacity: 1, - }, - arrowStyle: { - left: `${arrowLeft}px`, - [finalPosition === "bottom" ? "bottom" : "top"]: "100%", - borderColor: - finalPosition === "bottom" - ? "transparent transparent #2d2d30 transparent" - : "#2d2d30 transparent transparent transparent", - }, - isPositioned: true, - }); - }; - - // Try immediate measurement first - measure(); - - // If fonts aren't loaded yet, measure again after RAF - // This handles the edge case of first render before fonts load - const rafId = requestAnimationFrame(measure); - return () => cancelAnimationFrame(rafId); - }, [isHovered, align, position, triggerRef]); - - const handleTooltipMouseEnter = () => { - if (interactive) { - if (leaveTimerRef.current) { - clearTimeout(leaveTimerRef.current); - leaveTimerRef.current = null; - } - setIsHovered(true); - } - }; - - const handleTooltipMouseLeave = () => { - if (interactive) { - setIsHovered(false); - } - }; - - if (!isHovered) { - return null; - } - - return createPortal( -
{workspaceId && ( - - - Replace all chat history with this plan - + + + + + Replace all chat history with this plan + )} - + + + + + Open in terminal ({formatKeybind(KEYBINDS.OPEN_TERMINAL)}) - - + +
); diff --git a/src/components/tools/StatusSetToolCall.tsx b/src/components/tools/StatusSetToolCall.tsx index fc9942d46..442c0ccbd 100644 --- a/src/components/tools/StatusSetToolCall.tsx +++ b/src/components/tools/StatusSetToolCall.tsx @@ -2,7 +2,7 @@ import React from "react"; import type { StatusSetToolArgs, StatusSetToolResult } from "@/types/tools"; import { ToolContainer, ToolHeader, StatusIndicator } from "./shared/ToolPrimitives"; import { getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; -import { TooltipWrapper, Tooltip } from "../Tooltip"; +import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; interface StatusSetToolCallProps { args: StatusSetToolArgs; @@ -26,10 +26,12 @@ export const StatusSetToolCall: React.FC = ({ return ( - - {args.emoji} - status_set - + + + {args.emoji} + + status_set + {args.message} {errorMessage && ({errorMessage})} {statusDisplay} From 0c0cfdcbdb093d827822ba0a7097e60e53a65e6c Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 30 Oct 2025 11:10:17 -0400 Subject: [PATCH 30/30] Fix storybook imports in story files --- src/components/KebabMenu.stories.tsx | 2 +- src/components/Messages/AssistantMessage.stories.tsx | 2 +- src/components/StatusIndicator.stories.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/KebabMenu.stories.tsx b/src/components/KebabMenu.stories.tsx index df0213d49..7545b307d 100644 --- a/src/components/KebabMenu.stories.tsx +++ b/src/components/KebabMenu.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { KebabMenu } from "./KebabMenu"; import { action } from "@storybook/addon-actions"; import { TooltipProvider } from "@/components/ui/tooltip"; diff --git a/src/components/Messages/AssistantMessage.stories.tsx b/src/components/Messages/AssistantMessage.stories.tsx index 51d518685..c006b788d 100644 --- a/src/components/Messages/AssistantMessage.stories.tsx +++ b/src/components/Messages/AssistantMessage.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { AssistantMessage } from "./AssistantMessage"; import type { DisplayedMessage } from "@/types/message"; import { action } from "@storybook/addon-actions"; diff --git a/src/components/StatusIndicator.stories.tsx b/src/components/StatusIndicator.stories.tsx index 11e553429..1f40036bc 100644 --- a/src/components/StatusIndicator.stories.tsx +++ b/src/components/StatusIndicator.stories.tsx @@ -1,4 +1,4 @@ -import type { Meta, StoryObj } from "@storybook/react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "@storybook/addon-actions"; import { expect, userEvent, waitFor } from "@storybook/test"; import { StatusIndicator } from "./StatusIndicator";