From 7e074519848cda6a4f2c463d13c39ba15dfad31e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Fri, 5 Dec 2025 15:35:18 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20replace=20native?= =?UTF-8?q?=20UI=20elements=20with=20shadcn=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update components.json to use browser/components paths - Add shadcn context-menu, select, and dialog primitives - Update dialog.tsx with app-consistent styling (bg-dark, borders) - Update select.tsx with compact sizing and theme colors - Update button.tsx variants to use accent colors - Replace native onChange(e.target.value)} - disabled={disabled} - aria-label={ariaLabel} - className={`bg-separator text-foreground border-border-medium focus:border-accent rounded border px-1 py-0.5 text-xs focus:outline-none disabled:opacity-50 ${className}`} - > - {normalizedOptions.map((opt) => ( - - ))} - + + + + + + {normalizedOptions.map((opt) => ( + + {opt.label} + + ))} + + ); } diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index 3a5a5eeea..61b88702c 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -6,6 +6,7 @@ import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { GeneralSection } from "./sections/GeneralSection"; import { ProvidersSection } from "./sections/ProvidersSection"; import { ModelsSection } from "./sections/ModelsSection"; +import { Button } from "@/browser/components/ui/button"; import type { SettingsSection } from "./types"; const SECTIONS: SettingsSection[] = [ @@ -72,30 +73,31 @@ export function SettingsModal() { Settings {/* Close button in header on mobile only */} - + @@ -104,14 +106,15 @@ export function SettingsModal() {
{currentSection.label} - +
diff --git a/src/browser/components/Settings/sections/GeneralSection.tsx b/src/browser/components/Settings/sections/GeneralSection.tsx index 5699e76c2..fcb2fff95 100644 --- a/src/browser/components/Settings/sections/GeneralSection.tsx +++ b/src/browser/components/Settings/sections/GeneralSection.tsx @@ -1,5 +1,12 @@ import React from "react"; import { useTheme, THEME_OPTIONS, type ThemeMode } from "@/browser/contexts/ThemeContext"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; export function GeneralSection() { const { theme, setTheme } = useTheme(); @@ -13,17 +20,18 @@ export function GeneralSection() {
Theme
Choose your preferred theme
- +
diff --git a/src/browser/components/Settings/sections/ModelRow.tsx b/src/browser/components/Settings/sections/ModelRow.tsx index bcf82bbf5..2a4c463ec 100644 --- a/src/browser/components/Settings/sections/ModelRow.tsx +++ b/src/browser/components/Settings/sections/ModelRow.tsx @@ -4,6 +4,7 @@ import { GatewayIcon } from "@/browser/components/icons/GatewayIcon"; import { cn } from "@/common/lib/utils"; import { TooltipWrapper, Tooltip } from "@/browser/components/Tooltip"; import { ProviderWithIcon } from "@/browser/components/ProviderIcon"; +import { Button } from "@/browser/components/ui/button"; export interface ModelRowProps { provider: string; @@ -74,24 +75,26 @@ export function ModelRow(props: ModelRowProps) {
{props.isEditing ? ( <> - - + ) : ( <> @@ -116,22 +119,23 @@ export function ModelRow(props: ModelRowProps) { )} {/* Favorite/default button */} - + {props.isDefault ? "Default model" : "Set as default"} @@ -139,24 +143,26 @@ export function ModelRow(props: ModelRowProps) { {/* Edit/delete buttons only for custom models */} {props.isCustom && ( <> - - + )} diff --git a/src/browser/components/Settings/sections/ModelsSection.tsx b/src/browser/components/Settings/sections/ModelsSection.tsx index b255e60ef..1fc8de4fa 100644 --- a/src/browser/components/Settings/sections/ModelsSection.tsx +++ b/src/browser/components/Settings/sections/ModelsSection.tsx @@ -7,6 +7,14 @@ import { useGateway } from "@/browser/hooks/useGatewayModels"; import { ModelRow } from "./ModelRow"; import { useAPI } from "@/browser/contexts/API"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; +import { Button } from "@/browser/components/ui/button"; // Providers to exclude from the custom models UI (handled specially or internal) const HIDDEN_PROVIDERS = new Set(["mux-gateway"]); @@ -167,19 +175,22 @@ export function ModelsSection() { {/* Add new model form */}
-
- setNewModel((prev) => ({ ...prev, provider: e.target.value }))} - className="bg-modal-bg border-border-medium focus:border-accent shrink-0 rounded border px-2 py-1 text-xs focus:outline-none" + onValueChange={(value) => setNewModel((prev) => ({ ...prev, provider: value }))} > - - {SUPPORTED_PROVIDERS.map((p) => ( - - ))} - + + + + + {SUPPORTED_PROVIDERS.map((p) => ( + + {PROVIDER_DISPLAY_NAMES[p]} + + ))} + + - +
{error && !editing &&
{error}
}
diff --git a/src/browser/components/Settings/sections/ProvidersSection.tsx b/src/browser/components/Settings/sections/ProvidersSection.tsx index 834f80abc..f507c4199 100644 --- a/src/browser/components/Settings/sections/ProvidersSection.tsx +++ b/src/browser/components/Settings/sections/ProvidersSection.tsx @@ -6,6 +6,7 @@ import { ProviderWithIcon } from "@/browser/components/ProviderIcon"; import { useAPI } from "@/browser/contexts/API"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useGateway } from "@/browser/hooks/useGatewayModels"; +import { Button } from "@/browser/components/ui/button"; interface FieldConfig { key: string; @@ -214,10 +215,10 @@ export function ProvidersSection() { className="border-border-medium bg-background-secondary overflow-hidden rounded-md border" > {/* Provider header */} - + {/* Provider settings */} {isExpanded && ( @@ -266,20 +267,22 @@ export function ProvidersSection() { if (e.key === "Escape") handleCancelEdit(); }} /> - - +
) : (
@@ -294,23 +297,25 @@ export function ProvidersSection() { {(fieldConfig.type === "text" ? !!fieldValue : fieldConfig.type === "secret" && fieldIsSet) && ( - + )} - +
)} diff --git a/src/browser/components/SettingsButton.tsx b/src/browser/components/SettingsButton.tsx index 01382ed72..f6b7e099c 100644 --- a/src/browser/components/SettingsButton.tsx +++ b/src/browser/components/SettingsButton.tsx @@ -2,22 +2,24 @@ import { Settings } from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; +import { Button } from "@/browser/components/ui/button"; export function SettingsButton() { const { open } = useSettings(); return ( - + Settings ({formatKeybind(KEYBINDS.OPEN_SETTINGS)}) ); diff --git a/src/browser/components/ThemeSelector.tsx b/src/browser/components/ThemeSelector.tsx index e3a6f723f..79d0ca5e5 100644 --- a/src/browser/components/ThemeSelector.tsx +++ b/src/browser/components/ThemeSelector.tsx @@ -1,5 +1,12 @@ import { useTheme, THEME_OPTIONS, type ThemeMode } from "@/browser/contexts/ThemeContext"; import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; export function ThemeSelector() { const { theme, setTheme } = useTheme(); @@ -7,19 +14,22 @@ export function ThemeSelector() { return ( - + Theme: {currentLabel} ); diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index adfaf64df..794c876d1 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -5,6 +5,7 @@ import { TooltipWrapper, Tooltip } from "./Tooltip"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useGitStatus } from "@/browser/stores/GitStatusStore"; import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; +import { Button } from "@/browser/components/ui/button"; import type { RuntimeConfig } from "@/common/types/runtime"; import { useTutorial } from "@/browser/contexts/TutorialContext"; import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal"; @@ -64,15 +65,17 @@ export const WorkspaceHeader: React.FC = ({ - + Open terminal window ({formatKeybind(KEYBINDS.OPEN_TERMINAL)}) diff --git a/src/browser/components/ui/button.tsx b/src/browser/components/ui/button.tsx index 902ea85b8..e2c0a39f2 100644 --- a/src/browser/components/ui/button.tsx +++ b/src/browser/components/ui/button.tsx @@ -4,18 +4,17 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/common/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { - default: "bg-button-bg text-button-text shadow hover:bg-button-hover", + default: "bg-accent text-white shadow hover:bg-accent-dark", destructive: "bg-error text-white shadow-sm hover:bg-error/90", outline: - "border border-input-border bg-transparent shadow-sm hover:bg-button-bg hover:text-button-text", - secondary: - "bg-background-secondary text-foreground shadow-sm hover:bg-background-secondary/80", - ghost: "hover:bg-button-bg hover:text-button-text", - link: "text-plan-mode underline-offset-4 hover:underline", + "border border-border-medium bg-transparent shadow-sm hover:bg-hover hover:text-foreground", + secondary: "bg-border-medium text-foreground shadow-sm hover:bg-border-darker", + ghost: "hover:bg-hover hover:text-foreground", + link: "text-accent underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2", diff --git a/src/browser/components/ui/context-menu.tsx b/src/browser/components/ui/context-menu.tsx new file mode 100644 index 000000000..4237ac741 --- /dev/null +++ b/src/browser/components/ui/context-menu.tsx @@ -0,0 +1,184 @@ +import * as React from "react"; +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/common/lib/utils"; + +const ContextMenu = ContextMenuPrimitive.Root; + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger; + +const ContextMenuGroup = ContextMenuPrimitive.Group; + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuSub = ContextMenuPrimitive.Sub; + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return ( + + ); +}; +ContextMenuShortcut.displayName = "ContextMenuShortcut"; + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/src/browser/components/ui/dialog.tsx b/src/browser/components/ui/dialog.tsx new file mode 100644 index 000000000..8067c7f8c --- /dev/null +++ b/src/browser/components/ui/dialog.tsx @@ -0,0 +1,104 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "@/common/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 & { + /** Whether to show the close button (default: true) */ + showCloseButton?: boolean; + } +>(({ className, children, showCloseButton = true, ...props }, ref) => ( + + + + {children} + {showCloseButton && ( + + + 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/browser/components/ui/select.tsx b/src/browser/components/ui/select.tsx new file mode 100644 index 000000000..1c8633f86 --- /dev/null +++ b/src/browser/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 "@/common/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, +}; From 23729517ef4a68c771cba63257ddd79f26becc7a Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sun, 7 Dec 2025 18:57:05 -0500 Subject: [PATCH 2/2] Convert modals to use the dialog component --- src/browser/components/AuthTokenModal.tsx | 101 ++++---- .../components/DirectoryPickerModal.tsx | 74 +++--- src/browser/components/ForceDeleteModal.tsx | 86 ++++--- src/browser/components/Modal.tsx | 222 ------------------ src/browser/components/ProjectCreateModal.tsx | 98 ++++---- src/browser/components/SecretsModal.tsx | 190 ++++++++------- .../components/Settings/SettingsModal.tsx | 45 +--- src/browser/components/StartHereModal.tsx | 51 ++-- src/browser/components/ui/dialog.tsx | 78 +++++- 9 files changed, 420 insertions(+), 525 deletions(-) delete mode 100644 src/browser/components/Modal.tsx diff --git a/src/browser/components/AuthTokenModal.tsx b/src/browser/components/AuthTokenModal.tsx index 6110adec1..48d356d93 100644 --- a/src/browser/components/AuthTokenModal.tsx +++ b/src/browser/components/AuthTokenModal.tsx @@ -1,5 +1,13 @@ import { useState, useCallback } from "react"; -import { Modal } from "./Modal"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/browser/components/ui/dialog"; +import { Button } from "@/browser/components/ui/button"; interface AuthTokenModalProps { isOpen: boolean; @@ -48,64 +56,45 @@ export function AuthTokenModal(props: AuthTokenModalProps) { [token, onSubmit] ); + // This modal cannot be dismissed without providing a token + const handleOpenChange = useCallback(() => { + // Do nothing - modal cannot be closed without submitting + }, []); + return ( - undefined} title="Authentication Required"> -
-

- This server requires an authentication token. Enter the token provided when the server was - started. -

+ + + + Authentication Required + + This server requires an authentication token. Enter the token provided when the server was + started. + + - {props.error && ( -
- {props.error} -
- )} + + {props.error && ( +
+ {props.error} +
+ )} - setToken(e.target.value)} - placeholder="Enter auth token" - autoFocus - style={{ - padding: "10px 12px", - borderRadius: 4, - border: "1px solid var(--color-border)", - backgroundColor: "var(--color-input-background)", - color: "var(--color-text)", - fontSize: 14, - outline: "none", - }} - /> + setToken(e.target.value)} + placeholder="Enter auth token" + autoFocus + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted text-foreground rounded border px-3 py-2.5 text-sm focus:outline-none" + /> - - -
+ + + + + + ); } diff --git a/src/browser/components/DirectoryPickerModal.tsx b/src/browser/components/DirectoryPickerModal.tsx index 4b3268e77..8530985e1 100644 --- a/src/browser/components/DirectoryPickerModal.tsx +++ b/src/browser/components/DirectoryPickerModal.tsx @@ -1,5 +1,13 @@ import React, { useCallback, useEffect, useState } from "react"; -import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/browser/components/ui/dialog"; +import { Button } from "@/browser/components/ui/button"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import { DirectoryTree } from "./DirectoryTree"; import { useAPI } from "@/browser/contexts/API"; @@ -79,38 +87,48 @@ export const DirectoryPickerModal: React.FC = ({ onClose(); }, [onClose, onSelectPath, root]); - if (!isOpen) return null; + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open && !isLoading) { + onClose(); + } + }, + [isLoading, onClose] + ); + const entries = root?.children .filter((child) => child.isDirectory) .map((child) => ({ name: child.name, path: child.path })) ?? []; return ( - - {error &&
{error}
} -
- -
- - - Cancel - - void handleConfirm()} disabled={isLoading || !root}> - Select - - -
+ + + + Select Project Directory + + {root ? root.path : "Select a directory to use as your project root"} + + + {error &&
{error}
} +
+ +
+ + + + +
+
); }; diff --git a/src/browser/components/ForceDeleteModal.tsx b/src/browser/components/ForceDeleteModal.tsx index c6d2f803b..b470d468b 100644 --- a/src/browser/components/ForceDeleteModal.tsx +++ b/src/browser/components/ForceDeleteModal.tsx @@ -1,16 +1,19 @@ -import React, { useState } from "react"; +import React, { useState, useCallback } from "react"; import { - Modal, - ModalActions, - CancelButton, - DangerButton, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, ErrorSection, ErrorLabel, ErrorCodeBlock, WarningBox, WarningTitle, WarningText, -} from "./Modal"; +} from "@/browser/components/ui/dialog"; +import { Button } from "@/browser/components/ui/button"; interface ForceDeleteModalProps { isOpen: boolean; @@ -43,40 +46,47 @@ export const ForceDeleteModal: React.FC = ({ })(); }; + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open && !isDeleting) { + onClose(); + } + }, + [isDeleting, onClose] + ); + return ( - - - Git Error - {error} - + + + + Force Delete Workspace? + The workspace could not be removed normally + + + Git Error + {error} + - - This action cannot be undone - - Force deleting will permanently remove the workspace and{" "} - {error.includes("unpushed commits:") - ? "discard the unpushed commits shown above" - : "may discard uncommitted work or lose data"} - . This action cannot be undone. - - + + This action cannot be undone + + Force deleting will permanently remove the workspace and{" "} + {error.includes("unpushed commits:") + ? "discard the unpushed commits shown above" + : "may discard uncommitted work or lose data"} + . This action cannot be undone. + + - - - Cancel - - - {isDeleting ? "Deleting..." : "Force Delete"} - - - + + + + + + ); }; diff --git a/src/browser/components/Modal.tsx b/src/browser/components/Modal.tsx deleted file mode 100644 index 7e0dc3412..000000000 --- a/src/browser/components/Modal.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { useEffect, useCallback, useId } from "react"; -import { cn } from "@/common/lib/utils"; -import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; - -// Export utility components for backwards compatibility -export const ModalOverlay: React.FC<{ - children: React.ReactNode; - onClick?: () => void; - role?: string; - className?: string; -}> = ({ children, onClick, role, className }) => ( -
- {children} -
-); - -export const ModalContent: React.FC< - { - children: React.ReactNode; - maxWidth?: string; - maxHeight?: string; - className?: string; - } & React.HTMLAttributes -> = ({ children, maxWidth = "500px", maxHeight, className, ...props }) => ( -
- {children} -
-); - -export const ModalSubtitle: React.FC<{ - children: React.ReactNode; - id?: string; - className?: string; -}> = ({ children, id, className }) => ( -

- {children} -

-); - -export const ModalInfo: React.FC<{ - children: React.ReactNode; - className?: string; - id?: string; -}> = ({ children, className, id }) => ( -
- {children} -
-); - -export const ModalActions: React.FC<{ children: React.ReactNode; className?: string }> = ({ - children, - className, -}) =>
{children}
; - -// Reusable error/warning display components for modals -export const ErrorSection: React.FC<{ children: React.ReactNode; className?: string }> = ({ - children, - className, -}) =>
{children}
; - -export const ErrorLabel: React.FC<{ children: React.ReactNode; className?: string }> = ({ - children, - className, -}) => ( -
- {children} -
-); - -export const ErrorCodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = ({ - children, - className, -}) => ( -
-    {children}
-  
-); - -export const WarningBox: React.FC<{ children: React.ReactNode; className?: string }> = ({ - children, - className, -}) => ( -
- {children} -
-); - -export const WarningTitle: React.FC<{ children: React.ReactNode; className?: string }> = ({ - children, - className, -}) =>
{children}
; - -export const WarningText: React.FC<{ children: React.ReactNode; className?: string }> = ({ - children, - className, -}) =>
{children}
; - -// Button components - thin wrappers around shadcn Button with semantic variants -import { Button } from "@/browser/components/ui/button"; - -export { Button }; - -interface ModalButtonProps extends React.ButtonHTMLAttributes { - children: React.ReactNode; -} - -export const CancelButton: React.FC = ({ children, className, ...props }) => ( - -); - -export const PrimaryButton: React.FC = ({ children, className, ...props }) => ( - -); - -export const DangerButton: React.FC = ({ children, className, ...props }) => ( - -); - -// Modal wrapper component -interface ModalProps { - isOpen: boolean; - title: string; - subtitle?: string; - onClose: () => void; - children: React.ReactNode; - maxWidth?: string; - maxHeight?: string; - isLoading?: boolean; - describedById?: string; -} - -export const Modal: React.FC = ({ - isOpen, - title, - subtitle, - onClose, - children, - maxWidth, - maxHeight, - isLoading = false, - describedById, -}) => { - const headingId = useId(); - const subtitleId = subtitle ? `${headingId}-subtitle` : undefined; - const ariaDescribedBy = [subtitleId, describedById].filter(Boolean).join(" ") || undefined; - - const handleCancel = useCallback(() => { - if (!isLoading) { - onClose(); - } - }, [isLoading, onClose]); - - // Handle cancel keybind to close modal - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (matchesKeybind(e, KEYBINDS.CANCEL) && !isLoading) { - handleCancel(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isOpen, isLoading, handleCancel]); - - if (!isOpen) return null; - - return ( - - event.stopPropagation()} - > -

{title}

- {subtitle && {subtitle}} - {children} -
-
- ); -}; diff --git a/src/browser/components/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal.tsx index 9a6efde0e..a08e8453e 100644 --- a/src/browser/components/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal.tsx @@ -1,5 +1,12 @@ import React, { useState, useCallback } from "react"; -import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/browser/components/ui/dialog"; import { DirectoryPickerModal } from "./DirectoryPickerModal"; import { Button } from "@/browser/components/ui/button"; import type { ProjectConfig } from "@/node/config"; @@ -123,50 +130,59 @@ export const ProjectCreateModal: React.FC = ({ [handleSelect] ); + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open && !isCreating) { + handleCancel(); + } + }, + [isCreating, handleCancel] + ); + return ( <> - -
- { - setPath(e.target.value); - setError(""); - }} - onKeyDown={handleKeyDown} - placeholder="/home/user/projects/my-project" - autoFocus - disabled={isCreating} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted text-foreground min-w-0 flex-1 rounded border px-3 py-2 font-mono text-sm focus:outline-none disabled:opacity-50" - /> - {(isDesktop || hasWebFsPicker) && ( - + )} +
+ {error &&
{error}
} + + + - )} -
- {error &&
{error}
} - - - Cancel - - void handleSelect()} disabled={isCreating}> - {isCreating ? "Adding..." : "Add Project"} - - - + + + = ({ } }, [isOpen, initialSecrets]); - const handleCancel = () => { + const handleCancel = useCallback(() => { setSecrets(initialSecrets); setVisibleSecrets(new Set()); onClose(); - }; + }, [initialSecrets, onClose]); const handleSave = async () => { setIsLoading(true); @@ -120,90 +129,97 @@ const SecretsModal: React.FC = ({ setVisibleSecrets(newVisible); }; + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open && !isLoading) { + handleCancel(); + } + }, + [isLoading, handleCancel] + ); + return ( - - -

- Secrets are stored in ~/.mux/secrets.json (kept away from source code) but - namespaced per project. -

-

Secrets are injected as environment variables to compute commands (e.g. Bash)

-
- -
- {secrets.length === 0 ? ( -
No secrets configured
- ) : ( -
- - -
{/* Empty cell for eye icon column */} -
{/* Empty cell for delete button column */} - {secrets.map((secret, index) => ( - - updateSecret(index, "key", e.target.value)} - placeholder="SECRET_NAME" - disabled={isLoading} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim w-full rounded border px-2.5 py-1.5 font-mono text-[13px] text-white focus:outline-none" - /> - updateSecret(index, "value", e.target.value)} - placeholder="secret value" - disabled={isLoading} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim w-full rounded border px-2.5 py-1.5 font-mono text-[13px] text-white focus:outline-none" - /> - - - - ))} -
- )} -
- - - - - - Cancel - - void handleSave()} disabled={isLoading}> - {isLoading ? "Saving..." : "Save"} - - - + + + + Manage Secrets + Project: {projectName} + + +

+ Secrets are stored in ~/.mux/secrets.json (kept away from source code) but + namespaced per project. +

+

Secrets are injected as environment variables to compute commands (e.g. Bash)

+
+ +
+ {secrets.length === 0 ? ( +
No secrets configured
+ ) : ( +
+ + +
{/* Empty cell for eye icon column */} +
{/* Empty cell for delete button column */} + {secrets.map((secret, index) => ( + + updateSecret(index, "key", e.target.value)} + placeholder="SECRET_NAME" + disabled={isLoading} + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim w-full rounded border px-2.5 py-1.5 font-mono text-[13px] text-white focus:outline-none" + /> + updateSecret(index, "value", e.target.value)} + placeholder="secret value" + disabled={isLoading} + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim w-full rounded border px-2.5 py-1.5 font-mono text-[13px] text-white focus:outline-none" + /> + + + + ))} +
+ )} +
+ + + + + + + + +
); }; diff --git a/src/browser/components/Settings/SettingsModal.tsx b/src/browser/components/Settings/SettingsModal.tsx index 61b88702c..de6788186 100644 --- a/src/browser/components/Settings/SettingsModal.tsx +++ b/src/browser/components/Settings/SettingsModal.tsx @@ -1,8 +1,7 @@ -import React, { useEffect, useCallback } from "react"; +import React from "react"; import { Settings, Key, Cpu, X } from "lucide-react"; import { useSettings } from "@/browser/contexts/SettingsContext"; -import { ModalOverlay } from "@/browser/components/Modal"; -import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; +import { Dialog, DialogContent } from "@/browser/components/ui/dialog"; import { GeneralSection } from "./sections/GeneralSection"; import { ProvidersSection } from "./sections/ProvidersSection"; import { ModelsSection } from "./sections/ModelsSection"; @@ -33,38 +32,16 @@ const SECTIONS: SettingsSection[] = [ export function SettingsModal() { const { isOpen, close, activeSection, setActiveSection } = useSettings(); - const handleClose = useCallback(() => { - close(); - }, [close]); - - // Handle escape key - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (matchesKeybind(e, KEYBINDS.CANCEL)) { - e.preventDefault(); - handleClose(); - } - }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isOpen, handleClose]); - - if (!isOpen) return null; - const currentSection = SECTIONS.find((s) => s.id === activeSection) ?? SECTIONS[0]; const SectionComponent = currentSection.component; return ( - -
!open && close()}> + e.stopPropagation()} - className="bg-dark border-border flex h-[80vh] max-h-[600px] w-[95%] max-w-[800px] flex-col overflow-hidden rounded-lg border shadow-lg md:h-[70vh] md:flex-row" + className="flex h-[80vh] max-h-[600px] flex-col gap-0 overflow-hidden p-0 md:h-[70vh] md:flex-row" > {/* Sidebar - horizontal tabs on mobile, vertical on desktop */}
@@ -76,7 +53,7 @@ export function SettingsModal() {
-
- + + ); } diff --git a/src/browser/components/StartHereModal.tsx b/src/browser/components/StartHereModal.tsx index d05d2d6f1..7fc1aefc0 100644 --- a/src/browser/components/StartHereModal.tsx +++ b/src/browser/components/StartHereModal.tsx @@ -1,5 +1,13 @@ import React, { useState, useCallback } from "react"; -import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/browser/components/ui/dialog"; +import { Button } from "@/browser/components/ui/button"; interface StartHereModalProps { isOpen: boolean; @@ -28,22 +36,31 @@ export const StartHereModal: React.FC = ({ isOpen, onClose, } }, [isExecuting, onConfirm, onClose]); + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open && !isExecuting) { + handleCancel(); + } + }, + [isExecuting, handleCancel] + ); + return ( - - - - Cancel - - void handleConfirm()} disabled={isExecuting}> - {isExecuting ? "Starting..." : "OK"} - - - + + + + Start Here + This will replace all chat history with this message + + + + + + + ); }; diff --git a/src/browser/components/ui/dialog.tsx b/src/browser/components/ui/dialog.tsx index 8067c7f8c..0812ed4ea 100644 --- a/src/browser/components/ui/dialog.tsx +++ b/src/browser/components/ui/dialog.tsx @@ -32,16 +32,23 @@ const DialogContent = React.forwardRef< React.ComponentPropsWithoutRef & { /** Whether to show the close button (default: true) */ showCloseButton?: boolean; + /** Maximum width of the dialog (default: max-w-lg) */ + maxWidth?: string; + /** Maximum height of the dialog */ + maxHeight?: string; } ->(({ className, children, showCloseButton = true, ...props }, ref) => ( +>(({ className, children, showCloseButton = true, maxWidth, maxHeight, style, ...props }, ref) => ( {children} @@ -90,6 +97,66 @@ const DialogDescription = React.forwardRef< )); DialogDescription.displayName = DialogPrimitive.Description.displayName; +// Utility components for modal content +const DialogInfo = ({ children, className, id }: { children: React.ReactNode; className?: string; id?: string }) => ( +
+ {children} +
+); +DialogInfo.displayName = "DialogInfo"; + +// Error/Warning display components +const ErrorSection = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
{children}
+); +ErrorSection.displayName = "ErrorSection"; + +const ErrorLabel = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+); +ErrorLabel.displayName = "ErrorLabel"; + +const ErrorCodeBlock = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+    {children}
+  
+); +ErrorCodeBlock.displayName = "ErrorCodeBlock"; + +const WarningBox = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+); +WarningBox.displayName = "WarningBox"; + +const WarningTitle = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
{children}
+); +WarningTitle.displayName = "WarningTitle"; + +const WarningText = ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
{children}
+); +WarningText.displayName = "WarningText"; + export { Dialog, DialogPortal, @@ -101,4 +168,11 @@ export { DialogFooter, DialogTitle, DialogDescription, + DialogInfo, + ErrorSection, + ErrorLabel, + ErrorCodeBlock, + WarningBox, + WarningTitle, + WarningText, };