diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index 87fe50661df..2eca83f154c 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Locator, type Page } from "@playwright/test" -import { NO_PROVIDER, T0, provider } from "../utils/app" -import { openDraft, openSession } from "../utils/workspace" +import { NO_PROVIDER, T0, provider, sessionHref } from "../utils/app" +import { mockWorkspace, openDraft, openSession } from "../utils/workspace" test.use({ permissions: ["clipboard-read", "clipboard-write"] }) @@ -25,7 +25,7 @@ test("a catalog refresh cannot replace an existing session's model", async ({ pa }) const control = page.locator('[data-action="composer-model"]') - await expect(control).toHaveText("Alpha") + await expect(control).toHaveText("OpenCode / Alpha") await view.editor.fill("Keep my selected model") catalog.current = provider(beta) @@ -38,7 +38,7 @@ test("a catalog refresh cannot replace an existing session's model", async ({ pa data: {}, }, ]) - await expect(control).toHaveText("Select model") + await expect(control).toHaveText("OpenCode / alpha (unavailable)") await view.editor.press("Enter") await expect(page.getByText("Select an agent and model", { exact: true })).toBeVisible() await expect(view.editor).toHaveText("Keep my selected model") @@ -55,7 +55,7 @@ test("a catalog refresh cannot replace an existing session's model", async ({ pa data: {}, }, ]) - await expect(control).toHaveText("Alpha") + await expect(control).toHaveText("OpenCode / Alpha") await expect(page.getByRole("button", { name: "Choose model variant", exact: true })).toHaveText("high") await view.editor.press("Enter") await expect.poll(() => prompts.length).toBe(1) @@ -71,13 +71,13 @@ test("a catalog refresh cannot replace an existing session's model", async ({ pa data: {}, }, ]) - await expect(control).toHaveText("Select model") + await expect(control).toHaveText("OpenCode / alpha (unavailable)") await control.click() await page .getByRole("dialog", { name: "Select model" }) .getByRole("button", { name: "Beta Free", exact: true }) .click() - await expect(control).toHaveText("Beta") + await expect(control).toHaveText("OpenCode / Beta") await view.editor.fill("Use the replacement I chose") await view.editor.press("Enter") await expect.poll(() => prompts.length).toBe(2) @@ -87,6 +87,61 @@ test("a catalog refresh cannot replace an existing session's model", async ({ pa ]) }) +test("an existing session's model waits for its agent instead of showing a fallback", async ({ page }) => { + const sessionID = "ses_agent_pending" + await mockWorkspace(page, { + name: "AgentPending", + sessions: [{ id: sessionID, agent: "build", model: { id: "alpha", providerID: "opencode" } }], + provider: provider({ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta" }), + seed: { storage: { "opencode.global.dat:model": { recent: [{ providerID: "opencode", modelID: "beta" }] } } }, + }) + const agents = Promise.withResolvers() + await page.route(/\/api\/agent(\?|$)/, async (route) => { + await agents.promise + await route.fallback() + }) + const catalog = ["/api/model", "/api/provider", "/api/config"].map((path) => + page.waitForResponse((response) => new URL(response.url()).pathname === path), + ) + await page.goto(sessionHref(sessionID)) + await Promise.all(catalog) + await expect(page.locator('[data-component="composer-editor"]')).toBeEditable() + + const control = page.locator('[data-action="composer-model"]') + // The recent model must not stand in for the session's model while its agent is unresolved. + await expect(control).toBeHidden() + agents.resolve() + await expect(control).toHaveText("OpenCode / Alpha") +}) + +test("names a Console-managed provider without its workspace unless a direct connection shares it", async ({ + page, +}) => { + const sessions = [ + { id: "ses_managed_root", model: { id: "alpha", providerID: "opencode" } }, + { id: "ses_managed_openai", model: { id: "gpt", providerID: "console-openai" } }, + ] + const managed = { integrationID: "opencode" } + await openSession(page, { + name: "ManagedProvider", + sessions: sessions.map((session) => ({ ...session, agent: "build" })), + provider: { + all: [ + { ...managed, id: "opencode", name: "Anomaly / OpenCode", models: { alpha: { id: "alpha", name: "Alpha" } } }, + { ...managed, id: "console-openai", name: "Anomaly / OpenAI", models: { gpt: { id: "gpt", name: "GPT" } } }, + { id: "openai", name: "OpenAI", models: { gpt: { id: "gpt", name: "GPT" } } }, + ], + connected: ["opencode", "console-openai", "openai"], + default: {}, + }, + }) + + const control = page.locator('[data-action="composer-model"]') + await expect(control).toHaveText("OpenCode / Alpha") + await page.goto(sessionHref(sessions[1]!.id)) + await expect(control).toHaveText("Anomaly / OpenAI / GPT") +}) + async function draft(page: Page) { const { editor } = await openDraft(page, { name: "ComposerDraft", provider: NO_PROVIDER }) await editor.click() diff --git a/packages/app/e2e/regression/mobile-model-pickers.spec.ts b/packages/app/e2e/regression/mobile-model-pickers.spec.ts index 794523241a6..10bb5ec6c5e 100644 --- a/packages/app/e2e/regression/mobile-model-pickers.spec.ts +++ b/packages/app/e2e/regression/mobile-model-pickers.spec.ts @@ -35,7 +35,7 @@ for (const direction of ["ltr", "rtl"] as const) { await expect(variant).toBeFocused() const model = composer.locator('[data-action="composer-model"]') - await expect(model).toHaveText("Mobile Model A") + await expect(model).toHaveText("OpenCode / Mobile Model A") await expect(model).toHaveAttribute("data-control-type", "drawer") await model.click() const models = page.getByRole("dialog", { name: "Select model", exact: true }) @@ -88,7 +88,7 @@ for (const direction of ["ltr", "rtl"] as const) { await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCount(0) await search.press("Enter") await expect(models).toBeHidden() - await expect(model).toHaveText("Mobile Model B") + await expect(model).toHaveText("OpenCode / Mobile Model B") await expect(composer.getByRole("textbox", { name: "Prompt", exact: true })).toBeFocused() await model.click() @@ -136,7 +136,7 @@ for (const direction of ["ltr", "rtl"] as const) { await model.click() await expect(page.getByPlaceholder("Search models", { exact: true })).toBeFocused() await page.getByRole("menuitemradio", { name: /Mobile Model B/ }).click() - await expect(model).toHaveText("Mobile Model B") + await expect(model).toHaveText("OpenCode / Mobile Model B") await expect(page.locator('[data-slot="mobile-drawer-content"]')).toHaveCount(0) }) } diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts index fd153c5db09..c9a6b455123 100644 --- a/packages/app/e2e/regression/new-session.spec.ts +++ b/packages/app/e2e/regression/new-session.spec.ts @@ -453,7 +453,7 @@ for (const row of [ expect((await listed).ok()).toBe(true) const composer = page.locator('[data-component="composer"]') const send = composer.getByRole("button", { name: "Send", exact: true }) - await expect(composer.locator('[data-action="composer-model"]')).toHaveText("Accent Model") + await expect(composer.locator('[data-action="composer-model"]')).toHaveText("OpenCode / Accent Model") await view.editor.fill("Inspect this fixture workspace.") await expect(send).toBeEnabled() await expectBackground(send, "icon-button-contrast") diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts index 731699b389e..31f99c412a4 100644 --- a/packages/app/e2e/user-story/model-selection-flow.spec.ts +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -140,7 +140,7 @@ test("restores each existing session's model and variant when switching tabs", a const composer = page.locator('[data-component="composer"]') const modelControl = composer.locator('[data-action="composer-model"]') const variant = composer.getByRole("button", { name: "Choose model variant", exact: true }) - await expect(modelControl).toHaveText("Model A") + await expect(modelControl).toHaveText("OpenCode / Model A") await expect(variant).toHaveText("balanced") await variant.click() await page.getByRole("menuitemradio", { name: "high", exact: true }).click() @@ -148,7 +148,7 @@ test("restores each existing session's model and variant when switching tabs", a await page.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).click() await expect(page).toHaveURL(hrefB) - await expect(modelControl).toHaveText("Model B") + await expect(modelControl).toHaveText("OpenCode / Model B") await expect(variant).toHaveText("balanced") await variant.click() await page.getByRole("menuitemradio", { name: "high", exact: true }).click() @@ -157,11 +157,11 @@ test("restores each existing session's model and variant when switching tabs", a // A new draft starts from the current session's non-default model and chosen variant. await page.getByRole("button", { name: "New session", exact: true }).click() await expect(page).toHaveURL(/\/new-session\?draftId=/) - await expect(modelControl).toHaveText("Model B") + await expect(modelControl).toHaveText("OpenCode / Model B") await expect(variant).toHaveText("high") await page.locator(`[data-titlebar-tab-link][href="${hrefA}"]`).click() await expect(page).toHaveURL(hrefA) - await expect(modelControl).toHaveText("Model A") + await expect(modelControl).toHaveText("OpenCode / Model A") await expect(variant).toHaveText("high") }) diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index 6f37355544c..32f02b2d96d 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -17,6 +17,7 @@ export type ComposerControls = { } model: { selection: ModelSelection + providerName?: string paid: boolean loading: boolean } diff --git a/packages/app/src/composer/composer.stories.tsx b/packages/app/src/composer/composer.stories.tsx index 1314cdc317a..5e0a43e9a57 100644 --- a/packages/app/src/composer/composer.stories.tsx +++ b/packages/app/src/composer/composer.stories.tsx @@ -79,6 +79,7 @@ function ComposerStory(props: { const modelSelection = { ready: Object.assign(() => true, { promise: undefined }), + key: () => ({ providerID: modelOption().provider.id, modelID: modelOption().id }), current: () => modelOption(), recent: () => [modelOption()], list: () => [modelOption()], diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index 3fba623631c..d51c693a144 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -4,7 +4,6 @@ import { Button } from "@opencode/ui/button" import { useDialog } from "@opencode/ui/context/dialog" import { Icon } from "@opencode/ui/icon" import { Keybind } from "@opencode/ui/keybind" -import { ProviderModelIcon } from "@/providers/models/provider-group" import { Tooltip } from "@opencode/ui/tooltip" import { ComposerEditor } from "./editor/editor" import { ModelSelectorPopover } from "@/providers/models/select-dialog" @@ -23,6 +22,17 @@ export function Composer(props: { const dialog = useDialog() const command = useCommand() const language = useLanguage() + const modelName = () => { + const selection = props.model.model.selection + const model = selection.current() + + if (model) return model.name + const key = selection.key() + + if (key) return language.t("dialog.model.unavailable", { model: key.modelID }) + + return language.t("dialog.model.select.title") + } return (
@@ -44,8 +54,8 @@ export function Composer(props: { title={language.t("command.model.choose")} keybind={command.keybindParts("model.choose")} model={props.model.model.selection} - provider={props.model.model.selection.current()?.provider} - modelName={props.model.model.selection.current()?.name ?? language.t("dialog.model.select.title")} + providerName={props.model.model.providerName} + modelName={modelName()} onClose={props.model.restoreFocus} onUnpaidClick={() => dialog.show(() => )} /> @@ -61,7 +71,7 @@ function ComposerModelControl(props: { title: string keybind: string[] model: ComposerModel["model"]["selection"] - provider?: { id: string; canonical?: string; name: string } + providerName?: string modelName: string onClose: () => void onUnpaidClick: () => void @@ -71,15 +81,17 @@ function ComposerModelControl(props: { const content = () => ( <> - - {(provider) => ( - - )} - - {props.modelName} + + + {(name) => ( + <> + {name()} + {" / "} + + )} + + {props.modelName} + @@ -107,7 +119,7 @@ function ComposerModelControl(props: { data-control-type="dialog" variant="ghost-muted" size="normal" - class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group" + class="min-w-0 max-w-[60cqw] justify-start ![font-weight:440]" classList={{ "animate-in fade-in": shouldAnimate() }} style={{ height: "28px" }} onClick={props.onUnpaidClick} @@ -125,7 +137,7 @@ function ComposerModelControl(props: { variant="ghost-muted" size="normal" style={{ height: "28px" }} - class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group" + class="min-w-0 max-w-[60cqw] justify-start ![font-weight:440]" classList={{ "animate-in fade-in": shouldAnimate() }} data-action="composer-model" data-control-type={mobile() ? "drawer" : "popover"} diff --git a/packages/app/src/composer/selection.ts b/packages/app/src/composer/selection.ts index 5b24db5ec1f..d007c607216 100644 --- a/packages/app/src/composer/selection.ts +++ b/packages/app/src/composer/selection.ts @@ -10,14 +10,35 @@ import { useModels } from "@/providers/models/models" import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant" import { useComposerState } from "./persistence" import { useConfiguredModel } from "@/providers/models/configured" +import { consoleProviderGroup, consoleProviderName } from "@/providers/catalog/console" export function createComposerControls(input: { model?: ModelSelection } = {}) { const local = useLocal() const sdk = useWorkspaceLocation() const data = useData() const providers = useProviders(() => sdk().directory) + const canonical = (provider: { id: string; canonical?: string }) => + provider.canonical ?? provider.id.replace(/^console-/, "") + + // Like the model picker, a Console-managed provider drops its "Workspace / " prefix unless the + // same provider is also connected directly and the workspace is what tells them apart. + const providerName = (providerID: string) => { + const provider = providers.all().get(providerID) + + if (!provider) return providerID + const all = [...providers.all().values()] + const managed = consoleProviderGroup(all) + + if (!managed?.providers.includes(provider)) return provider.name + const direct = all.some((item) => !managed.providers.includes(item) && canonical(item) === canonical(provider)) + + return direct ? provider.name : consoleProviderName(managed, provider.name) + } return createMemo(() => { + const selection = input.model ?? local.model + const key = selection.key() + return { agents: { available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []), @@ -27,11 +48,14 @@ export function createComposerControls(input: { model?: ModelSelection } = {}) { select: local.agent.set, }, model: { - selection: input.model ?? local.model, + selection, + // A model the catalog no longer offers still names its provider. + providerName: key && providerName(key.providerID), paid: providers.paid().length > 0, + // The session's model applies only to its agent, so resolve agents before showing a model. loading: - !(input.model ?? local.model).ready() || - (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || + !selection.ready() || + data.location.agent.list({ directory: sdk().directory }) === undefined || !providers.ready(), }, } @@ -78,14 +102,18 @@ export function createComposerModelSelection(input: { return modelID ? [{ providerID: provider.id, modelID }] : [] })[0] - const current = () => { + const key = () => { if (!configuredModel.ready()) return - const key = [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find( + return [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find( (item): item is ModelKey => !!item && valid(item), ) + } - return key ? models.find(key) : undefined + const current = () => { + const item = key() + + return item ? models.find(item) : undefined } const recentModels = createMemo(() => @@ -99,6 +127,7 @@ export function createComposerModelSelection(input: { trackSessionCommit: local.model.trackSessionCommit, remembered: () => Object.fromEntries(Object.entries(remembered).map(([name, model]) => [name, { model }])), ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }), + key, current, recent: recentModels, list: models.list, diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 2e5933a7fe2..7c99f964894 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -22,6 +22,7 @@ const selectedModel = { const selection = { ready: Object.assign(() => true, { promise: undefined }), + key: () => ({ providerID: "provider-1", modelID: "model-1" }), current: () => selectedModel, recent: () => [selectedModel], list: () => [selectedModel], diff --git a/packages/app/src/providers/models/selection.tsx b/packages/app/src/providers/models/selection.tsx index c6308de0e7f..3e0b9a12b54 100644 --- a/packages/app/src/providers/models/selection.tsx +++ b/packages/app/src/providers/models/selection.tsx @@ -266,18 +266,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, } - const current = createMemo(() => { + // The chosen model, kept even when the catalog no longer offers it. + const key = createMemo(() => { if (!configuredModel.ready()) return const session = durable() // Catalog availability must not replace a user's choice with a default on the next submission. - const item = + return ( scope()?.model ?? (session?.model && (!session.agent || session.agent === agent.current()?.name) ? { providerID: session.model.providerID, modelID: session.model.id } : undefined) ?? firstModel(() => agent.current()?.model, fallback) + ) + }) + + const current = createMemo(() => { + const item = key() if (!item) return @@ -384,6 +390,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const model = { ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }), + key, current, recent, list: models.list, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 50f72ebd3bf..e7ae4682a9a 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -112,6 +112,7 @@ export const dict = { "dialog.model.select.title": "Select model", "dialog.model.search.placeholder": "Search models", "dialog.model.empty": "No model results", + "dialog.model.unavailable": "{{model}} (unavailable)", "dialog.model.manage": "Manage models", "dialog.model.manage.description": "Customize which models appear in the model selector.", "dialog.model.chatgptPlan": "Using ChatGPT plan", diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx index 33fade018de..676598e3ab0 100644 --- a/packages/app/src/session/story-model.tsx +++ b/packages/app/src/session/story-model.tsx @@ -58,6 +58,7 @@ const storyComposerModel = { const modelSelection = { ready: modelReady, + key: () => ({ providerID: storyComposerModel.provider.id, modelID: storyComposerModel.id }), current: () => storyComposerModel, recent: () => [storyComposerModel], list: () => [storyComposerModel],