mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
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:
committed by
GitHub
Unverified
parent
9a7bfb063c
commit
141a155906
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -58,6 +58,7 @@ const storyComposerModel = {
|
||||
|
||||
const modelSelection = {
|
||||
ready: modelReady,
|
||||
key: () => ({ providerID: storyComposerModel.provider.id, modelID: storyComposerModel.id }),
|
||||
current: () => storyComposerModel,
|
||||
recent: () => [storyComposerModel],
|
||||
list: () => [storyComposerModel],
|
||||
|
||||
Reference in New Issue
Block a user