diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md index 2a95f677..e5bfcc35 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md +++ b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md index 93cdb985..ee653f64 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md +++ b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md index 1f6ec00d..b82a0ac5 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4ZSEZBJJ3AMEBTR7SH36FW4-models-are-read-only-while-t.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md b/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md index eb6b6bad..ee6127b3 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M4ZYQVGB6C87WBEV12WPQ8PF-a-withdrawn-message-returns.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/composer.tsx/01M5JMY120BQ7Q19708TEHREZF-the-chat-holds-the-model-and.md b/.lane/memory/apps/app/src/composer.tsx/01M5JMY120BQ7Q19708TEHREZF-the-chat-holds-the-model-and.md index 1defe677..aed12252 100644 --- a/.lane/memory/apps/app/src/composer.tsx/01M5JMY120BQ7Q19708TEHREZF-the-chat-holds-the-model-and.md +++ b/.lane/memory/apps/app/src/composer.tsx/01M5JMY120BQ7Q19708TEHREZF-the-chat-holds-the-model-and.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/composer.tsx/01M5JWBZ3CQJG8D8DPWZ0G6W69-the-stored-last-pick-only-se.md b/.lane/memory/apps/app/src/composer.tsx/01M5JWBZ3CQJG8D8DPWZ0G6W69-the-stored-last-pick-only-se.md new file mode 100644 index 00000000..29ca8c0f --- /dev/null +++ b/.lane/memory/apps/app/src/composer.tsx/01M5JWBZ3CQJG8D8DPWZ0G6W69-the-stored-last-pick-only-se.md @@ -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 diff --git a/.lane/memory/apps/app/src/dashboard.tsx/01M5AYX7CX6GW2PYNTV3BS5F91-with-no-usable-model-on-this.md b/.lane/memory/apps/app/src/dashboard.tsx/01M5AYX7CX6GW2PYNTV3BS5F91-with-no-usable-model-on-this.md index a03f5a0b..64e197d9 100644 --- a/.lane/memory/apps/app/src/dashboard.tsx/01M5AYX7CX6GW2PYNTV3BS5F91-with-no-usable-model-on-this.md +++ b/.lane/memory/apps/app/src/dashboard.tsx/01M5AYX7CX6GW2PYNTV3BS5F91-with-no-usable-model-on-this.md @@ -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 --- diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index dcd5701a..47473350 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -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"; @@ -750,7 +751,8 @@ export function App() { const value = await host.request>({ 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?.(); diff --git a/apps/app/src/composer.tsx b/apps/app/src/composer.tsx index e51793c1..70e16fcb 100644 --- a/apps/app/src/composer.tsx +++ b/apps/app/src/composer.tsx @@ -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; @@ -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. @@ -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; diff --git a/apps/app/src/dashboard.tsx b/apps/app/src/dashboard.tsx index 5ababd68..b497c46c 100644 --- a/apps/app/src/dashboard.tsx +++ b/apps/app/src/dashboard.tsx @@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger, + useLocalStorage, } from "@ace/ui"; import { IconChevronDown, @@ -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"; @@ -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(); + const [models, setModels] = useState(); + const [automatic, setAutomatic] = useState(); useEffect(() => { if (!local || !connected) return; let active = true; // An unread answer offers no setup; starting then reports whatever failed. - host.request({ op: "models" }).then( + host.request({ op: "models" }).then( (value) => active && setModels(value), () => active && setModels(undefined), ); + host.request({ 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"; @@ -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} diff --git a/apps/app/src/settings.tsx b/apps/app/src/settings.tsx index a50a37e4..cd16b192 100644 --- a/apps/app/src/settings.tsx +++ b/apps/app/src/settings.tsx @@ -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("/"); @@ -269,10 +269,32 @@ function DefaultModel({ settings, disabled }: { settings?: HostSettings; disable onChange={(event) => void save(event.target.value)} > - {value && !available.includes(value) && ( + {value && !available.has(value) && ( )} - {available.map((model) => )} + {settings?.providers.map((provider) => { + const models = settings.models.filter((model) => model.provider === provider.id); + if (models.length) { + return ( + + {models.map(({ modelId }) => ( + + ))} + + ); + } + const missing = provider.source === "missing"; + return ( + + + + ); + })} ); diff --git a/apps/host/src/manage.ts b/apps/host/src/manage.ts index e1aa406c..b657c254 100644 --- a/apps/host/src/manage.ts +++ b/apps/host/src/manage.ts @@ -28,10 +28,10 @@ export async function models(): Promise { return available(providers()); } -export async function defaultModel(): Promise { +/** `models` lets Settings resolve it without waiting on a keychain prompt. */ +export async function defaultModel(models = providers()): Promise { 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); diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index bcfac83e..0fb699ad 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -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; diff --git a/apps/host/src/settings.ts b/apps/host/src/settings.ts index 2157a303..464e8a79 100644 --- a/apps/host/src/settings.ts +++ b/apps/host/src/settings.ts @@ -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, @@ -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 { 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( @@ -59,12 +59,14 @@ export async function settings(fresh?: boolean): Promise { ), ), 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, };