Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 3414bb6abb49dfc4
raw_hash: c619f3850424f109
vouched: 2026-10-22T22:19:55Z
body_hash: 85707ad58ff21113
raw_hash: 847e872f7c9185bd
vouched: 2026-10-22T22:40:41Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-22T20:29:16Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 3414bb6abb49dfc4
raw_hash: c619f3850424f109
vouched: 2026-10-22T22:19:55Z
body_hash: 85707ad58ff21113
raw_hash: 847e872f7c9185bd
vouched: 2026-10-22T22:40:41Z
lines: 330-1237
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T12:44:15Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: 69f69de77ff20470
raw_hash: 555034163125d546
vouched: 2026-10-22T20:30:45Z
body_hash: fff8af7bdbd806b6
raw_hash: 95d50568f62a74cf
vouched: 2026-10-22T22:40:41Z
lines: 101-290
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T14:16:28Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: 69f69de77ff20470
raw_hash: 555034163125d546
vouched: 2026-10-22T20:30:45Z
body_hash: fff8af7bdbd806b6
raw_hash: 95d50568f62a74cf
vouched: 2026-10-22T22:40:41Z
lines: 104-326
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function Composer
created: 2026-10-22T20:30:39Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: 69f69de77ff20470
raw_hash: 555034163125d546
body_hash: fff8af7bdbd806b6
raw_hash: 95d50568f62a74cf
vouched: 2026-10-22T22:40:41Z
lines: 97-433
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5JWBZ3CQJG8D8DPWZ0G6W69
anchor: function lastModel
created: 2026-10-22T22:40:36Z
norm: '1'
sig: dcc0d3280225e195
body_hash: 3b9c52e539b0ae1a
raw_hash: 3d00decc42601e06
lines: 55-64
---

the stored last pick only seeds new local channels; a chat's composer always shows the chat's own model, never this copy
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Dashboard
created: 2026-10-19T20:51:03Z
norm: '1'
sig: 4bfb45497157c0e0
body_hash: e77359bc3330d503
raw_hash: 7884a688975d2f4c
vouched: 2026-10-22T22:08:11Z
body_hash: 175813998987d08e
raw_hash: 03e954d8ae7aee07
vouched: 2026-10-22T22:40:41Z
lines: 256-606
---

Expand Down
4 changes: 3 additions & 1 deletion apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { chosen, deployed, gateway, remember } from "./address";
import { type Attention, glow, useAttention } from "./attention";
import { ArchiveChannel } from "./archive-channel";
import { Channel, ChannelDetails, type ChannelDraft } from "./channel";
import { lastModel } from "./composer";
import { useCompute } from "./compute";
import { Dashboard } from "./dashboard";
import { DeleteChannel } from "./delete-channel";
Expand Down Expand Up @@ -750,7 +751,8 @@ export function App() {
const value = await host.request<Pick<Listing, "id" | "name" | "project">>({
op: "create",
project: target.path,
...(cloud ? { cloud } : {}),
// A cloud channel's models are the compute service's, which this host's pick may lack.
...(cloud ? { cloud } : { model: lastModel() }),
});
if (cloud) {
onCreated?.();
Expand Down
15 changes: 15 additions & 0 deletions apps/app/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ const ACE = /(^|\s)@ace\b/i;
const LEADING = /^\s*@ace\b/i;

const key = (model: ModelRef) => `${model.provider}/${model.modelId}`;
/** The latest pick in this client, as `provider/id` or `auto`; new channels start on it. */
export const LAST_MODEL = `ace:last-model:${host.url}`;

export function lastModel(): ModelRef | undefined {
try {
const value: unknown = JSON.parse(localStorage.getItem(LAST_MODEL) || "null");
if (typeof value !== "string" || value === "auto") return;
const [provider, ...rest] = value.split("/");
return { provider: provider!, modelId: rest.join("/") };
} catch {
return;
}
}
// Errors a provider gives for the model or its key, which another try with them would repeat.
const MODEL_FAULT =
/\b(401|403|404)\b|api key|unauthori[sz]ed|authenticat|permission|does not exist|not found|access to/i;
Expand Down Expand Up @@ -123,6 +136,7 @@ export function Composer(
// Kept beside the channel's draft so a deliberate mode survives relaunch.
const scope = `/channels/${channel.id}`;
const [stored, changeMode] = useLocalStorage(`ace:mode:${scope}`, "ace");
const [, setLast] = useLocalStorage(LAST_MODEL, "auto");
// The channel refuses a blocked teammate's ask, stop, and kill too.
const blocked = shared === false && channel.owner !== user;
// Agent mode only once a teammate's access is known to be on, as it isn't while their Mac is offline.
Expand Down Expand Up @@ -267,6 +281,7 @@ export function Composer(
...(nextEffort ? { effort: nextEffort } : {}),
});
setPicking({ model: key(result.model), effort: result.effort, from });
setLast(next);
} catch (error) {
setPicking(undefined);
const { message } = error as Error;
Expand Down
36 changes: 31 additions & 5 deletions apps/app/src/dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Tooltip,
TooltipContent,
TooltipTrigger,
useLocalStorage,
} from "@ace/ui";
import {
IconChevronDown,
Expand All @@ -25,11 +26,11 @@ import {
IconPlus,
IconRotate,
} from "@ace/ui/icons";
import type { GithubList, ModelRef } from "@ace/channel/protocol";
import type { Listing, People, Presence } from "@ace/host/protocol";
import type { GithubList, ModelOption, ModelRef } from "@ace/channel/protocol";
import type { Listing, People, Presence, Settings } from "@ace/host/protocol";

import type { Attention } from "./attention";
import { Notice } from "./composer";
import { LAST_MODEL, Notice } from "./composer";
import { desktop, here } from "./desktop";
import { prompt, StateIcon } from "./github";
import { useGithub } from "./github-cache";
Expand Down Expand Up @@ -353,21 +354,43 @@ export function Dashboard(
const owner = (channel?: Listing) =>
channel && channel.owner !== user ? people[channel.owner] || channel.owner : undefined;
const settingsVersion = useSyncExternalStore(host.subscribe, () => host.settingsVersion);
const [models, setModels] = useState<ModelRef[]>();
const [models, setModels] = useState<ModelOption[]>();
const [automatic, setAutomatic] = useState<ModelRef>();
useEffect(() => {
if (!local || !connected) return;
let active = true;
// An unread answer offers no setup; starting then reports whatever failed.
host.request<ModelRef[]>({ op: "models" }).then(
host.request<ModelOption[]>({ op: "models" }).then(
(value) => active && setModels(value),
() => active && setModels(undefined),
);
host.request<Settings>({ op: "settings" }).then(
(value) => active && setAutomatic(value.automatic),
() => active && setAutomatic(undefined),
);
return () => {
active = false;
};
}, [local, connected, settingsVersion]);
// A cloud channel runs on the service's credentials, not this host's.
const unprovided = models?.length === 0 && place === "local";
// `create` starts a local channel on this pick, which a pick in any chat's composer also sets.
const [model, setModel] = useLocalStorage(LAST_MODEL, "auto");
const choices = models?.length && place === "local"
? models.map(({ provider, modelId }) => ({
id: `${provider}/${modelId}`,
name: modelId,
vendor: provider,
}))
: undefined;
if (choices && model !== "auto" && !choices.some((value) => value.id === model)) {
choices.unshift({ id: model, name: model.split("/").slice(1).join("/"), vendor: "" });
}
choices?.unshift({
id: "auto",
name: automatic ? `Automatic (${automatic.modelId})` : "Automatic",
vendor: "",
});
const hour = new Date().getHours();
const greeting = hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening";

Expand Down Expand Up @@ -456,6 +479,9 @@ export function Dashboard(
modes={MODES}
mentions={MENTIONS}
mode="ace"
models={choices}
model={model}
onModelChange={setModel}
agents={cloud ? PLACES : undefined}
agent={place}
onAgentChange={setPlace}
Expand Down
28 changes: 25 additions & 3 deletions apps/app/src/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ function DefaultModel({ settings, disabled }: { settings?: HostSettings; disable
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}`) || [];
const available = new Set(settings?.models.map((model) => `${model.provider}/${model.modelId}`));

async function save(value: string) {
const split = value.indexOf("/");
Expand Down Expand Up @@ -269,10 +269,32 @@ function DefaultModel({ settings, disabled }: { settings?: HostSettings; disable
onChange={(event) => void save(event.target.value)}
>
<option value="">{settings ? "Automatic" : "Loading…"}</option>
{value && !available.includes(value) && (
{value && !available.has(value) && (
<option value={value} disabled>{value} (unavailable)</option>
)}
{available.map((model) => <option key={model} value={model}>{model}</option>)}
{settings?.providers.map((provider) => {
const models = settings.models.filter((model) => model.provider === provider.id);
if (models.length) {
return (
<optgroup key={provider.id} label={provider.name}>
{models.map(({ modelId }) => (
<option key={modelId} value={`${provider.id}/${modelId}`}>{modelId}</option>
))}
</optgroup>
);
}
const missing = provider.source === "missing";
return (
<optgroup
key={provider.id}
label={`${provider.name} (${missing ? "no key" : "unavailable"})`}
>
<option disabled>
{missing ? "Add a key" : "Check its key"} in Providers to use {provider.name} models
</option>
</optgroup>
);
})}
</Select>
</Row>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/host/src/manage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ export async function models(): Promise<ModelRef[]> {
return available(providers());
}

export async function defaultModel(): Promise<ModelRef> {
/** `models` lets Settings resolve it without waiting on a keychain prompt. */
export async function defaultModel(models = providers()): Promise<ModelRef> {
if (config.model) return parseModel(config.model);
const saved = preference();
const models = providers();
// A saved default whose key expired or was removed must not block agents another key can run.
if (saved && await usable(models, saved)) return saved;
return choose(models);
Expand Down
2 changes: 2 additions & 0 deletions apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ export type Settings = {
model: ModelRef | null;
modelOverride?: ModelRef;
models: ModelRef[];
/** What a new chat on Automatic runs: `modelOverride`, a usable `model`, or Ace's pick. */
automatic?: ModelRef;
/** Some keychain reads behind `models` are still under way; `models` uses their last finished reads. */
waiting?: string;
error?: string;
Expand Down
6 changes: 4 additions & 2 deletions apps/host/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
settled,
store,
} from "./keys";
import { models, parseModel } from "./manage";
import { defaultModel, models, parseModel } from "./manage";
import { canonical } from "./move";
import {
compute,
Expand Down Expand Up @@ -48,7 +48,7 @@ function provider(id: ProviderId) {
/** Settings never waits on a keychain prompt; `fresh` reads again names whose last read prompted. */
export async function settings(fresh?: boolean): Promise<Settings> {
const catalog = settled(fresh);
const [statuses, usable] = await Promise.all([
const [statuses, usable, automatic] = await Promise.all([
Promise.all(
[...providers].map(async ([id, { name, key }]) =>
Object.assign(
Expand All @@ -59,12 +59,14 @@ export async function settings(fresh?: boolean): Promise<Settings> {
),
),
available(catalog.models).catch((error: Error) => error),
defaultModel(catalog.models).catch(() => undefined),
]);
return {
providers: statuses,
model: preference(),
modelOverride: config.model ? parseModel(config.model) : undefined,
models: usable instanceof Error ? [] : usable,
...(automatic ? { automatic } : {}),
waiting: catalog.waiting() ? KEYCHAIN_WAITING : undefined,
error: usable instanceof Error ? usable.message : undefined,
};
Expand Down