compassmock/src/components/settings-modal.tsx
Nicholai feb7dc2643 refactor(settings): unify agent tabs into single Agent section
Consolidate AI Model, Skills, Code Bridge, and Identity into
one Agent tab with pill-style sub-navigation. Reduces top-level
settings from 8 tabs to 5 (page) / 6 (modal).
2026-02-16 18:54:45 -07:00

455 lines
15 KiB
TypeScript
Executable File

"use client"
import * as React from "react"
import { IconPlus } from "@tabler/icons-react"
import { toast } from "sonner"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
// import { useChatStateOptional } from "@/components/agent/chat-provider"
// import { ChatView } from "@/components/agent/chat-view"
import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-status"
import { SyncControls } from "@/components/netsuite/sync-controls"
import { GoogleDriveConnectionStatus } from "@/components/google/connection-status"
import { AppearanceTab } from "@/components/settings/appearance-tab"
import { AgentTab } from "@/components/settings/agent-tab"
import { TeamTab } from "@/components/settings/team-tab"
import { useNative } from "@/hooks/use-native"
import { useBiometricAuth } from "@/hooks/use-biometric-auth"
import { cn } from "@/lib/utils"
const SETTINGS_TABS = [
{ value: "team", label: "Team" },
{ value: "general", label: "General" },
{ value: "notifications", label: "Notifications" },
{ value: "appearance", label: "Theme" },
{ value: "integrations", label: "Integrations" },
{ value: "agent", label: "Agent" },
] as const
const CREATE_SETTING_TAB = {
value: "create-setting",
label: "Create",
} as const
interface CustomSettingTab {
readonly value: string
readonly label: string
readonly prompt: string
}
function makeCustomSettingValue(
label: string,
existingTabs: ReadonlyArray<CustomSettingTab>
): string {
const normalized = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
const base = normalized.length > 0 ? normalized : "setting"
let candidate = `custom-${base}`
let suffix = 2
while (existingTabs.some((tab) => tab.value === candidate)) {
candidate = `custom-${base}-${suffix}`
suffix += 1
}
return candidate
}
export function SettingsModal({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [emailNotifs, setEmailNotifs] = React.useState(true)
const [pushNotifs, setPushNotifs] = React.useState(true)
const [weeklyDigest, setWeeklyDigest] = React.useState(false)
const [timezone, setTimezone] = React.useState("America/New_York")
const [customTabs, setCustomTabs] = React.useState<ReadonlyArray<CustomSettingTab>>([])
const [activeTab, setActiveTab] = React.useState<string>("general")
const [newSettingName, setNewSettingName] = React.useState("")
const [newSettingPrompt, setNewSettingPrompt] = React.useState("")
// const [isMobileChatOpen, setIsMobileChatOpen] = React.useState(false)
// const chatState = useChatStateOptional()
const menuTabs = React.useMemo(
() => [...SETTINGS_TABS, ...customTabs],
[customTabs]
)
const sendCreateSettingToChat = React.useCallback(() => {
toast.info("AI chat is currently disabled in settings")
}, [])
const openCreateSettingFlow = React.useCallback(() => {
setActiveTab(CREATE_SETTING_TAB.value)
// setIsMobileChatOpen(true)
// chatState?.sendMessage({ text: "Create a new setting" })
}, [])
const handleSectionSelect = React.useCallback((value: string) => {
if (value === CREATE_SETTING_TAB.value) {
openCreateSettingFlow()
return
}
setActiveTab(value)
}, [openCreateSettingFlow])
const createCustomSetting = React.useCallback(() => {
const label = newSettingName.trim()
const details = newSettingPrompt.trim()
if (!label) {
toast.error("Add a setting name first")
return
}
if (!details) {
toast.error("Describe what the setting should do")
return
}
const nextTab: CustomSettingTab = {
value: makeCustomSettingValue(label, customTabs),
label,
prompt: details,
}
setCustomTabs((prev) => [...prev, nextTab])
setActiveTab(nextTab.value)
// setIsMobileChatOpen(true)
setNewSettingName("")
setNewSettingPrompt("")
sendCreateSettingToChat()
toast.success(`Added ${nextTab.label} to settings`)
}, [customTabs, newSettingName, newSettingPrompt, sendCreateSettingToChat])
const native = useNative()
const biometric = useBiometricAuth()
const renderContent = () => {
switch (activeTab) {
case "team":
return <TeamTab />
case "general":
return (
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="timezone" className="text-xs">
Timezone
</Label>
<Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger id="timezone" className="h-9 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="America/New_York">Eastern (ET)</SelectItem>
<SelectItem value="America/Chicago">Central (CT)</SelectItem>
<SelectItem value="America/Denver">Mountain (MT)</SelectItem>
<SelectItem value="America/Los_Angeles">Pacific (PT)</SelectItem>
<SelectItem value="Europe/London">London (GMT)</SelectItem>
<SelectItem value="Europe/Berlin">Berlin (CET)</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-xs">Weekly digest</Label>
<p className="text-muted-foreground text-xs">
Receive a summary of activity each week.
</p>
</div>
<Switch
checked={weeklyDigest}
onCheckedChange={setWeeklyDigest}
className="shrink-0"
/>
</div>
</div>
)
case "notifications":
return (
<div className="space-y-4 pt-2">
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-xs">Email notifications</Label>
<p className="text-muted-foreground text-xs">
Get notified about project updates via email.
</p>
</div>
<Switch
checked={emailNotifs}
onCheckedChange={setEmailNotifs}
className="shrink-0"
/>
</div>
<Separator />
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-xs">Push notifications</Label>
<p className="text-muted-foreground text-xs">
{native
? "Receive push notifications on your device."
: "Receive push notifications in your browser."}
</p>
</div>
<Switch
checked={pushNotifs}
onCheckedChange={setPushNotifs}
className="shrink-0"
/>
</div>
{native && biometric.isAvailable && (
<>
<Separator />
<div className="flex items-center justify-between gap-4">
<div>
<Label className="text-xs">Biometric lock</Label>
<p className="text-muted-foreground text-xs">
Require Face ID or fingerprint when returning to the app.
</p>
</div>
<Switch
checked={biometric.isEnabled}
onCheckedChange={biometric.setEnabled}
className="shrink-0"
/>
</div>
</>
)}
</div>
)
case "appearance":
return <div className="pt-2"><AppearanceTab /></div>
case "integrations":
return (
<div className="space-y-4 pt-2">
<GoogleDriveConnectionStatus />
<Separator />
<NetSuiteConnectionStatus />
<SyncControls />
</div>
)
case "agent":
return <div className="flex min-h-0 flex-1 pt-2"><AgentTab /></div>
case CREATE_SETTING_TAB.value:
return (
<div className="space-y-4 pt-2">
<div className="rounded-lg border bg-muted/20 p-3">
<p className="text-sm font-medium">Create a new setting</p>
<p className="text-muted-foreground mt-1 text-xs">
Describe the setting you want, then send it to AI chat.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-setting-name" className="text-xs">
Setting name
</Label>
<Input
id="new-setting-name"
value={newSettingName}
onChange={(e) => setNewSettingName(e.target.value)}
placeholder="Example: Project defaults"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-setting-prompt" className="text-xs">
What should this setting do?
</Label>
<Textarea
id="new-setting-prompt"
value={newSettingPrompt}
onChange={(e) => setNewSettingPrompt(e.target.value)}
placeholder="Describe the controls and behavior you want"
rows={4}
className="resize-none"
/>
</div>
<Button onClick={createCustomSetting} className="w-full">
<IconPlus className="size-4" />
Create Setting with AI
</Button>
</div>
)
default:
const customTab = customTabs.find((tab) => tab.value === activeTab)
if (customTab) {
return (
<div className="space-y-4 pt-2">
<div className="rounded-lg border p-3">
<p className="text-sm font-medium">{customTab.label}</p>
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
{customTab.prompt}
</p>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => sendCreateSettingToChat()}
>
Send to AI Chat
</Button>
</div>
)
}
return null
}
}
const isWideTab = activeTab === "team"
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"w-full p-0 transition-[max-width] duration-200",
"h-[85vh] md:h-[700px]",
isWideTab
? "sm:max-w-[900px]"
: "sm:max-w-[600px] md:max-w-[700px]"
)}
>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Manage your app preferences.</DialogDescription>
</DialogHeader>
{/* Fixed layout: sidebar + content, no modal-level scroll */}
<div className="grid h-[calc(100%-73px)] grid-cols-1 gap-6 p-6 md:grid-cols-[180px_1fr]">
{/* Left Column - Navigation (Desktop only) */}
<aside className="hidden md:block">
<div className="flex h-full flex-col justify-between rounded-xl border bg-muted/20 p-2">
<div className="flex flex-col gap-1">
{menuTabs.map((tab) => (
<Button
key={tab.value}
type="button"
variant={tab.value === activeTab ? "secondary" : "ghost"}
className="h-8 w-full justify-start text-sm"
onClick={() => setActiveTab(tab.value)}
>
{tab.label}
</Button>
))}
</div>
<div className="mt-4 shrink-0">
<Separator className="mb-4" />
<Button
type="button"
variant={activeTab === CREATE_SETTING_TAB.value ? "secondary" : "outline"}
className="h-8 w-full justify-start gap-1.5 text-sm"
onClick={openCreateSettingFlow}
>
<IconPlus className="size-4 shrink-0" />
<span className="truncate">{CREATE_SETTING_TAB.label}</span>
</Button>
</div>
</div>
</aside>
{/* Right Column - Content */}
<div className="flex min-h-0 flex-col">
{/* Mobile Navigation */}
<div className="mb-4 md:hidden">
<Select value={activeTab} onValueChange={handleSectionSelect}>
<SelectTrigger className="h-10 w-full">
<SelectValue placeholder="Select section" />
</SelectTrigger>
<SelectContent>
{menuTabs.map((tab) => (
<SelectItem key={tab.value} value={tab.value}>
{tab.label}
</SelectItem>
))}
<SelectItem value={CREATE_SETTING_TAB.value}>
{CREATE_SETTING_TAB.label}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Settings Content - flex so children can fill height */}
<div className="flex min-h-0 flex-1 flex-col">
{renderContent()}
</div>
</div>
</div>
{/* Mobile Chat Overlay - COMMENTED OUT */}
{/*
<div
className={`fixed inset-x-0 bottom-0 z-50 transform transition-transform duration-300 ease-out md:hidden ${
isMobileChatOpen ? "translate-y-0" : "translate-y-full"
}`}
>
<div className="mx-4 mb-4 overflow-hidden rounded-xl border bg-background/95 shadow-lg backdrop-blur-sm">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-sm font-medium">AI Assistant</span>
<Button
variant="ghost"
size="sm"
onClick={() => setIsMobileChatOpen(false)}
>
Close
</Button>
</div>
<div className="h-[50vh]">
<ChatView
variant="panel"
hideSuggestions
inputPlaceholder="Create a new setting"
/>
</div>
</div>
</div>
{isMobileChatOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setIsMobileChatOpen(false)}
/>
)}
*/}
</DialogContent>
</Dialog>
)
}