rowboat/apps/x/apps/renderer/src/components/settings-dialog.tsx

1431 lines
58 KiB
TypeScript

"use client"
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, Mail, BookOpen, ChevronRight, Plus, X } from "lucide-react"
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
type ConfigTab = "models" | "mcp" | "security" | "appearance" | "note-tagging"
interface TabConfig {
id: ConfigTab
label: string
icon: React.ElementType
path?: string
description: string
}
const tabs: TabConfig[] = [
{
id: "models",
label: "Models",
icon: Key,
path: "config/models.json",
description: "Configure LLM providers and API keys",
},
{
id: "mcp",
label: "MCP Servers",
icon: Server,
path: "config/mcp.json",
description: "Configure MCP server connections",
},
{
id: "security",
label: "Security",
icon: Shield,
path: "config/security.json",
description: "Configure allowed shell commands",
},
{
id: "appearance",
label: "Appearance",
icon: Palette,
description: "Customize the look and feel",
},
{
id: "note-tagging",
label: "Note Tagging",
icon: Tags,
path: "config/tags.json",
description: "Configure tags for notes and emails",
},
]
interface SettingsDialogProps {
children: React.ReactNode
}
// --- Theme option for Appearance tab ---
function ThemeOption({
label,
icon: Icon,
isSelected,
onClick,
}: {
label: string
icon: React.ElementType
isSelected: boolean
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all",
isSelected
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<Icon className={cn("size-6", isSelected ? "text-primary" : "text-muted-foreground")} />
<span className={cn("text-sm font-medium", isSelected ? "text-primary" : "text-foreground")}>
{label}
</span>
</button>
)
}
function AppearanceSettings() {
const { theme, setTheme } = useTheme()
return (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium mb-3">Theme</h4>
<p className="text-xs text-muted-foreground mb-4">
Select your preferred color scheme
</p>
<div className="grid grid-cols-3 gap-3">
<ThemeOption
label="Light"
icon={Sun}
isSelected={theme === "light"}
onClick={() => setTheme("light")}
/>
<ThemeOption
label="Dark"
icon={Moon}
isSelected={theme === "dark"}
onClick={() => setTheme("dark")}
/>
<ThemeOption
label="System"
icon={Monitor}
isSelected={theme === "system"}
onClick={() => setTheme("system")}
/>
</div>
</div>
</div>
)
}
// --- Model Settings UI ---
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
interface LlmModelOption {
id: string
name?: string
release_date?: string
}
const primaryProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
{ id: "openai", name: "OpenAI", description: "GPT models" },
{ id: "anthropic", name: "Anthropic", description: "Claude models" },
{ id: "google", name: "Gemini", description: "Google AI Studio" },
{ id: "ollama", name: "Ollama (Local)", description: "Run models locally" },
]
const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
{ id: "openrouter", name: "OpenRouter", description: "Multiple models, one key" },
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Vercel's AI Gateway" },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Custom OpenAI-compatible API" },
]
const preferredDefaults: Partial<Record<LlmProviderFlavor, string>> = {
openai: "gpt-5.2",
anthropic: "claude-opus-4-6-20260202",
}
const defaultBaseURLs: Partial<Record<LlmProviderFlavor, string>> = {
ollama: "http://localhost:11434",
"openai-compatible": "http://localhost:1234/v1",
}
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>>({
openai: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
anthropic: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
google: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
openrouter: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
aigateway: { apiKey: "", baseURL: "", models: [""], knowledgeGraphModel: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", models: [""], knowledgeGraphModel: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", models: [""], knowledgeGraphModel: "" },
})
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
const [configLoading, setConfigLoading] = useState(true)
const [showMoreProviders, setShowMoreProviders] = useState(false)
const activeConfig = providerConfigs[provider]
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
const requiresBaseURL = provider === "ollama" || provider === "openai-compatible"
const isLocalProvider = provider === "ollama" || provider === "openai-compatible"
const modelsForProvider = modelsCatalog[provider] || []
const showModelInput = isLocalProvider || modelsForProvider.length === 0
const isMoreProvider = moreProviders.some(p => p.id === provider)
const primaryModel = activeConfig.models[0] || ""
const canTest =
primaryModel.trim().length > 0 &&
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
const updateConfig = useCallback(
(prov: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; models: string[]; knowledgeGraphModel: string }>) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], ...updates },
}))
setTestState({ status: "idle" })
},
[]
)
const updateModelAt = useCallback(
(prov: LlmProviderFlavor, index: number, value: string) => {
setProviderConfigs(prev => {
const models = [...prev[prov].models]
models[index] = value
return { ...prev, [prov]: { ...prev[prov], models } }
})
setTestState({ status: "idle" })
},
[]
)
const addModel = useCallback(
(prov: LlmProviderFlavor) => {
setProviderConfigs(prev => ({
...prev,
[prov]: { ...prev[prov], models: [...prev[prov].models, ""] },
}))
},
[]
)
const removeModel = useCallback(
(prov: LlmProviderFlavor, index: number) => {
setProviderConfigs(prev => {
const models = prev[prov].models.filter((_, i) => i !== index)
return { ...prev, [prov]: { ...prev[prov], models: models.length > 0 ? models : [""] } }
})
setTestState({ status: "idle" })
},
[]
)
// Load current config from file
useEffect(() => {
if (!dialogOpen) return
async function loadCurrentConfig() {
try {
setConfigLoading(true)
const result = await window.ipc.invoke("workspace:readFile", {
path: "config/models.json",
})
const parsed = JSON.parse(result.data)
if (parsed?.provider?.flavor && parsed?.model) {
const flavor = parsed.provider.flavor as LlmProviderFlavor
setProvider(flavor)
setDefaultProvider(flavor)
setProviderConfigs(prev => {
const next = { ...prev };
// Hydrate all saved providers from the providers map
if (parsed.providers) {
for (const [key, entry] of Object.entries(parsed.providers)) {
if (key in next) {
const e = entry as any;
const savedModels: string[] = Array.isArray(e.models) && e.models.length > 0
? e.models
: e.model ? [e.model] : [""];
next[key as LlmProviderFlavor] = {
apiKey: e.apiKey || "",
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
models: savedModels,
knowledgeGraphModel: e.knowledgeGraphModel || "",
};
}
}
}
// Active provider takes precedence from top-level config,
// but only if it exists in the providers map (wasn't deleted)
if (parsed.providers?.[flavor]) {
const existingModels = next[flavor].models;
const activeModels = existingModels[0] === parsed.model
? existingModels
: [parsed.model, ...existingModels.filter((m: string) => m && m !== parsed.model)];
next[flavor] = {
apiKey: parsed.provider.apiKey || "",
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
models: activeModels.length > 0 ? activeModels : [""],
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
};
}
return next;
})
}
} catch {
// No existing config or parse error - use defaults
} finally {
setConfigLoading(false)
}
}
loadCurrentConfig()
}, [dialogOpen])
// Load models catalog
useEffect(() => {
if (!dialogOpen) return
async function loadModels() {
try {
setModelsLoading(true)
setModelsError(null)
const result = await window.ipc.invoke("models:list", null)
const catalog: Record<string, LlmModelOption[]> = {}
for (const p of result.providers || []) {
catalog[p.id] = p.models || []
}
setModelsCatalog(catalog)
} catch {
setModelsError("Failed to load models list")
setModelsCatalog({})
} finally {
setModelsLoading(false)
}
}
loadModels()
}, [dialogOpen])
// Set default models from catalog when catalog loads
useEffect(() => {
if (Object.keys(modelsCatalog).length === 0) return
setProviderConfigs(prev => {
const next = { ...prev }
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
for (const prov of cloudProviders) {
const catalog = modelsCatalog[prov]
if (catalog?.length && !next[prov].models[0]) {
const preferred = preferredDefaults[prov]
const hasPreferred = preferred && catalog.some(m => m.id === preferred)
const defaultModel = hasPreferred ? preferred! : (catalog[0]?.id || "")
next[prov] = { ...next[prov], models: [defaultModel] }
}
}
return next
})
}, [modelsCatalog])
const handleTestAndSave = useCallback(async () => {
if (!canTest) return
setTestState({ status: "testing" })
try {
const allModels = activeConfig.models.map(m => m.trim()).filter(Boolean)
const providerConfig = {
provider: {
flavor: provider,
apiKey: activeConfig.apiKey.trim() || undefined,
baseURL: activeConfig.baseURL.trim() || undefined,
},
model: allModels[0] || "",
models: allModels,
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
await window.ipc.invoke("models:saveConfig", providerConfig)
setDefaultProvider(provider)
setTestState({ status: "success" })
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Model configuration saved")
} else {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
}
} catch {
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [canTest, provider, activeConfig])
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
const config = providerConfigs[prov]
const allModels = config.models.map(m => m.trim()).filter(Boolean)
if (!allModels[0]) return
try {
await window.ipc.invoke("models:saveConfig", {
provider: {
flavor: prov,
apiKey: config.apiKey.trim() || undefined,
baseURL: config.baseURL.trim() || undefined,
},
model: allModels[0],
models: allModels,
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
})
setDefaultProvider(prov)
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Default provider updated")
} catch {
toast.error("Failed to set default provider")
}
}, [providerConfigs])
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
try {
const result = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
const parsed = JSON.parse(result.data)
if (parsed?.providers?.[prov]) {
delete parsed.providers[prov]
}
// If the deleted provider is the current top-level active one,
// switch top-level config to the current default provider
if (parsed?.provider?.flavor === prov && defaultProvider && defaultProvider !== prov) {
const defConfig = providerConfigs[defaultProvider]
const defModels = defConfig.models.map(m => m.trim()).filter(Boolean)
parsed.provider = {
flavor: defaultProvider,
apiKey: defConfig.apiKey.trim() || undefined,
baseURL: defConfig.baseURL.trim() || undefined,
}
parsed.model = defModels[0] || ""
parsed.models = defModels
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
data: JSON.stringify(parsed, null, 2),
})
setProviderConfigs(prev => ({
...prev,
[prov]: { apiKey: "", baseURL: defaultBaseURLs[prov] || "", models: [""], knowledgeGraphModel: "" },
}))
setTestState({ status: "idle" })
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Provider configuration removed")
} catch {
toast.error("Failed to remove provider")
}
}, [defaultProvider, providerConfigs])
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => {
const isDefault = defaultProvider === p.id
const isSelected = provider === p.id
const hasModel = providerConfigs[p.id].models[0]?.trim().length > 0
return (
<button
key={p.id}
onClick={() => {
setProvider(p.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-md border px-3 py-2.5 text-left transition-colors relative",
isSelected
? "border-primary bg-primary/5"
: "border-border hover:bg-accent"
)}
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">{p.name}</span>
{isDefault && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
Default
</span>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
{!isDefault && hasModel && isSelected && (
<div className="mt-1.5 flex items-center gap-3">
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleSetDefault(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
Set as default
</span>
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleDeleteProvider(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
>
Remove
</span>
</div>
)}
</button>
)
}
if (configLoading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-4">
{/* Provider selection */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 grid-cols-2">
{primaryProviders.map(renderProviderCard)}
</div>
{(showMoreProviders || isMoreProvider) ? (
<div className="grid gap-2 grid-cols-2 mt-2">
{moreProviders.map(renderProviderCard)}
</div>
) : (
<button
onClick={() => setShowMoreProviders(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
>
More providers...
</button>
)}
</div>
{/* Model selection - side by side */}
<div className="grid grid-cols-2 gap-3">
{/* Assistant models (left column) */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : (
<div className="space-y-2">
{activeConfig.models.map((model, index) => (
<div key={index} className="group/model relative">
{showModelInput ? (
<Input
value={model}
onChange={(e) => updateModelAt(provider, index, e.target.value)}
placeholder="Enter model"
/>
) : (
<Select
value={model}
onValueChange={(value) => updateModelAt(provider, index, value)}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{activeConfig.models.length > 1 && (
<button
onClick={() => removeModel(provider, index)}
className="absolute right-8 top-1/2 -translate-y-1/2 flex size-6 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/model:opacity-100"
>
<X className="size-3.5" />
</button>
)}
</div>
))}
<button
onClick={() => addModel(provider)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Plus className="size-3.5" />
Add assistant model
</button>
</div>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
{/* Knowledge graph model (right column) */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading...
</div>
) : showModelInput ? (
<Input
value={activeConfig.knowledgeGraphModel}
onChange={(e) => updateConfig(provider, { knowledgeGraphModel: e.target.value })}
placeholder={primaryModel || "Enter model"}
/>
) : (
<Select
value={activeConfig.knowledgeGraphModel || "__same__"}
onValueChange={(value) => updateConfig(provider, { knowledgeGraphModel: value === "__same__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{modelsForProvider.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{/* API Key */}
{showApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{provider === "openai-compatible" ? "API Key (optional)" : "API Key"}
</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateConfig(provider, { apiKey: e.target.value })}
placeholder="Paste your API key"
/>
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => updateConfig(provider, { baseURL: e.target.value })}
placeholder={
provider === "ollama"
? "http://localhost:11434"
: provider === "openai-compatible"
? "http://localhost:1234/v1"
: "https://ai-gateway.vercel.sh/v1"
}
/>
</div>
)}
{/* Test status */}
{testState.status === "error" && (
<div className="text-sm text-destructive">
{testState.error || "Connection test failed"}
</div>
)}
{testState.status === "success" && (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
Connected and saved
</div>
)}
{/* Test & Save button */}
<Button
onClick={handleTestAndSave}
disabled={!canTest || testState.status === "testing"}
className="w-full"
>
{testState.status === "testing" ? (
<><Loader2 className="size-4 animate-spin mr-2" />Testing connection...</>
) : (
"Test & Save"
)}
</Button>
</div>
)
}
// --- Rowboat Model Settings (when signed in via Rowboat) ---
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [gatewayModels, setGatewayModels] = useState<LlmModelOption[]>([])
const [selectedModel, setSelectedModel] = useState("")
const [selectedKgModel, setSelectedKgModel] = useState("")
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!dialogOpen) return
async function load() {
setLoading(true)
try {
// Fetch gateway models
const listResult = await window.ipc.invoke("models:list", null)
const rowboatProvider = listResult.providers?.find((p: { id: string }) => p.id === "rowboat")
const models = rowboatProvider?.models || []
setGatewayModels(models)
// Read current selection from config
try {
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
const parsed = JSON.parse(configResult.data)
if (parsed?.model) setSelectedModel(parsed.model)
if (parsed?.knowledgeGraphModel) setSelectedKgModel(parsed.knowledgeGraphModel)
} catch {
// No config yet — pick first model as default
if (models.length > 0) setSelectedModel(models[0].id)
}
} catch {
toast.error("Failed to load models")
} finally {
setLoading(false)
}
}
load()
}, [dialogOpen])
const handleSave = useCallback(async () => {
if (!selectedModel) return
setSaving(true)
try {
await window.ipc.invoke("models:saveConfig", {
provider: { flavor: "openrouter" as const },
model: selectedModel,
knowledgeGraphModel: selectedKgModel || undefined,
})
window.dispatchEvent(new Event("models-config-changed"))
toast.success("Model configuration saved")
} catch {
toast.error("Failed to save model configuration")
} finally {
setSaving(false)
}
}, [selectedModel, selectedKgModel])
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Select the models Rowboat uses. These are provided through your Rowboat account.
</p>
{/* Assistant model */}
<div className="space-y-2">
<label className="text-sm font-medium">Assistant model</label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Knowledge graph model */}
<div className="space-y-2">
<label className="text-sm font-medium">Knowledge graph model</label>
<Select value={selectedKgModel || "__same__"} onValueChange={(v) => setSelectedKgModel(v === "__same__" ? "" : v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Same as assistant" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Save */}
<Button onClick={handleSave} disabled={!selectedModel || saving}>
{saving ? (
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
) : (
"Save"
)}
</Button>
</div>
)
}
// --- Note Tagging Settings ---
interface TagDef {
tag: string
type: string
applicability: "email" | "notes" | "both"
description: string
example?: string
noteEffect?: "create" | "skip" | "none"
}
const NOTE_TAG_TYPE_ORDER = [
"relationship", "relationship-sub", "topic", "action", "status", "source",
]
const EMAIL_TAG_TYPE_ORDER = [
"relationship", "topic", "email-type", "filter", "action", "status",
]
const TAG_TYPE_LABELS: Record<string, string> = {
"relationship": "Relationship",
"relationship-sub": "Relationship Sub-Tags",
"topic": "Topic",
"email-type": "Email Type",
"filter": "Filter",
"action": "Action",
"status": "Status",
"source": "Source",
}
const DEFAULT_TAGS: TagDef[] = [
{ tag: "investor", type: "relationship", applicability: "both", noteEffect: "create", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." },
{ tag: "customer", type: "relationship", applicability: "both", noteEffect: "create", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
{ tag: "prospect", type: "relationship", applicability: "both", noteEffect: "create", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
{ tag: "partner", type: "relationship", applicability: "both", noteEffect: "create", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
{ tag: "vendor", type: "relationship", applicability: "both", noteEffect: "create", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
{ tag: "product", type: "relationship", applicability: "both", noteEffect: "skip", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
{ tag: "candidate", type: "relationship", applicability: "both", noteEffect: "create", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
{ tag: "team", type: "relationship", applicability: "both", noteEffect: "create", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
{ tag: "advisor", type: "relationship", applicability: "both", noteEffect: "create", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." },
{ tag: "personal", type: "relationship", applicability: "both", noteEffect: "create", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
{ tag: "press", type: "relationship", applicability: "both", noteEffect: "create", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" },
{ tag: "community", type: "relationship", applicability: "both", noteEffect: "create", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." },
{ tag: "government", type: "relationship", applicability: "both", noteEffect: "create", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
{ tag: "primary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." },
{ tag: "secondary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." },
{ tag: "executive-assistant", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." },
{ tag: "cc", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." },
{ tag: "referred-by", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." },
{ tag: "former", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
{ tag: "champion", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
{ tag: "blocker", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
{ tag: "sales", type: "topic", applicability: "both", noteEffect: "create", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." },
{ tag: "support", type: "topic", applicability: "both", noteEffect: "create", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" },
{ tag: "legal", type: "topic", applicability: "both", noteEffect: "create", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
{ tag: "finance", type: "topic", applicability: "both", noteEffect: "create", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." },
{ tag: "hiring", type: "topic", applicability: "both", noteEffect: "create", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" },
{ tag: "fundraising", type: "topic", applicability: "both", noteEffect: "create", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." },
{ tag: "travel", type: "topic", applicability: "both", noteEffect: "skip", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." },
{ tag: "event", type: "topic", applicability: "both", noteEffect: "create", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" },
{ tag: "shopping", type: "topic", applicability: "both", noteEffect: "skip", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
{ tag: "health", type: "topic", applicability: "both", noteEffect: "skip", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." },
{ tag: "learning", type: "topic", applicability: "both", noteEffect: "skip", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
{ tag: "research", type: "topic", applicability: "both", noteEffect: "create", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
{ tag: "intro", type: "email-type", applicability: "both", noteEffect: "create", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." },
{ tag: "followup", type: "email-type", applicability: "both", noteEffect: "create", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" },
{ tag: "scheduling", type: "email-type", applicability: "email", noteEffect: "skip", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
{ tag: "cold-outreach", type: "email-type", applicability: "email", noteEffect: "skip", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." },
{ tag: "newsletter", type: "email-type", applicability: "email", noteEffect: "skip", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
{ tag: "notification", type: "email-type", applicability: "email", noteEffect: "skip", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
{ tag: "spam", type: "filter", applicability: "email", noteEffect: "skip", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
{ tag: "promotion", type: "filter", applicability: "email", noteEffect: "skip", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
{ tag: "social", type: "filter", applicability: "email", noteEffect: "skip", description: "Social media notifications", example: "John Smith commented on your post." },
{ tag: "forums", type: "filter", applicability: "email", noteEffect: "skip", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
{ tag: "action-required", type: "action", applicability: "both", noteEffect: "create", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
{ tag: "fyi", type: "action", applicability: "email", noteEffect: "skip", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" },
{ tag: "urgent", type: "action", applicability: "both", noteEffect: "create", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." },
{ tag: "waiting", type: "action", applicability: "both", noteEffect: "create", description: "Waiting on a response from them" },
{ tag: "unread", type: "status", applicability: "email", noteEffect: "none", description: "Not yet processed" },
{ tag: "to-reply", type: "status", applicability: "email", noteEffect: "none", description: "Need to respond" },
{ tag: "done", type: "status", applicability: "email", noteEffect: "none", description: "Handled, can be archived" },
{ tag: "active", type: "status", applicability: "notes", noteEffect: "none", description: "Currently relevant, recent activity" },
{ tag: "archived", type: "status", applicability: "notes", noteEffect: "none", description: "No longer active, kept for reference" },
{ tag: "stale", type: "status", applicability: "notes", noteEffect: "none", description: "No activity in 60+ days, needs attention or archive" },
{ tag: "email", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from email" },
{ tag: "meeting", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from meeting transcript" },
{ tag: "browser", type: "source", applicability: "notes", noteEffect: "none", description: "Content captured from web browsing" },
{ tag: "web-search", type: "source", applicability: "notes", noteEffect: "none", description: "Information from web search" },
{ tag: "manual", type: "source", applicability: "notes", noteEffect: "none", description: "Manually entered by user" },
{ tag: "import", type: "source", applicability: "notes", noteEffect: "none", description: "Imported from another system" },
]
function TagGroupTable({
group,
tags,
collapsed,
onToggle,
onAdd,
onUpdate,
onRemove,
getGlobalIndex,
isEmail,
}: {
group: { type: string; label: string; tags: TagDef[] }
tags: TagDef[]
collapsed: boolean
onToggle: () => void
onAdd: () => void
onUpdate: (index: number, field: keyof TagDef, value: string | boolean) => void
onRemove: (index: number) => void
getGlobalIndex: (type: string, localIndex: number) => number
isEmail: boolean
}) {
return (
<div>
<div className="flex items-center justify-between mb-1.5">
<button
onClick={onToggle}
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight className={cn("size-3.5 transition-transform", !collapsed && "rotate-90")} />
{group.label}
<span className="text-[10px] ml-0.5">({group.tags.length})</span>
</button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onAdd}
>
<Plus className="size-3 mr-1" />
Add
</Button>
</div>
{!collapsed && group.tags.length > 0 && (
<div className="border rounded-md overflow-hidden">
<div className={cn(
"gap-1 bg-muted/50 px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider grid",
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
)}>
<div>Label</div>
<div>Description</div>
<div>Example</div>
{isEmail && <div className="text-center" title="Emails with this label will be excluded from creating notes">Skip notes</div>}
<div />
</div>
{group.tags.map((tag, localIdx) => {
const globalIdx = getGlobalIndex(group.type, localIdx)
return (
<div key={globalIdx} className={cn(
"gap-1 border-t px-2 py-0.5 items-center grid",
isEmail ? "grid-cols-[100px_1fr_1fr_60px_24px]" : "grid-cols-[100px_1fr_1fr_24px]"
)}>
<Input
value={tag.tag}
onChange={e => onUpdate(globalIdx, "tag", e.target.value)}
className="h-7 text-xs"
placeholder="tag-name"
title={tag.tag}
/>
<Input
value={tag.description}
onChange={e => onUpdate(globalIdx, "description", e.target.value)}
className="h-7 text-xs"
placeholder="Description"
title={tag.description}
/>
<Input
value={tag.example || ""}
onChange={e => onUpdate(globalIdx, "example", e.target.value)}
className="h-7 text-xs"
placeholder="Example"
title={tag.example || ""}
/>
{isEmail && (
<div className="flex justify-center">
<Switch
checked={tag.noteEffect === "skip"}
onCheckedChange={checked => onUpdate(globalIdx, "noteEffect", checked ? "skip" : "create")}
className="scale-75"
/>
</div>
)}
<button
onClick={() => onRemove(globalIdx)}
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
>
<X className="size-3.5" />
</button>
</div>
)
})}
</div>
)}
{!collapsed && group.tags.length === 0 && (
<div className="text-xs text-muted-foreground italic px-2">No tags in this group</div>
)}
</div>
)
}
function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [tags, setTags] = useState<TagDef[]>([])
const [originalTags, setOriginalTags] = useState<TagDef[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const [activeSection, setActiveSection] = useState<"notes" | "email">("notes")
const hasChanges = JSON.stringify(tags) !== JSON.stringify(originalTags)
useEffect(() => {
if (!dialogOpen) return
async function load() {
setLoading(true)
try {
const result = await window.ipc.invoke("workspace:readFile", { path: "config/tags.json" })
const parsed = JSON.parse(result.data)
setTags(parsed)
setOriginalTags(parsed)
} catch {
setTags([...DEFAULT_TAGS])
setOriginalTags([...DEFAULT_TAGS])
} finally {
setLoading(false)
}
}
load()
}, [dialogOpen])
const noteGroups = useMemo(() => {
const map = new Map<string, TagDef[]>()
for (const tag of tags) {
if (tag.applicability === "email") continue
const list = map.get(tag.type) ?? []
list.push(tag)
map.set(tag.type, list)
}
return NOTE_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
type,
label: TAG_TYPE_LABELS[type],
tags: map.get(type) ?? [],
}))
}, [tags])
const emailGroups = useMemo(() => {
const map = new Map<string, TagDef[]>()
for (const tag of tags) {
if (tag.applicability === "notes") continue
const list = map.get(tag.type) ?? []
list.push(tag)
map.set(tag.type, list)
}
return EMAIL_TAG_TYPE_ORDER.filter(type => map.has(type)).map(type => ({
type,
label: TAG_TYPE_LABELS[type],
tags: map.get(type) ?? [],
}))
}, [tags])
const getGlobalIndex = useCallback((type: string, localIndex: number) => {
let count = 0
for (let i = 0; i < tags.length; i++) {
if (tags[i].type === type) {
if (count === localIndex) return i
count++
}
}
return -1
}, [tags])
const updateTag = useCallback((index: number, field: keyof TagDef, value: string | boolean) => {
setTags(prev => prev.map((t, i) => i === index ? { ...t, [field]: value } : t))
}, [])
const removeTag = useCallback((index: number) => {
setTags(prev => prev.filter((_, i) => i !== index))
}, [])
const addTag = useCallback((type: string) => {
const isEmailSection = activeSection === "email"
const applicability = isEmailSection ? "email" as const : "notes" as const
// For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both"
const emailOnlyTypes = ["email-type", "filter"]
const notesOnlyTypes = ["relationship-sub", "source"]
let finalApplicability: "email" | "notes" | "both" = "both"
if (emailOnlyTypes.includes(type)) finalApplicability = "email"
else if (notesOnlyTypes.includes(type)) finalApplicability = "notes"
else finalApplicability = isEmailSection ? "email" : applicability
const newTag: TagDef = {
tag: "",
type,
applicability: finalApplicability === "email" && !isEmailSection ? "both" : finalApplicability === "notes" && isEmailSection ? "both" : finalApplicability,
description: "",
noteEffect: isEmailSection ? "create" : "none",
}
const lastIndex = tags.reduce((acc, t, i) => t.type === type ? i : acc, -1)
if (lastIndex === -1) {
setTags(prev => [...prev, newTag])
} else {
setTags(prev => [...prev.slice(0, lastIndex + 1), newTag, ...prev.slice(lastIndex + 1)])
}
}, [tags, activeSection])
const handleSave = useCallback(async () => {
setSaving(true)
try {
await window.ipc.invoke("workspace:writeFile", {
path: "config/tags.json",
data: JSON.stringify(tags, null, 2),
})
setOriginalTags([...tags])
toast.success("Tag configuration saved")
} catch {
toast.error("Failed to save tag configuration")
} finally {
setSaving(false)
}
}, [tags])
const handleReset = useCallback(() => {
if (!confirm("Reset all tags to defaults? This will discard your changes.")) return
setTags([...DEFAULT_TAGS])
}, [])
const toggleGroup = useCallback((type: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(type)) next.delete(type)
else next.add(type)
return next
})
}, [])
if (loading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
const currentGroups = activeSection === "notes" ? noteGroups : emailGroups
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-1 mb-3 border-b">
<button
onClick={() => setActiveSection("notes")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
activeSection === "notes"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<BookOpen className="size-3.5" />
Note Tags
</button>
<button
onClick={() => setActiveSection("email")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors",
activeSection === "email"
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
<Mail className="size-3.5" />
Email Labels
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
{currentGroups.map(group => (
<TagGroupTable
key={group.type}
group={group}
tags={tags}
collapsed={collapsedGroups.has(group.type)}
onToggle={() => toggleGroup(group.type)}
onAdd={() => addTag(group.type)}
onUpdate={updateTag}
onRemove={removeTag}
getGlobalIndex={getGlobalIndex}
isEmail={activeSection === "email"}
/>
))}
</div>
<div className="pt-3 border-t mt-3 flex items-center justify-between">
<div>
{hasChanges && (
<span className="text-xs text-muted-foreground">Unsaved changes</span>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
Reset to defaults
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children }: SettingsDialogProps) {
const [open, setOpen] = useState(false)
const [activeTab, setActiveTab] = useState<ConfigTab>("models")
const [content, setContent] = useState("")
const [originalContent, setOriginalContent] = useState("")
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rowboatConnected, setRowboatConnected] = useState(false)
// Check if user is signed in to Rowboat
useEffect(() => {
if (!open) return
window.ipc.invoke('oauth:getState', null).then((result) => {
const connected = result.config?.rowboat?.connected ?? false
setRowboatConnected(connected)
}).catch(() => {
setRowboatConnected(false)
})
}, [open])
const visibleTabs = useMemo(() => tabs, [])
const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0]
const isJsonTab = activeTab === "mcp" || activeTab === "security"
const formatJson = (jsonString: string): string => {
try {
return JSON.stringify(JSON.parse(jsonString), null, 2)
} catch {
return jsonString
}
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models" || tab === "note-tagging") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
setError(null)
try {
const result = await window.ipc.invoke("workspace:readFile", {
path: tabConfig.path,
})
const formattedContent = formatJson(result.data)
setContent(formattedContent)
setOriginalContent(formattedContent)
} catch {
setError(`Failed to load ${tabConfig.label} config`)
setContent("")
setOriginalContent("")
} finally {
setLoading(false)
}
}, [])
const saveConfig = async () => {
if (!isJsonTab || !activeTabConfig.path) return
setSaving(true)
setError(null)
try {
JSON.parse(content)
await window.ipc.invoke("workspace:writeFile", {
path: activeTabConfig.path,
data: content,
})
setOriginalContent(content)
} catch (err) {
if (err instanceof SyntaxError) {
setError("Invalid JSON syntax")
} else {
setError(`Failed to save ${activeTabConfig.label} config`)
}
} finally {
setSaving(false)
}
}
const handleFormat = () => {
setContent(formatJson(content))
}
const hasChanges = content !== originalContent
useEffect(() => {
if (open && isJsonTab) {
loadConfig(activeTab)
}
}, [open, activeTab, isJsonTab, loadConfig])
const handleTabChange = (tab: ConfigTab) => {
if (isJsonTab && hasChanges) {
if (!confirm("You have unsaved changes. Discard them?")) {
return
}
}
setActiveTab(tab)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="max-w-[900px]! w-[900px] h-[600px] p-0 gap-0 overflow-hidden"
>
<div className="flex h-full overflow-hidden">
{/* Sidebar */}
<div className="w-48 border-r bg-muted/30 p-2 flex flex-col">
<div className="px-2 py-3 mb-2">
<h2 className="font-semibold text-sm">Settings</h2>
</div>
<nav className="flex flex-col gap-1">
{visibleTabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={cn(
"flex items-center gap-2 px-2 py-2 rounded-md text-sm transition-colors text-left",
activeTab === tab.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
)}
>
<tab.icon className="size-4" />
{tab.label}
</button>
))}
</nav>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 min-h-0">
{/* Header */}
<div className="px-4 py-3 border-b">
<h3 className="font-medium text-sm">{activeTabConfig.label}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{activeTab === "models" && rowboatConnected
? "Select your default models"
: activeTabConfig.description}
</p>
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "models" ? (
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />
: <ModelSettings dialogOpen={open} />
) : activeTab === "note-tagging" ? (
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...
</div>
) : (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full h-full resize-none bg-muted/50 rounded-md p-3 font-mono text-sm border-0 focus:outline-none focus:ring-1 focus:ring-ring"
spellCheck={false}
placeholder="Loading configuration..."
/>
)}
</div>
{/* Footer - only show for JSON config tabs */}
{isJsonTab && (
<div className="px-4 py-3 border-t flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{error && (
<span className="text-xs text-destructive">{error}</span>
)}
{hasChanges && !error && (
<span className="text-xs text-muted-foreground">
Unsaved changes
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleFormat}
disabled={loading || saving}
>
Format
</Button>
<Button
size="sm"
onClick={saveConfig}
disabled={loading || saving || !hasChanges}
>
{saving ? "Saving..." : "Save"}
</Button>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}