Skip to content
Open
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 @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed
- Silenced a false-positive `MaxListenersExceededWarning` logged on every request proxied through an external rewrite. [#1697](https://github.com/sourcebot-dev/sourcebot/pull/1697)
- Fixed Ask model selections reverting to a stale default after the model configuration changed during a browser session. [#1710](https://github.com/sourcebot-dev/sourcebot/pull/1710)

## [5.1.15] - 2026-09-29

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const LandingPage = ({
}}
className="min-h-[50px]"
isRedirecting={isLoading}
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={[]}
askCommands={askCommands}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const LandingPageChatBox = ({
}}
className="min-h-[50px]"
isRedirecting={isLoading}
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts}
askCommands={askCommands}
Expand Down
5 changes: 1 addition & 4 deletions packages/web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ 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";
import { NavigationGuardProvider } from "next-navigation-guard";
import { getRepositorySyncCounts } from "@/features/repos/repositorySyncCounts.server";
import { getConnectionSyncCounts } from "@/features/connections/connectionSyncCounts.server";
Expand Down Expand Up @@ -183,15 +182,13 @@ export default async function Layout(props: LayoutProps) {
timeoutMs: 3000
});

const languageModels = await getConfiguredLanguageModelsInfo();

return (
<AccountLinkingGuard callbackUrl="/">
<RoleProvider role={role}>
<HasLicenseProvider
hasLicense={offlineLicense !== null || license !== null}
>
<LanguageModelProvider languageModels={languageModels}>
<LanguageModelProvider>
<SyntaxGuideProvider>
{/* Keep one guard provider above both sidebar and content so browser history is tracked before guarded routes mount. */}
<NavigationGuardProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export const ChatThread = ({
const [failedMcpServers, setFailedMcpServers] = useState<McpServerLoadFailureData[]>([]);
const [isFailedMcpBannerVisible, setIsFailedMcpBannerVisible] = useState(false);

const { selectedLanguageModel } = useSelectedLanguageModel();
const { selectedLanguageModel } = useSelectedLanguageModel(languageModels);

// 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 @@ -566,6 +566,7 @@ export const ChatThread = ({
isTurnInProgress={isTurnInProgress}
isNetworkActive={isNetworkActive}
onStop={stop}
languageModels={languageModels}
selectedSearchScopes={selectedSearchScopes}
searchContexts={searchContexts}
askCommands={askCommands}
Expand Down
6 changes: 4 additions & 2 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 { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
import { FileMentionComponent, MentionChip } from "@/features/chat/components/mentionChip";
import { AttachmentData, CustomEditor, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types";
import { AttachmentData, CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor, SearchScope } from "@/features/chat/types";
import { insertMention, slateContentToString } from "@/features/chat/utils";
import { createPastedTextAttachment, getSubmittedTextBytes, PendingAttachment, PendingImageAttachment, readFilesAsAttachments, shouldAutoConvertPaste, toAttachmentData, uploadImageAttachment } from "@/features/chat/attachmentUtils";
import { AttachmentButton } from "./attachmentButton";
Expand Down Expand Up @@ -56,6 +56,7 @@ interface ChatBoxProps {
isTurnInProgress?: boolean;
isNetworkActive?: boolean;
isDisabled?: boolean;
languageModels: LanguageModelInfo[];
selectedSearchScopes: SearchScope[];
searchContexts: SearchContextQuery[];
askCommands: AskCommandDefinition[];
Expand All @@ -78,6 +79,7 @@ const ChatBoxComponent = ({
isDisabled,
isLoginWallEnabled,
isAuthenticated,
languageModels,
selectedSearchScopes,
searchContexts,
maxImageBytes = ATTACHMENT_MAX_IMAGE_BYTES,
Expand Down Expand Up @@ -107,7 +109,7 @@ const ChatBoxComponent = ({
}).flat(),
askCommands,
});
const { selectedLanguageModel } = useSelectedLanguageModel();
const { selectedLanguageModel } = useSelectedLanguageModel(languageModels);
const { toast } = useToast();
const isMac = useIsMac();
const isAskEnabled = useHasEntitlement('ask');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const ChatBoxToolbar = ({
onDisabledMcpServerIdsChange,
isAuthenticated,
}: ChatBoxToolbarProps) => {
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel();
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel(languageModels);

return (
<>
Expand Down
61 changes: 10 additions & 51 deletions packages/web/src/features/chat/languageModelContext.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,39 @@
'use client';

import { createContext, useEffect, useMemo, type ReactNode } from "react";
import { createContext, 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;
storedLanguageModel: 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.
// Single owner of the persisted model preference, mounted in the (app) layout.
// It must not hold the configured model list: layouts do not re-render on
// client-side navigation, so that list would go stale after a config change.
// `useSelectedLanguageModel` resolves the preference against the page's list.
export const LanguageModelProvider = ({
languageModels,
children,
}: LanguageModelProviderProps) => {
const fallbackLanguageModel = languageModels.length > 0 ? languageModels[0] : undefined;
const [selectedLanguageModel, setSelectedLanguageModel] = useLocalStorage<LanguageModelInfo | undefined>(
const [storedLanguageModel, setSelectedLanguageModel] = useLocalStorage<LanguageModelInfo | undefined>(
"selectedLanguageModel",
fallbackLanguageModel,
undefined,
{
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,
storedLanguageModel,
setSelectedLanguageModel,
}), [languageModels, selectedLanguageModel, setSelectedLanguageModel]);
}), [storedLanguageModel, setSelectedLanguageModel]);

return (
<SelectedLanguageModelContext.Provider value={value}>
Expand Down
173 changes: 173 additions & 0 deletions packages/web/src/features/chat/useSelectedLanguageModel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
import { LanguageModelSelector } from "./components/chatBox/languageModelSelector";
import { LanguageModelProvider } from "./languageModelContext";
import { LanguageModelInfo } from "./types";
import { useSelectedLanguageModel } from "./useSelectedLanguageModel";

vi.mock("./components/chatBox/modelProviderLogo", () => ({
ModelProviderLogo: () => null,
}));

const STORAGE_KEY = "selectedLanguageModel";

const makeModel = (model: string, overrides: Partial<LanguageModelInfo> = {}): LanguageModelInfo => ({
provider: "anthropic",
model,
inputModalities: ["text"],
supportedDocumentTypes: [],
...overrides,
});

const oldModels = [makeModel("old-default"), makeModel("old-other")];
const newModels = [makeModel("new-default"), makeModel("new-other")];

// Mirrors a chat page: the toolbar selector and the chat thread each resolve
// the selection against the page's model list.
const Toolbar = ({ languageModels }: { languageModels: LanguageModelInfo[] }) => {
const { selectedLanguageModel, setSelectedLanguageModel } = useSelectedLanguageModel(languageModels);
return (
<LanguageModelSelector
languageModels={languageModels}
selectedModel={selectedLanguageModel}
onSelectedModelChange={setSelectedLanguageModel}
/>
);
};

const Thread = ({ languageModels }: { languageModels: LanguageModelInfo[] }) => {
const { selectedLanguageModel } = useSelectedLanguageModel(languageModels);
return (
<output data-testid="submitted-model" data-modalities={selectedLanguageModel?.inputModalities.join(",")}>
{selectedLanguageModel?.model ?? "none"}
</output>
);
};

const ChatPage = ({ languageModels }: { languageModels: LanguageModelInfo[] }) => (
<>
<Toolbar languageModels={languageModels} />
<Thread languageModels={languageModels} />
</>
);

// The provider is mounted once by the (app) layout and survives client-side
// navigation, while each page renders with a freshly loaded model list.
const renderSession = (languageModels: LanguageModelInfo[]) => {
const result = render(
<LanguageModelProvider>
<ChatPage languageModels={languageModels} />
</LanguageModelProvider>
);

return {
navigateWithModels: (nextModels: LanguageModelInfo[]) => result.rerender(
<LanguageModelProvider>
<ChatPage languageModels={nextModels} />
</LanguageModelProvider>
),
};
};

const selectModel = (currentModel: string, nextModel: string) => {
fireEvent.click(screen.getByRole("button", { name: currentModel }));
fireEvent.click(screen.getByRole("option", { name: nextModel }));
};

const getStoredModel = () => {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as LanguageModelInfo).model : undefined;
};

beforeAll(() => {
// cmdk relies on these browser APIs, which jsdom does not implement.
globalThis.ResizeObserver ??= class {
observe() {}
unobserve() {}
disconnect() {}
};
Element.prototype.scrollIntoView ??= () => {};
});

// Node 25 ships a global localStorage that shadows jsdom's, so install a fresh
// in-memory store for each test.
const installMockLocalStorage = () => {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
key: (index: number) => Array.from(store.keys())[index] ?? null,
removeItem: (key: string) => {
store.delete(key);
},
setItem: (key: string, value: string) => {
store.set(key, value);
},
};
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
};

beforeEach(() => {
installMockLocalStorage();
});

afterEach(() => {
cleanup();
});

describe("useSelectedLanguageModel", () => {
test("restores the stored model on mount", () => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(oldModels[1]));

renderSession(oldModels);

expect(screen.getByRole("button", { name: "old-other" })).toBeTruthy();
expect(screen.getByTestId("submitted-model").textContent).toBe("old-other");
});

test("keeps a newly configured model selected after the config changes mid-session", () => {
const session = renderSession(oldModels);
session.navigateWithModels(newModels);

selectModel("new-default", "new-other");

expect(screen.getByRole("button", { name: "new-other" })).toBeTruthy();
expect(screen.getByTestId("submitted-model").textContent).toBe("new-other");
expect(getStoredModel()).toBe("new-other");
});

test("converges on the first configured model when the selected model is removed", () => {
const session = renderSession(oldModels);
selectModel("old-default", "old-other");
expect(getStoredModel()).toBe("old-other");

session.navigateWithModels(newModels);

expect(screen.getByRole("button", { name: "new-default" })).toBeTruthy();
expect(screen.getByTestId("submitted-model").textContent).toBe("new-default");
// Reconciliation is derived, not written back, so consumers cannot
// trigger each other into a storage write loop.
expect(getStoredModel()).toBe("old-other");
});

test("uses the current config's capabilities for a stored model", () => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(oldModels[1]));
const session = renderSession(oldModels);

session.navigateWithModels([oldModels[0], makeModel("old-other", { inputModalities: ["text", "image"] })]);

expect(screen.getByTestId("submitted-model").dataset.modalities).toBe("text,image");
});

test("selects nothing when no models are configured", () => {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(oldModels[0]));

renderSession([]);

expect(screen.getByTestId("submitted-model").textContent).toBe("none");
});
});
Loading
Loading