Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ export const LandingPage = ({
}}
className="min-h-[50px]"
isRedirecting={isLoading}
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={[]}
isDisabled={isChatBoxDisabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export const LandingPageChatBox = ({
}}
className="min-h-[50px]"
isRedirecting={isLoading}
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts}
isDisabled={isChatBoxDisabled}
Expand Down
58 changes: 32 additions & 26 deletions packages/web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -175,39 +177,43 @@ export default async function Layout(props: LayoutProps) {
timeoutMs: 3000
});

const languageModels = await getConfiguredLanguageModelsInfo();

return (
<RoleProvider role={role}>
<HasLicenseProvider
hasLicense={offlineLicense !== null || license !== null}
>
<SyntaxGuideProvider>
<div className="fixed inset-0 flex bg-shell">
<SidebarProvider defaultOpen={cookieStore.get("sidebar_state")?.value !== "false"}>
{sidebar}
<div className="flex-1 min-h-0 flex flex-col pt-2 pb-2 pr-2 pl-2 md:pl-0">
<div className="flex-1 min-h-0 bg-background flex flex-col border border-[#e6e6e6] dark:border-[#1d1d1f] rounded-xl overflow-hidden">
<BannerHeightObserver>
<BannerSlot
role={role}
license={license}
offlineLicense={offlineLicense}
hasPermissionSyncEntitlement={hasPermissionSyncEntitlement}
hasPendingFirstSync={hasPendingFirstSync}
currentVersion={SOURCEBOT_VERSION}
latestVersion={latestVersion}
/>
</BannerHeightObserver>
<div className="flex-1 min-h-0 overflow-y-scroll [scrollbar-gutter:stable]">
{children}
<LanguageModelProvider languageModels={languageModels}>
<SyntaxGuideProvider>
<div className="fixed inset-0 flex bg-shell">
<SidebarProvider defaultOpen={cookieStore.get("sidebar_state")?.value !== "false"}>
{sidebar}
<div className="flex-1 min-h-0 flex flex-col pt-2 pb-2 pr-2 pl-2 md:pl-0">
<div className="flex-1 min-h-0 bg-background flex flex-col border border-[#e6e6e6] dark:border-[#1d1d1f] rounded-xl overflow-hidden">
<BannerHeightObserver>
<BannerSlot
role={role}
license={license}
offlineLicense={offlineLicense}
hasPermissionSyncEntitlement={hasPermissionSyncEntitlement}
hasPendingFirstSync={hasPendingFirstSync}
currentVersion={SOURCEBOT_VERSION}
latestVersion={latestVersion}
/>
</BannerHeightObserver>
<div className="flex-1 min-h-0 overflow-y-scroll [scrollbar-gutter:stable]">
{children}
</div>
</div>
</div>
</div>
</SidebarProvider>
</div>
<SyntaxReferenceGuide />
<GitHubStarToast />
<CheckoutReturnHandler />
</SyntaxGuideProvider>
</SidebarProvider>
</div>
<SyntaxReferenceGuide />
<GitHubStarToast />
<CheckoutReturnHandler />
</SyntaxGuideProvider>
</LanguageModelProvider>
</HasLicenseProvider>
</RoleProvider>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -478,7 +476,6 @@ export const ChatThread = ({
isTurnInProgress={isTurnInProgress}
isNetworkActive={isNetworkActive}
onStop={stop}
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts}
isDisabled={languageModels.length === 0}
Expand Down
8 changes: 2 additions & 6 deletions packages/web/src/features/chat/components/chatBox/chatBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -37,7 +37,6 @@ interface ChatBoxProps {
isTurnInProgress?: boolean;
isNetworkActive?: boolean;
isDisabled?: boolean;
languageModels: LanguageModelInfo[];
selectedSearchScopes: SearchScope[];
searchContexts: SearchContextQuery[];
isLoginWallEnabled: boolean;
Expand All @@ -55,7 +54,6 @@ const ChatBoxComponent = ({
isDisabled,
isLoginWallEnabled,
isAuthenticated,
languageModels,
selectedSearchScopes,
searchContexts,
}: ChatBoxProps) => {
Expand All @@ -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<boolean>(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ export const ChatBoxToolbar = ({
onDisabledMcpServerIdsChange,
isAuthenticated,
}: ChatBoxToolbarProps) => {
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel({
languageModels,
});
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel();

return (
<>
Expand Down
84 changes: 84 additions & 0 deletions packages/web/src/features/chat/languageModelContext.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectedLanguageModelContextValue | null>(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<LanguageModelInfo | undefined>(
"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<SelectedLanguageModelContextValue>(() => ({
languageModels,
selectedLanguageModel,
setSelectedLanguageModel,
}), [languageModels, selectedLanguageModel, setSelectedLanguageModel]);

return (
<SelectedLanguageModelContext.Provider value={value}>
{children}
</SelectedLanguageModelContext.Provider>
);
};
44 changes: 9 additions & 35 deletions packages/web/src/features/chat/useSelectedLanguageModel.ts
Original file line number Diff line number Diff line change
@@ -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<LanguageModelInfo | undefined>(
"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,
};
}
};
Loading