-
{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}}
+ >
+ )}
+ >
+
+
+ );
+}
+
+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}}
>
}
>