From ed71fd4b6a10fa47a6c2231129f7c5f0e8ca5b51 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 16 Oct 2026 20:51:20 -0400 Subject: [PATCH 1/2] feat(app): reorganize settings into grouped pages with shared row components Settings now groups pages under the app (General, Providers, Team, Beta) and This Mac (Ace Helper, Computer use, Updates). This Mac is split: team connectivity moves to Team, and helper, CLI, and tools stay together. Rows use shared Row, TextRow, Segmented, Value, and Confirm components with at most one short description line. Page descriptions, disclosures, and long explainers are gone. Why: apps/app/src/settings-layout.tsx#function Row | rows keep at most one short description line; longer guidance belongs in docs, not settings --- apps/app/src/command-settings.tsx | 41 +-- apps/app/src/directory-settings.tsx | 195 +++++++------ apps/app/src/host-settings.tsx | 303 +++++++++----------- apps/app/src/native-settings.tsx | 39 +-- apps/app/src/settings-layout.tsx | 157 +++++++++-- apps/app/src/settings.tsx | 423 +++++++++++----------------- apps/app/src/updates.tsx | 64 ++--- 7 files changed, 582 insertions(+), 640 deletions(-) diff --git a/apps/app/src/command-settings.tsx b/apps/app/src/command-settings.tsx index 9c4ea28c..d8547dc5 100644 --- a/apps/app/src/command-settings.tsx +++ b/apps/app/src/command-settings.tsx @@ -10,39 +10,12 @@ const code = (text: string) => {text}; function describe({ name, path, link, shell }: CommandState) { const dir = path.slice(0, -name.length - 1); - if (link === "missing") { - return ( - <> - Use this app's channels from Terminal with {code(name)}. Ace links it in{" "} - {dir}, so app updates keep it current. - - ); - } - if (link === "other") { - return <>{path} exists and isn't Ace's. Remove it to install {code(name)}.; - } - if (link === "stale") { - return <>{path} links to another copy of Ace. Install to point it at this one.; - } - if (shell === path) { - return ( - <> - Run {code(name)} in Terminal. {path} links to this app, so app updates keep it current. - - ); - } - if (shell) { - return ( - <>Installed at {path}, but your shell runs {shell} first. Put {dir} earlier on your PATH. - ); - } - return ( - <> - Installed at {path}, but your shell can't find it. Add {code(`export PATH="${dir}:$PATH"`)} - {" "} - to your shell profile. - - ); + if (link === "missing") return <>Run Ace from Terminal as {code(name)}; + if (link === "other") return <>{path} belongs to something else; + if (link === "stale") return <>{path} points to another copy of Ace; + if (shell === path) return path; + if (shell) return <>Your shell runs {shell} first; + return <>{dir} isn't on your PATH; } /** The desktop app's own CLI; a browser has no app to link. */ @@ -69,7 +42,7 @@ export function CommandLine({ active }: { active: boolean }) { const current = state.value; return ( Command line tool {code(current.name)} : "Command line tool"} + label="Command line tool" description={{current ? describe(current) : "Checking…"}} footer={ <> diff --git a/apps/app/src/directory-settings.tsx b/apps/app/src/directory-settings.tsx index 41678154..9f5d1ac2 100644 --- a/apps/app/src/directory-settings.tsx +++ b/apps/app/src/directory-settings.tsx @@ -4,17 +4,65 @@ import { Button, Input } from "@ace/ui"; import type { Diagnostics } from "@ace/host/protocol"; import { host } from "./host"; -import { Problem, Row, Status } from "./settings-layout"; +import { useDiagnostics } from "./host-settings"; +import { Confirm, Group, Page, Problem, Row, Status, Value } from "./settings-layout"; const SETUP = "https://github.com/githubnext/ace#team-directory"; const secrets = { - keychain: "Saved in Keychain.", - environment: "Set in Ace Helper's environment.", - missing: "Not saved.", - unavailable: "Keychain access needed.", + keychain: "Saved in Keychain", + environment: "From the environment", + missing: "Not set", + unavailable: "Keychain access needed", }; +const tailnet = { + missing: "Not installed", + offline: "Disconnected", + running: "Connected", + error: "Couldn't check", +}; + +export function TeamSettings({ active }: { active: boolean }) { + const checks = useDiagnostics(active); + const checked = checks.value; + return ( + + checks.refresh(true)} + > + Refresh + + } + > + {checked && ( + <> + {checked.tailscale.error}} + > + {tailnet[checked.tailscale.state]} + + checks.refresh()} /> + + )} + + {!checks.open + ? Waiting for Ace Helper… + : !checked && !checks.error && Checking…} + {checks.error && checks.open && {checks.error}} + + ); +} + /** A time today, or a date and time when older, so a stale sync doesn't read as recent. */ function when(time: number) { const date = new Date(time); @@ -27,7 +75,7 @@ function when(time: number) { type Action = { row?: "directory" | "secret"; busy?: string; error?: string; message?: string }; /** Join, change, or leave the team's directory, and save or remove the team's shared secret. */ -export function TeamDirectory( +function TeamDirectory( { directory, onChange }: { directory: Diagnostics["directory"]; onChange: () => void }, ) { const [editing, setEditing] = useState(false); @@ -76,30 +124,20 @@ export function TeamDirectory( <> - {directory.url - ? `${directory.url} · ${ - directory.synced - ? `Last synced ${when(directory.synced)}.` - : directory.error - ? "Not synced yet." - : "Waiting for the first sync…" - }` - : "Not configured. Join the directory your team deployed to see everyone's channels, even while their hosts sleep."} - {" "} - Deploying a directory is a command-line step for whoever sets up the team;{" "} + Not joined.{" "} - see how - . + Set up a directory + - } + )} footer={ <> {editing && ( @@ -143,8 +181,8 @@ export function TeamDirectory( aria-label="Team secret" maxLength={4096} placeholder={saved - ? "Team secret (leave blank to keep the saved one)" - : "Team secret (ACE_SECRET)"} + ? "Team secret (blank keeps the saved one)" + : "Team secret"} value={secret} disabled={!!action.busy} onChange={(event) => setSecret(event.target.value)} @@ -163,34 +201,20 @@ export function TeamDirectory( )} {confirm === "leave" && ( -
-

- Leave the directory? Teammates' channels whose hosts are offline disappear here, - and this host's channels stop being listed for them. -

-
- - -
-
+ + void run( + "directory", + "Leaving the directory…", + "Left the directory.", + () => host.request({ op: "directory-set", url: null }), + )} + onCancel={() => setConfirm(undefined)} + > + Leave the directory? Teammates stop seeing this host's channels while it's offline. + )} {directory.error && ( @@ -202,6 +226,15 @@ export function TeamDirectory( } > + {directory.url && ( + + {directory.synced + ? `Synced ${when(directory.synced)}` + : directory.error + ? "Not synced" + : "Syncing…"} + + )} {!editing && ( - - - + + void run( + "secret", + "Removing the secret…", + "Secret removed.", + () => host.request({ op: "key-forget", name: "ACE_SECRET" }), + )} + onCancel={() => setConfirm(undefined)} + > + Remove the team secret? This host loses access to the directory and hosted channels. + )} {directory.secret.waiting && {directory.secret.waiting}…} {directory.secret.error && {directory.secret.error}} diff --git a/apps/app/src/host-settings.tsx b/apps/app/src/host-settings.tsx index 916949b9..68509063 100644 --- a/apps/app/src/host-settings.tsx +++ b/apps/app/src/host-settings.tsx @@ -6,80 +6,96 @@ import type { Diagnostics } from "@ace/host/protocol"; import { CommandLine } from "./command-settings"; import { desktop } from "./desktop"; -import { TeamDirectory } from "./directory-settings"; import { host } from "./host"; -import { Disclosure, Group, Problem, Row, Status } from "./settings-layout"; +import { Confirm, Group, Page, Problem, Row, Status, Value } from "./settings-layout"; -const tailnet = { - missing: "Tailscale is not installed", - offline: "Tailscale is disconnected", - running: "Tailscale is connected", - error: "Tailscale could not be checked", -}; - -export function HostStatus({ active }: { active: boolean }) { +/** The host's tool and team checks, re-read while `active` and whenever `refresh` is called. */ +export function useDiagnostics(active: boolean) { const status = useSyncExternalStore(host.subscribe, () => host.status); const [retry, setRetry] = useState(0); - /** Refresh also syncs the team directory, so its row shows that attempt rather than a stale one. */ + /** A refresh can also sync the team directory, so its row shows that attempt rather than a stale one. */ const syncing = useRef(false); const [checks, setChecks] = useState<{ value?: Diagnostics; error?: string }>({}); - const [helper, setHelper] = useState<{ value?: HelperState; error?: string }>({}); - const [action, setAction] = useState<{ busy?: HelperAction; error?: string }>({}); - const [confirm, setConfirm] = useState(false); - // Re-read when the section is shown again; results from an action in flight still land here. useEffect(() => { - if (!active) return; + if (!active || status !== "open") return; let live = true; const sync = syncing.current; syncing.current = false; - if (desktop) { - desktop.helper("status").then( - (value) => { - if (live) setHelper({ value }); - }, - (error: Error) => { - if (live) setHelper({ error: error.message }); - }, - ); - } - if (status === "open") { - host.request({ op: "diagnostics", ...(sync ? { sync } : {}) }).then( - (value) => { - if (live) setChecks({ value }); - }, - (error: Error) => { - if (live) setChecks({ error: error.message }); - }, - ); - } + host.request({ op: "diagnostics", ...(sync ? { sync } : {}) }).then( + (value) => { + if (live) setChecks({ value }); + }, + (error: Error) => { + if (live) setChecks({ error: error.message }); + }, + ); return () => { live = false; }; }, [active, status, retry]); - // The helper retries loading its shell environment in the background; clear the warning once it does. + // The shell environment, the first directory sync, and a keychain read waiting on a prompt all + // land in the background; show their results without a manual refresh. useEffect(() => { - if (!active || checks.value?.shell.state !== "pending") return; - const timer = setTimeout(() => setRetry((value) => value + 1), 5000); + const value = checks.value; + if (!active || !value) return; + const { directory, shell } = value; + const pending = shell.state === "pending" + || directory.secret.waiting + || (directory.url && !directory.synced && !directory.failed); + if (!pending) return; + const timer = setTimeout(() => setRetry((value) => value + 1), 3000); return () => clearTimeout(timer); }, [active, checks]); - // The first directory sync and a keychain read waiting on a prompt land in the background; show - // their results without a manual refresh. + return { + open: status === "open", + ...checks, + refresh(sync?: boolean) { + syncing.current = !!sync; + setRetry((value) => value + 1); + }, + }; +} + +function describe(helper?: HelperState) { + if (!helper) return "Checking…"; + if (helper.running && !helper.managed) { + return "Run by ace serve. Stop it to manage Ace Helper here."; + } + if (helper.running) return "Running. Starts at login."; + if (helper.unresponsive) return "Not responding yet"; + if (helper.service === "approval") return "Needs approval in Login Items"; + return "Stopped"; +} + +export function HostStatus({ active }: { active: boolean }) { + const checks = useDiagnostics(active); + const [tick, setTick] = useState(0); + const [helper, setHelper] = useState<{ value?: HelperState; error?: string }>({}); + const [action, setAction] = useState<{ busy?: HelperAction; error?: string }>({}); + const [confirm, setConfirm] = useState(false); + useEffect(() => { - const directory = checks.value?.directory; - if (!active || !directory) return; - if (!directory.secret.waiting && (!directory.url || directory.synced || directory.failed)) { - return; - } - const timer = setTimeout(() => setRetry((value) => value + 1), 3000); - return () => clearTimeout(timer); - }, [active, checks]); + if (!active || !desktop) return; + let live = true; + desktop.helper("status").then( + (value) => { + if (live) setHelper({ value }); + }, + (error: Error) => { + if (live) setHelper({ error: error.message }); + }, + ); + return () => { + live = false; + }; + }, [active, tick]); useEffect(() => { if (!active || !helper.value?.unresponsive) return; - const timer = setTimeout(() => setRetry((value) => value + 1), 2000); + const timer = setTimeout(() => setTick((value) => value + 1), 2000); return () => clearTimeout(timer); }, [active, helper]); @@ -90,7 +106,7 @@ export function HostStatus({ active }: { active: boolean }) { try { setHelper({ value: await desktop.helper(command) }); setAction({}); - setRetry((value) => value + 1); + checks.refresh(); } catch (error) { setAction({ error: (error as Error).message }); } @@ -102,41 +118,22 @@ export function HostStatus({ active }: { active: boolean }) { const checked = checks.value; return ( -
+ {desktop && ( - {terminal - ? "Using a command-line host. Stop ace serve before managing Ace Helper here." - : current?.running - ? "Running in the background. It starts at login and keeps channels available after you quit Ace." - : current?.unresponsive - ? "Running, but not responding yet. Ace keeps checking; channels on this Mac wait until it answers." - : current?.service === "approval" - ? "macOS approval required. Allow Ace in Login Items to run Ace Helper in the background." - : current - ? "Stopped. Ace Helper starts at login and keeps channels available after you quit Ace." - : "Checking Ace Helper…"} - - } + label="Status" + description={{describe(current)}} footer={ <> {confirm && ( -
-

- This host's channels and tools will go offline. Work resumes when you start - Ace Helper. Background hosting will stay off until you enable it again. -

-
- - -
-
+ void control("stop")} + onCancel={() => setConfirm(false)} + > + Channels on this Mac go offline until you start Ace Helper again. + )} {action.busy && Updating Ace Helper…} {(action.error || helper.error) && ( @@ -181,107 +178,67 @@ export function HostStatus({ active }: { active: boolean }) { )}
+ + +
)} - {status !== "open" - ? Connect to Ace Helper to check tools and team connectivity. - : checked - ? ( + checks.refresh(true)} + > + Refresh + + } + > + {checked && ( <> - - {checked.tailscale.error}} - /> - setRetry((value) => value + 1)} - /> - - - {checked.shell.error}} + > + + {checked.shell.state === "adopted" + ? "Login shell" : checked.shell.state === "pending" - ? "Not loaded yet, so tools resolve from a minimal PATH and may be reported missing. Ace Helper keeps retrying in the background." - : "Not loaded, so tools resolve from a minimal PATH and may be reported missing. Restart Ace Helper to try again."} - footer={checked.shell.error && {checked.shell.error}} + ? "Loading…" + : "Not loaded"} + + + {checked.tools.map((tool) => ( + {tool.error}} > - - {checked.shell.state === "adopted" ? "Loaded" : "Needs attention"} - + {(tool.version || !tool.path) && {tool.version || "Not found"}} - {checked.tools.map((tool) => ( - {tool.error}} - > - - {tool.error ? "Needs attention" : "Available"} - - - ))} - - - ) - : !checks.error && Checking this host…} - {checks.error && status === "open" && {checks.error}} - - {checked && ( -
- {checked.tools.map((tool) => ( -
-
{tool.name}
-
- {[tool.version, tool.path].filter(Boolean).join(" · ") || "Not found"} -
-
))} -
Directory
-
{checked.directory.url || "Not configured"}
-
+ )} -
- - {desktop && ( - <> - - - - )} -
-
-
+ + {!checks.open + ? Waiting for Ace Helper… + : !checked && !checks.error && Checking…} + {checks.error && checks.open && {checks.error}} + ); } diff --git a/apps/app/src/native-settings.tsx b/apps/app/src/native-settings.tsx index 0bc674c6..7f516bd3 100644 --- a/apps/app/src/native-settings.tsx +++ b/apps/app/src/native-settings.tsx @@ -4,7 +4,7 @@ import { Button } from "@ace/ui"; import { IconCheck } from "@ace/ui/icons"; import type { NativeAction, NativeState } from "@ace/desktop/protocol"; -import { Disclosure, Group, Problem, Row } from "./settings-layout"; +import { Group, Page, Problem, Row, Value } from "./settings-layout"; type Permission = Extract["permission"]; @@ -12,17 +12,17 @@ const permissions = [ { key: "accessibility", label: "Accessibility", - description: "Read and operate controls in other apps.", + description: "Read and operate controls in other apps", }, { key: "screenRecording", label: "Screen Recording", - description: "See window contents in screenshots.", + description: "See window contents in screenshots", }, { key: "eventSynthesizing", label: "Keyboard input", - description: "Type and press shortcuts in other apps.", + description: "Type and press shortcuts in other apps", }, ] as const; @@ -99,29 +99,29 @@ export function NativeStatus( const error = action.error || result.error || value?.error; return ( -
+ {error}} > - + {needsPermission ? "Permissions needed" : value ? states[value.state] : result.error - ? "Could not check computer use" + ? "Couldn't check" : "Checking…"} - + {permissions.map(({ key, label, description }) => ( {!value - ? Not checked + ? Not checked : value[key] ? ( @@ -142,23 +142,14 @@ export function NativeStatus( ))} - - {value ? clipboardPolicies[value.clipboardRead.policy] : "Not checked"} - + {value ? clipboardPolicies[value.clipboardRead.policy] : "Not checked"} - -

- Agents use these permissions on the host running the channel's tools. For a hosted - channel, that is its workspace. The channel's collaborator agent setting also applies to - computer use. Checking clipboard status does not read the clipboard. -

-
-
+ ); } diff --git a/apps/app/src/settings-layout.tsx b/apps/app/src/settings-layout.tsx index 029c974b..8a0673ee 100644 --- a/apps/app/src/settings-layout.tsx +++ b/apps/app/src/settings-layout.tsx @@ -1,23 +1,31 @@ -import type { ComponentProps, ReactNode } from "react"; +import { type ComponentProps, type ReactNode, useState } from "react"; -/** A titled list of rows separated by hairlines, without a surrounding card. */ -export function Group( - { title, description, children }: { - title?: string; - description?: ReactNode; - children: ReactNode; - }, -) { +import { Button, Input } from "@ace/ui"; + +export function Page({ children }: { children: ReactNode }) { + return
{children}
; +} + +/** A titled list of rows separated by hairlines, with an optional control beside the title. */ +export function Group({ title, action, children }: { + title?: string; + action?: ReactNode; + children: ReactNode; +}) { return (
- {title &&

{title}

} - {description &&

{description}

} + {(title || action) && ( +
+ {title &&

{title}

} + {action &&
{action}
} +
+ )}
{children}
); } -/** Label and description on the left, compact controls on the right; stacks in narrow panes. */ +/** Label and at most one line of description on the left, controls on the right. */ export function Row({ label, description, children, footer, id }: { label: ReactNode; description?: ReactNode; @@ -30,9 +38,11 @@ export function Row({ label, description, children, footer, id }: {
-
{label}
+
{label}
{description && ( -
{description}
+
+ {description} +
)}
{children &&
{children}
} @@ -42,15 +52,99 @@ export function Row({ label, description, children, footer, id }: { ); } -/** Secondary detail that should not dominate common settings. */ -export function Disclosure({ summary, children }: { summary: string; children: ReactNode }) { +/** A read-only value in a row's control slot. */ +export function Value({ children }: { children: ReactNode }) { + return {children}; +} + +/** A text setting saved explicitly; Save appears once the draft differs from the saved value. */ +export function TextRow({ label, description, saved, onSave, footer, disabled, ...props }: + & Omit, "value" | "onChange" | "onSubmit"> + & { + label: string; + description?: ReactNode; + saved?: string; + onSave: (value: string) => Promise; + footer?: ReactNode; + }) +{ + const [draft, setDraft] = useState(); + const [action, setAction] = useState<{ busy?: boolean; error?: string }>({}); + const value = draft ?? (saved || ""); + const dirty = draft !== undefined && draft.trim() !== (saved || ""); + const locked = saved === undefined || disabled || action.busy; + + async function save() { + setAction({ busy: true }); + try { + await onSave(value.trim()); + setDraft(undefined); + setAction({}); + } catch (error) { + setAction({ error: (error as Error).message }); + } + } + + return ( + + {footer} + {action.error && {action.error}} + + )} + > +
{ + event.preventDefault(); + void save(); + }} + > + setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape" || draft === undefined) return; + // Escape discards the draft before it reaches the dialog. + event.stopPropagation(); + setDraft(undefined); + }} + {...props} + /> + {dirty && } +
+
+ ); +} + +export function Segmented({ value, options, onChange, ...props }: { + value: T; + options: { value: T; label: string }[]; + onChange: (value: T) => void; + "aria-labelledby": string; +}) { return ( -
- - {summary} - -
{children}
-
+
+ {options.map((option) => ( + + ))} +
); } @@ -65,6 +159,25 @@ export function Select({ className, ...props }: ComponentProps<"select">) { ); } +/** An inline confirmation for a destructive action, shown in a row's footer. */ +export function Confirm({ children, action, busy, onConfirm, onCancel }: { + children: ReactNode; + action: string; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
+

{children}

+
+ + +
+
+ ); +} + export function Status({ children }: { children: ReactNode }) { return

{children}

; } diff --git a/apps/app/src/settings.tsx b/apps/app/src/settings.tsx index 728f1b8d..ee0feb27 100644 --- a/apps/app/src/settings.tsx +++ b/apps/app/src/settings.tsx @@ -4,7 +4,6 @@ import { Button, Dialog, DialogContent, - DialogDescription, DialogTitle, Input, Switch, @@ -13,9 +12,9 @@ import { } from "@ace/ui"; import { IconArrowDownToLine, - IconCloud, + IconBolt, + IconGlobe, IconKey, - IconLaptop, IconPointer, type IconProps, IconServer, @@ -25,60 +24,52 @@ import type { ProviderSettings, Settings as HostSettings } from "@ace/host/proto import { useCompute } from "./compute"; import { desktop } from "./desktop"; +import { TeamSettings } from "./directory-settings"; import { useFolder } from "./folder"; import { host } from "./host"; import { HostStatus } from "./host-settings"; import { NativeStatus } from "./native-settings"; -import { Group, Problem, Row, Select, Status } from "./settings-layout"; +import { + Confirm, + Group, + Page, + Problem, + Row, + Segmented, + Select, + Status, + TextRow, +} from "./settings-layout"; import { UpdateSettings } from "./updates"; -type Section = "general" | "providers" | "computer" | "host" | "updates" | "beta"; +type Section = "general" | "providers" | "team" | "beta" | "host" | "computer" | "updates"; -type Info = { id: Section; label: string; description: string; icon: ComponentType }; +type Info = { id: Section; label: string; icon: ComponentType; desktop?: boolean }; -const all: (Info & { desktop?: boolean })[] = [ - { - id: "general", - label: "General", - description: "Appearance, where cloned projects go, 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, - }, +const only = (sections: Info[]) => sections.filter((section) => !section.desktop || desktop); + +const groups: { label?: string; sections: Info[] }[] = [ { - id: "computer", - label: "Computer use", - description: "Let agents see and operate apps on this Mac.", - icon: IconPointer, - desktop: true, + sections: [ + { id: "general", label: "General", icon: IconSettings }, + { id: "providers", label: "Providers", icon: IconKey }, + { id: "team", label: "Team", icon: IconGlobe }, + { id: "beta", label: "Beta", icon: IconBolt }, + ], }, { - 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, - }, - { - id: "beta", - label: "Beta", - description: "Unfinished features to try. They may change or go away.", - icon: IconCloud, + sections: only([ + { id: "host", label: desktop ? "Ace Helper" : "Host", icon: IconServer }, + { id: "computer", label: "Computer use", icon: IconPointer, desktop: true }, + { id: "updates", label: "Updates", icon: IconArrowDownToLine, desktop: true }, + ]), }, ]; -const sections = all.filter((section) => !section.desktop || desktop); +const labels = Object.fromEntries( + groups.flatMap((group) => group.sections).map(({ id, label }) => [id, label]), +) as Record; const themes: { value: Theme; label: string }[] = [ { value: "system", label: "System" }, @@ -88,8 +79,8 @@ const themes: { value: Theme; label: string }[] = [ const sources = { keychain: "Saved in Keychain", - environment: "Using an environment key", - missing: "No key added", + environment: "From the environment", + missing: "Not set", unavailable: "Keychain access needed", }; @@ -118,8 +109,8 @@ function Provider({ provider, disabled }: { provider: ProviderSettings; disabled message: op === "key-check" ? "Connection verified." : op === "key-remove" - ? "Saved key removed." - : "Key checked and saved.", + ? "Key removed." + : "Key saved.", }); } catch (error) { setAction({ error: (error as Error).message }); @@ -130,19 +121,9 @@ function Provider({ provider, disabled }: { provider: ProviderSettings; disabled - {sources[provider.source]} - {provider.override && ( - <> - {" · "} - {provider.override}{" "} - takes priority over a saved key. Remove that environment override and restart Ace - Helper to use Keychain. - - )} - - } + description={provider.override + ? `${provider.override} overrides Keychain` + : sources[provider.source]} footer={ <> {editing && ( @@ -163,7 +144,7 @@ function Provider({ provider, disabled }: { provider: ProviderSettings; disabled value={value} maxLength={4096} disabled={locked} - placeholder={provider.stored ? "Enter a replacement key" : "Paste your API key"} + placeholder="API key" onChange={(event) => setValue(event.target.value)} onKeyDown={(event) => { if (event.key !== "Escape" || action.busy) return; @@ -190,26 +171,18 @@ function Provider({ provider, disabled }: { provider: ProviderSettings; disabled )} {confirm && ( -
-

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

-
- - -
-
+ void run("key-remove")} + onCancel={() => setConfirm(false)} + > + Remove the saved {provider.name} key? + )} {provider.waiting && {provider.waiting}…} {provider.error && {provider.error}} - {action.busy && Checking with {provider.name} or waiting for Keychain…} + {action.busy && Checking…} {action.error && {action.error}} {action.message && {action.message}} @@ -235,7 +208,7 @@ function Provider({ provider, disabled }: { provider: ProviderSettings; disabled setEditing(true); }} > - {provider.stored ? "Replace key" : "Add key"} + {provider.stored ? "Replace" : "Add key"} )} {provider.stored && !confirm && ( @@ -279,20 +252,17 @@ function DefaultModel({ settings, disabled }: { settings?: HostSettings; disable {settings?.waiting && {settings.waiting}…} - {action.busy && Saving default model…} {action.error && {action.error}} } > setUrl(event.target.value)} - /> - - - {error && {error}} - {action.error && {action.error}} - - } - > - void save(enabled, draft)} - /> - - ); -} - -/** A text field: the host expands `~`, and browsers cannot pick a host folder. */ -function ProjectsFolder({ disabled }: { disabled: boolean }) { - const { folder: saved, error } = useFolder(); - const [value, setValue] = useState(); - const [action, setAction] = useState<{ busy?: boolean; error?: string }>({}); - const draft = value ?? saved ?? ""; - const locked = !saved || disabled || action.busy; - - async function save() { + async function toggle(enabled: boolean) { setAction({ busy: true }); try { - await host.request({ op: "preferences", folder: draft }); - setValue(undefined); + await save(enabled, compute!.url); setAction({}); } catch (error) { setAction({ error: (error as Error).message }); @@ -391,61 +296,29 @@ function ProjectsFolder({ disabled }: { disabled: boolean }) { } return ( - -
{ - event.preventDefault(); - void save(); - }} - > - setValue(event.target.value)} - /> - -
- {error && {error}} - {action.error && {action.error}} - - } - /> - ); -} - -function Appearance() { - const { theme, set } = useTheme(); - return ( - - - + void toggle(enabled)} + /> +
+ save(!!compute?.enabled, url)} + /> + ); } @@ -465,8 +338,9 @@ export function Settings( /** Only Refresh reads again a key whose last read waited on a Keychain prompt. */ const refreshing = useRef(false); const [result, setResult] = useState<{ settings?: HostSettings; error?: string }>({}); + const { theme, set: setTheme } = useTheme(); + const { folder, error: folderError } = useFolder(); const offline = status !== "open"; - const current = sections.find(({ id }) => id === section)!; function show(next: Section) { setSection(next); @@ -527,8 +401,8 @@ export function Settings( ); const problems = ( <> - {offline && Waiting for Ace Helper to reconnect…} - {reading && Reading provider settings…} + {offline && Waiting for Ace Helper…} + {reading && Loading…} {result.error && {result.error}} {result.settings?.error && {result.settings.error}} @@ -544,62 +418,99 @@ export function Settings(
Settings -
-
-

{current.label}

- {current.description} +
+

{labels[section]}

{visited.has("general") && ( -