diff --git a/CHANGELOG.md b/CHANGELOG.md index 764582a76..bd94d0181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `ws` to `^8.20.1`. [#1286](https://github.com/sourcebot-dev/sourcebot/pull/1286) - Upgraded `hono` to `^4.12.24`. [#1289](https://github.com/sourcebot-dev/sourcebot/pull/1289) - Surfaced an actionable error when the Lighthouse licensing service is unreachable, instead of a generic "unexpected error". [#1293](https://github.com/sourcebot-dev/sourcebot/pull/1293) +- Fixed the selected language model rapidly flipping in local storage after a language model was removed. [#1295](https://github.com/sourcebot-dev/sourcebot/pull/1295) ## [5.0.1] - 2026-06-04 diff --git a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx index ee6bb22b2..f18ea5c74 100644 --- a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx @@ -75,7 +75,6 @@ export const LandingPage = ({ }} className="min-h-[50px]" isRedirecting={isLoading} - languageModels={languageModels} selectedSearchScopes={selectedSearchScopes} searchContexts={[]} isDisabled={isChatBoxDisabled} diff --git a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx index 0d4bdd3df..ed749450f 100644 --- a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx +++ b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx @@ -42,7 +42,6 @@ export const LandingPageChatBox = ({ }} className="min-h-[50px]" isRedirecting={isLoading} - languageModels={languageModels} selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} isDisabled={isChatBoxDisabled} diff --git a/packages/web/src/app/(app)/layout.tsx b/packages/web/src/app/(app)/layout.tsx index 3ea8d56dd..8563e686b 100644 --- a/packages/web/src/app/(app)/layout.tsx +++ b/packages/web/src/app/(app)/layout.tsx @@ -36,6 +36,8 @@ import { CheckoutReturnHandler } from "@/features/billing/checkoutReturnHandler" import { RoleProvider } from "@/features/auth/roleProvider"; import { HasLicenseProvider } from "@/features/billing/hasLicenseProvider"; import { tryGetLatestSourcebotTag } from "./components/banners/actions"; +import { LanguageModelProvider } from "@/features/chat/languageModelContext"; +import { getConfiguredLanguageModelsInfo } from "@/features/chat/utils.server"; interface LayoutProps { children: React.ReactNode; @@ -175,39 +177,43 @@ export default async function Layout(props: LayoutProps) { timeoutMs: 3000 }); + const languageModels = await getConfiguredLanguageModelsInfo(); + return ( - -
- - {sidebar} -
-
- - - -
- {children} + + +
+ + {sidebar} +
+
+ + + +
+ {children} +
-
- -
- - - - + +
+ + + + + ) diff --git a/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx b/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx index bbdb4a9e2..9ceae4311 100644 --- a/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx +++ b/packages/web/src/ee/features/chat/components/chatThread/chatThread.tsx @@ -115,9 +115,7 @@ export const ChatThread = ({ }); const [isFailedMcpBannerVisible, setIsFailedMcpBannerVisible] = useState(false); - const { selectedLanguageModel } = useSelectedLanguageModel({ - languageModels, - }); + const { selectedLanguageModel } = useSelectedLanguageModel(); // Refs to capture the latest request params for the transport body. // The transport is created once (useMemo) but params change over time, @@ -478,7 +476,6 @@ export const ChatThread = ({ isTurnInProgress={isTurnInProgress} isNetworkActive={isNetworkActive} onStop={stop} - languageModels={languageModels} selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} isDisabled={languageModels.length === 0} diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index ed9f46153..e405e8266 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -3,7 +3,7 @@ import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types"; +import { CustomEditor, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types"; import { insertMention, slateContentToString } from "@/features/chat/utils"; import { cn } from "@/lib/utils"; import { useIsMac } from "@/hooks/useIsMac"; @@ -37,7 +37,6 @@ interface ChatBoxProps { isTurnInProgress?: boolean; isNetworkActive?: boolean; isDisabled?: boolean; - languageModels: LanguageModelInfo[]; selectedSearchScopes: SearchScope[]; searchContexts: SearchContextQuery[]; isLoginWallEnabled: boolean; @@ -55,7 +54,6 @@ const ChatBoxComponent = ({ isDisabled, isLoginWallEnabled, isAuthenticated, - languageModels, selectedSearchScopes, searchContexts, }: ChatBoxProps) => { @@ -82,9 +80,7 @@ const ChatBoxComponent = ({ return []; }).flat(), }); - const { selectedLanguageModel } = useSelectedLanguageModel({ - languageModels, - }); + const { selectedLanguageModel } = useSelectedLanguageModel(); const { toast } = useToast(); const isAskEnabled = useHasEntitlement('ask'); const [isLoginDialogOpen, setIsLoginDialogOpen] = useState(false); diff --git a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx index 4e1913d89..4f8f48e3a 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBoxToolbar.tsx @@ -34,9 +34,7 @@ export const ChatBoxToolbar = ({ onDisabledMcpServerIdsChange, isAuthenticated, }: ChatBoxToolbarProps) => { - const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({ - languageModels, - }); + const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel(); return ( <> diff --git a/packages/web/src/features/chat/languageModelContext.tsx b/packages/web/src/features/chat/languageModelContext.tsx new file mode 100644 index 000000000..aca76d846 --- /dev/null +++ b/packages/web/src/features/chat/languageModelContext.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { createContext, useEffect, useMemo, type ReactNode } from "react"; +import { useLocalStorage } from "usehooks-ts"; +import { LanguageModelInfo } from "./types"; +import { getLanguageModelKey } from "./utils"; + +export interface SelectedLanguageModelContextValue { + languageModels: LanguageModelInfo[]; + selectedLanguageModel: LanguageModelInfo | undefined; + setSelectedLanguageModel: (model: LanguageModelInfo | undefined) => void; +} + +export const SelectedLanguageModelContext = createContext(null); + +interface LanguageModelProviderProps { + languageModels: LanguageModelInfo[]; + children: ReactNode; +} + +// Single owner of the selected-language-model state. Mounted once (in the (app) +// layout), so the selection lives in one place instead of being re-derived by a +// `useSelectedLanguageModel` hook in every consumer. Previously each consumer +// ran its own reset effect against the shared "selectedLanguageModel" +// localStorage key, and because 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. +export const LanguageModelProvider = ({ + languageModels, + children, +}: LanguageModelProviderProps) => { + const fallbackLanguageModel = languageModels.length > 0 ? languageModels[0] : undefined; + const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage( + "selectedLanguageModel", + fallbackLanguageModel, + { + initializeWithValue: false, + } + ); + + // Handle the case where the selected language model is no longer available. + // Reset to the fallback language model in this case. Only write when the + // resolved selection actually differs (compared by key, since the stored + // value is a fresh object reference on every read) — otherwise the effect + // would re-write on every render. + useEffect(() => { + const selectedKey = selectedLanguageModel + ? getLanguageModelKey(selectedLanguageModel) + : undefined; + + const isSelectedModelAvailable = selectedKey !== undefined && languageModels.some( + (model) => getLanguageModelKey(model) === selectedKey + ); + + if (isSelectedModelAvailable) { + return; + } + + const fallbackKey = fallbackLanguageModel + ? getLanguageModelKey(fallbackLanguageModel) + : undefined; + + if (fallbackKey !== selectedKey) { + setSelectedLanguageModel(fallbackLanguageModel); + } + }, [ + fallbackLanguageModel, + languageModels, + selectedLanguageModel, + setSelectedLanguageModel, + ]); + + const value = useMemo(() => ({ + languageModels, + selectedLanguageModel, + setSelectedLanguageModel, + }), [languageModels, selectedLanguageModel, setSelectedLanguageModel]); + + return ( + + {children} + + ); +}; diff --git a/packages/web/src/features/chat/useSelectedLanguageModel.ts b/packages/web/src/features/chat/useSelectedLanguageModel.ts index a22b59400..8983ccf52 100644 --- a/packages/web/src/features/chat/useSelectedLanguageModel.ts +++ b/packages/web/src/features/chat/useSelectedLanguageModel.ts @@ -1,43 +1,17 @@ 'use client'; -import { useLocalStorage } from "usehooks-ts"; -import { LanguageModelInfo } from "./types"; -import { useEffect } from "react"; -import { getLanguageModelKey } from "./utils"; +import { useContext } from "react"; +import { SelectedLanguageModelContext } from "./languageModelContext"; -type Props = { - languageModels: LanguageModelInfo[]; -} - -export const useSelectedLanguageModel = ({ - languageModels, -}: Props) => { - const fallbackLanguageModel = languageModels.length > 0 ? languageModels[0] : undefined; - const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage( - "selectedLanguageModel", - fallbackLanguageModel, - { - initializeWithValue: false, - } - ); - - // Handle the case where the selected language model is no longer - // available. Reset to the fallback language model in this case. - useEffect(() => { - if (!selectedLanguageModel || !languageModels.find( - (model) => getLanguageModelKey(model) === getLanguageModelKey(selectedLanguageModel) - )) { - setSelectedLanguageModel(fallbackLanguageModel); - } - }, [ - fallbackLanguageModel, - languageModels, - selectedLanguageModel, - setSelectedLanguageModel, - ]); +export const useSelectedLanguageModel = () => { + const context = useContext(SelectedLanguageModelContext); + if (!context) { + throw new Error("useSelectedLanguageModel must be used within a LanguageModelProvider"); + } + const { selectedLanguageModel, setSelectedLanguageModel } = context; return { selectedLanguageModel, setSelectedLanguageModel, }; -} \ No newline at end of file +};