feat(settings): redesign modal with improved layout and Agent tab (#66)

* feat(settings): redesign modal with improved layout and Agent tab

- Unified desktop/mobile layout using single Dialog component
- Desktop: 2-column grid (nav 180px | content)
- Mobile: Single column with dropdown navigation
- Fixed modal height to prevent content stretching
- Removed AI chat panel (commented out for future use)
- Renamed 'Slab Memory' to 'Agent' tab
- Added mock Agent settings: Signet ID (ETH) input and Configure button
- Added useChatStateOptional hook to chat-provider for safe context usage
- Fixed provider order in dashboard layout

* chore: add auth-bypass.ts to gitignore for local dev

---------

Co-authored-by: Avery Felts <averyfelts@Averys-MacBook-Air.local>
This commit is contained in:
aaf2tbz 2026-02-11 00:55:45 -07:00 committed by GitHub
parent 67412a0b00
commit 04180d4305
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 418 additions and 229 deletions

2
.gitignore vendored
View File

@ -38,3 +38,5 @@ ios/App/build/
android/.gradle/
android/build/
android/app/build/
# Local auth bypass (dev only)
src/lib/auth-bypass.ts

View File

@ -42,8 +42,8 @@ export default async function DashboardLayout({
: []
return (
<SettingsProvider>
<ChatProvider>
<SettingsProvider>
<ProjectListProvider projects={projectList}>
<PageActionsProvider>
<CommandMenuProvider>
@ -84,7 +84,7 @@ export default async function DashboardLayout({
</CommandMenuProvider>
</PageActionsProvider>
</ProjectListProvider>
</ChatProvider>
</SettingsProvider>
</ChatProvider>
)
}

View File

@ -76,6 +76,10 @@ export function useChatState(): ChatStateValue {
return ctx
}
export function useChatStateOptional(): ChatStateValue | null {
return React.useContext(ChatStateContext)
}
// --- Render state context ---
interface RenderContextValue {

View File

@ -81,6 +81,10 @@ type RepoStats = {
interface ChatViewProps {
readonly variant: "page" | "panel"
readonly minimal?: boolean
readonly hideSuggestions?: boolean
onActivate?: () => void
readonly inputPlaceholder?: string
}
const REPO = "High-Performance-Structures/compass"
@ -387,6 +391,7 @@ function ChatInput({
onSend,
onNewChat,
className,
onActivate,
}: {
readonly textareaRef: React.RefObject<
HTMLTextAreaElement | null
@ -398,6 +403,7 @@ function ChatInput({
readonly onSend: (text: string) => void
readonly onNewChat?: () => void
readonly className?: string
readonly onActivate?: () => void
}) {
const isRecording = recorder.state === "recording"
const isTranscribing = recorder.state === "transcribing"
@ -406,6 +412,8 @@ function ChatInput({
return (
<PromptInput
className={className}
onClickCapture={onActivate}
onFocusCapture={onActivate}
onSubmit={({ text }) => {
if (!text.trim() || isGenerating) return
onSend(text.trim())
@ -484,7 +492,13 @@ function ChatInput({
)
}
export function ChatView({ variant }: ChatViewProps) {
export function ChatView({
variant,
minimal = false,
hideSuggestions = false,
onActivate,
inputPlaceholder,
}: ChatViewProps) {
const chat = useChatState()
const isPage = variant === "page"
const textareaRef = useRef<HTMLTextAreaElement>(null)
@ -809,22 +823,45 @@ export function ChatView({ variant }: ChatViewProps) {
}
// --- PANEL variant ---
if (minimal) {
return (
<div className="w-full p-2">
<ChatInput
textareaRef={textareaRef}
placeholder={inputPlaceholder ?? "Create a new setting"}
recorder={recorder}
status={chat.status}
isGenerating={chat.isGenerating}
onSend={handleActiveSend}
onActivate={onActivate}
/>
</div>
)
}
return (
<div className="flex h-full w-full flex-col">
{/* Conversation */}
<Conversation className="flex-1">
<ConversationContent>
{chat.messages.length === 0 ? (
<div className="flex flex-col items-center gap-4 pt-8">
<Suggestions>
{suggestions.map((s) => (
<Suggestion
key={s}
suggestion={s}
onClick={handleSuggestion}
/>
))}
</Suggestions>
<div
className={cn(
"flex flex-col items-center gap-4",
hideSuggestions ? "h-full" : "pt-8"
)}
>
{!hideSuggestions && (
<Suggestions>
{suggestions.map((s) => (
<Suggestion
key={s}
suggestion={s}
onClick={handleSuggestion}
/>
))}
</Suggestions>
)}
</div>
) : (
chat.messages.map((msg, idx) => (
@ -851,7 +888,7 @@ export function ChatView({ variant }: ChatViewProps) {
<div className="p-3">
<ChatInput
textareaRef={textareaRef}
placeholder="Ask anything..."
placeholder={inputPlaceholder ?? "Ask anything..."}
recorder={recorder}
status={chat.status}
isGenerating={chat.isGenerating}

View File

@ -1,12 +1,20 @@
"use client"
import * as React from "react"
import { IconPlus } from "@tabler/icons-react"
import { toast } from "sonner"
import {
ResponsiveDialog,
ResponsiveDialogBody,
} from "@/components/ui/responsive-dialog"
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,
@ -15,17 +23,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
import { Separator } from "@/components/ui/separator"
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 { MemoriesTable } from "@/components/agent/memories-table"
import { SkillsTab } from "@/components/settings/skills-tab"
import { AIModelTab } from "@/components/settings/ai-model-tab"
import { AppearanceTab } from "@/components/settings/appearance-tab"
@ -33,6 +36,47 @@ import { ClaudeCodeTab } from "@/components/settings/claude-code-tab"
import { useNative } from "@/hooks/use-native"
import { useBiometricAuth } from "@/hooks/use-biometric-auth"
const SETTINGS_TABS = [
{ value: "general", label: "General" },
{ value: "notifications", label: "Notifications" },
{ value: "appearance", label: "Theme" },
{ value: "integrations", label: "Integrations" },
{ value: "ai-model", label: "AI Model" },
{ value: "agent", label: "Agent" },
{ value: "skills", label: "Skills" },
] as const
const CREATE_SETTING_TAB = {
value: "create-setting",
label: "Create Setting",
} 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,
@ -44,177 +88,87 @@ export function SettingsModal({
const [pushNotifs, setPushNotifs] = React.useState(true)
const [weeklyDigest, setWeeklyDigest] = React.useState(false)
const [timezone, setTimezone] = React.useState("America/New_York")
const [signetId, setSignetId] = React.useState("")
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 generalPage = (
<>
<div className="space-y-1.5">
<Label htmlFor="timezone" className="text-xs">
Timezone
</Label>
<Select value={timezone} onValueChange={setTimezone}>
<SelectTrigger id="timezone" className="w-full h-9">
<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 className="min-w-0 flex-1">
<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>
</>
)
const notificationsPage = (
<>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<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 className="min-w-0 flex-1">
<Label className="text-xs">Push notifications</Label>
<p className="text-muted-foreground text-xs">
Receive push notifications in your browser.
</p>
</div>
<Switch
checked={pushNotifs}
onCheckedChange={setPushNotifs}
className="shrink-0"
/>
</div>
</>
)
const appearancePage = <AppearanceTab />
const integrationsPage = (
<>
<GoogleDriveConnectionStatus />
<Separator />
<NetSuiteConnectionStatus />
<SyncControls />
<Separator />
<ClaudeCodeTab />
</>
)
const slabMemoryPage = <MemoriesTable />
const aiModelPage = <AIModelTab />
const skillsPage = <SkillsTab />
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title="Settings"
description="Manage your app preferences."
className="sm:max-w-2xl"
>
<ResponsiveDialogBody
pages={[generalPage, notificationsPage, appearancePage, integrationsPage, aiModelPage, slabMemoryPage, skillsPage]}
>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full inline-flex justify-start overflow-x-auto">
<TabsTrigger value="general" className="text-xs sm:text-sm shrink-0">
General
</TabsTrigger>
<TabsTrigger value="notifications" className="text-xs sm:text-sm shrink-0">
Notifications
</TabsTrigger>
<TabsTrigger value="appearance" className="text-xs sm:text-sm shrink-0">
Appearance
</TabsTrigger>
<TabsTrigger value="integrations" className="text-xs sm:text-sm shrink-0">
Integrations
</TabsTrigger>
<TabsTrigger value="ai-model" className="text-xs sm:text-sm shrink-0">
AI Model
</TabsTrigger>
<TabsTrigger value="slab-memory" className="text-xs sm:text-sm shrink-0">
Slab Memory
</TabsTrigger>
<TabsTrigger value="skills" className="text-xs sm:text-sm shrink-0">
Skills
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-3 pt-3">
const renderContent = () => {
switch (activeTab) {
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="w-full h-9">
<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>
<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>
@ -222,7 +176,7 @@ export function SettingsModal({
<Separator />
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div>
<Label className="text-xs">Weekly digest</Label>
<p className="text-muted-foreground text-xs">
Receive a summary of activity each week.
@ -234,14 +188,14 @@ export function SettingsModal({
className="shrink-0"
/>
</div>
</TabsContent>
</div>
)
<TabsContent
value="notifications"
className="space-y-3 pt-3"
>
case "notifications":
return (
<div className="space-y-4 pt-2">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div>
<Label className="text-xs">Email notifications</Label>
<p className="text-muted-foreground text-xs">
Get notified about project updates via email.
@ -257,7 +211,7 @@ export function SettingsModal({
<Separator />
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div>
<Label className="text-xs">Push notifications</Label>
<p className="text-muted-foreground text-xs">
{native
@ -275,9 +229,8 @@ export function SettingsModal({
{native && biometric.isAvailable && (
<>
<Separator />
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<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.
@ -291,49 +244,243 @@ export function SettingsModal({
</div>
</>
)}
</TabsContent>
</div>
)
<TabsContent
value="appearance"
className="space-y-3 pt-3"
>
<AppearanceTab />
</TabsContent>
case "appearance":
return <div className="pt-2"><AppearanceTab /></div>
<TabsContent
value="integrations"
className="space-y-3 pt-3"
>
case "integrations":
return (
<div className="space-y-4 pt-2">
<GoogleDriveConnectionStatus />
<Separator />
<NetSuiteConnectionStatus />
<SyncControls />
<Separator />
<ClaudeCodeTab />
</TabsContent>
</div>
)
<TabsContent
value="ai-model"
className="space-y-3 pt-3"
>
<AIModelTab />
</TabsContent>
case "ai-model":
return <div className="pt-2"><AIModelTab /></div>
<TabsContent
value="slab-memory"
className="space-y-3 pt-3"
>
<MemoriesTable />
</TabsContent>
case "agent":
return (
<div className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label htmlFor="signet-id" className="text-xs">
Signet ID (ETH)
</Label>
<Input
id="signet-id"
value={signetId}
onChange={(e) => setSignetId(e.target.value)}
placeholder="0x..."
className="h-9 font-mono"
type="password"
/>
</div>
<TabsContent
value="skills"
className="space-y-3 pt-3"
>
<SkillsTab />
</TabsContent>
</Tabs>
</ResponsiveDialogBody>
</ResponsiveDialog>
<Separator />
<Button className="w-full">
Configure your agent
</Button>
</div>
)
case "skills":
return <div className="pt-2"><SkillsTab /></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
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[85vh] max-h-[700px] w-full max-w-[600px] overflow-hidden p-0 md:h-auto md:max-h-[85vh] md:max-w-[700px]">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Manage your app preferences.</DialogDescription>
</DialogHeader>
<div className="flex h-[calc(85vh-80px)] flex-col overflow-hidden md:h-[500px]">
{/* Desktop: 2 columns | Mobile: Single column */}
<div className="flex flex-1 flex-col gap-6 overflow-hidden p-6 md:grid md:grid-cols-[180px_1fr]">
{/* Left Column - Navigation (Desktop only) */}
<aside className="hidden md:flex md:flex-col md:overflow-hidden">
<div className="flex h-full flex-col justify-between rounded-xl border bg-muted/20 p-2">
<div className="flex flex-col gap-1 overflow-y-auto">
{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" />
{CREATE_SETTING_TAB.label}
</Button>
</div>
</div>
</aside>
{/* Middle Column - Content */}
<div className="flex min-h-0 flex-col overflow-hidden">
{/* 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 - Scrollable */}
<div className="flex-1 overflow-y-auto pr-2">
{renderContent()}
</div>
</div>
{/* Right Column - AI Chat (Desktop only) - COMMENTED OUT */}
{/*
<div className="hidden overflow-hidden rounded-xl border bg-background/95 shadow-lg backdrop-blur-sm md:block">
<ChatView
variant="panel"
hideSuggestions
inputPlaceholder="Create a new setting"
/>
</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>
)
}

View File

@ -158,7 +158,6 @@ export function AppearanceTab() {
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>