diff --git a/.lane/memory/apps/app/src/settings-layout.tsx/01M53NF1MY4XSFFQ30BQ6434BX-rows-keep-at-most-one-short.md b/.lane/memory/apps/app/src/settings-layout.tsx/01M53NF1MY4XSFFQ30BQ6434BX-rows-keep-at-most-one-short.md new file mode 100644 index 0000000..9bb4b78 --- /dev/null +++ b/.lane/memory/apps/app/src/settings-layout.tsx/01M53NF1MY4XSFFQ30BQ6434BX-rows-keep-at-most-one-short.md @@ -0,0 +1,12 @@ +--- +id: 01M53NF1MY4XSFFQ30BQ6434BX +anchor: function Row +created: 2026-10-17T00:51:20Z +norm: '1' +sig: ce567dc489eab7f6 +body_hash: 753e688471da9d8a +raw_hash: fe1fe2956ed4d51f +lines: 29-53 +--- + +rows keep at most one short description line; longer guidance belongs in docs, not settings diff --git a/apps/app/src/command-settings.tsx b/apps/app/src/command-settings.tsx index 9c4ea28..d8547dc 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 4167815..9f5d1ac 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 916949b..6850906 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 0bc674c..7f516bd 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 029c974..8a0673e 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 728f1b8..ee0feb2 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") && ( -