From 781a9f66e418b1fcbf295e9f19be6e1f698b9d85 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 19 Oct 2026 23:17:34 -0700 Subject: [PATCH] fix(app): show an opened session's model right away (#57131) --- packages/app/e2e/regression/composer.spec.ts | 94 ++++++++++++++++++- packages/app/e2e/regression/tabs.spec.ts | 46 +++++++++ packages/app/src/composer/adapter.ts | 2 + packages/app/src/composer/composer.tsx | 48 +++++++--- packages/app/src/composer/model.ts | 19 +++- packages/app/src/composer/selection.ts | 62 ++++++++++-- .../app/src/providers/models/selection.tsx | 22 ++++- packages/app/src/shell/titlebar/tab-strip.tsx | 18 ++++ 8 files changed, 281 insertions(+), 30 deletions(-) diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index 9ce57f69bf2..9c54dc373ae 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -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() + 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 + } +} + 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") diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 4fb78986592..592532737fc 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -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) +}) diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index bb67e5d4020..5f5ea4400f5 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -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[] } } } diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index ec6b3d94dea..337c644c9f0 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -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={ dialog.show(() => )} @@ -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((previous) => previous ?? props.loading) + // A preview paints the chip in its resting state, and the loaded names replace it in place. + const shouldAnimate = createMemo((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(() => , 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() + + 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 ( - + } > + {/* While loading, the drawer would open on a catalog that isn't there yet, so the inert chip stands in. */} 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( - () => , - 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()} diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index b61725d798c..d501508decc 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -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: { diff --git a/packages/app/src/composer/selection.ts b/packages/app/src/composer/selection.ts index c920984505e..d9313dc573f 100644 --- a/packages/app/src/composer/selection.ts +++ b/packages/app/src/composer/selection.ts @@ -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(() => { 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(), }, } }) diff --git a/packages/app/src/providers/models/selection.tsx b/packages/app/src/providers/models/selection.tsx index 0773ccfac1f..10dad04f5c4 100644 --- a/packages/app/src/providers/models/selection.tsx +++ b/packages/app/src/providers/models/selection.tsx @@ -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["model"], "trackSessionCommit"> & - Partial["model"], "trackSessionCommit">> +export type ModelSelection = Omit["model"], "trackSessionCommit" | "preview"> & + Partial["model"], "trackSessionCommit" | "preview">> diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx index 2a7f54fd4df..7896b0e555d 100644 --- a/packages/app/src/shell/titlebar/tab-strip.tsx +++ b/packages/app/src/shell/titlebar/tab-strip.tsx @@ -35,6 +35,7 @@ function SessionTabSlot(props: { onRename: (title: string) => Promise 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)} > { @@ -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} /> )