fix(app): show an opened session's model right away (#57131)

This commit is contained in:
Kit Langton
2026-10-19 23:17:34 -07:00
committed by GitHub
Unverified
parent ee04c57d05
commit 781a9f66e4
8 changed files with 281 additions and 30 deletions
+90 -4
View File
@@ -1,5 +1,5 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { NO_PROVIDER, T0, provider, sessionHref } from "../utils/app"
import { NO_PROVIDER, SERVER, T0, holdRoute, provider, sessionHref } from "../utils/app"
import { mockWorkspace, openDraft, openSession } from "../utils/workspace"
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
@@ -87,7 +87,7 @@ 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 }) => {
test("an existing session's model never shows a fallback while its agent loads", async ({ page }) => {
const sessionID = "ses_agent_pending"
await mockWorkspace(page, {
name: "AgentPending",
@@ -101,21 +101,106 @@ test("an existing session's model waits for its agent instead of showing a fallb
await route.fallback()
})
// Every chip text painted from the first frame, so a fallback shown for even one frame fails the test.
await page.addInitScript(() => {
const seen = new Set<string>()
window.__chipTexts = seen
const tick = () => {
const text = document.querySelector('[data-action="composer-model"]')?.textContent
if (text) seen.add(text)
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
const catalog = ["/api/model", "/api/provider", "/api/config"].map((path) =>
page.waitForResponse((response) => new URL(response.url()).pathname === path),
)
const agentRequested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/agent")
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()
// The recent model must not stand in for the session's model while its agent is unresolved. The chip may stay
// hidden or name the session's own model, which the loaded selection then keeps.
await agentRequested
const agentLoaded = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/agent")
agents.resolve()
await agentLoaded
await expect(control).toHaveText("OpenCode / Alpha")
expect(await page.evaluate(() => [...(window.__chipTexts ?? [])])).toEqual(["OpenCode / Alpha"])
})
test("an opened session in another Location names its model from a loaded catalog before its agents load", async ({
page,
}) => {
const other = "C:/OpenCode/WarmOther"
await mockWorkspace(page, {
name: "WarmOpen",
sessions: [
{ id: "ses_warm_here", title: "Here", agent: "build", model: { id: "alpha", providerID: "opencode" } },
{
id: "ses_warm_there",
title: "There",
directory: other,
location: { directory: other },
agent: "build",
model: { id: "beta", providerID: "opencode", variant: "high" },
},
],
provider: provider({ id: "alpha", name: "Alpha" }, { id: "beta", name: "Beta (latest)", variants: { high: {} } }),
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
const control = page.locator('[data-action="composer-model"]')
await page.goto(sessionHref("ses_warm_here"))
await expect(control).toHaveText("OpenCode / Alpha")
const held = await holdRoute(
page,
(url) =>
["/api/agent", "/api/config"].includes(url.pathname) && url.searchParams.get("location[directory]") === other,
)
try {
await page.locator(`[data-slot="vertical-tabs"] a[href="${sessionHref("ses_warm_there", SERVER)}"]`).click()
await held.arrived
await expect(control).toHaveText("OpenCode / Beta")
await expect(page.getByRole("button", { name: "Choose model variant" })).toHaveText("high")
const painted = await control.elementHandle()
const loaded = ["/api/agent", "/api/config"].map((path) =>
page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === path && url.searchParams.get("location[directory]") === other
}),
)
// A click before the catalog loads opens the dialog once it does.
await control.click()
held.release()
await Promise.all(loaded)
await expect(page.getByRole("dialog")).toBeVisible()
// The loaded selection keeps the same chip instead of replacing it.
await expect(control).toHaveText("OpenCode / Beta")
expect(await painted?.evaluate((element) => element.isConnected)).toBe(true)
} finally {
held.release()
}
})
declare global {
interface Window {
__chipTexts?: Set<string>
}
}
test("names a Console-managed provider without its workspace unless a direct connection shares it", async ({
page,
}) => {
@@ -375,6 +460,7 @@ test("keeps focus in a dialog that replaces the model dialog and returns it to t
name: "ComposerDialogFocus",
provider: provider({ id: "paid", name: "Paid", cost: { input: 1, output: 1 } }),
})
await editor.fill("keep me")
await editor.press("End")
await editor.press("ArrowLeft")
+46
View File
@@ -7,6 +7,7 @@ import {
draftHref,
expectPath,
holdRoute,
pageMessagesFrom,
project,
seed,
session,
@@ -369,10 +370,13 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
const actions = await Promise.all(
["vertical-tabs-home", "vertical-tabs-search", "vertical-tabs-new-session"].map(async (action) => {
const box = await sidebar.locator(`[data-action="${action}"]`).boundingBox()
if (!box) throw new Error(`${action} is not rendered`)
return box
}),
)
const gaps = actions.slice(1).map((box, index) => box.y - (actions[index].y + actions[index].height))
expect(gaps).toEqual([4, 4])
@@ -1075,3 +1079,45 @@ async function twoServers(
tabs: [input.a[0]!.id, { session: input.b[0]!.id, server: REMOTE_SERVER }],
})
}
test("loads a tab's transcript while the pointer rests on it, and not when it passes over", async ({ page }) => {
const requested: string[] = []
page.on("request", (request) => {
const id = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)?.[1]
if (id && request.method() === "GET") requested.push(id)
})
await mockWorkspace(page, {
name: "HoverPrefetch",
sessions: [a, b, c],
pageMessages: pageMessagesFrom(
Object.fromEntries(
[a, b, c].map((item): [string, SessionMessageInfo[]] => [
item.id,
[{ id: `msg_${item.id}`, type: "user", time: { created: 1700000000000 }, text: item.title }],
]),
),
),
seed: { settings: { appearance: { tabLayout: "vertical" } } },
})
await page.goto(sessionHref(a.id))
await expectSessionTitle(page, a.title)
const tab = (id: string) => page.locator(`[data-slot="vertical-tabs"] a[href="${sessionHref(id, SERVER)}"]`)
const message = (id: string) => page.locator(`[data-timeline-virtual-content] [data-message-id="msg_${id}"]`)
await expect(message(a.id)).toBeVisible()
// Raw moves keep the pass well under the dwell, which hover() actionability checks can exceed on slow runners.
const [passed, rest] = await Promise.all([tab(c.id).boundingBox(), tab(a.id).boundingBox()])
await page.mouse.move(passed!.x + passed!.width / 2, passed!.y + passed!.height / 2)
await page.mouse.move(rest!.x + rest!.width / 2, rest!.y + rest!.height / 2)
await tab(b.id).hover()
await expect.poll(() => requested.includes(b.id)).toBe(true)
// A dwell started by the pass would have fired before the one on the rested tab.
expect(requested).not.toContain(c.id)
await tab(b.id).click()
await expect(message(b.id)).toBeVisible()
expect(requested.filter((id) => id === b.id)).toHaveLength(1)
})
+2
View File
@@ -20,6 +20,8 @@ export type ComposerControls = {
providerName?: string
paid: boolean
loading: boolean
/** While loading, the expected model's names from a catalog already loaded for another Location. */
preview?: { provider?: string; model: string; variant: string; variants: string[] }
}
}
+35 -13
View File
@@ -1,4 +1,4 @@
import { Show, createMemo } from "solid-js"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
@@ -27,6 +27,9 @@ export function Composer(props: {
const language = useLanguage()
const modelName = () => {
const preview = props.model.model.preview
if (preview) return preview.model
const selection = props.model.model.selection
const model = selection.current()
@@ -45,7 +48,7 @@ export function Composer(props: {
borderUnderlay={props.borderUnderlay}
readOnly={props.readOnly}
class={props.class}
modelControlsVisible={!props.model.model.loading}
modelControlsVisible={!props.model.model.loading || !!props.model.model.preview}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
@@ -54,11 +57,12 @@ export function Composer(props: {
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
preview={!!props.model.model.preview}
paid={props.model.model.paid}
title={language.t("command.model.choose")}
keybind={command.keybindParts("model.choose")}
model={props.model.model.selection}
providerName={props.model.model.providerName}
providerName={props.model.model.preview?.provider ?? props.model.model.providerName}
modelName={modelName()}
onClose={props.model.restoreFocus}
onUnpaidClick={() => dialog.show(() => <DialogSelectModelUnpaid model={props.model.model.selection} />)}
@@ -71,6 +75,7 @@ export function Composer(props: {
function ComposerModelControl(props: {
loading: boolean
preview: boolean
paid: boolean
title: string
keybind: string[]
@@ -80,11 +85,30 @@ function ComposerModelControl(props: {
onClose: () => void
onUnpaidClick: () => void
}) {
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
// A preview paints the chip in its resting state, and the loaded names replace it in place.
const shouldAnimate = createMemo<boolean>((previous) => previous ?? (props.loading && !props.preview))
const mobile = createMediaQuery("(max-width: 767px)")
const dialog = useDialog()
const managed = createMemo(() => consoleModelGroup(props.model.list()))
const open = (detail: number) => {
if (!props.paid) return props.onUnpaidClick()
// A pointer open keeps focus in the composer, so the dialog returns it to the caret. A keyboard open
// (a click with no pointer presses) returns focus to this trigger instead of moving it to the editor.
void dialog.show(() => <DialogSelectModel model={props.model} />, detail === 0 ? undefined : props.onClose)
}
// A previewed chip is visible before the catalog loads, so a click on it opens the dialog once loading finishes.
const [deferred, setDeferred] = createSignal<number>()
createEffect(() => {
const detail = deferred()
if (props.loading || detail === undefined) return
setDeferred(undefined)
open(detail)
})
const providerName = (provider: { id: string; name: string }) => {
const group = managed()
@@ -113,7 +137,7 @@ function ComposerModelControl(props: {
)
return (
<Show when={!props.loading}>
<Show when={!props.loading || props.preview}>
<Tooltip
inactive={mobile()}
contentClass={props.model.current() ? "!p-0 rounded-md" : undefined}
@@ -141,8 +165,9 @@ function ComposerModelControl(props: {
</Show>
}
>
{/* While loading, the drawer would open on a catalog that isn't there yet, so the inert chip stands in. */}
<Show
when={mobile()}
when={mobile() && !props.loading}
fallback={
<Button
data-action="composer-model"
@@ -154,13 +179,10 @@ function ComposerModelControl(props: {
style={{ height: "28px" }}
onMouseDown={(event: MouseEvent) => event.preventDefault()}
onClick={(event: MouseEvent) => {
if (!props.paid) return props.onUnpaidClick()
// A pointer open keeps focus in the composer, so the dialog returns it to the caret. A keyboard open
// (a click with no pointer presses) returns focus to this trigger instead of moving it to the editor.
void dialog.show(
() => <DialogSelectModel model={props.model} />,
event.detail === 0 ? undefined : props.onClose,
)
if (!props.loading) return open(event.detail)
// On mobile this inert chip stands in for the drawer trigger, so a click there is dropped.
if (!mobile()) setDeferred(event.detail)
}}
>
{content()}
+16 -3
View File
@@ -351,9 +351,11 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const existing = pathImages.get(path)
if (existing) return existing
const url = readLocalImage(serverSDK.api, sdk().directory, path, pathImageReads.signal)
.then((blob) => blob && URL.createObjectURL(blob))
.catch(() => undefined)
pathImages.set(path, url)
// A failed read retries the next time the attachment shows.
void url.then((value) => value || pathImages.delete(path))
@@ -428,9 +430,20 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
: undefined
},
variant: {
options: () => variants().map((value) => ({ id: value, label: value })),
current: () => adapter.controls().model.selection.variant.current() ?? "default",
onSelect: (value) => adapter.controls().model.selection.variant.set(value === "default" ? undefined : value),
options: () => {
const preview = adapter.controls().model.preview
return (preview ? ["default", ...preview.variants] : variants()).map((value) => ({ id: value, label: value }))
},
current: () =>
adapter.controls().model.preview?.variant ??
adapter.controls().model.selection.variant.current() ??
"default",
onSelect: (value) => {
if (adapter.controls().model.loading) return
adapter.controls().model.selection.variant.set(value === "default" ? undefined : value)
},
keybind: () => command.keybindParts("model.variant.cycle"),
},
submit: {
+54 -8
View File
@@ -5,29 +5,31 @@ import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { normalizeAgentList, normalizeProviderList } from "@/runtime/server/global-sync/utils"
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"
import { useGlobal } from "@/runtime/server/runtime"
export function createComposerControls(input: { model?: ModelSelection } = {}) {
const local = useLocal()
const sdk = useWorkspaceLocation()
const data = useData()
const providers = useProviders(() => sdk().directory)
const models = useGlobal().models
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)
const providerName = (providerID: string, catalog = providers.all()) => {
const provider = catalog.get(providerID)
if (!provider) return providerID
const all = [...providers.all().values()]
const all = [...catalog.values()]
const managed = consoleProviderGroup(all)
if (!managed?.providers.includes(provider)) return provider.name
@@ -36,6 +38,52 @@ export function createComposerControls(input: { model?: ModelSelection } = {}) {
return direct ? provider.name : consoleProviderName(managed, provider.name)
}
const loading = () => {
const selection = input.model ?? local.model
return (
!selection.ready() || data.location.agent.list({ directory: sdk().directory }) === undefined || !providers.ready()
)
}
// Until this Location's catalog loads, the default Location's or one visited earlier nearly always lists the same
// model, so it names the chips; the loaded names then replace them in place.
const preview = createMemo(() => {
if (!loading()) return
const expected = (input.model ?? local.model).preview?.()
if (!expected) return
const catalogs = providers.ready()
? [providers.all()]
: [undefined, ...data.session.list().map((item) => item.location)].map((location) => {
const provider = data.location.provider.list(location)
const catalog = data.location.model.list(location)
return provider && catalog ? normalizeProviderList(provider, catalog).all : undefined
})
const match = catalogs
.map((all) => all && { all, model: all.get(expected.model.providerID)?.models[expected.model.modelID] })
.find((item) => !!item?.model)
if (!match?.model) return
const variants = Object.keys(match.model.variants ?? {})
return {
provider: providerName(expected.model.providerID, match.all),
model: match.model.name.replace("(latest)", "").trim(),
variants,
variant:
resolveModelVariant({
variants,
selected: expected.selected,
configured: undefined,
preferred: models.store.variant?.[`${expected.model.providerID}/${expected.model.modelID}`],
}) ?? "default",
}
})
return createMemo<ComposerControls>(() => {
const selection = input.model ?? local.model
const key = selection.key()
@@ -54,10 +102,8 @@ export function createComposerControls(input: { model?: ModelSelection } = {}) {
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:
!selection.ready() ||
data.location.agent.list({ directory: sdk().directory }) === undefined ||
!providers.ready(),
loading: loading(),
preview: preview(),
},
}
})
@@ -290,6 +290,23 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return models.find(item)
})
// What `key` and `selected` will resolve to once the Location's agents and config load, read from state already
// here: a local draft, else the Session's durable model unless the draft picked another agent.
const preview = () => {
if (!savedReady()) return
const draft = scope()
if (draft?.model) return { model: draft.model, selected: draft.variant }
const session = durable()
if (!session?.model || (draft?.agent && draft.agent !== session.agent)) return
return {
model: { providerID: session.model.providerID, modelID: session.model.id },
selected: session.model.variant ?? null,
}
}
const configured = (model = current()) => {
const item = agent.current()
@@ -399,6 +416,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const model = {
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
key,
preview,
current,
recent,
favorite: { list: favorite, toggle: models.favorite.toggle },
@@ -561,5 +579,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
})
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit" | "preview"> &
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit" | "preview">>
@@ -35,6 +35,7 @@ function SessionTabSlot(props: {
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
onIntent: (hovered: boolean) => void
}) {
const sortable = useSortable({
get id() {
@@ -59,6 +60,8 @@ function SessionTabSlot(props: {
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
onPointerEnter={() => props.onIntent(true)}
onPointerLeave={() => props.onIntent(false)}
>
<TabNavItem
ref={(el) => {
@@ -176,6 +179,20 @@ function SessionTabEntry(props: {
onCleanup(() => window.clearTimeout(timer))
})
// A pointer resting on a tab usually clicks it next, so its transcript loads during the dwell. Passing over loads nothing.
let intent: number | undefined
const hover = (hovered: boolean) => {
window.clearTimeout(intent)
const ctx = props.serverCtx
const value = session()
if (!hovered || !ctx || !value || props.active || ctx.sdk.connection.status() !== "connected") return
intent = window.setTimeout(() => void ctx.data.session.message.sync(value.id).catch(() => undefined), 100)
}
onCleanup(() => window.clearTimeout(intent))
createEffect(() => {
const value = session()
@@ -215,6 +232,7 @@ function SessionTabEntry(props: {
onRename={rename}
onNavigate={props.onNavigate}
onClose={props.onClose}
onIntent={hover}
/>
</Show>
)