From f33036015957c12ca45634e98b8569c2932b54e6 Mon Sep 17 00:00:00 2001
From: Doron Shoham <63230079+shohamd4@users.noreply.github.com>
Date: Thu, 28 Aug 2025 12:19:26 +0300
Subject: [PATCH 1/2] feat(web): Add configurable default search mode setting
Resolves #471 by allowing organizations to set their default search mode
to "Code Search" instead of "Ask" to prevent accidental token consumption.
- Add defaultSearchMode field to orgMetadata schema with "precise" | "agentic" enum
- Implement getDefaultSearchMode and setDefaultSearchMode server actions with owner-only permissions
- Create DefaultSearchModeCard component with validation for language model availability
- Integrate org default with cookie-based user preference fallback chain
- Add audit logging for setting changes and proper error handling
---
packages/web/src/actions.ts | 83 ++++++++++++++-
packages/web/src/app/[domain]/page.tsx | 12 ++-
.../components/defaultSearchModeCard.tsx | 100 ++++++++++++++++++
.../app/[domain]/settings/(general)/page.tsx | 18 +++-
packages/web/src/types.ts | 1 +
5 files changed, 208 insertions(+), 6 deletions(-)
create mode 100644 packages/web/src/app/[domain]/settings/(general)/components/defaultSearchModeCard.tsx
diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts
index b23f3ae2a..b4b4832c2 100644
--- a/packages/web/src/actions.ts
+++ b/packages/web/src/actions.ts
@@ -2161,6 +2161,87 @@ 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 () => {
+ 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 (mode === "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 mergedMetadata = {
+ ...(currentMetadata ?? {}),
+ defaultSearchMode: mode,
+ };
+
+ 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: {
+ defaultSearchMode: mode
+ }
+ });
+
+ return {
+ success: true,
+ };
+ }, /* minRequiredRole = */ OrgRole.OWNER);
+ });
+});
+
////// Helpers ///////
const parseConnectionConfig = (config: string) => {
@@ -2266,4 +2347,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..38f782e53 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,18 @@ 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;
+
+ // 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 : orgDefaultMode;
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..5b0c2c3e3
--- /dev/null
+++ b/packages/web/src/app/[domain]/settings/(general)/components/defaultSearchModeCard.tsx
@@ -0,0 +1,100 @@
+'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' || !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);
+ toast({
+ title: "Failed to update",
+ description: "An error occurred while updating the default search mode.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsUpdating(false);
+ }
+ };
+
+ return (
+