diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts
index b23f3ae2a..17a9adf92 100644
--- a/packages/web/src/actions.ts
+++ b/packages/web/src/actions.ts
@@ -40,6 +40,7 @@ import { getAuditService } from "@/ee/features/audit/factory";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { getOrgMetadata } from "@/lib/utils";
import { getOrgFromDomain } from "./data/org";
+import { searchModeSchema } from "@/types";
const ajv = new Ajv({
validateFormats: false,
@@ -2161,6 +2162,100 @@ export async function setAgenticSearchTutorialDismissedCookie(dismissed: boolean
});
}
+export const getDefaultSearchMode = async (domain: string): Promise<"precise" | "agentic" | ServiceError> => sew(async () => {
+ const org = await getOrgFromDomain(domain);
+ if (!org) {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.NOT_FOUND,
+ message: "Organization not found",
+ } satisfies ServiceError;
+ }
+
+ // If no metadata is set, return default (precise)
+ if (org.metadata === null) {
+ return "precise";
+ }
+
+ const orgMetadata = getOrgMetadata(org);
+ if (!orgMetadata) {
+ return {
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
+ errorCode: ErrorCode.INVALID_ORG_METADATA,
+ message: "Invalid organization metadata",
+ } satisfies ServiceError;
+ }
+
+ return orgMetadata.defaultSearchMode ?? "precise";
+});
+
+export const setDefaultSearchMode = async (domain: string, mode: "precise" | "agentic"): Promise<{ success: boolean } | ServiceError> => sew(async () => {
+ // Runtime validation to guard server action from invalid input
+ const parsed = searchModeSchema.safeParse(mode);
+ if (!parsed.success) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "Invalid default search mode",
+ } satisfies ServiceError;
+ }
+ const validatedMode = parsed.data;
+
+ return await withAuth(async (userId) => {
+ return await withOrgMembership(userId, domain, async ({ org }) => {
+ // Validate that agentic mode is not being set when no language models are configured
+ if (validatedMode === "agentic") {
+ const { getConfiguredLanguageModelsInfo } = await import("@/features/chat/actions");
+ const languageModels = await getConfiguredLanguageModelsInfo();
+ if (languageModels.length === 0) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.INVALID_REQUEST_BODY,
+ message: "Cannot set Ask mode as default when no language models are configured",
+ } satisfies ServiceError;
+ }
+ }
+
+ const currentMetadata = getOrgMetadata(org);
+ const previousMode = currentMetadata?.defaultSearchMode ?? "precise";
+ const mergedMetadata = {
+ ...(currentMetadata ?? {}),
+ defaultSearchMode: validatedMode,
+ };
+
+ await prisma.org.update({
+ where: {
+ id: org.id,
+ },
+ data: {
+ metadata: mergedMetadata,
+ },
+ });
+
+ await auditService.createAudit({
+ action: "org.settings.default_search_mode_updated",
+ actor: {
+ id: userId,
+ type: "user"
+ },
+ target: {
+ id: org.id.toString(),
+ type: "org"
+ },
+ orgId: org.id,
+ metadata: {
+ previousDefaultSearchMode: previousMode,
+ newDefaultSearchMode: validatedMode
+ }
+ });
+
+ return {
+ success: true,
+ };
+ }, /* minRequiredRole = */ OrgRole.OWNER);
+ });
+});
+
////// Helpers ///////
const parseConnectionConfig = (config: string) => {
@@ -2266,4 +2361,4 @@ export const encryptValue = async (value: string) => {
export const decryptValue = async (iv: string, encryptedValue: string) => {
return decrypt(iv, encryptedValue);
-}
\ No newline at end of file
+}
diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx
index 09bf65a9f..27c79cab9 100644
--- a/packages/web/src/app/[domain]/page.tsx
+++ b/packages/web/src/app/[domain]/page.tsx
@@ -1,4 +1,4 @@
-import { getRepos, getSearchContexts } from "@/actions";
+import { getDefaultSearchMode, getRepos, getSearchContexts } from "@/actions";
import { Footer } from "@/app/components/footer";
import { getOrgFromDomain } from "@/data/org";
import { getConfiguredLanguageModelsInfo, getUserChatHistory } from "@/features/chat/actions";
@@ -48,14 +48,21 @@ export default async function Home(props: { params: Promise<{ domain: string }>
const indexedRepos = repos.filter((repo) => repo.indexedAt !== undefined);
- // Read search mode from cookie, defaulting to agentic if not set
- // (assuming a language model is configured).
+ // Get org's default search mode
+ const defaultSearchMode = await getDefaultSearchMode(domain);
+ // If there was an error or no setting found, default to precise (search)
+ const orgDefaultMode = isServiceError(defaultSearchMode) ? "precise" : defaultSearchMode;
+ const effectiveOrgDefaultMode =
+ orgDefaultMode === "agentic" && models.length === 0 ? "precise" : orgDefaultMode;
+
+ // Read search mode from cookie, defaulting to the org's default setting if not set
const cookieStore = await cookies();
const searchModeCookie = cookieStore.get(SEARCH_MODE_COOKIE_NAME);
const initialSearchMode = (
searchModeCookie?.value === "agentic" ||
searchModeCookie?.value === "precise"
- ) ? searchModeCookie.value : models.length > 0 ? "agentic" : "precise";
+ ) ? ((searchModeCookie.value === "agentic" && models.length === 0) ? "precise" : searchModeCookie.value)
+ : effectiveOrgDefaultMode;
const isAgenticSearchTutorialDismissed = cookieStore.get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true";
diff --git a/packages/web/src/app/[domain]/settings/(general)/components/defaultSearchModeCard.tsx b/packages/web/src/app/[domain]/settings/(general)/components/defaultSearchModeCard.tsx
new file mode 100644
index 000000000..1a0af564c
--- /dev/null
+++ b/packages/web/src/app/[domain]/settings/(general)/components/defaultSearchModeCard.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { setDefaultSearchMode } from "@/actions";
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { LoadingButton } from "@/components/ui/loading-button";
+import { OrgRole } from "@sourcebot/db";
+import { MessageCircleIcon, SearchIcon } from "lucide-react";
+import { useState } from "react";
+import { useParams } from "next/navigation";
+import { useToast } from "@/components/hooks/use-toast";
+
+interface DefaultSearchModeCardProps {
+ initialDefaultMode: "precise" | "agentic";
+ currentUserRole: OrgRole;
+ isAskModeAvailable: boolean;
+}
+
+export const DefaultSearchModeCard = ({ initialDefaultMode, currentUserRole, isAskModeAvailable }: DefaultSearchModeCardProps) => {
+ const { domain } = useParams<{ domain: string }>();
+ // If Ask mode is not available and the initial mode is agentic, force it to precise
+ const effectiveInitialMode = !isAskModeAvailable && initialDefaultMode === "agentic" ? "precise" : initialDefaultMode;
+ const [defaultSearchMode, setDefaultSearchModeState] = useState<"precise" | "agentic">(effectiveInitialMode);
+ const [isUpdating, setIsUpdating] = useState(false);
+ const isReadOnly = currentUserRole !== OrgRole.OWNER;
+ const { toast } = useToast();
+
+ const handleUpdateDefaultSearchMode = async () => {
+ if (isReadOnly) {
+ return;
+ }
+
+ setIsUpdating(true);
+ try {
+ const result = await setDefaultSearchMode(domain as string, defaultSearchMode);
+ if (!result || typeof result !== 'object') {
+ throw new Error('Unexpected response');
+ }
+ // If this is a ServiceError, surface its message
+ if ('statusCode' in result && 'errorCode' in result && 'message' in result) {
+ toast({
+ title: "Failed to update",
+ description: result.message,
+ variant: "destructive",
+ });
+ return;
+ }
+ if (!result.success) {
+ throw new Error('Failed to update default search mode');
+ }
+ toast({
+ title: "Default search mode updated",
+ description: `Default search mode has been set to ${defaultSearchMode === "agentic" ? "Ask" : "Code Search"}.`,
+ variant: "success",
+ });
+ } catch (error) {
+ console.error('Error updating default search mode:', error);
+ // If we already showed a specific error above, do nothing here; otherwise fallback
+ if (!(error instanceof Error && /Unexpected response/.test(error.message))) {
+ toast({
+ title: "Failed to update",
+ description: "An error occurred while updating the default search mode.",
+ variant: "destructive",
+ });
+ }
+ } finally {
+ setIsUpdating(false);
+ }
+ };
+
+ return (
+