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 (