Skip to content

Commit 07ad057

Browse files
fix(web): stop rapid localStorage flip when removing a language model
The selected-language-model state was owned by a useSelectedLanguageModel hook instantiated in three chat components, each running its own reset effect against the shared "selectedLanguageModel" localStorage key. Since usehooks-ts broadcasts a storage event on every write, those instances re-triggered each other into a rapid write loop when a model was removed. Lift the state into a single LanguageModelProvider mounted once in the (app) layout. The hook is now a thin context consumer, so there is exactly one owner of the localStorage state and one reset effect. The reset is also made idempotent (only writes when the resolved selection differs by key). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 147aa95 commit 07ad057

8 files changed

Lines changed: 129 additions & 76 deletions

File tree

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ export const LandingPage = ({
7575
}}
7676
className="min-h-[50px]"
7777
isRedirecting={isLoading}
78-
languageModels={languageModels}
7978
selectedSearchScopes={selectedSearchScopes}
8079
searchContexts={[]}
8180
isDisabled={isChatBoxDisabled}

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ export const LandingPageChatBox = ({
4242
}}
4343
className="min-h-[50px]"
4444
isRedirecting={isLoading}
45-
languageModels={languageModels}
4645
selectedSearchScopes={selectedSearchScopes}
4746
searchContexts={searchContexts}
4847
isDisabled={isChatBoxDisabled}

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

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import { CheckoutReturnHandler } from "@/features/billing/checkoutReturnHandler"
3636
import { RoleProvider } from "@/features/auth/roleProvider";
3737
import { HasLicenseProvider } from "@/features/billing/hasLicenseProvider";
3838
import { tryGetLatestSourcebotTag } from "./components/banners/actions";
39+
import { LanguageModelProvider } from "@/features/chat/languageModelContext";
40+
import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server";
3941

4042
interface LayoutProps {
4143
children: React.ReactNode;
@@ -175,39 +177,43 @@ export default async function Layout(props: LayoutProps) {
175177
timeoutMs: 3000
176178
});
177179

180+
const languageModels = await getConfiguredLanguageModelsInfo();
181+
178182
return (
179183
<RoleProvider role={role}>
180184
<HasLicenseProvider
181185
hasLicense={offlineLicense !== null || license !== null}
182186
>
183-
<SyntaxGuideProvider>
184-
<div className="fixed inset-0 flex bg-shell">
185-
<SidebarProvider defaultOpen={cookieStore.get("sidebar_state")?.value !== "false"}>
186-
{sidebar}
187-
<div className="flex-1 min-h-0 flex flex-col pt-2 pb-2 pr-2 pl-2 md:pl-0">
188-
<div className="flex-1 min-h-0 bg-background flex flex-col border border-[#e6e6e6] dark:border-[#1d1d1f] rounded-xl overflow-hidden">
189-
<BannerHeightObserver>
190-
<BannerSlot
191-
role={role}
192-
license={license}
193-
offlineLicense={offlineLicense}
194-
hasPermissionSyncEntitlement={hasPermissionSyncEntitlement}
195-
hasPendingFirstSync={hasPendingFirstSync}
196-
currentVersion={SOURCEBOT_VERSION}
197-
latestVersion={latestVersion}
198-
/>
199-
</BannerHeightObserver>
200-
<div className="flex-1 min-h-0 overflow-y-scroll [scrollbar-gutter:stable]">
201-
{children}
187+
<LanguageModelProvider languageModels={languageModels}>
188+
<SyntaxGuideProvider>
189+
<div className="fixed inset-0 flex bg-shell">
190+
<SidebarProvider defaultOpen={cookieStore.get("sidebar_state")?.value !== "false"}>
191+
{sidebar}
192+
<div className="flex-1 min-h-0 flex flex-col pt-2 pb-2 pr-2 pl-2 md:pl-0">
193+
<div className="flex-1 min-h-0 bg-background flex flex-col border border-[#e6e6e6] dark:border-[#1d1d1f] rounded-xl overflow-hidden">
194+
<BannerHeightObserver>
195+
<BannerSlot
196+
role={role}
197+
license={license}
198+
offlineLicense={offlineLicense}
199+
hasPermissionSyncEntitlement={hasPermissionSyncEntitlement}
200+
hasPendingFirstSync={hasPendingFirstSync}
201+
currentVersion={SOURCEBOT_VERSION}
202+
latestVersion={latestVersion}
203+
/>
204+
</BannerHeightObserver>
205+
<div className="flex-1 min-h-0 overflow-y-scroll [scrollbar-gutter:stable]">
206+
{children}
207+
</div>
202208
</div>
203209
</div>
204-
</div>
205-
</SidebarProvider>
206-
</div>
207-
<SyntaxReferenceGuide />
208-
<GitHubStarToast />
209-
<CheckoutReturnHandler />
210-
</SyntaxGuideProvider>
210+
</SidebarProvider>
211+
</div>
212+
<SyntaxReferenceGuide />
213+
<GitHubStarToast />
214+
<CheckoutReturnHandler />
215+
</SyntaxGuideProvider>
216+
</LanguageModelProvider>
211217
</HasLicenseProvider>
212218
</RoleProvider>
213219
)

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,7 @@ export const ChatThread = ({
115115
});
116116
const [isFailedMcpBannerVisible, setIsFailedMcpBannerVisible] = useState(false);
117117

118-
const { selectedLanguageModel } = useSelectedLanguageModel({
119-
languageModels,
120-
});
118+
const { selectedLanguageModel } = useSelectedLanguageModel();
121119

122120
// Refs to capture the latest request params for the transport body.
123121
// The transport is created once (useMemo) but params change over time,
@@ -478,7 +476,6 @@ export const ChatThread = ({
478476
isTurnInProgress={isTurnInProgress}
479477
isNetworkActive={isNetworkActive}
480478
onStop={stop}
481-
languageModels={languageModels}
482479
selectedSearchScopes={selectedSearchScopes}
483480
searchContexts={searchContexts}
484481
isDisabled={languageModels.length === 0}

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
44
import { Button } from "@/components/ui/button";
55
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
6-
import { CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types";
6+
import { CustomEditor, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types";
77
import { insertMention, slateContentToString } from "@/features/chat/utils";
88
import { cn } from "@/lib/utils";
99
import { useIsMac } from "@/hooks/useIsMac";
@@ -37,7 +37,6 @@ interface ChatBoxProps {
3737
isTurnInProgress?: boolean;
3838
isNetworkActive?: boolean;
3939
isDisabled?: boolean;
40-
languageModels: LanguageModelInfo[];
4140
selectedSearchScopes: SearchScope[];
4241
searchContexts: SearchContextQuery[];
4342
isLoginWallEnabled: boolean;
@@ -55,7 +54,6 @@ const ChatBoxComponent = ({
5554
isDisabled,
5655
isLoginWallEnabled,
5756
isAuthenticated,
58-
languageModels,
5957
selectedSearchScopes,
6058
searchContexts,
6159
}: ChatBoxProps) => {
@@ -82,9 +80,7 @@ const ChatBoxComponent = ({
8280
return [];
8381
}).flat(),
8482
});
85-
const { selectedLanguageModel } = useSelectedLanguageModel({
86-
languageModels,
87-
});
83+
const { selectedLanguageModel } = useSelectedLanguageModel();
8884
const { toast } = useToast();
8985
const isAskEnabled = useHasEntitlement('ask');
9086
const [isLoginDialogOpen, setIsLoginDialogOpen] = useState<boolean>(false);

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ export const ChatBoxToolbar = ({
3434
onDisabledMcpServerIdsChange,
3535
isAuthenticated,
3636
}: ChatBoxToolbarProps) => {
37-
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({
38-
languageModels,
39-
});
37+
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel();
4038

4139
return (
4240
<>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use client';
2+
3+
import { createContext, useEffect, useMemo, type ReactNode } from "react";
4+
import { useLocalStorage } from "usehooks-ts";
5+
import { LanguageModelInfo } from "./types";
6+
import { getLanguageModelKey } from "./utils";
7+
8+
export interface SelectedLanguageModelContextValue {
9+
languageModels: LanguageModelInfo[];
10+
selectedLanguageModel: LanguageModelInfo | undefined;
11+
setSelectedLanguageModel: (model: LanguageModelInfo | undefined) => void;
12+
}
13+
14+
export const SelectedLanguageModelContext = createContext<SelectedLanguageModelContextValue | null>(null);
15+
16+
interface LanguageModelProviderProps {
17+
languageModels: LanguageModelInfo[];
18+
children: ReactNode;
19+
}
20+
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.
28+
export const LanguageModelProvider = ({
29+
languageModels,
30+
children,
31+
}: LanguageModelProviderProps) => {
32+
const fallbackLanguageModel = languageModels.length > 0 ? languageModels[0] : undefined;
33+
const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage<LanguageModelInfo | undefined>(
34+
"selectedLanguageModel",
35+
fallbackLanguageModel,
36+
{
37+
initializeWithValue: false,
38+
}
39+
);
40+
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+
73+
const value = useMemo<SelectedLanguageModelContextValue>(() => ({
74+
languageModels,
75+
selectedLanguageModel,
76+
setSelectedLanguageModel,
77+
}), [languageModels, selectedLanguageModel, setSelectedLanguageModel]);
78+
79+
return (
80+
<SelectedLanguageModelContext.Provider value={value}>
81+
{children}
82+
</SelectedLanguageModelContext.Provider>
83+
);
84+
};
Lines changed: 9 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,17 @@
11
'use client';
22

3-
import { useLocalStorage } from "usehooks-ts";
4-
import { LanguageModelInfo } from "./types";
5-
import { useEffect } from "react";
6-
import { getLanguageModelKey } from "./utils";
3+
import { useContext } from "react";
4+
import { SelectedLanguageModelContext } from "./languageModelContext";
75

8-
type Props = {
9-
languageModels: LanguageModelInfo[];
10-
}
11-
12-
export const useSelectedLanguageModel = ({
13-
languageModels,
14-
}: Props) => {
15-
const fallbackLanguageModel = languageModels.length > 0 ? languageModels[0] : undefined;
16-
const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage<LanguageModelInfo | undefined>(
17-
"selectedLanguageModel",
18-
fallbackLanguageModel,
19-
{
20-
initializeWithValue: false,
21-
}
22-
);
23-
24-
// Handle the case where the selected language model is no longer
25-
// available. Reset to the fallback language model in this case.
26-
useEffect(() => {
27-
if (!selectedLanguageModel || !languageModels.find(
28-
(model) => getLanguageModelKey(model) === getLanguageModelKey(selectedLanguageModel)
29-
)) {
30-
setSelectedLanguageModel(fallbackLanguageModel);
31-
}
32-
}, [
33-
fallbackLanguageModel,
34-
languageModels,
35-
selectedLanguageModel,
36-
setSelectedLanguageModel,
37-
]);
6+
export const useSelectedLanguageModel = () => {
7+
const context = useContext(SelectedLanguageModelContext);
8+
if (!context) {
9+
throw new Error("useSelectedLanguageModel must be used within a LanguageModelProvider");
10+
}
3811

12+
const { selectedLanguageModel, setSelectedLanguageModel } = context;
3913
return {
4014
selectedLanguageModel,
4115
setSelectedLanguageModel,
4216
};
43-
}
17+
};

0 commit comments

Comments
 (0)