fix(app): show the model's provider and match TUI model resolution in the composer (#56225)

Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-16 19:44:16 +00:00
committed by GitHub
Unverified
parent 9a7bfb063c
commit 141a155906
12 changed files with 146 additions and 38 deletions
+62 -7
View File
@@ -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<void>()
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()
@@ -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)
})
}
@@ -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")
@@ -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")
})
+1
View File
@@ -17,6 +17,7 @@ export type ComposerControls = {
}
model: {
selection: ModelSelection
providerName?: string
paid: boolean
loading: boolean
}
@@ -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()],
+27 -15
View File
@@ -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 (
<div class="flex flex-col gap-3">
@@ -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(() => <DialogSelectModelUnpaid model={props.model.model.selection} />)}
/>
@@ -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 = () => (
<>
<Show when={props.provider}>
{(provider) => (
<ProviderModelIcon
provider={provider()}
class="shrink-0 opacity-40 transition-opacity duration-150 group-hover:opacity-100"
/>
)}
</Show>
<span class="truncate leading-4">{props.modelName}</span>
<span class="flex min-w-0 whitespace-nowrap leading-4">
<Show when={props.providerName}>
{(name) => (
<>
<span class="shrink-0 text-v2-text-text-faint">{name()}</span>
<span class="shrink-0 whitespace-pre text-v2-text-text-faint">{" / "}</span>
</>
)}
</Show>
<span class="truncate">{props.modelName}</span>
</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" />
</span>
@@ -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"}
+35 -6
View File
@@ -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<ComposerControls>(() => {
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,
+1
View File
@@ -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],
@@ -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<ModelKey | undefined>(() => {
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,
+1
View File
@@ -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",
+1
View File
@@ -58,6 +58,7 @@ const storyComposerModel = {
const modelSelection = {
ready: modelReady,
key: () => ({ providerID: storyComposerModel.provider.id, modelID: storyComposerModel.id }),
current: () => storyComposerModel,
recent: () => [storyComposerModel],
list: () => [storyComposerModel],