Skip to content

Commit cb7ea80

Browse files
committed
fix(web): resolve selected language model against the page's model list
The (app) layout captured the configured models once and never re-rendered on client navigation, so after a config change the provider reset new selections to its stale default. The provider now only stores the preference, and each consumer resolves it against its page's current list.
1 parent b4d1a02 commit cb7ea80

9 files changed

Lines changed: 214 additions & 62 deletions

File tree

‎packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const LandingPage = ({
8787
}}
8888
className="min-h-[50px]"
8989
isRedirecting={isLoading}
90+
languageModels={languageModels}
9091
selectedSearchScopes={selectedSearchScopes}
9192
searchContexts={[]}
9293
askCommands={askCommands}

‎packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const LandingPageChatBox = ({
5050
}}
5151
className="min-h-[50px]"
5252
isRedirecting={isLoading}
53+
languageModels={languageModels}
5354
selectedSearchScopes={selectedSearchScopes}
5455
searchContexts={searchContexts}
5556
askCommands={askCommands}

‎packages/web/src/app/(app)/layout.tsx‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import { RoleProvider } from "@/features/auth/roleProvider";
3232
import { HasLicenseProvider } from "@/features/billing/hasLicenseProvider";
3333
import { tryGetLatestSourcebotTag } from "./components/banners/actions";
3434
import { LanguageModelProvider } from "@/features/chat/languageModelContext";
35-
import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server";
3635
import { NavigationGuardProvider } from "next-navigation-guard";
3736
import { getRepositorySyncCounts } from "@/features/repos/repositorySyncCounts.server";
3837
import { getConnectionSyncCounts } from "@/features/connections/connectionSyncCounts.server";
@@ -183,15 +182,13 @@ export default async function Layout(props: LayoutProps) {
183182
timeoutMs: 3000
184183
});
185184

186-
const languageModels = await getConfiguredLanguageModelsInfo();
187-
188185
return (
189186
<AccountLinkingGuard callbackUrl="/">
190187
<RoleProvider role={role}>
191188
<HasLicenseProvider
192189
hasLicense={offlineLicense !== null || license !== null}
193190
>
194-
<LanguageModelProvider languageModels={languageModels}>
191+
<LanguageModelProvider>
195192
<SyntaxGuideProvider>
196193
{/* Keep one guard provider above both sidebar and content so browser history is tracked before guarded routes mount. */}
197194
<NavigationGuardProvider>

‎packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export const ChatThread = ({
126126
const [failedMcpServers, setFailedMcpServers] = useState<McpServerLoadFailureData[]>([]);
127127
const [isFailedMcpBannerVisible, setIsFailedMcpBannerVisible] = useState(false);
128128

129-
const { selectedLanguageModel } = useSelectedLanguageModel();
129+
const { selectedLanguageModel } = useSelectedLanguageModel(languageModels);
130130

131131
// Refs to capture the latest request params for the transport body.
132132
// The transport is created once (useMemo) but params change over time,
@@ -566,6 +566,7 @@ export const ChatThread = ({
566566
isTurnInProgress={isTurnInProgress}
567567
isNetworkActive={isNetworkActive}
568568
onStop={stop}
569+
languageModels={languageModels}
569570
selectedSearchScopes={selectedSearchScopes}
570571
searchContexts={searchContexts}
571572
askCommands={askCommands}

‎packages/web/src/features/chat/components/chatBox/chatBox.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { Button } from "@/components/ui/button";
44
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
55
import { FileMentionComponent, MentionChip } from "@/features/chat/components/mentionChip";
6-
import { AttachmentData, CustomEditor, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types";
6+
import { AttachmentData, CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types";
77
import { insertMention, slateContentToString } from "@/features/chat/utils";
88
import { createPastedTextAttachment, getSubmittedTextBytes, PendingAttachment, PendingImageAttachment, readFilesAsAttachments, shouldAutoConvertPaste, toAttachmentData, uploadImageAttachment } from "@/features/chat/attachmentUtils";
99
import { AttachmentButton } from "./attachmentButton";
@@ -56,6 +56,7 @@ interface ChatBoxProps {
5656
isTurnInProgress?: boolean;
5757
isNetworkActive?: boolean;
5858
isDisabled?: boolean;
59+
languageModels: LanguageModelInfo[];
5960
selectedSearchScopes: SearchScope[];
6061
searchContexts: SearchContextQuery[];
6162
askCommands: AskCommandDefinition[];
@@ -78,6 +79,7 @@ const ChatBoxComponent = ({
7879
isDisabled,
7980
isLoginWallEnabled,
8081
isAuthenticated,
82+
languageModels,
8183
selectedSearchScopes,
8284
searchContexts,
8385
maxImageBytes = ATTACHMENT_MAX_IMAGE_BYTES,
@@ -107,7 +109,7 @@ const ChatBoxComponent = ({
107109
}).flat(),
108110
askCommands,
109111
});
110-
const { selectedLanguageModel } = useSelectedLanguageModel();
112+
const { selectedLanguageModel } = useSelectedLanguageModel(languageModels);
111113
const { toast } = useToast();
112114
const isMac = useIsMac();
113115
const isAskEnabled = useHasEntitlement('ask');

‎packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const ChatBoxToolbar = ({
3636
onDisabledMcpServerIdsChange,
3737
isAuthenticated,
3838
}: ChatBoxToolbarProps) => {
39-
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel();
39+
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel(languageModels);
4040

4141
return (
4242
<>

‎packages/web/src/features/chat/languageModelContext.tsx‎

Lines changed: 10 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,39 @@
11
'use client';
22

3-
import { createContext, useEffect, useMemo, type ReactNode } from "react";
3+
import { createContext, useMemo, type ReactNode } from "react";
44
import { useLocalStorage } from "usehooks-ts";
55
import { LanguageModelInfo } from "./types";
6-
import { getLanguageModelKey } from "./utils";
76

87
export interface SelectedLanguageModelContextValue {
9-
languageModels: LanguageModelInfo[];
10-
selectedLanguageModel: LanguageModelInfo | undefined;
8+
storedLanguageModel: LanguageModelInfo | undefined;
119
setSelectedLanguageModel: (model: LanguageModelInfo | undefined) => void;
1210
}
1311

1412
export const SelectedLanguageModelContext = createContext<SelectedLanguageModelContextValue | null>(null);
1513

1614
interface LanguageModelProviderProps {
17-
languageModels: LanguageModelInfo[];
1815
children: ReactNode;
1916
}
2017

21-
// Single owner of the selected-language-model state. Mounted once (in the (app)
22-
// layout), so the selection lives in one place instead of being re-derived by a
23-
// `useSelectedLanguageModel` hook in every consumer. Previously each consumer
24-
// ran its own reset effect against the shared "selectedLanguageModel"
25-
// localStorage key, and because usehooks-ts broadcasts a storage event on every
26-
// write, those instances re-triggered each other into a rapid write loop when a
27-
// model was removed.
18+
// Single owner of the persisted model preference, mounted in the (app) layout.
19+
// It must not hold the configured model list: layouts do not re-render on
20+
// client-side navigation, so that list would go stale after a config change.
21+
// `useSelectedLanguageModel` resolves the preference against the page's list.
2822
export const LanguageModelProvider = ({
29-
languageModels,
3023
children,
3124
}: LanguageModelProviderProps) => {
32-
const fallbackLanguageModel = languageModels.length > 0 ? languageModels[0] : undefined;
33-
const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage<LanguageModelInfo | undefined>(
25+
const [storedLanguageModel, setSelectedLanguageModel] = useLocalStorage<LanguageModelInfo | undefined>(
3426
"selectedLanguageModel",
35-
fallbackLanguageModel,
27+
undefined,
3628
{
3729
initializeWithValue: false,
3830
}
3931
);
4032

41-
// Handle the case where the selected language model is no longer available.
42-
// Reset to the fallback language model in this case. Only write when the
43-
// resolved selection actually differs (compared by key, since the stored
44-
// value is a fresh object reference on every read) — otherwise the effect
45-
// would re-write on every render.
46-
useEffect(() => {
47-
const selectedKey = selectedLanguageModel
48-
? getLanguageModelKey(selectedLanguageModel)
49-
: undefined;
50-
51-
const isSelectedModelAvailable = selectedKey !== undefined && languageModels.some(
52-
(model) => getLanguageModelKey(model) === selectedKey
53-
);
54-
55-
if (isSelectedModelAvailable) {
56-
return;
57-
}
58-
59-
const fallbackKey = fallbackLanguageModel
60-
? getLanguageModelKey(fallbackLanguageModel)
61-
: undefined;
62-
63-
if (fallbackKey !== selectedKey) {
64-
setSelectedLanguageModel(fallbackLanguageModel);
65-
}
66-
}, [
67-
fallbackLanguageModel,
68-
languageModels,
69-
selectedLanguageModel,
70-
setSelectedLanguageModel,
71-
]);
72-
7333
const value = useMemo<SelectedLanguageModelContextValue>(() => ({
74-
languageModels,
75-
selectedLanguageModel,
34+
storedLanguageModel,
7635
setSelectedLanguageModel,
77-
}), [languageModels, selectedLanguageModel, setSelectedLanguageModel]);
36+
}), [storedLanguageModel, setSelectedLanguageModel]);
7837

7938
return (
8039
<SelectedLanguageModelContext.Provider value={value}>
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2+
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
3+
import { LanguageModelSelector } from "./components/chatBox/languageModelSelector";
4+
import { LanguageModelProvider } from "./languageModelContext";
5+
import { LanguageModelInfo } from "./types";
6+
import { useSelectedLanguageModel } from "./useSelectedLanguageModel";
7+
8+
vi.mock("./components/chatBox/modelProviderLogo", () => ({
9+
ModelProviderLogo: () => null,
10+
}));
11+
12+
const STORAGE_KEY = "selectedLanguageModel";
13+
14+
const makeModel = (model: string, overrides: Partial<LanguageModelInfo> = {}): LanguageModelInfo => ({
15+
provider: "anthropic",
16+
model,
17+
inputModalities: ["text"],
18+
supportedDocumentTypes: [],
19+
...overrides,
20+
});
21+
22+
const oldModels = [makeModel("old-default"), makeModel("old-other")];
23+
const newModels = [makeModel("new-default"), makeModel("new-other")];
24+
25+
// Mirrors a chat page: the toolbar selector and the chat thread each resolve
26+
// the selection against the page's model list.
27+
const Toolbar = ({ languageModels }: { languageModels: LanguageModelInfo[] }) => {
28+
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel(languageModels);
29+
return (
30+
<LanguageModelSelector
31+
languageModels={languageModels}
32+
selectedModel={selectedLanguageModel}
33+
onSelectedModelChange={setSelectedLanguageModel}
34+
/>
35+
);
36+
};
37+
38+
const Thread = ({ languageModels }: { languageModels: LanguageModelInfo[] }) => {
39+
const { selectedLanguageModel } = useSelectedLanguageModel(languageModels);
40+
return (
41+
<output data-testid="submitted-model" data-modalities={selectedLanguageModel?.inputModalities.join(",")}>
42+
{selectedLanguageModel?.model ?? "none"}
43+
</output>
44+
);
45+
};
46+
47+
const ChatPage = ({ languageModels }: { languageModels: LanguageModelInfo[] }) => (
48+
<>
49+
<Toolbar languageModels={languageModels} />
50+
<Thread languageModels={languageModels} />
51+
</>
52+
);
53+
54+
// The provider is mounted once by the (app) layout and survives client-side
55+
// navigation, while each page renders with a freshly loaded model list.
56+
const renderSession = (languageModels: LanguageModelInfo[]) => {
57+
const result = render(
58+
<LanguageModelProvider>
59+
<ChatPage languageModels={languageModels} />
60+
</LanguageModelProvider>
61+
);
62+
63+
return {
64+
navigateWithModels: (nextModels: LanguageModelInfo[]) => result.rerender(
65+
<LanguageModelProvider>
66+
<ChatPage languageModels={nextModels} />
67+
</LanguageModelProvider>
68+
),
69+
};
70+
};
71+
72+
const selectModel = (currentModel: string, nextModel: string) => {
73+
fireEvent.click(screen.getByRole("button", { name: currentModel }));
74+
fireEvent.click(screen.getByRole("option", { name: nextModel }));
75+
};
76+
77+
const getStoredModel = () => {
78+
const stored = window.localStorage.getItem(STORAGE_KEY);
79+
return stored ? (JSON.parse(stored) as LanguageModelInfo).model : undefined;
80+
};
81+
82+
beforeAll(() => {
83+
// cmdk relies on these browser APIs, which jsdom does not implement.
84+
globalThis.ResizeObserver ??= class {
85+
observe() {}
86+
unobserve() {}
87+
disconnect() {}
88+
};
89+
Element.prototype.scrollIntoView ??= () => {};
90+
});
91+
92+
// Node 25 ships a global localStorage that shadows jsdom's, so install a fresh
93+
// in-memory store for each test.
94+
const installMockLocalStorage = () => {
95+
const store = new Map<string, string>();
96+
const storage: Storage = {
97+
get length() {
98+
return store.size;
99+
},
100+
clear: () => store.clear(),
101+
getItem: (key: string) => store.get(key) ?? null,
102+
key: (index: number) => Array.from(store.keys())[index] ?? null,
103+
removeItem: (key: string) => {
104+
store.delete(key);
105+
},
106+
setItem: (key: string, value: string) => {
107+
store.set(key, value);
108+
},
109+
};
110+
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
111+
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
112+
};
113+
114+
beforeEach(() => {
115+
installMockLocalStorage();
116+
});
117+
118+
afterEach(() => {
119+
cleanup();
120+
});
121+
122+
describe("useSelectedLanguageModel", () => {
123+
test("restores the stored model on mount", () => {
124+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(oldModels[1]));
125+
126+
renderSession(oldModels);
127+
128+
expect(screen.getByRole("button", { name: "old-other" })).toBeTruthy();
129+
expect(screen.getByTestId("submitted-model").textContent).toBe("old-other");
130+
});
131+
132+
test("keeps a newly configured model selected after the config changes mid-session", () => {
133+
const session = renderSession(oldModels);
134+
session.navigateWithModels(newModels);
135+
136+
selectModel("new-default", "new-other");
137+
138+
expect(screen.getByRole("button", { name: "new-other" })).toBeTruthy();
139+
expect(screen.getByTestId("submitted-model").textContent).toBe("new-other");
140+
expect(getStoredModel()).toBe("new-other");
141+
});
142+
143+
test("converges on the first configured model when the selected model is removed", () => {
144+
const session = renderSession(oldModels);
145+
selectModel("old-default", "old-other");
146+
expect(getStoredModel()).toBe("old-other");
147+
148+
session.navigateWithModels(newModels);
149+
150+
expect(screen.getByRole("button", { name: "new-default" })).toBeTruthy();
151+
expect(screen.getByTestId("submitted-model").textContent).toBe("new-default");
152+
// Reconciliation is derived, not written back, so consumers cannot
153+
// trigger each other into a storage write loop.
154+
expect(getStoredModel()).toBe("old-other");
155+
});
156+
157+
test("uses the current config's capabilities for a stored model", () => {
158+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(oldModels[1]));
159+
const session = renderSession(oldModels);
160+
161+
session.navigateWithModels([oldModels[0], makeModel("old-other", { inputModalities: ["text", "image"] })]);
162+
163+
expect(screen.getByTestId("submitted-model").dataset.modalities).toBe("text,image");
164+
});
165+
166+
test("selects nothing when no models are configured", () => {
167+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(oldModels[0]));
168+
169+
renderSession([]);
170+
171+
expect(screen.getByTestId("submitted-model").textContent).toBe("none");
172+
});
173+
});

0 commit comments

Comments
 (0)