import { type ComponentType, useEffect, useState, useSyncExternalStore } from "react"; import { Button, Dialog, DialogContent, DialogDescription, DialogTitle, Input, type Theme, useTheme, } from "@ace/ui"; import { IconArrowDownToLine, IconKey, IconLaptop, IconPointer, type IconProps, IconServer, IconSettings, } from "@ace/ui/icons"; import type { ProviderSettings, Settings as HostSettings } from "@ace/host/protocol"; import { desktop } from "./desktop"; import { host } from "./host"; import { HostStatus } from "./host-settings"; import { NativeStatus } from "./native-settings"; import { Group, Problem, Row, Select, Status } from "./settings-layout"; import { UpdateSettings } from "./updates"; type Section = "general" | "providers" | "computer" | "host" | "updates"; type Info = { id: Section; label: string; description: string; icon: ComponentType }; const all: (Info & { desktop?: boolean })[] = [ { id: "general", label: "General", description: "Appearance and the model new chats start with.", icon: IconSettings, }, { id: "providers", label: "Providers", description: "Provider keys stay in your system keychain. Changes apply to the next model request.", icon: IconKey, }, { id: "computer", label: "Computer use", description: "Let agents see and operate apps on this Mac.", icon: IconPointer, desktop: true, }, { id: "host", label: desktop ? "This Mac" : "This host", description: "Ace Helper, team connectivity, and tools.", icon: desktop ? IconLaptop : IconServer, }, { id: "updates", label: "Updates", description: "Keep Ace and Ace Helper up to date.", icon: IconArrowDownToLine, desktop: true, }, ]; const sections = all.filter((section) => !section.desktop || desktop); const themes: { value: Theme; label: string }[] = [ { value: "system", label: "System" }, { value: "light", label: "Light" }, { value: "dark", label: "Dark" }, ]; const sources = { keychain: "Saved in Keychain", environment: "Using an environment key", missing: "No key added", unavailable: "Keychain access needed", }; function Provider({ provider, disabled }: { provider: ProviderSettings; disabled: boolean }) { const [value, setValue] = useState(""); const [editing, setEditing] = useState(false); const [confirm, setConfirm] = useState(false); const [action, setAction] = useState<{ busy?: boolean; error?: string; message?: string }>({}); const id = `key-${provider.id}`; const locked = disabled || action.busy; async function run(op: "key-set" | "key-check" | "key-remove") { setAction({ busy: true }); try { await host.request( op === "key-set" ? { op, provider: provider.id, value } : { op, provider: provider.id }, ); if (op === "key-set") { setValue(""); setEditing(false); } setConfirm(false); setAction({ message: op === "key-check" ? "Connection verified." : op === "key-remove" ? "Saved key removed." : "Key checked and saved.", }); } catch (error) { setAction({ error: (error as Error).message }); } } return ( {sources[provider.source]} {provider.override && ( <> {" · "} {provider.override}{" "} takes priority over a saved key. Remove that environment override and restart Ace Helper to use Keychain. )} } footer={ <> {editing && (
{ event.preventDefault(); void run("key-set"); }} > setValue(event.target.value)} onKeyDown={(event) => { if (event.key !== "Escape" || action.busy) return; // Escape cancels the key form before it reaches the dialog. event.stopPropagation(); setEditing(false); setValue(""); }} />
)} {confirm && (

Remove this saved key? New model requests will need another credential.

)} {provider.error && {provider.error}} {action.busy && Checking with {provider.name} or waiting for Keychain…} {action.error && {action.error}} {action.message && {action.message}} } > {(provider.source === "keychain" || provider.source === "environment") && ( )} {!editing && ( )} {provider.stored && !confirm && ( )}
); } function DefaultModel({ settings, disabled }: { settings?: HostSettings; disabled: boolean }) { const [action, setAction] = useState<{ busy?: boolean; error?: string }>({}); const chosen = settings?.modelOverride || settings?.model; const value = chosen ? `${chosen.provider}/${chosen.modelId}` : ""; const available = settings?.models.map((model) => `${model.provider}/${model.modelId}`) || []; async function save(value: string) { const split = value.indexOf("/"); const model = value ? { provider: value.slice(0, split), modelId: value.slice(split + 1) } : null; setAction({ busy: true }); try { await host.request({ op: "preferences", model }); setAction({}); } catch (error) { setAction({ error: (error as Error).message }); } } return ( {action.busy && Saving default model…} {action.error && {action.error}} } > ); } function Appearance() { const { theme, set } = useTheme(); return ( ); } export function Settings( { onClose, initialSection }: { onClose: () => void; initialSection?: "updates" }, ) { const status = useSyncExternalStore(host.subscribe, () => host.status); const [section, setSection] = useState
( // Updates exist only in the desktop app; browsers keep their usual first section. (desktop && initialSection) || (status === "open" ? "general" : "host"), ); // Sections stay mounted after their first visit so in-flight key, helper, and update actions // keep their busy state and results; hidden sections stop their own polling. const [visited, setVisited] = useState(() => new Set([section])); const version = useSyncExternalStore(host.subscribe, () => host.settingsVersion); const [retry, setRetry] = useState(0); const [result, setResult] = useState<{ settings?: HostSettings; error?: string }>({}); const offline = status !== "open"; const current = sections.find(({ id }) => id === section)!; function show(next: Section) { setSection(next); setVisited((seen) => seen.has(next) ? seen : new Set(seen).add(next)); } useEffect(() => { if (!desktop) return; const updates = () => show("updates"); window.addEventListener("ace:updates", updates); return () => window.removeEventListener("ace:updates", updates); }, []); // Reading provider settings touches Keychain, so pages that don't show it never request it. const needed = visited.has("general") || visited.has("providers"); useEffect(() => { if (status !== "open" || !needed) return; let active = true; host.request({ op: "settings" }).then( (settings) => { if (active) setResult({ settings }); }, (error: Error) => { // Keep the last snapshot so mounted provider rows keep their drafts and busy state. if (active) setResult((last) => ({ settings: last.settings, error: error.message })); }, ); return () => { active = false; }; }, [status, version, retry, needed]); const reading = !result.settings && !result.error && !offline; const refresh = (label: string) => ( ); const problems = ( <> {offline && Waiting for Ace Helper to reconnect…} {reading && Reading provider settings…} {result.error && {result.error}} {result.settings?.error && {result.settings.error}} ); return ( { if (!open) onClose(); }} >
Settings

{current.label}

{current.description}
{visited.has("general") && ( )} {visited.has("providers") && ( )} {desktop && visited.has("computer") && ( )} {visited.has("host") && ( )} {desktop && visited.has("updates") && ( )}
); }