Unify all toggle switches into a single responsive ToggleSwitch component

Replaces 12+ inline toggle button implementations across the app with a
shared ToggleSwitch component that scales up on mobile for better touch
targets. Default size is h-6/w-10 on mobile, h-5/w-9 on desktop; "lg"
variant is h-7/w-12 on mobile, h-6/w-11 on desktop.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-04-04 10:00:39 -05:00
parent 3d685335eb
commit dbb5f0c4a9
9 changed files with 128 additions and 226 deletions
+5 -15
View File
@@ -24,6 +24,7 @@ import {
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import {
Popover,
PopoverContent,
@@ -1208,21 +1209,10 @@ export function NewIssueDialog() {
{assigneeAdapterType === "claude_local" && (
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
assigneeChrome ? "bg-green-600" : "bg-muted"
)}
onClick={() => setAssigneeChrome((value) => !value)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
assigneeChrome ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
<ToggleSwitch
checked={assigneeChrome}
onCheckedChange={() => setAssigneeChrome((value) => !value)}
/>
</div>
)}
</div>
+9 -34
View File
@@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
@@ -886,26 +887,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</div>
</div>
{onUpdate || onFieldUpdate ? (
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
<ToggleSwitch
checked={executionWorkspacesEnabled}
onCheckedChange={() =>
commitField(
"execution_workspace_enabled",
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
/>
) : (
<span className="text-xs text-muted-foreground">
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
@@ -925,14 +914,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
If disabled, new issues stay on the project's primary checkout unless someone opts in.
</div>
</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
<ToggleSwitch
checked={executionWorkspaceDefaultMode === "isolated_workspace"}
onCheckedChange={() =>
commitField(
"execution_workspace_default_mode",
updateExecutionWorkspacePolicy({
@@ -942,16 +926,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
: "isolated_workspace",
})!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspaceDefaultMode === "isolated_workspace"
? "translate-x-4.5"
: "translate-x-0.5",
)}
/>
</button>
/>
</div>
<div className="border-t border-border/60 pt-2">
+9 -31
View File
@@ -4,6 +4,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import {
Dialog,
DialogContent,
@@ -111,23 +112,11 @@ export function ToggleField({
<span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />}
</div>
<button
data-slot="toggle"
<ToggleSwitch
checked={checked}
onCheckedChange={onChange}
data-testid={toggleTestId}
type="button"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
checked ? "bg-green-600" : "bg-muted"
)}
onClick={() => onChange(!checked)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
/>
</div>
);
}
@@ -162,21 +151,10 @@ export function ToggleWithNumber({
<span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />}
</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
checked ? "bg-green-600" : "bg-muted"
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
<ToggleSwitch
checked={checked}
onCheckedChange={onCheckedChange}
/>
</div>
{showNumber && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
+59
View File
@@ -0,0 +1,59 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ToggleSwitchProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
size?: "default" | "lg";
}
export const ToggleSwitch = React.forwardRef<
HTMLButtonElement,
ToggleSwitchProps
>(
(
{ checked, onCheckedChange, size = "default", className, disabled, ...props },
ref,
) => {
const isLg = size === "lg";
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={checked}
data-slot="toggle"
disabled={disabled}
className={cn(
"relative inline-flex shrink-0 items-center rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
// Track: larger on mobile (<640px), standard on desktop
isLg ? "h-7 w-12 sm:h-6 sm:w-11" : "h-6 w-10 sm:h-5 sm:w-9",
checked ? "bg-green-600" : "bg-muted",
className,
)}
onClick={() => onCheckedChange(!checked)}
{...props}
>
<span
className={cn(
"pointer-events-none inline-block rounded-full bg-white shadow-sm transition-transform",
// Thumb
isLg ? "size-5.5 sm:size-5" : "size-4.5 sm:size-3.5",
// Slide position
checked
? isLg
? "translate-x-5 sm:translate-x-5"
: "translate-x-5 sm:translate-x-4.5"
: "translate-x-0.5",
)}
/>
</button>
);
},
);
ToggleSwitch.displayName = "ToggleSwitch";
+9 -36
View File
@@ -25,6 +25,7 @@ import { queryKeys } from "../lib/queryKeys";
import { AgentConfigForm } from "../components/AgentConfigForm";
import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { MarkdownEditor } from "../components/MarkdownEditor";
import { assetsApi } from "../api/assets";
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
@@ -1627,30 +1628,16 @@ function ConfigurationTab({
Lets this agent create or hire agents and implicitly assign tasks.
</p>
</div>
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={canCreateAgents}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canCreateAgents ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
<ToggleSwitch
checked={canCreateAgents}
onCheckedChange={() =>
updatePermissions.mutate({
canCreateAgents: !canCreateAgents,
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
})
}
disabled={updatePermissions.isPending}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canCreateAgents ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
/>
</div>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1">
@@ -1659,30 +1646,16 @@ function ConfigurationTab({
{taskAssignHint}
</p>
</div>
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={canAssignTasks}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canAssignTasks ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
<ToggleSwitch
checked={canAssignTasks}
onCheckedChange={() =>
updatePermissions.mutate({
canCreateAgents,
canAssignTasks: !canAssignTasks,
})
}
disabled={updatePermissions.isPending || taskAssignLocked}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canAssignTasks ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
/>
</div>
</div>
</div>
+11 -37
View File
@@ -4,7 +4,7 @@ import { FlaskConical } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
export function InstanceExperimentalSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
@@ -82,24 +82,12 @@ export function InstanceExperimentalSettings() {
and existing issue runs.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle isolated workspaces experimental setting"
<ToggleSwitch
checked={enableIsolatedWorkspaces}
onCheckedChange={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
aria-label="Toggle isolated workspaces experimental setting"
/>
</div>
</section>
@@ -112,26 +100,12 @@ export function InstanceExperimentalSettings() {
automatically when backend changes or migrations make the current boot stale.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle guarded dev-server auto-restart"
<ToggleSwitch
checked={autoRestartDevServerWhenIdle}
onCheckedChange={() => toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })}
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
autoRestartDevServerWhenIdle ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
aria-label="Toggle guarded dev-server auto-restart"
/>
</div>
</section>
</div>
+11 -38
View File
@@ -7,6 +7,7 @@ import { instanceSettingsApi } from "@/api/instanceSettings";
import { Button } from "../components/ui/button";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { cn } from "../lib/utils";
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
@@ -95,28 +96,12 @@ export function InstanceGeneralSettings() {
default.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle username log censoring"
<ToggleSwitch
checked={censorUsernameInLogs}
onCheckedChange={() => updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs })}
disabled={updateGeneralMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
updateGeneralMutation.mutate({
censorUsernameInLogs: !censorUsernameInLogs,
})
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
censorUsernameInLogs ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
aria-label="Toggle username log censoring"
/>
</div>
</section>
@@ -129,24 +114,12 @@ export function InstanceGeneralSettings() {
toggling panels. This is off by default.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle keyboard shortcuts"
<ToggleSwitch
checked={keyboardShortcuts}
onCheckedChange={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
disabled={updateGeneralMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
keyboardShortcuts ? "bg-green-600" : "bg-muted",
)}
onClick={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
keyboardShortcuts ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
aria-label="Toggle keyboard shortcuts"
/>
</div>
</section>
+7 -17
View File
@@ -27,6 +27,7 @@ import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
import { timeAgo } from "../lib/timeAgo";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
@@ -710,24 +711,13 @@ export function RoutineDetail() {
}}
disabled={runRoutine.isPending}
/>
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={automationEnabled}
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
<ToggleSwitch
size="lg"
checked={automationEnabled}
onCheckedChange={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
disabled={automationToggleDisabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
automationEnabled ? "bg-emerald-500" : "bg-muted"
} ${automationToggleDisabled ? "cursor-not-allowed opacity-50" : ""}`}
onClick={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
automationEnabled ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
/>
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
{automationLabel}
</span>
+8 -18
View File
@@ -11,6 +11,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
@@ -640,29 +641,18 @@ export function Routines() {
</td>
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={enabled}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
disabled={isStatusPending || isArchived}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? "bg-foreground" : "bg-muted"
} ${isStatusPending || isArchived ? "cursor-not-allowed opacity-50" : ""}`}
onClick={() =>
<ToggleSwitch
size="lg"
checked={enabled}
onCheckedChange={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: nextRoutineStatus(routine.status, !enabled),
})
}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
enabled ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
disabled={isStatusPending || isArchived}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
/>
<span className="text-xs text-muted-foreground">
{isArchived ? "Archived" : enabled ? "On" : "Off"}
</span>