diff --git a/web/src/components/SessionControls.test.tsx b/web/src/components/SessionControls.test.tsx index bfe8a7a..1349aa3 100644 --- a/web/src/components/SessionControls.test.tsx +++ b/web/src/components/SessionControls.test.tsx @@ -12,7 +12,8 @@ vi.mock("../state/connection", () => ({ newRequestId: () => "r", authIdentity: authMock, })); -vi.mock("../state/models", () => ({ fetchModels: vi.fn(), modelCatalog: () => [] })); +const fetchModelsMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); +vi.mock("../state/models", () => ({ fetchModels: fetchModelsMock, modelCatalog: () => [] })); vi.mock("./SessionExportModal", () => ({ openExportModal: vi.fn() })); import SessionControls from "./SessionControls"; @@ -20,6 +21,7 @@ import { getSession, ingestSessionList, focusSession, + mergeSession, _resetSessionsForTest, } from "../state/sessions"; import type { SessionInfo } from "../protocol/types"; @@ -50,6 +52,7 @@ function mockAuth(providers?: string[]): void { afterEach(() => { cleanup(); _resetSessionsForTest(); + fetchModelsMock.mockClear(); requestMock.mockReset(); requestMock.mockImplementation(() => Promise.resolve(undefined)); authMock.mockReset(); @@ -383,3 +386,27 @@ describe("ForkButton", () => { expect(await findByText(/Cannot fork/)).toBeTruthy(); }); }); + +describe("ModelPicker — catalog follows the backend", () => { + it("fetches the focused session's backend catalog on mount", () => { + mockAuth(["claude", "codex"]); + ingestSessionList([sess("codex")]); + focusSession("s"); + render(() => ); + // Not the daemon default — the SESSION's backend. + expect(fetchModelsMock).toHaveBeenCalledWith("codex"); + }); + + it("refetches when the session's backend switches (the reported bug)", async () => { + mockAuth(["claude", "codex"]); + ingestSessionList([sess("claude")]); + focusSession("s"); + render(() => ); + expect(fetchModelsMock).toHaveBeenCalledWith("claude"); + fetchModelsMock.mockClear(); + + // A `/provider` switch arrives as an info_update flipping providerId. + mergeSession({ id: "s", providerId: "codex" }); + await waitFor(() => expect(fetchModelsMock).toHaveBeenCalledWith("codex")); + }); +}); diff --git a/web/src/components/SessionControls.tsx b/web/src/components/SessionControls.tsx index f724ded..d9cff52 100644 --- a/web/src/components/SessionControls.tsx +++ b/web/src/components/SessionControls.tsx @@ -12,7 +12,7 @@ * silently doing nothing (or worse, desyncing the local store). */ -import { Component, For, Show, createEffect, createSignal, onCleanup } from "solid-js"; +import { Component, For, Show, createEffect, createSignal, on, onCleanup } from "solid-js"; import { authIdentity, @@ -88,7 +88,11 @@ const SessionControls: Component = () => { - + @@ -256,15 +260,17 @@ const ModePicker: Component<{ // Module-level so `/model` (bare) can open the picker programmatically. const [modelPickerOpen, setModelPickerOpen] = createSignal(false); -/** Open the focused session's model picker (wired to the bare `/model` slash). */ +/** Open the focused session's model picker (wired to the bare `/model` slash). + * Fetches the FOCUSED session's backend catalog — not the daemon default. */ export function openModelPicker(): void { setModelPickerOpen(true); - void fetchModels(); + void fetchModels(focusedSession()?.providerId); } const ModelPicker: Component<{ sessionId: string; current?: string; + provider?: string; }> = (props) => { const open = modelPickerOpen; const setOpen = setModelPickerOpen; @@ -272,6 +278,20 @@ const ModelPicker: Component<{ const act = createAction(); let rootEl: HTMLDivElement | undefined; useDismissable(() => rootEl, open, () => setOpen(false)); + // Track the session's backend — the catalog is per-backend, so a + // `/provider` switch (or tabbing to a session on another backend) must + // swap the list (the reported bug: switching to codex kept showing + // claude's models). NOT forced: a backend already fetched live serves + // from cache instantly (no daemon round-trip while navigating sessions), + // and a not-yet-live backend still refetches. This must run regardless of + // whether the picker is open — the `/model` slash and the help modal read + // the same catalog. + createEffect( + on( + () => props.provider, + (provider) => void fetchModels(provider), + ), + ); return (