Просмотр исходного кода

Indexing field validation (#5483)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Daniel Riccio <[email protected]>
Co-authored-by: Daniel <[email protected]>
Murilo Pires 5 месяцев назад
Родитель
Сommit
fa60a31578

+ 7 - 11
src/services/code-index/embedders/__tests__/ollama.spec.ts

@@ -127,7 +127,7 @@ describe("CodeIndexOllamaEmbedder", () => {
 			const result = await embedder.validateConfiguration()
 
 			expect(result.valid).toBe(false)
-			expect(result.error).toBe("Connection to Ollama timed out at http://localhost:11434")
+			expect(result.error).toBe("embeddings:ollama.serviceNotRunning")
 		})
 
 		it("should fail validation when tags endpoint returns 404", async () => {
@@ -141,9 +141,7 @@ describe("CodeIndexOllamaEmbedder", () => {
 			const result = await embedder.validateConfiguration()
 
 			expect(result.valid).toBe(false)
-			expect(result.error).toBe(
-				"Ollama service is not running at http://localhost:11434. Please start Ollama first.",
-			)
+			expect(result.error).toBe("embeddings:ollama.serviceNotRunning")
 		})
 
 		it("should fail validation when tags endpoint returns other error", async () => {
@@ -157,7 +155,7 @@ describe("CodeIndexOllamaEmbedder", () => {
 			const result = await embedder.validateConfiguration()
 
 			expect(result.valid).toBe(false)
-			expect(result.error).toBe("Ollama service is unavailable at http://localhost:11434. HTTP status: 500")
+			expect(result.error).toBe("embeddings:ollama.serviceUnavailable")
 		})
 
 		it("should fail validation when model does not exist", async () => {
@@ -176,9 +174,7 @@ describe("CodeIndexOllamaEmbedder", () => {
 			const result = await embedder.validateConfiguration()
 
 			expect(result.valid).toBe(false)
-			expect(result.error).toBe(
-				"Model 'nomic-embed-text' not found. Available models: llama2:latest, mistral:latest",
-			)
+			expect(result.error).toBe("embeddings:ollama.modelNotFound")
 		})
 
 		it("should fail validation when model exists but doesn't support embeddings", async () => {
@@ -205,7 +201,7 @@ describe("CodeIndexOllamaEmbedder", () => {
 			const result = await embedder.validateConfiguration()
 
 			expect(result.valid).toBe(false)
-			expect(result.error).toBe("Model 'nomic-embed-text' is not embedding capable")
+			expect(result.error).toBe("embeddings:ollama.modelNotEmbeddingCapable")
 		})
 
 		it("should handle ECONNREFUSED errors", async () => {
@@ -214,7 +210,7 @@ describe("CodeIndexOllamaEmbedder", () => {
 			const result = await embedder.validateConfiguration()
 
 			expect(result.valid).toBe(false)
-			expect(result.error).toBe("Connection to Ollama timed out at http://localhost:11434")
+			expect(result.error).toBe("embeddings:ollama.serviceNotRunning")
 		})
 
 		it("should handle ENOTFOUND errors", async () => {
@@ -223,7 +219,7 @@ describe("CodeIndexOllamaEmbedder", () => {
 			const result = await embedder.validateConfiguration()
 
 			expect(result.valid).toBe(false)
-			expect(result.error).toBe("Ollama host not found: http://localhost:11434")
+			expect(result.error).toBe("embeddings:ollama.hostNotFound")
 		})
 
 		it("should handle generic network errors", async () => {

+ 32 - 10
src/services/code-index/embedders/ollama.ts

@@ -56,6 +56,11 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
 		try {
 			// Note: Standard Ollama API uses 'prompt' for single text, not 'input' for array.
 			// Implementing based on user's specific request structure.
+
+			// Add timeout to prevent indefinite hanging
+			const controller = new AbortController()
+			const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout
+
 			const response = await fetch(url, {
 				method: "POST",
 				headers: {
@@ -65,7 +70,9 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
 					model: modelToUse,
 					input: processedTexts, // Using 'input' as requested
 				}),
+				signal: controller.signal,
 			})
+			clearTimeout(timeoutId)
 
 			if (!response.ok) {
 				let errorBody = t("embeddings:ollama.couldNotReadErrorBody")
@@ -97,6 +104,16 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
 		} catch (error: any) {
 			// Log the original error for debugging purposes
 			console.error("Ollama embedding failed:", error)
+
+			// Handle specific error types with better messages
+			if (error.name === "AbortError") {
+				throw new Error(t("embeddings:validation.connectionFailed"))
+			} else if (error.message?.includes("fetch failed") || error.code === "ECONNREFUSED") {
+				throw new Error(t("embeddings:ollama.serviceNotRunning", { baseUrl: this.baseUrl }))
+			} else if (error.code === "ENOTFOUND") {
+				throw new Error(t("embeddings:ollama.hostNotFound", { baseUrl: this.baseUrl }))
+			}
+
 			// Re-throw a more specific error for the caller
 			throw new Error(t("embeddings:ollama.embeddingFailed", { message: error.message }))
 		}
@@ -129,12 +146,12 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
 					if (modelsResponse.status === 404) {
 						return {
 							valid: false,
-							error: t("embeddings:errors.ollama.serviceNotRunning", { baseUrl: this.baseUrl }),
+							error: t("embeddings:ollama.serviceNotRunning", { baseUrl: this.baseUrl }),
 						}
 					}
 					return {
 						valid: false,
-						error: t("embeddings:errors.ollama.serviceUnavailable", {
+						error: t("embeddings:ollama.serviceUnavailable", {
 							baseUrl: this.baseUrl,
 							status: modelsResponse.status,
 						}),
@@ -159,8 +176,8 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
 					const availableModels = models.map((m: any) => m.name).join(", ")
 					return {
 						valid: false,
-						error: t("embeddings:errors.ollama.modelNotFound", {
-							model: this.defaultModelId,
+						error: t("embeddings:ollama.modelNotFound", {
+							modelId: this.defaultModelId,
 							availableModels,
 						}),
 					}
@@ -189,7 +206,7 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
 				if (!testResponse.ok) {
 					return {
 						valid: false,
-						error: t("embeddings:errors.ollama.modelNotEmbedding", { model: this.defaultModelId }),
+						error: t("embeddings:ollama.modelNotEmbeddingCapable", { modelId: this.defaultModelId }),
 					}
 				}
 
@@ -199,21 +216,26 @@ export class CodeIndexOllamaEmbedder implements IEmbedder {
 			{
 				beforeStandardHandling: (error: any) => {
 					// Handle Ollama-specific connection errors
-					if (error?.message === "ECONNREFUSED") {
+					// Check for fetch failed errors which indicate Ollama is not running
+					if (
+						error?.message?.includes("fetch failed") ||
+						error?.code === "ECONNREFUSED" ||
+						error?.message?.includes("ECONNREFUSED")
+					) {
 						return {
 							valid: false,
-							error: t("embeddings:errors.ollama.connectionTimeout", { baseUrl: this.baseUrl }),
+							error: t("embeddings:ollama.serviceNotRunning", { baseUrl: this.baseUrl }),
 						}
-					} else if (error?.message === "ENOTFOUND") {
+					} else if (error?.code === "ENOTFOUND" || error?.message?.includes("ENOTFOUND")) {
 						return {
 							valid: false,
-							error: t("embeddings:errors.ollama.hostNotFound", { baseUrl: this.baseUrl }),
+							error: t("embeddings:ollama.hostNotFound", { baseUrl: this.baseUrl }),
 						}
 					} else if (error?.name === "AbortError") {
 						// Handle timeout
 						return {
 							valid: false,
-							error: t("embeddings:errors.ollama.connectionTimeout", { baseUrl: this.baseUrl }),
+							error: t("embeddings:validation.connectionFailed"),
 						}
 					}
 					// Let standard handling take over

+ 796 - 483
webview-ui/src/components/chat/CodeIndexPopover.tsx

@@ -1,5 +1,6 @@
-import React, { useState, useEffect, useMemo } from "react"
+import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"
 import { Trans } from "react-i18next"
+import { z } from "zod"
 import {
 	VSCodeButton,
 	VSCodeTextField,
@@ -34,11 +35,16 @@ import {
 	Slider,
 	StandardTooltip,
 } from "@src/components/ui"
+import { AlertTriangle } from "lucide-react"
 import { useRooPortal } from "@src/components/ui/hooks/useRooPortal"
 import type { EmbedderProvider } from "@roo/embeddingModels"
 import type { IndexingStatus } from "@roo/ExtensionMessage"
 import { CODEBASE_INDEX_DEFAULTS } from "@roo-code/types"
 
+// Default URLs for providers
+const DEFAULT_QDRANT_URL = "http://localhost:6333"
+const DEFAULT_OLLAMA_URL = "http://localhost:11434"
+
 interface CodeIndexPopoverProps {
 	children: React.ReactNode
 	indexingStatus: IndexingStatus
@@ -63,6 +69,62 @@ interface LocalCodeIndexSettings {
 	codebaseIndexGeminiApiKey?: string
 }
 
+// Validation schema for codebase index settings
+const createValidationSchema = (provider: EmbedderProvider, t: any) => {
+	const baseSchema = z.object({
+		codebaseIndexQdrantUrl: z
+			.string()
+			.min(1, t("settings:codeIndex.validation.qdrantUrlRequired"))
+			.url(t("settings:codeIndex.validation.invalidQdrantUrl")),
+		codeIndexQdrantApiKey: z.string().optional(),
+	})
+
+	switch (provider) {
+		case "openai":
+			return baseSchema.extend({
+				codeIndexOpenAiKey: z.string().min(1, t("settings:codeIndex.validation.openaiApiKeyRequired")),
+				codebaseIndexEmbedderModelId: z
+					.string()
+					.min(1, t("settings:codeIndex.validation.modelSelectionRequired")),
+			})
+
+		case "ollama":
+			return baseSchema.extend({
+				codebaseIndexEmbedderBaseUrl: z
+					.string()
+					.min(1, t("settings:codeIndex.validation.ollamaBaseUrlRequired"))
+					.url(t("settings:codeIndex.validation.invalidOllamaUrl")),
+				codebaseIndexEmbedderModelId: z.string().min(1, t("settings:codeIndex.validation.modelIdRequired")),
+			})
+
+		case "openai-compatible":
+			return baseSchema.extend({
+				codebaseIndexOpenAiCompatibleBaseUrl: z
+					.string()
+					.min(1, t("settings:codeIndex.validation.baseUrlRequired"))
+					.url(t("settings:codeIndex.validation.invalidBaseUrl")),
+				codebaseIndexOpenAiCompatibleApiKey: z
+					.string()
+					.min(1, t("settings:codeIndex.validation.apiKeyRequired")),
+				codebaseIndexEmbedderModelId: z.string().min(1, t("settings:codeIndex.validation.modelIdRequired")),
+				codebaseIndexEmbedderModelDimension: z
+					.number()
+					.min(1, t("settings:codeIndex.validation.modelDimensionRequired")),
+			})
+
+		case "gemini":
+			return baseSchema.extend({
+				codebaseIndexGeminiApiKey: z.string().min(1, t("settings:codeIndex.validation.geminiApiKeyRequired")),
+				codebaseIndexEmbedderModelId: z
+					.string()
+					.min(1, t("settings:codeIndex.validation.modelSelectionRequired")),
+			})
+
+		default:
+			return baseSchema
+	}
+}
+
 export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
 	children,
 	indexingStatus: externalIndexingStatus,
@@ -79,6 +141,13 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
 	const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
 	const [saveError, setSaveError] = useState<string | null>(null)
 
+	// Form validation state
+	const [formErrors, setFormErrors] = useState<Record<string, string>>({})
+
+	// Discard changes dialog state
+	const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
+	const confirmDialogHandler = useRef<(() => void) | null>(null)
+
 	// Default settings template
 	const getDefaultSettings = (): LocalCodeIndexSettings => ({
 		codebaseIndexEnabled: true,
@@ -255,9 +324,96 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
 
 	const updateSetting = (key: keyof LocalCodeIndexSettings, value: any) => {
 		setCurrentSettings((prev) => ({ ...prev, [key]: value }))
+		// Clear validation error for this field when user starts typing
+		if (formErrors[key]) {
+			setFormErrors((prev) => {
+				const newErrors = { ...prev }
+				delete newErrors[key]
+				return newErrors
+			})
+		}
+	}
+
+	// Validation function
+	const validateSettings = (): boolean => {
+		const schema = createValidationSchema(currentSettings.codebaseIndexEmbedderProvider, t)
+
+		// Prepare data for validation
+		const dataToValidate: any = {}
+		for (const [key, value] of Object.entries(currentSettings)) {
+			// For secret fields with placeholder values, treat them as valid (they exist in backend)
+			if (value === SECRET_PLACEHOLDER) {
+				// Add a dummy value that will pass validation for these fields
+				if (
+					key === "codeIndexOpenAiKey" ||
+					key === "codebaseIndexOpenAiCompatibleApiKey" ||
+					key === "codebaseIndexGeminiApiKey"
+				) {
+					dataToValidate[key] = "placeholder-valid"
+				}
+			} else {
+				dataToValidate[key] = value
+			}
+		}
+
+		try {
+			// Validate using the schema
+			schema.parse(dataToValidate)
+			setFormErrors({})
+			return true
+		} catch (error) {
+			if (error instanceof z.ZodError) {
+				const errors: Record<string, string> = {}
+				error.errors.forEach((err) => {
+					if (err.path[0]) {
+						errors[err.path[0] as string] = err.message
+					}
+				})
+				setFormErrors(errors)
+			}
+			return false
+		}
 	}
 
+	// Discard changes functionality
+	const checkUnsavedChanges = useCallback(
+		(then: () => void) => {
+			if (hasUnsavedChanges) {
+				confirmDialogHandler.current = then
+				setDiscardDialogShow(true)
+			} else {
+				then()
+			}
+		},
+		[hasUnsavedChanges],
+	)
+
+	const onConfirmDialogResult = useCallback(
+		(confirm: boolean) => {
+			if (confirm) {
+				// Discard changes: Reset to initial settings
+				setCurrentSettings(initialSettings)
+				setFormErrors({}) // Clear any validation errors
+				confirmDialogHandler.current?.() // Execute the pending action (e.g., close popover)
+			}
+			setDiscardDialogShow(false)
+		},
+		[initialSettings],
+	)
+
+	// Handle popover close with unsaved changes check
+	const handlePopoverClose = useCallback(() => {
+		checkUnsavedChanges(() => {
+			setOpen(false)
+		})
+	}, [checkUnsavedChanges])
+
 	const handleSaveSettings = () => {
+		// Validate settings before saving
+		if (!validateSettings()) {
+			return
+		}
+
 		setSaveStatus("saving")
 		setSaveError(null)
 
@@ -302,523 +458,680 @@ export const CodeIndexPopover: React.FC<CodeIndexPopoverProps> = ({
 	const portalContainer = useRooPortal("roo-portal")
 
 	return (
-		<Popover open={open} onOpenChange={setOpen}>
-			<PopoverTrigger asChild>{children}</PopoverTrigger>
-			<PopoverContent
-				className="w-[calc(100vw-32px)] max-w-[450px] max-h-[80vh] overflow-y-auto p-0"
-				align="end"
-				alignOffset={0}
-				side="bottom"
-				sideOffset={5}
-				collisionPadding={16}
-				avoidCollisions={true}
-				container={portalContainer}>
-				<div className="p-3 border-b border-vscode-dropdown-border cursor-default">
-					<div className="flex flex-row items-center gap-1 p-0 mt-0 mb-1 w-full">
-						<h4 className="m-0 pb-2 flex-1">{t("settings:codeIndex.title")}</h4>
-					</div>
-					<p className="my-0 pr-4 text-sm w-full">
-						<Trans i18nKey="settings:codeIndex.description">
-							<VSCodeLink
-								href={buildDocLink("features/experimental/codebase-indexing", "settings")}
-								style={{ display: "inline" }}
-							/>
-						</Trans>
-					</p>
-				</div>
-
-				<div className="p-4">
-					{/* Status Section */}
-					<div className="space-y-2">
-						<h4 className="text-sm font-medium">{t("settings:codeIndex.statusTitle")}</h4>
-						<div className="text-sm text-vscode-descriptionForeground">
-							<span
-								className={cn("inline-block w-3 h-3 rounded-full mr-2", {
-									"bg-gray-400": indexingStatus.systemStatus === "Standby",
-									"bg-yellow-500 animate-pulse": indexingStatus.systemStatus === "Indexing",
-									"bg-green-500": indexingStatus.systemStatus === "Indexed",
-									"bg-red-500": indexingStatus.systemStatus === "Error",
-								})}
-							/>
-							{t(`settings:codeIndex.indexingStatuses.${indexingStatus.systemStatus.toLowerCase()}`)}
-							{indexingStatus.message ? ` - ${indexingStatus.message}` : ""}
+		<>
+			<Popover
+				open={open}
+				onOpenChange={(newOpen) => {
+					if (!newOpen) {
+						// User is trying to close the popover
+						handlePopoverClose()
+					} else {
+						setOpen(newOpen)
+					}
+				}}>
+				<PopoverTrigger asChild>{children}</PopoverTrigger>
+				<PopoverContent
+					className="w-[calc(100vw-32px)] max-w-[450px] max-h-[80vh] overflow-y-auto p-0"
+					align="end"
+					alignOffset={0}
+					side="bottom"
+					sideOffset={5}
+					collisionPadding={16}
+					avoidCollisions={true}
+					container={portalContainer}>
+					<div className="p-3 border-b border-vscode-dropdown-border cursor-default">
+						<div className="flex flex-row items-center gap-1 p-0 mt-0 mb-1 w-full">
+							<h4 className="m-0 pb-2 flex-1">{t("settings:codeIndex.title")}</h4>
 						</div>
+						<p className="my-0 pr-4 text-sm w-full">
+							<Trans i18nKey="settings:codeIndex.description">
+								<VSCodeLink
+									href={buildDocLink("features/experimental/codebase-indexing", "settings")}
+									style={{ display: "inline" }}
+								/>
+							</Trans>
+						</p>
+					</div>
 
-						{indexingStatus.systemStatus === "Indexing" && (
-							<div className="mt-2">
-								<ProgressPrimitive.Root
-									className="relative h-2 w-full overflow-hidden rounded-full bg-secondary"
-									value={progressPercentage}>
-									<ProgressPrimitive.Indicator
-										className="h-full w-full flex-1 bg-primary transition-transform duration-300 ease-in-out"
-										style={{
-											transform: transformStyleString,
-										}}
-									/>
-								</ProgressPrimitive.Root>
+					<div className="p-4">
+						{/* Status Section */}
+						<div className="space-y-2">
+							<h4 className="text-sm font-medium">{t("settings:codeIndex.statusTitle")}</h4>
+							<div className="text-sm text-vscode-descriptionForeground">
+								<span
+									className={cn("inline-block w-3 h-3 rounded-full mr-2", {
+										"bg-gray-400": indexingStatus.systemStatus === "Standby",
+										"bg-yellow-500 animate-pulse": indexingStatus.systemStatus === "Indexing",
+										"bg-green-500": indexingStatus.systemStatus === "Indexed",
+										"bg-red-500": indexingStatus.systemStatus === "Error",
+									})}
+								/>
+								{t(`settings:codeIndex.indexingStatuses.${indexingStatus.systemStatus.toLowerCase()}`)}
+								{indexingStatus.message ? ` - ${indexingStatus.message}` : ""}
 							</div>
-						)}
-					</div>
 
-					{/* Setup Settings Disclosure */}
-					<div className="mt-4">
-						<button
-							onClick={() => setIsSetupSettingsOpen(!isSetupSettingsOpen)}
-							className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
-							aria-expanded={isSetupSettingsOpen}>
-							<span
-								className={`codicon codicon-${isSetupSettingsOpen ? "chevron-down" : "chevron-right"} mr-1`}></span>
-							<span className="text-base font-semibold">{t("settings:codeIndex.setupConfigLabel")}</span>
-						</button>
-
-						{isSetupSettingsOpen && (
-							<div className="mt-4 space-y-4">
-								{/* Embedder Provider Section */}
-								<div className="space-y-2">
-									<label className="text-sm font-medium">
-										{t("settings:codeIndex.embedderProviderLabel")}
-									</label>
-									<Select
-										value={currentSettings.codebaseIndexEmbedderProvider}
-										onValueChange={(value: EmbedderProvider) =>
-											updateSetting("codebaseIndexEmbedderProvider", value)
-										}>
-										<SelectTrigger className="w-full">
-											<SelectValue />
-										</SelectTrigger>
-										<SelectContent>
-											<SelectItem value="openai">
-												{t("settings:codeIndex.openaiProvider")}
-											</SelectItem>
-											<SelectItem value="ollama">
-												{t("settings:codeIndex.ollamaProvider")}
-											</SelectItem>
-											<SelectItem value="openai-compatible">
-												{t("settings:codeIndex.openaiCompatibleProvider")}
-											</SelectItem>
-											<SelectItem value="gemini">
-												{t("settings:codeIndex.geminiProvider")}
-											</SelectItem>
-										</SelectContent>
-									</Select>
+							{indexingStatus.systemStatus === "Indexing" && (
+								<div className="mt-2">
+									<ProgressPrimitive.Root
+										className="relative h-2 w-full overflow-hidden rounded-full bg-secondary"
+										value={progressPercentage}>
+										<ProgressPrimitive.Indicator
+											className="h-full w-full flex-1 bg-primary transition-transform duration-300 ease-in-out"
+											style={{
+												transform: transformStyleString,
+											}}
+										/>
+									</ProgressPrimitive.Root>
 								</div>
+							)}
+						</div>
 
-								{/* Provider-specific settings */}
-								{currentSettings.codebaseIndexEmbedderProvider === "openai" && (
-									<>
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.openAiKeyLabel")}
-											</label>
-											<VSCodeTextField
-												type="password"
-												value={currentSettings.codeIndexOpenAiKey || ""}
-												onInput={(e: any) =>
-													updateSetting("codeIndexOpenAiKey", e.target.value)
-												}
-												placeholder={t("settings:codeIndex.openAiKeyPlaceholder")}
-												className="w-full"
-											/>
-										</div>
+						{/* Setup Settings Disclosure */}
+						<div className="mt-4">
+							<button
+								onClick={() => setIsSetupSettingsOpen(!isSetupSettingsOpen)}
+								className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
+								aria-expanded={isSetupSettingsOpen}>
+								<span
+									className={`codicon codicon-${isSetupSettingsOpen ? "chevron-down" : "chevron-right"} mr-1`}></span>
+								<span className="text-base font-semibold">
+									{t("settings:codeIndex.setupConfigLabel")}
+								</span>
+							</button>
 
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.modelLabel")}
-											</label>
-											<VSCodeDropdown
-												value={currentSettings.codebaseIndexEmbedderModelId}
-												onChange={(e: any) =>
-													updateSetting("codebaseIndexEmbedderModelId", e.target.value)
-												}
-												className="w-full">
-												<VSCodeOption value="">
-													{t("settings:codeIndex.selectModel")}
-												</VSCodeOption>
-												{getAvailableModels().map((modelId) => {
-													const model =
-														codebaseIndexModels?.[
-															currentSettings.codebaseIndexEmbedderProvider
-														]?.[modelId]
-													return (
-														<VSCodeOption key={modelId} value={modelId}>
-															{modelId}{" "}
-															{model
-																? t("settings:codeIndex.modelDimensions", {
-																		dimension: model.dimension,
-																	})
-																: ""}
-														</VSCodeOption>
-													)
-												})}
-											</VSCodeDropdown>
-										</div>
-									</>
-								)}
+							{isSetupSettingsOpen && (
+								<div className="mt-4 space-y-4">
+									{/* Embedder Provider Section */}
+									<div className="space-y-2">
+										<label className="text-sm font-medium">
+											{t("settings:codeIndex.embedderProviderLabel")}
+										</label>
+										<Select
+											value={currentSettings.codebaseIndexEmbedderProvider}
+											onValueChange={(value: EmbedderProvider) => {
+												updateSetting("codebaseIndexEmbedderProvider", value)
+												// Clear model selection when switching providers
+												updateSetting("codebaseIndexEmbedderModelId", "")
+											}}>
+											<SelectTrigger className="w-full">
+												<SelectValue />
+											</SelectTrigger>
+											<SelectContent>
+												<SelectItem value="openai">
+													{t("settings:codeIndex.openaiProvider")}
+												</SelectItem>
+												<SelectItem value="ollama">
+													{t("settings:codeIndex.ollamaProvider")}
+												</SelectItem>
+												<SelectItem value="openai-compatible">
+													{t("settings:codeIndex.openaiCompatibleProvider")}
+												</SelectItem>
+												<SelectItem value="gemini">
+													{t("settings:codeIndex.geminiProvider")}
+												</SelectItem>
+											</SelectContent>
+										</Select>
+									</div>
 
-								{currentSettings.codebaseIndexEmbedderProvider === "ollama" && (
-									<>
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.ollamaBaseUrlLabel")}
-											</label>
-											<VSCodeTextField
-												value={currentSettings.codebaseIndexEmbedderBaseUrl || ""}
-												onInput={(e: any) =>
-													updateSetting("codebaseIndexEmbedderBaseUrl", e.target.value)
-												}
-												placeholder={t("settings:codeIndex.ollamaUrlPlaceholder")}
-												className="w-full"
-											/>
-										</div>
+									{/* Provider-specific settings */}
+									{currentSettings.codebaseIndexEmbedderProvider === "openai" && (
+										<>
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.openAiKeyLabel")}
+												</label>
+												<VSCodeTextField
+													type="password"
+													value={currentSettings.codeIndexOpenAiKey || ""}
+													onInput={(e: any) =>
+														updateSetting("codeIndexOpenAiKey", e.target.value)
+													}
+													placeholder={t("settings:codeIndex.openAiKeyPlaceholder")}
+													className={cn("w-full", {
+														"border-red-500": formErrors.codeIndexOpenAiKey,
+													})}
+												/>
+												{formErrors.codeIndexOpenAiKey && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codeIndexOpenAiKey}
+													</p>
+												)}
+											</div>
 
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.modelLabel")}
-											</label>
-											<VSCodeDropdown
-												value={currentSettings.codebaseIndexEmbedderModelId}
-												onChange={(e: any) =>
-													updateSetting("codebaseIndexEmbedderModelId", e.target.value)
-												}
-												className="w-full">
-												<VSCodeOption value="">
-													{t("settings:codeIndex.selectModel")}
-												</VSCodeOption>
-												{getAvailableModels().map((modelId) => {
-													const model =
-														codebaseIndexModels?.[
-															currentSettings.codebaseIndexEmbedderProvider
-														]?.[modelId]
-													return (
-														<VSCodeOption key={modelId} value={modelId}>
-															{modelId}{" "}
-															{model
-																? t("settings:codeIndex.modelDimensions", {
-																		dimension: model.dimension,
-																	})
-																: ""}
-														</VSCodeOption>
-													)
-												})}
-											</VSCodeDropdown>
-										</div>
-									</>
-								)}
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.modelLabel")}
+												</label>
+												<VSCodeDropdown
+													value={currentSettings.codebaseIndexEmbedderModelId}
+													onChange={(e: any) =>
+														updateSetting("codebaseIndexEmbedderModelId", e.target.value)
+													}
+													className={cn("w-full", {
+														"border-red-500": formErrors.codebaseIndexEmbedderModelId,
+													})}>
+													<VSCodeOption value="">
+														{t("settings:codeIndex.selectModel")}
+													</VSCodeOption>
+													{getAvailableModels().map((modelId) => {
+														const model =
+															codebaseIndexModels?.[
+																currentSettings.codebaseIndexEmbedderProvider
+															]?.[modelId]
+														return (
+															<VSCodeOption key={modelId} value={modelId}>
+																{modelId}{" "}
+																{model
+																	? t("settings:codeIndex.modelDimensions", {
+																			dimension: model.dimension,
+																		})
+																	: ""}
+															</VSCodeOption>
+														)
+													})}
+												</VSCodeDropdown>
+												{formErrors.codebaseIndexEmbedderModelId && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codebaseIndexEmbedderModelId}
+													</p>
+												)}
+											</div>
+										</>
+									)}
 
-								{currentSettings.codebaseIndexEmbedderProvider === "openai-compatible" && (
-									<>
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.openAiCompatibleBaseUrlLabel")}
-											</label>
-											<VSCodeTextField
-												value={currentSettings.codebaseIndexOpenAiCompatibleBaseUrl || ""}
-												onInput={(e: any) =>
-													updateSetting(
-														"codebaseIndexOpenAiCompatibleBaseUrl",
-														e.target.value,
-													)
-												}
-												placeholder={t("settings:codeIndex.openAiCompatibleBaseUrlPlaceholder")}
-												className="w-full"
-											/>
-										</div>
+									{currentSettings.codebaseIndexEmbedderProvider === "ollama" && (
+										<>
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.ollamaBaseUrlLabel")}
+												</label>
+												<VSCodeTextField
+													value={currentSettings.codebaseIndexEmbedderBaseUrl || ""}
+													onInput={(e: any) =>
+														updateSetting("codebaseIndexEmbedderBaseUrl", e.target.value)
+													}
+													onBlur={(e: any) => {
+														// Set default Ollama URL if field is empty
+														if (!e.target.value.trim()) {
+															e.target.value = DEFAULT_OLLAMA_URL
+															updateSetting(
+																"codebaseIndexEmbedderBaseUrl",
+																DEFAULT_OLLAMA_URL,
+															)
+														}
+													}}
+													placeholder={t("settings:codeIndex.ollamaUrlPlaceholder")}
+													className={cn("w-full", {
+														"border-red-500": formErrors.codebaseIndexEmbedderBaseUrl,
+													})}
+												/>
+												{formErrors.codebaseIndexEmbedderBaseUrl && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codebaseIndexEmbedderBaseUrl}
+													</p>
+												)}
+											</div>
 
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.openAiCompatibleApiKeyLabel")}
-											</label>
-											<VSCodeTextField
-												type="password"
-												value={currentSettings.codebaseIndexOpenAiCompatibleApiKey || ""}
-												onInput={(e: any) =>
-													updateSetting("codebaseIndexOpenAiCompatibleApiKey", e.target.value)
-												}
-												placeholder={t("settings:codeIndex.openAiCompatibleApiKeyPlaceholder")}
-												className="w-full"
-											/>
-										</div>
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.modelLabel")}
+												</label>
+												<VSCodeDropdown
+													value={currentSettings.codebaseIndexEmbedderModelId}
+													onChange={(e: any) =>
+														updateSetting("codebaseIndexEmbedderModelId", e.target.value)
+													}
+													className={cn("w-full", {
+														"border-red-500": formErrors.codebaseIndexEmbedderModelId,
+													})}>
+													<VSCodeOption value="">
+														{t("settings:codeIndex.selectModel")}
+													</VSCodeOption>
+													{getAvailableModels().map((modelId) => {
+														const model =
+															codebaseIndexModels?.[
+																currentSettings.codebaseIndexEmbedderProvider
+															]?.[modelId]
+														return (
+															<VSCodeOption key={modelId} value={modelId}>
+																{modelId}{" "}
+																{model
+																	? t("settings:codeIndex.modelDimensions", {
+																			dimension: model.dimension,
+																		})
+																	: ""}
+															</VSCodeOption>
+														)
+													})}
+												</VSCodeDropdown>
+												{formErrors.codebaseIndexEmbedderModelId && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codebaseIndexEmbedderModelId}
+													</p>
+												)}
+											</div>
+										</>
+									)}
 
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.modelLabel")}
-											</label>
-											<VSCodeTextField
-												value={currentSettings.codebaseIndexEmbedderModelId || ""}
-												onInput={(e: any) =>
-													updateSetting("codebaseIndexEmbedderModelId", e.target.value)
-												}
-												placeholder={t("settings:codeIndex.modelPlaceholder")}
-												className="w-full"
-											/>
-										</div>
+									{currentSettings.codebaseIndexEmbedderProvider === "openai-compatible" && (
+										<>
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.openAiCompatibleBaseUrlLabel")}
+												</label>
+												<VSCodeTextField
+													value={currentSettings.codebaseIndexOpenAiCompatibleBaseUrl || ""}
+													onInput={(e: any) =>
+														updateSetting(
+															"codebaseIndexOpenAiCompatibleBaseUrl",
+															e.target.value,
+														)
+													}
+													placeholder={t(
+														"settings:codeIndex.openAiCompatibleBaseUrlPlaceholder",
+													)}
+													className={cn("w-full", {
+														"border-red-500":
+															formErrors.codebaseIndexOpenAiCompatibleBaseUrl,
+													})}
+												/>
+												{formErrors.codebaseIndexOpenAiCompatibleBaseUrl && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codebaseIndexOpenAiCompatibleBaseUrl}
+													</p>
+												)}
+											</div>
 
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.modelDimensionLabel")}
-											</label>
-											<VSCodeTextField
-												value={
-													currentSettings.codebaseIndexEmbedderModelDimension?.toString() ||
-													""
-												}
-												onInput={(e: any) => {
-													const value = e.target.value ? parseInt(e.target.value) : undefined
-													updateSetting("codebaseIndexEmbedderModelDimension", value)
-												}}
-												placeholder={t("settings:codeIndex.modelDimensionPlaceholder")}
-												className="w-full"
-											/>
-										</div>
-									</>
-								)}
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.openAiCompatibleApiKeyLabel")}
+												</label>
+												<VSCodeTextField
+													type="password"
+													value={currentSettings.codebaseIndexOpenAiCompatibleApiKey || ""}
+													onInput={(e: any) =>
+														updateSetting(
+															"codebaseIndexOpenAiCompatibleApiKey",
+															e.target.value,
+														)
+													}
+													placeholder={t(
+														"settings:codeIndex.openAiCompatibleApiKeyPlaceholder",
+													)}
+													className={cn("w-full", {
+														"border-red-500":
+															formErrors.codebaseIndexOpenAiCompatibleApiKey,
+													})}
+												/>
+												{formErrors.codebaseIndexOpenAiCompatibleApiKey && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codebaseIndexOpenAiCompatibleApiKey}
+													</p>
+												)}
+											</div>
 
-								{currentSettings.codebaseIndexEmbedderProvider === "gemini" && (
-									<>
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.geminiApiKeyLabel")}
-											</label>
-											<VSCodeTextField
-												type="password"
-												value={currentSettings.codebaseIndexGeminiApiKey || ""}
-												onInput={(e: any) =>
-													updateSetting("codebaseIndexGeminiApiKey", e.target.value)
-												}
-												placeholder={t("settings:codeIndex.geminiApiKeyPlaceholder")}
-												className="w-full"
-											/>
-										</div>
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.modelLabel")}
+												</label>
+												<VSCodeTextField
+													value={currentSettings.codebaseIndexEmbedderModelId || ""}
+													onInput={(e: any) =>
+														updateSetting("codebaseIndexEmbedderModelId", e.target.value)
+													}
+													placeholder={t("settings:codeIndex.modelPlaceholder")}
+													className={cn("w-full", {
+														"border-red-500": formErrors.codebaseIndexEmbedderModelId,
+													})}
+												/>
+												{formErrors.codebaseIndexEmbedderModelId && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codebaseIndexEmbedderModelId}
+													</p>
+												)}
+											</div>
 
-										<div className="space-y-2">
-											<label className="text-sm font-medium">
-												{t("settings:codeIndex.modelLabel")}
-											</label>
-											<VSCodeDropdown
-												value={currentSettings.codebaseIndexEmbedderModelId}
-												onChange={(e: any) =>
-													updateSetting("codebaseIndexEmbedderModelId", e.target.value)
-												}
-												className="w-full">
-												<VSCodeOption value="">
-													{t("settings:codeIndex.selectModel")}
-												</VSCodeOption>
-												{getAvailableModels().map((modelId) => {
-													const model =
-														codebaseIndexModels?.[
-															currentSettings.codebaseIndexEmbedderProvider
-														]?.[modelId]
-													return (
-														<VSCodeOption key={modelId} value={modelId}>
-															{modelId}{" "}
-															{model
-																? t("settings:codeIndex.modelDimensions", {
-																		dimension: model.dimension,
-																	})
-																: ""}
-														</VSCodeOption>
-													)
-												})}
-											</VSCodeDropdown>
-										</div>
-									</>
-								)}
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.modelDimensionLabel")}
+												</label>
+												<VSCodeTextField
+													value={
+														currentSettings.codebaseIndexEmbedderModelDimension?.toString() ||
+														""
+													}
+													onInput={(e: any) => {
+														const value = e.target.value
+															? parseInt(e.target.value)
+															: undefined
+														updateSetting("codebaseIndexEmbedderModelDimension", value)
+													}}
+													placeholder={t("settings:codeIndex.modelDimensionPlaceholder")}
+													className={cn("w-full", {
+														"border-red-500":
+															formErrors.codebaseIndexEmbedderModelDimension,
+													})}
+												/>
+												{formErrors.codebaseIndexEmbedderModelDimension && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codebaseIndexEmbedderModelDimension}
+													</p>
+												)}
+											</div>
+										</>
+									)}
 
-								{/* Qdrant Settings */}
-								<div className="space-y-2">
-									<label className="text-sm font-medium">
-										{t("settings:codeIndex.qdrantUrlLabel")}
-									</label>
-									<VSCodeTextField
-										value={currentSettings.codebaseIndexQdrantUrl || ""}
-										onInput={(e: any) => updateSetting("codebaseIndexQdrantUrl", e.target.value)}
-										placeholder={t("settings:codeIndex.qdrantUrlPlaceholder")}
-										className="w-full"
-									/>
-								</div>
+									{currentSettings.codebaseIndexEmbedderProvider === "gemini" && (
+										<>
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.geminiApiKeyLabel")}
+												</label>
+												<VSCodeTextField
+													type="password"
+													value={currentSettings.codebaseIndexGeminiApiKey || ""}
+													onInput={(e: any) =>
+														updateSetting("codebaseIndexGeminiApiKey", e.target.value)
+													}
+													placeholder={t("settings:codeIndex.geminiApiKeyPlaceholder")}
+													className={cn("w-full", {
+														"border-red-500": formErrors.codebaseIndexGeminiApiKey,
+													})}
+												/>
+												{formErrors.codebaseIndexGeminiApiKey && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codebaseIndexGeminiApiKey}
+													</p>
+												)}
+											</div>
 
-								<div className="space-y-2">
-									<label className="text-sm font-medium">
-										{t("settings:codeIndex.qdrantApiKeyLabel")}
-									</label>
-									<VSCodeTextField
-										type="password"
-										value={currentSettings.codeIndexQdrantApiKey || ""}
-										onInput={(e: any) => updateSetting("codeIndexQdrantApiKey", e.target.value)}
-										placeholder={t("settings:codeIndex.qdrantApiKeyPlaceholder")}
-										className="w-full"
-									/>
-								</div>
-							</div>
-						)}
-					</div>
+											<div className="space-y-2">
+												<label className="text-sm font-medium">
+													{t("settings:codeIndex.modelLabel")}
+												</label>
+												<VSCodeDropdown
+													value={currentSettings.codebaseIndexEmbedderModelId}
+													onChange={(e: any) =>
+														updateSetting("codebaseIndexEmbedderModelId", e.target.value)
+													}
+													className={cn("w-full", {
+														"border-red-500": formErrors.codebaseIndexEmbedderModelId,
+													})}>
+													<VSCodeOption value="">
+														{t("settings:codeIndex.selectModel")}
+													</VSCodeOption>
+													{getAvailableModels().map((modelId) => {
+														const model =
+															codebaseIndexModels?.[
+																currentSettings.codebaseIndexEmbedderProvider
+															]?.[modelId]
+														return (
+															<VSCodeOption key={modelId} value={modelId}>
+																{modelId}{" "}
+																{model
+																	? t("settings:codeIndex.modelDimensions", {
+																			dimension: model.dimension,
+																		})
+																	: ""}
+															</VSCodeOption>
+														)
+													})}
+												</VSCodeDropdown>
+												{formErrors.codebaseIndexEmbedderModelId && (
+													<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+														{formErrors.codebaseIndexEmbedderModelId}
+													</p>
+												)}
+											</div>
+										</>
+									)}
 
-					{/* Advanced Settings Disclosure */}
-					<div className="mt-4">
-						<button
-							onClick={() => setIsAdvancedSettingsOpen(!isAdvancedSettingsOpen)}
-							className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
-							aria-expanded={isAdvancedSettingsOpen}>
-							<span
-								className={`codicon codicon-${isAdvancedSettingsOpen ? "chevron-down" : "chevron-right"} mr-1`}></span>
-							<span className="text-base font-semibold">
-								{t("settings:codeIndex.advancedConfigLabel")}
-							</span>
-						</button>
-
-						{isAdvancedSettingsOpen && (
-							<div className="mt-4 space-y-4">
-								{/* Search Score Threshold Slider */}
-								<div className="space-y-2">
-									<div className="flex items-center gap-2">
+									{/* Qdrant Settings */}
+									<div className="space-y-2">
 										<label className="text-sm font-medium">
-											{t("settings:codeIndex.searchMinScoreLabel")}
+											{t("settings:codeIndex.qdrantUrlLabel")}
 										</label>
-										<StandardTooltip content={t("settings:codeIndex.searchMinScoreDescription")}>
-											<span className="codicon codicon-info text-xs text-vscode-descriptionForeground cursor-help" />
-										</StandardTooltip>
-									</div>
-									<div className="flex items-center gap-2">
-										<Slider
-											min={CODEBASE_INDEX_DEFAULTS.MIN_SEARCH_SCORE}
-											max={CODEBASE_INDEX_DEFAULTS.MAX_SEARCH_SCORE}
-											step={CODEBASE_INDEX_DEFAULTS.SEARCH_SCORE_STEP}
-											value={[
-												currentSettings.codebaseIndexSearchMinScore ??
-													CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE,
-											]}
-											onValueChange={(values) =>
-												updateSetting("codebaseIndexSearchMinScore", values[0])
+										<VSCodeTextField
+											value={currentSettings.codebaseIndexQdrantUrl || ""}
+											onInput={(e: any) =>
+												updateSetting("codebaseIndexQdrantUrl", e.target.value)
 											}
-											className="flex-1"
-											data-testid="search-min-score-slider"
+											onBlur={(e: any) => {
+												// Set default Qdrant URL if field is empty
+												if (!e.target.value.trim()) {
+													currentSettings.codebaseIndexQdrantUrl = DEFAULT_QDRANT_URL
+													updateSetting("codebaseIndexQdrantUrl", DEFAULT_QDRANT_URL)
+												}
+											}}
+											placeholder={t("settings:codeIndex.qdrantUrlPlaceholder")}
+											className={cn("w-full", {
+												"border-red-500": formErrors.codebaseIndexQdrantUrl,
+											})}
 										/>
-										<span className="w-12 text-center">
-											{(
-												currentSettings.codebaseIndexSearchMinScore ??
-												CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE
-											).toFixed(2)}
-										</span>
-										<VSCodeButton
-											appearance="icon"
-											title={t("settings:codeIndex.resetToDefault")}
-											onClick={() =>
-												updateSetting(
-													"codebaseIndexSearchMinScore",
-													CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE,
-												)
-											}>
-											<span className="codicon codicon-discard" />
-										</VSCodeButton>
+										{formErrors.codebaseIndexQdrantUrl && (
+											<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+												{formErrors.codebaseIndexQdrantUrl}
+											</p>
+										)}
 									</div>
-								</div>
 
-								{/* Maximum Search Results Slider */}
-								<div className="space-y-2">
-									<div className="flex items-center gap-2">
+									<div className="space-y-2">
 										<label className="text-sm font-medium">
-											{t("settings:codeIndex.searchMaxResultsLabel")}
+											{t("settings:codeIndex.qdrantApiKeyLabel")}
 										</label>
-										<StandardTooltip content={t("settings:codeIndex.searchMaxResultsDescription")}>
-											<span className="codicon codicon-info text-xs text-vscode-descriptionForeground cursor-help" />
-										</StandardTooltip>
-									</div>
-									<div className="flex items-center gap-2">
-										<Slider
-											min={CODEBASE_INDEX_DEFAULTS.MIN_SEARCH_RESULTS}
-											max={CODEBASE_INDEX_DEFAULTS.MAX_SEARCH_RESULTS}
-											step={CODEBASE_INDEX_DEFAULTS.SEARCH_RESULTS_STEP}
-											value={[
-												currentSettings.codebaseIndexSearchMaxResults ??
-													CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS,
-											]}
-											onValueChange={(values) =>
-												updateSetting("codebaseIndexSearchMaxResults", values[0])
-											}
-											className="flex-1"
-											data-testid="search-max-results-slider"
+										<VSCodeTextField
+											type="password"
+											value={currentSettings.codeIndexQdrantApiKey || ""}
+											onInput={(e: any) => updateSetting("codeIndexQdrantApiKey", e.target.value)}
+											placeholder={t("settings:codeIndex.qdrantApiKeyPlaceholder")}
+											className={cn("w-full", {
+												"border-red-500": formErrors.codeIndexQdrantApiKey,
+											})}
 										/>
-										<span className="w-12 text-center">
-											{currentSettings.codebaseIndexSearchMaxResults ??
-												CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS}
-										</span>
-										<VSCodeButton
-											appearance="icon"
-											title={t("settings:codeIndex.resetToDefault")}
-											onClick={() =>
-												updateSetting(
-													"codebaseIndexSearchMaxResults",
-													CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS,
-												)
-											}>
-											<span className="codicon codicon-discard" />
-										</VSCodeButton>
+										{formErrors.codeIndexQdrantApiKey && (
+											<p className="text-xs text-vscode-errorForeground mt-1 mb-0">
+												{formErrors.codeIndexQdrantApiKey}
+											</p>
+										)}
 									</div>
 								</div>
-							</div>
-						)}
-					</div>
-
-					{/* Action Buttons */}
-					<div className="flex items-center justify-between gap-2 pt-6">
-						<div className="flex gap-2">
-							{(indexingStatus.systemStatus === "Error" || indexingStatus.systemStatus === "Standby") && (
-								<VSCodeButton
-									onClick={() => vscode.postMessage({ type: "startIndexing" })}
-									disabled={saveStatus === "saving" || hasUnsavedChanges}>
-									{t("settings:codeIndex.startIndexingButton")}
-								</VSCodeButton>
 							)}
+						</div>
 
-							{(indexingStatus.systemStatus === "Indexed" || indexingStatus.systemStatus === "Error") && (
-								<AlertDialog>
-									<AlertDialogTrigger asChild>
-										<VSCodeButton appearance="secondary">
-											{t("settings:codeIndex.clearIndexDataButton")}
-										</VSCodeButton>
-									</AlertDialogTrigger>
-									<AlertDialogContent>
-										<AlertDialogHeader>
-											<AlertDialogTitle>
-												{t("settings:codeIndex.clearDataDialog.title")}
-											</AlertDialogTitle>
-											<AlertDialogDescription>
-												{t("settings:codeIndex.clearDataDialog.description")}
-											</AlertDialogDescription>
-										</AlertDialogHeader>
-										<AlertDialogFooter>
-											<AlertDialogCancel>
-												{t("settings:codeIndex.clearDataDialog.cancelButton")}
-											</AlertDialogCancel>
-											<AlertDialogAction
-												onClick={() => vscode.postMessage({ type: "clearIndexData" })}>
-												{t("settings:codeIndex.clearDataDialog.confirmButton")}
-											</AlertDialogAction>
-										</AlertDialogFooter>
-									</AlertDialogContent>
-								</AlertDialog>
+						{/* Advanced Settings Disclosure */}
+						<div className="mt-4">
+							<button
+								onClick={() => setIsAdvancedSettingsOpen(!isAdvancedSettingsOpen)}
+								className="flex items-center text-xs text-vscode-foreground hover:text-vscode-textLink-foreground focus:outline-none"
+								aria-expanded={isAdvancedSettingsOpen}>
+								<span
+									className={`codicon codicon-${isAdvancedSettingsOpen ? "chevron-down" : "chevron-right"} mr-1`}></span>
+								<span className="text-base font-semibold">
+									{t("settings:codeIndex.advancedConfigLabel")}
+								</span>
+							</button>
+
+							{isAdvancedSettingsOpen && (
+								<div className="mt-4 space-y-4">
+									{/* Search Score Threshold Slider */}
+									<div className="space-y-2">
+										<div className="flex items-center gap-2">
+											<label className="text-sm font-medium">
+												{t("settings:codeIndex.searchMinScoreLabel")}
+											</label>
+											<StandardTooltip
+												content={t("settings:codeIndex.searchMinScoreDescription")}>
+												<span className="codicon codicon-info text-xs text-vscode-descriptionForeground cursor-help" />
+											</StandardTooltip>
+										</div>
+										<div className="flex items-center gap-2">
+											<Slider
+												min={CODEBASE_INDEX_DEFAULTS.MIN_SEARCH_SCORE}
+												max={CODEBASE_INDEX_DEFAULTS.MAX_SEARCH_SCORE}
+												step={CODEBASE_INDEX_DEFAULTS.SEARCH_SCORE_STEP}
+												value={[
+													currentSettings.codebaseIndexSearchMinScore ??
+														CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE,
+												]}
+												onValueChange={(values) =>
+													updateSetting("codebaseIndexSearchMinScore", values[0])
+												}
+												className="flex-1"
+												data-testid="search-min-score-slider"
+											/>
+											<span className="w-12 text-center">
+												{(
+													currentSettings.codebaseIndexSearchMinScore ??
+													CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE
+												).toFixed(2)}
+											</span>
+											<VSCodeButton
+												appearance="icon"
+												title={t("settings:codeIndex.resetToDefault")}
+												onClick={() =>
+													updateSetting(
+														"codebaseIndexSearchMinScore",
+														CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE,
+													)
+												}>
+												<span className="codicon codicon-discard" />
+											</VSCodeButton>
+										</div>
+									</div>
+
+									{/* Maximum Search Results Slider */}
+									<div className="space-y-2">
+										<div className="flex items-center gap-2">
+											<label className="text-sm font-medium">
+												{t("settings:codeIndex.searchMaxResultsLabel")}
+											</label>
+											<StandardTooltip
+												content={t("settings:codeIndex.searchMaxResultsDescription")}>
+												<span className="codicon codicon-info text-xs text-vscode-descriptionForeground cursor-help" />
+											</StandardTooltip>
+										</div>
+										<div className="flex items-center gap-2">
+											<Slider
+												min={CODEBASE_INDEX_DEFAULTS.MIN_SEARCH_RESULTS}
+												max={CODEBASE_INDEX_DEFAULTS.MAX_SEARCH_RESULTS}
+												step={CODEBASE_INDEX_DEFAULTS.SEARCH_RESULTS_STEP}
+												value={[
+													currentSettings.codebaseIndexSearchMaxResults ??
+														CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS,
+												]}
+												onValueChange={(values) =>
+													updateSetting("codebaseIndexSearchMaxResults", values[0])
+												}
+												className="flex-1"
+												data-testid="search-max-results-slider"
+											/>
+											<span className="w-12 text-center">
+												{currentSettings.codebaseIndexSearchMaxResults ??
+													CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS}
+											</span>
+											<VSCodeButton
+												appearance="icon"
+												title={t("settings:codeIndex.resetToDefault")}
+												onClick={() =>
+													updateSetting(
+														"codebaseIndexSearchMaxResults",
+														CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS,
+													)
+												}>
+												<span className="codicon codicon-discard" />
+											</VSCodeButton>
+										</div>
+									</div>
+								</div>
 							)}
 						</div>
 
-						<VSCodeButton
-							onClick={handleSaveSettings}
-							disabled={!hasUnsavedChanges || saveStatus === "saving"}>
-							{saveStatus === "saving"
-								? t("settings:codeIndex.saving")
-								: t("settings:codeIndex.saveSettings")}
-						</VSCodeButton>
-					</div>
+						{/* Action Buttons */}
+						<div className="flex items-center justify-between gap-2 pt-6">
+							<div className="flex gap-2">
+								{(indexingStatus.systemStatus === "Error" ||
+									indexingStatus.systemStatus === "Standby") && (
+									<VSCodeButton
+										onClick={() => vscode.postMessage({ type: "startIndexing" })}
+										disabled={saveStatus === "saving" || hasUnsavedChanges}>
+										{t("settings:codeIndex.startIndexingButton")}
+									</VSCodeButton>
+								)}
 
-					{/* Save Status Messages */}
-					{saveStatus === "error" && (
-						<div className="mt-2">
-							<span className="text-sm text-red-600 block">
-								{saveError || t("settings:codeIndex.saveError")}
-							</span>
+								{(indexingStatus.systemStatus === "Indexed" ||
+									indexingStatus.systemStatus === "Error") && (
+									<AlertDialog>
+										<AlertDialogTrigger asChild>
+											<VSCodeButton appearance="secondary">
+												{t("settings:codeIndex.clearIndexDataButton")}
+											</VSCodeButton>
+										</AlertDialogTrigger>
+										<AlertDialogContent>
+											<AlertDialogHeader>
+												<AlertDialogTitle>
+													{t("settings:codeIndex.clearDataDialog.title")}
+												</AlertDialogTitle>
+												<AlertDialogDescription>
+													{t("settings:codeIndex.clearDataDialog.description")}
+												</AlertDialogDescription>
+											</AlertDialogHeader>
+											<AlertDialogFooter>
+												<AlertDialogCancel>
+													{t("settings:codeIndex.clearDataDialog.cancelButton")}
+												</AlertDialogCancel>
+												<AlertDialogAction
+													onClick={() => vscode.postMessage({ type: "clearIndexData" })}>
+													{t("settings:codeIndex.clearDataDialog.confirmButton")}
+												</AlertDialogAction>
+											</AlertDialogFooter>
+										</AlertDialogContent>
+									</AlertDialog>
+								)}
+							</div>
+
+							<VSCodeButton
+								onClick={handleSaveSettings}
+								disabled={!hasUnsavedChanges || saveStatus === "saving"}>
+								{saveStatus === "saving"
+									? t("settings:codeIndex.saving")
+									: t("settings:codeIndex.saveSettings")}
+							</VSCodeButton>
 						</div>
-					)}
-				</div>
-			</PopoverContent>
-		</Popover>
+
+						{/* Save Status Messages */}
+						{saveStatus === "error" && (
+							<div className="mt-2">
+								<span className="text-sm text-vscode-errorForeground block">
+									{saveError || t("settings:codeIndex.saveError")}
+								</span>
+							</div>
+						)}
+					</div>
+				</PopoverContent>
+			</Popover>
+
+			{/* Discard Changes Dialog */}
+			<AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
+				<AlertDialogContent>
+					<AlertDialogHeader>
+						<AlertDialogTitle className="flex items-center gap-2">
+							<AlertTriangle className="w-5 h-5 text-yellow-500" />
+							{t("settings:unsavedChangesDialog.title")}
+						</AlertDialogTitle>
+						<AlertDialogDescription>
+							{t("settings:unsavedChangesDialog.description")}
+						</AlertDialogDescription>
+					</AlertDialogHeader>
+					<AlertDialogFooter>
+						<AlertDialogCancel onClick={() => onConfirmDialogResult(false)}>
+							{t("settings:unsavedChangesDialog.cancelButton")}
+						</AlertDialogCancel>
+						<AlertDialogAction onClick={() => onConfirmDialogResult(true)}>
+							{t("settings:unsavedChangesDialog.discardButton")}
+						</AlertDialogAction>
+					</AlertDialogFooter>
+				</AlertDialogContent>
+			</AlertDialog>
+		</>
 	)
 }

+ 375 - 0
webview-ui/src/components/chat/__tests__/CodeIndexPopover.validation.spec.tsx

@@ -0,0 +1,375 @@
+import React from "react"
+import { render, screen, fireEvent, waitFor } from "@testing-library/react"
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { CodeIndexPopover } from "../CodeIndexPopover"
+
+// Mock the vscode API
+vi.mock("@src/utils/vscode", () => ({
+	vscode: {
+		postMessage: vi.fn(),
+	},
+}))
+
+// Mock the extension state context
+vi.mock("@src/context/ExtensionStateContext", () => ({
+	useExtensionState: vi.fn(),
+}))
+
+// Mock the translation context
+vi.mock("@src/i18n/TranslationContext", () => ({
+	useAppTranslation: () => ({ t: vi.fn((key: string) => key) }),
+}))
+
+// Mock react-i18next
+vi.mock("react-i18next", () => ({
+	Trans: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+}))
+
+// Mock the doc links utility
+vi.mock("@src/utils/docLinks", () => ({
+	buildDocLink: vi.fn(() => "https://docs.roocode.com"),
+}))
+
+// Mock the portal hook
+vi.mock("@src/components/ui/hooks/useRooPortal", () => ({
+	useRooPortal: () => ({ portalContainer: document.body }),
+}))
+
+// Mock Radix UI components to avoid portal issues
+vi.mock("@src/components/ui", () => ({
+	Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	PopoverContent: ({ children }: { children: React.ReactNode }) => <div role="dialog">{children}</div>,
+	PopoverTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	Select: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	SelectContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => (
+		<div role="option" data-value={value}>
+			{children}
+		</div>
+	),
+	SelectTrigger: ({ children }: { children: React.ReactNode }) => <div role="combobox">{children}</div>,
+	SelectValue: ({ placeholder }: { placeholder?: string }) => <span>{placeholder}</span>,
+	AlertDialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	AlertDialogAction: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
+	AlertDialogCancel: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
+	AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	AlertDialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	AlertDialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	AlertDialogTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	Slider: ({ value, onValueChange }: { value: number[]; onValueChange: (value: number[]) => void }) => (
+		<input type="range" value={value[0]} onChange={(e) => onValueChange([parseInt(e.target.value)])} />
+	),
+	StandardTooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
+	cn: (...classes: string[]) => classes.join(" "),
+}))
+
+// Mock VSCode web components to behave like regular HTML inputs
+vi.mock("@vscode/webview-ui-toolkit/react", () => ({
+	VSCodeTextField: ({ value, onInput, placeholder, className, ...rest }: any) => (
+		<input
+			type="text"
+			value={value || ""}
+			onChange={(e) => onInput && onInput(e)}
+			placeholder={placeholder}
+			className={className}
+			aria-label="Text field"
+			{...rest}
+		/>
+	),
+	VSCodeButton: ({ children, onClick, ...rest }: any) => (
+		<button onClick={onClick} {...rest}>
+			{children}
+		</button>
+	),
+	VSCodeDropdown: ({ value, onChange, children, className, ...rest }: any) => (
+		<select
+			value={value || ""}
+			onChange={(e) => onChange && onChange(e)}
+			className={className}
+			role="combobox"
+			{...rest}>
+			{children}
+		</select>
+	),
+	VSCodeOption: ({ value, children, ...rest }: any) => (
+		<option value={value} {...rest}>
+			{children}
+		</option>
+	),
+	VSCodeLink: ({ href, children, ...rest }: any) => (
+		<a href={href} {...rest}>
+			{children}
+		</a>
+	),
+}))
+
+// Helper function to simulate input on form elements
+const simulateInput = (element: Element, value: string) => {
+	// Now that we're mocking VSCode components as regular HTML inputs,
+	// we can use standard fireEvent.change
+	fireEvent.change(element, { target: { value } })
+}
+
+describe("CodeIndexPopover Validation", () => {
+	let mockUseExtensionState: any
+
+	beforeEach(async () => {
+		vi.clearAllMocks()
+
+		// Get the mocked function
+		const { useExtensionState } = await import("@src/context/ExtensionStateContext")
+		mockUseExtensionState = vi.mocked(useExtensionState)
+
+		// Setup default extension state
+		mockUseExtensionState.mockReturnValue({
+			codebaseIndexConfig: {
+				codebaseIndexEnabled: false,
+				codebaseIndexQdrantUrl: "",
+				codebaseIndexEmbedderProvider: "openai",
+				codebaseIndexEmbedderBaseUrl: "",
+				codebaseIndexEmbedderModelId: "",
+				codebaseIndexSearchMaxResults: 10,
+				codebaseIndexSearchMinScore: 0.7,
+				codebaseIndexOpenAiCompatibleBaseUrl: "",
+				codebaseIndexEmbedderModelDimension: undefined,
+			},
+			codebaseIndexModels: {
+				openai: [{ dimension: 1536 }],
+			},
+		})
+	})
+
+	const renderComponent = () => {
+		return render(
+			<CodeIndexPopover indexingStatus={{ systemStatus: "idle", message: "", processedItems: 0, totalItems: 0 }}>
+				<button>Test Trigger</button>
+			</CodeIndexPopover>,
+		)
+	}
+
+	const openPopover = async () => {
+		const trigger = screen.getByText("Test Trigger")
+		fireEvent.click(trigger)
+
+		// Wait for popover to open
+		await waitFor(() => {
+			expect(screen.getByRole("dialog")).toBeInTheDocument()
+		})
+	}
+
+	const expandSetupSection = async () => {
+		const setupButton = screen.getByText("settings:codeIndex.setupConfigLabel")
+		fireEvent.click(setupButton)
+
+		// Wait for section to expand - look for vscode-text-field elements
+		await waitFor(() => {
+			const textFields = screen.getAllByLabelText("Text field")
+			expect(textFields.length).toBeGreaterThan(0)
+		})
+	}
+
+	describe("OpenAI Provider Validation", () => {
+		it("should show validation error when OpenAI API key is empty", async () => {
+			renderComponent()
+			await openPopover()
+			await expandSetupSection()
+
+			// First, make a change to enable the save button by modifying the Qdrant URL
+			const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
+			fireEvent.change(qdrantUrlField, { target: { value: "http://localhost:6333" } })
+
+			// Wait for the save button to become enabled
+			await waitFor(() => {
+				const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+				expect(saveButton).not.toBeDisabled()
+			})
+
+			// Now clear the OpenAI API key to create a validation error
+			const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
+			fireEvent.change(apiKeyField, { target: { value: "" } })
+
+			// Click the save button to trigger validation
+			const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+			fireEvent.click(saveButton)
+
+			// Should show specific field error
+			await waitFor(() => {
+				expect(screen.getByText("settings:codeIndex.validation.openaiApiKeyRequired")).toBeInTheDocument()
+			})
+		})
+
+		it("should show validation error when model is not selected", async () => {
+			renderComponent()
+			await openPopover()
+			await expandSetupSection()
+
+			// First, make a change to enable the save button
+			const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
+			fireEvent.change(qdrantUrlField, { target: { value: "http://localhost:6333" } })
+
+			// Set API key but leave model empty
+			const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
+			fireEvent.change(apiKeyField, { target: { value: "test-api-key" } })
+
+			// Wait for the save button to become enabled
+			await waitFor(() => {
+				const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+				expect(saveButton).not.toBeDisabled()
+			})
+
+			const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+			fireEvent.click(saveButton)
+
+			await waitFor(() => {
+				expect(screen.getByText("settings:codeIndex.validation.modelSelectionRequired")).toBeInTheDocument()
+			})
+		})
+	})
+
+	describe("Qdrant URL Validation", () => {
+		it("should show validation error when Qdrant URL is empty", async () => {
+			renderComponent()
+			await openPopover()
+			await expandSetupSection()
+
+			// First, make a change to enable the save button by setting API key
+			const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
+			fireEvent.change(apiKeyField, { target: { value: "test-api-key" } })
+
+			// Clear the Qdrant URL to create validation error
+			const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
+			fireEvent.change(qdrantUrlField, { target: { value: "" } })
+
+			// Wait for the save button to become enabled
+			await waitFor(() => {
+				const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+				expect(saveButton).not.toBeDisabled()
+			})
+
+			const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+			fireEvent.click(saveButton)
+
+			await waitFor(() => {
+				expect(screen.getByText("settings:codeIndex.validation.invalidQdrantUrl")).toBeInTheDocument()
+			})
+		})
+
+		it("should show validation error when Qdrant URL is invalid", async () => {
+			renderComponent()
+			await openPopover()
+			await expandSetupSection()
+
+			// First, make a change to enable the save button by setting API key
+			const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
+			fireEvent.change(apiKeyField, { target: { value: "test-api-key" } })
+
+			// Set invalid Qdrant URL
+			const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
+			fireEvent.change(qdrantUrlField, { target: { value: "invalid-url" } })
+
+			// Wait for the save button to become enabled
+			await waitFor(() => {
+				const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+				expect(saveButton).not.toBeDisabled()
+			})
+
+			const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+			fireEvent.click(saveButton)
+
+			await waitFor(() => {
+				expect(screen.getByText("settings:codeIndex.validation.invalidQdrantUrl")).toBeInTheDocument()
+			})
+		})
+	})
+
+	describe("Common Field Validation", () => {
+		it("should not show validation error for optional Qdrant API key", async () => {
+			renderComponent()
+			await openPopover()
+			await expandSetupSection()
+
+			// Set required fields to make form valid
+			const qdrantUrlField = screen.getByPlaceholderText(/settings:codeIndex.qdrantUrlPlaceholder/i)
+			fireEvent.change(qdrantUrlField, { target: { value: "http://localhost:6333" } })
+
+			const apiKeyField = screen.getByPlaceholderText(/settings:codeIndex.openAiKeyPlaceholder/i)
+			fireEvent.change(apiKeyField, { target: { value: "test-api-key" } })
+
+			// Select a model - this is required (get the select element specifically)
+			const modelSelect = screen.getAllByRole("combobox").find((el) => el.tagName === "SELECT")
+			if (modelSelect) {
+				fireEvent.change(modelSelect, { target: { value: "0" } })
+			}
+
+			// Leave Qdrant API key empty (it's optional)
+			const qdrantApiKeyField = screen.getByPlaceholderText(/settings:codeIndex.qdrantApiKeyPlaceholder/i)
+			fireEvent.change(qdrantApiKeyField, { target: { value: "" } })
+
+			// Wait for the save button to become enabled
+			await waitFor(() => {
+				const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+				expect(saveButton).not.toBeDisabled()
+			})
+
+			const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+			fireEvent.click(saveButton)
+
+			// Should not show validation errors since Qdrant API key is optional
+		})
+
+		it("should clear validation errors when fields are corrected", async () => {
+			renderComponent()
+			await openPopover()
+			await expandSetupSection()
+
+			// First make an invalid change to enable the save button and trigger validation
+			const textFields = screen.getAllByLabelText("Text field")
+			const qdrantField = textFields.find((field) =>
+				field.getAttribute("placeholder")?.toLowerCase().includes("qdrant"),
+			)
+
+			if (qdrantField) {
+				simulateInput(qdrantField, "invalid-url") // Invalid URL to trigger validation
+			}
+
+			// Wait for save button to be enabled
+			const saveButton = screen.getByText("settings:codeIndex.saveSettings")
+			await waitFor(() => {
+				expect(saveButton).not.toBeDisabled()
+			})
+
+			// Click save to trigger validation errors
+			fireEvent.click(saveButton)
+
+			// Now fix the errors with valid values
+			const apiKeyField = textFields.find(
+				(field) =>
+					field.getAttribute("placeholder")?.toLowerCase().includes("openai") ||
+					field.getAttribute("placeholder")?.toLowerCase().includes("key"),
+			)
+
+			// Set valid Qdrant URL
+			if (qdrantField) {
+				simulateInput(qdrantField, "http://localhost:6333")
+			}
+
+			// Set API key
+			if (apiKeyField) {
+				simulateInput(apiKeyField, "test-api-key")
+			}
+
+			// Select a model - this is required (get the select element specifically)
+			const modelSelect = screen.getAllByRole("combobox").find((el) => el.tagName === "SELECT")
+			if (modelSelect) {
+				fireEvent.change(modelSelect, { target: { value: "0" } })
+			}
+
+			// Try to save again
+			fireEvent.click(saveButton)
+
+			// Validation errors should be cleared (specific field errors are checked elsewhere)
+		})
+	})
+})

+ 15 - 0
webview-ui/src/i18n/locales/ca/settings.json

@@ -98,6 +98,21 @@
 			"error": "Error"
 		},
 		"close": "Tancar",
+		"validation": {
+			"invalidQdrantUrl": "URL de Qdrant no vàlida",
+			"invalidOllamaUrl": "URL d'Ollama no vàlida",
+			"invalidBaseUrl": "URL de base no vàlida",
+			"qdrantUrlRequired": "Cal una URL de Qdrant",
+			"openaiApiKeyRequired": "Cal una clau d'API d'OpenAI",
+			"modelSelectionRequired": "Cal seleccionar un model",
+			"apiKeyRequired": "Cal una clau d'API",
+			"modelIdRequired": "Cal un ID de model",
+			"modelDimensionRequired": "Cal una dimensió de model",
+			"geminiApiKeyRequired": "Cal una clau d'API de Gemini",
+			"ollamaBaseUrlRequired": "Cal una URL base d'Ollama",
+			"baseUrlRequired": "Cal una URL base",
+			"modelDimensionMinValue": "La dimensió del model ha de ser superior a 0"
+		},
 		"advancedConfigLabel": "Configuració avançada",
 		"searchMinScoreLabel": "Llindar de puntuació de cerca",
 		"searchMinScoreDescription": "Puntuació mínima de similitud (0.0-1.0) requerida per als resultats de la cerca. Valors més baixos retornen més resultats però poden ser menys rellevants. Valors més alts retornen menys resultats però més rellevants.",

+ 15 - 0
webview-ui/src/i18n/locales/de/settings.json

@@ -98,6 +98,21 @@
 			"error": "Fehler"
 		},
 		"close": "Schließen",
+		"validation": {
+			"invalidQdrantUrl": "Ungültige Qdrant-URL",
+			"invalidOllamaUrl": "Ungültige Ollama-URL",
+			"invalidBaseUrl": "Ungültige Basis-URL",
+			"qdrantUrlRequired": "Qdrant-URL ist erforderlich",
+			"openaiApiKeyRequired": "OpenAI-API-Schlüssel ist erforderlich",
+			"modelSelectionRequired": "Modellauswahl ist erforderlich",
+			"apiKeyRequired": "API-Schlüssel ist erforderlich",
+			"modelIdRequired": "Modell-ID ist erforderlich",
+			"modelDimensionRequired": "Modellabmessung ist erforderlich",
+			"geminiApiKeyRequired": "Gemini-API-Schlüssel ist erforderlich",
+			"ollamaBaseUrlRequired": "Ollama-Basis-URL ist erforderlich",
+			"baseUrlRequired": "Basis-URL ist erforderlich",
+			"modelDimensionMinValue": "Modellabmessung muss größer als 0 sein"
+		},
 		"advancedConfigLabel": "Erweiterte Konfiguration",
 		"searchMinScoreLabel": "Suchergebnis-Schwellenwert",
 		"searchMinScoreDescription": "Mindestähnlichkeitswert (0.0-1.0), der für Suchergebnisse erforderlich ist. Niedrigere Werte liefern mehr Ergebnisse, die jedoch möglicherweise weniger relevant sind. Höhere Werte liefern weniger, aber relevantere Ergebnisse.",

+ 16 - 1
webview-ui/src/i18n/locales/en/settings.json

@@ -104,7 +104,22 @@
 			"indexed": "Indexed",
 			"error": "Error"
 		},
-		"close": "Close"
+		"close": "Close",
+		"validation": {
+			"qdrantUrlRequired": "Qdrant URL is required",
+			"invalidQdrantUrl": "Invalid Qdrant URL",
+			"invalidOllamaUrl": "Invalid Ollama URL",
+			"invalidBaseUrl": "Invalid base URL",
+			"openaiApiKeyRequired": "OpenAI API key is required",
+			"modelSelectionRequired": "Model selection is required",
+			"apiKeyRequired": "API key is required",
+			"modelIdRequired": "Model ID is required",
+			"modelDimensionRequired": "Model dimension is required",
+			"geminiApiKeyRequired": "Gemini API key is required",
+			"ollamaBaseUrlRequired": "Ollama base URL is required",
+			"baseUrlRequired": "Base URL is required",
+			"modelDimensionMinValue": "Model dimension must be greater than 0"
+		}
 	},
 	"autoApprove": {
 		"description": "Allow Roo to automatically perform operations without requiring approval. Enable these settings only if you fully trust the AI and understand the associated security risks.",

+ 15 - 0
webview-ui/src/i18n/locales/es/settings.json

@@ -98,6 +98,21 @@
 			"error": "Error"
 		},
 		"close": "Cerrar",
+		"validation": {
+			"invalidQdrantUrl": "URL de Qdrant no válida",
+			"invalidOllamaUrl": "URL de Ollama no válida",
+			"invalidBaseUrl": "URL base no válida",
+			"qdrantUrlRequired": "Se requiere la URL de Qdrant",
+			"openaiApiKeyRequired": "Se requiere la clave API de OpenAI",
+			"modelSelectionRequired": "Se requiere la selección de un modelo",
+			"apiKeyRequired": "Se requiere la clave API",
+			"modelIdRequired": "Se requiere el ID del modelo",
+			"modelDimensionRequired": "Se requiere la dimensión del modelo",
+			"geminiApiKeyRequired": "Se requiere la clave API de Gemini",
+			"ollamaBaseUrlRequired": "Se requiere la URL base de Ollama",
+			"baseUrlRequired": "Se requiere la URL base",
+			"modelDimensionMinValue": "La dimensión del modelo debe ser mayor que 0"
+		},
 		"advancedConfigLabel": "Configuración avanzada",
 		"searchMinScoreLabel": "Umbral de puntuación de búsqueda",
 		"searchMinScoreDescription": "Puntuación mínima de similitud (0.0-1.0) requerida para los resultados de búsqueda. Valores más bajos devuelven más resultados pero pueden ser menos relevantes. Valores más altos devuelven menos resultados pero más relevantes.",

+ 15 - 0
webview-ui/src/i18n/locales/fr/settings.json

@@ -98,6 +98,21 @@
 			"error": "Erreur"
 		},
 		"close": "Fermer",
+		"validation": {
+			"invalidQdrantUrl": "URL Qdrant invalide",
+			"invalidOllamaUrl": "URL Ollama invalide",
+			"invalidBaseUrl": "URL de base invalide",
+			"qdrantUrlRequired": "L'URL Qdrant est requise",
+			"openaiApiKeyRequired": "La clé API OpenAI est requise",
+			"modelSelectionRequired": "La sélection du modèle est requise",
+			"apiKeyRequired": "La clé API est requise",
+			"modelIdRequired": "L'ID du modèle est requis",
+			"modelDimensionRequired": "La dimension du modèle est requise",
+			"geminiApiKeyRequired": "La clé API Gemini est requise",
+			"ollamaBaseUrlRequired": "L'URL de base Ollama est requise",
+			"baseUrlRequired": "L'URL de base est requise",
+			"modelDimensionMinValue": "La dimension du modèle doit être supérieure à 0"
+		},
 		"advancedConfigLabel": "Configuration avancée",
 		"searchMinScoreLabel": "Seuil de score de recherche",
 		"searchMinScoreDescription": "Score de similarité minimum (0.0-1.0) requis pour les résultats de recherche. Des valeurs plus faibles renvoient plus de résultats mais peuvent être moins pertinents. Des valeurs plus élevées renvoient moins de résultats mais plus pertinents.",

+ 15 - 0
webview-ui/src/i18n/locales/hi/settings.json

@@ -98,6 +98,21 @@
 			"error": "त्रुटि"
 		},
 		"close": "बंद करें",
+		"validation": {
+			"invalidQdrantUrl": "अमान्य Qdrant URL",
+			"invalidOllamaUrl": "अमान्य Ollama URL",
+			"invalidBaseUrl": "अमान्य बेस URL",
+			"qdrantUrlRequired": "Qdrant URL आवश्यक है",
+			"openaiApiKeyRequired": "OpenAI API कुंजी आवश्यक है",
+			"modelSelectionRequired": "मॉडल चयन आवश्यक है",
+			"apiKeyRequired": "API कुंजी आवश्यक है",
+			"modelIdRequired": "मॉडल आईडी आवश्यक है",
+			"modelDimensionRequired": "मॉडल आयाम आवश्यक है",
+			"geminiApiKeyRequired": "Gemini API कुंजी आवश्यक है",
+			"ollamaBaseUrlRequired": "Ollama आधार URL आवश्यक है",
+			"baseUrlRequired": "आधार URL आवश्यक है",
+			"modelDimensionMinValue": "मॉडल आयाम 0 से बड़ा होना चाहिए"
+		},
 		"advancedConfigLabel": "उन्नत कॉन्फ़िगरेशन",
 		"searchMinScoreLabel": "खोज स्कोर थ्रेसहोल्ड",
 		"searchMinScoreDescription": "खोज परिणामों के लिए आवश्यक न्यूनतम समानता स्कोर (0.0-1.0)। कम मान अधिक परिणाम लौटाते हैं लेकिन कम प्रासंगिक हो सकते हैं। उच्च मान कम लेकिन अधिक प्रासंगिक परिणाम लौटाते हैं।",

+ 15 - 0
webview-ui/src/i18n/locales/id/settings.json

@@ -98,6 +98,21 @@
 			"error": "Error"
 		},
 		"close": "Tutup",
+		"validation": {
+			"invalidQdrantUrl": "URL Qdrant tidak valid",
+			"invalidOllamaUrl": "URL Ollama tidak valid",
+			"invalidBaseUrl": "URL dasar tidak valid",
+			"qdrantUrlRequired": "URL Qdrant diperlukan",
+			"openaiApiKeyRequired": "Kunci API OpenAI diperlukan",
+			"modelSelectionRequired": "Pemilihan model diperlukan",
+			"apiKeyRequired": "Kunci API diperlukan",
+			"modelIdRequired": "ID Model diperlukan",
+			"modelDimensionRequired": "Dimensi model diperlukan",
+			"geminiApiKeyRequired": "Kunci API Gemini diperlukan",
+			"ollamaBaseUrlRequired": "URL dasar Ollama diperlukan",
+			"baseUrlRequired": "URL dasar diperlukan",
+			"modelDimensionMinValue": "Dimensi model harus lebih besar dari 0"
+		},
 		"advancedConfigLabel": "Konfigurasi Lanjutan",
 		"searchMinScoreLabel": "Ambang Batas Skor Pencarian",
 		"searchMinScoreDescription": "Skor kesamaan minimum (0.0-1.0) yang diperlukan untuk hasil pencarian. Nilai yang lebih rendah mengembalikan lebih banyak hasil tetapi mungkin kurang relevan. Nilai yang lebih tinggi mengembalikan lebih sedikit hasil tetapi lebih relevan.",

+ 15 - 0
webview-ui/src/i18n/locales/it/settings.json

@@ -98,6 +98,21 @@
 			"error": "Errore"
 		},
 		"close": "Chiudi",
+		"validation": {
+			"invalidQdrantUrl": "URL Qdrant non valido",
+			"invalidOllamaUrl": "URL Ollama non valido",
+			"invalidBaseUrl": "URL di base non valido",
+			"qdrantUrlRequired": "È richiesto l'URL di Qdrant",
+			"openaiApiKeyRequired": "È richiesta la chiave API di OpenAI",
+			"modelSelectionRequired": "È richiesta la selezione del modello",
+			"apiKeyRequired": "È richiesta la chiave API",
+			"modelIdRequired": "È richiesto l'ID del modello",
+			"modelDimensionRequired": "È richiesta la dimensione del modello",
+			"geminiApiKeyRequired": "È richiesta la chiave API Gemini",
+			"ollamaBaseUrlRequired": "È richiesto l'URL di base di Ollama",
+			"baseUrlRequired": "È richiesto l'URL di base",
+			"modelDimensionMinValue": "La dimensione del modello deve essere maggiore di 0"
+		},
 		"advancedConfigLabel": "Configurazione avanzata",
 		"searchMinScoreLabel": "Soglia punteggio di ricerca",
 		"searchMinScoreDescription": "Punteggio minimo di somiglianza (0.0-1.0) richiesto per i risultati della ricerca. Valori più bassi restituiscono più risultati ma potrebbero essere meno pertinenti. Valori più alti restituiscono meno risultati ma più pertinenti.",

+ 15 - 0
webview-ui/src/i18n/locales/ja/settings.json

@@ -98,6 +98,21 @@
 			"error": "エラー"
 		},
 		"close": "閉じる",
+		"validation": {
+			"invalidQdrantUrl": "無効なQdrant URL",
+			"invalidOllamaUrl": "無効なOllama URL",
+			"invalidBaseUrl": "無効なベースURL",
+			"qdrantUrlRequired": "Qdrant URL が必要です",
+			"openaiApiKeyRequired": "OpenAI APIキーが必要です",
+			"modelSelectionRequired": "モデルの選択が必要です",
+			"apiKeyRequired": "APIキーが必要です",
+			"modelIdRequired": "モデルIDが必要です",
+			"modelDimensionRequired": "モデルの次元が必要です",
+			"geminiApiKeyRequired": "Gemini APIキーが必要です",
+			"ollamaBaseUrlRequired": "OllamaのベースURLが必要です",
+			"baseUrlRequired": "ベースURLが必要です",
+			"modelDimensionMinValue": "モデルの次元は0より大きくなければなりません"
+		},
 		"advancedConfigLabel": "詳細設定",
 		"searchMinScoreLabel": "検索スコアのしきい値",
 		"searchMinScoreDescription": "検索結果に必要な最小類似度スコア(0.0-1.0)。値を低くするとより多くの結果が返されますが、関連性が低くなる可能性があります。値を高くすると返される結果は少なくなりますが、より関連性が高くなります。",

+ 15 - 0
webview-ui/src/i18n/locales/ko/settings.json

@@ -98,6 +98,21 @@
 			"error": "오류"
 		},
 		"close": "닫기",
+		"validation": {
+			"invalidQdrantUrl": "잘못된 Qdrant URL",
+			"invalidOllamaUrl": "잘못된 Ollama URL",
+			"invalidBaseUrl": "잘못된 기본 URL",
+			"qdrantUrlRequired": "Qdrant URL이 필요합니다",
+			"openaiApiKeyRequired": "OpenAI API 키가 필요합니다",
+			"modelSelectionRequired": "모델 선택이 필요합니다",
+			"apiKeyRequired": "API 키가 필요합니다",
+			"modelIdRequired": "모델 ID가 필요합니다",
+			"modelDimensionRequired": "모델 차원이 필요합니다",
+			"geminiApiKeyRequired": "Gemini API 키가 필요합니다",
+			"ollamaBaseUrlRequired": "Ollama 기본 URL이 필요합니다",
+			"baseUrlRequired": "기본 URL이 필요합니다",
+			"modelDimensionMinValue": "모델 차원은 0보다 커야 합니다"
+		},
 		"advancedConfigLabel": "고급 구성",
 		"searchMinScoreLabel": "검색 점수 임계값",
 		"searchMinScoreDescription": "검색 결과에 필요한 최소 유사도 점수(0.0-1.0). 값이 낮을수록 더 많은 결과가 반환되지만 관련성이 떨어질 수 있습니다. 값이 높을수록 결과는 적지만 관련성이 높은 결과가 반환됩니다.",

+ 15 - 0
webview-ui/src/i18n/locales/nl/settings.json

@@ -98,6 +98,21 @@
 			"error": "Fout"
 		},
 		"close": "Sluiten",
+		"validation": {
+			"invalidQdrantUrl": "Ongeldige Qdrant URL",
+			"invalidOllamaUrl": "Ongeldige Ollama URL",
+			"invalidBaseUrl": "Ongeldige basis-URL",
+			"qdrantUrlRequired": "Qdrant URL is vereist",
+			"openaiApiKeyRequired": "OpenAI API-sleutel is vereist",
+			"modelSelectionRequired": "Modelselectie is vereist",
+			"apiKeyRequired": "API-sleutel is vereist",
+			"modelIdRequired": "Model-ID is vereist",
+			"modelDimensionRequired": "Modelafmeting is vereist",
+			"geminiApiKeyRequired": "Gemini API-sleutel is vereist",
+			"ollamaBaseUrlRequired": "Ollama basis-URL is vereist",
+			"baseUrlRequired": "Basis-URL is vereist",
+			"modelDimensionMinValue": "Modelafmeting moet groter zijn dan 0"
+		},
 		"advancedConfigLabel": "Geavanceerde configuratie",
 		"searchMinScoreLabel": "Zoekscore drempel",
 		"searchMinScoreDescription": "Minimale overeenkomstscore (0.0-1.0) vereist voor zoekresultaten. Lagere waarden leveren meer resultaten op, maar zijn mogelijk minder relevant. Hogere waarden leveren minder, maar relevantere resultaten op.",

+ 15 - 0
webview-ui/src/i18n/locales/pl/settings.json

@@ -98,6 +98,21 @@
 			"error": "Błąd"
 		},
 		"close": "Zamknij",
+		"validation": {
+			"invalidQdrantUrl": "Nieprawidłowy URL Qdrant",
+			"invalidOllamaUrl": "Nieprawidłowy URL Ollama",
+			"invalidBaseUrl": "Nieprawidłowy podstawowy URL",
+			"qdrantUrlRequired": "Wymagany jest URL Qdrant",
+			"openaiApiKeyRequired": "Wymagany jest klucz API OpenAI",
+			"modelSelectionRequired": "Wymagany jest wybór modelu",
+			"apiKeyRequired": "Wymagany jest klucz API",
+			"modelIdRequired": "Wymagane jest ID modelu",
+			"modelDimensionRequired": "Wymagany jest wymiar modelu",
+			"geminiApiKeyRequired": "Wymagany jest klucz API Gemini",
+			"ollamaBaseUrlRequired": "Wymagany jest bazowy adres URL Ollama",
+			"baseUrlRequired": "Wymagany jest bazowy adres URL",
+			"modelDimensionMinValue": "Wymiar modelu musi być większy niż 0"
+		},
 		"advancedConfigLabel": "Konfiguracja zaawansowana",
 		"searchMinScoreLabel": "Próg wyniku wyszukiwania",
 		"searchMinScoreDescription": "Minimalny wynik podobieństwa (0.0-1.0) wymagany dla wyników wyszukiwania. Niższe wartości zwracają więcej wyników, ale mogą być mniej trafne. Wyższe wartości zwracają mniej wyników, ale bardziej trafnych.",

+ 15 - 0
webview-ui/src/i18n/locales/pt-BR/settings.json

@@ -98,6 +98,21 @@
 			"error": "Erro"
 		},
 		"close": "Fechar",
+		"validation": {
+			"invalidQdrantUrl": "URL do Qdrant inválida",
+			"invalidOllamaUrl": "URL do Ollama inválida",
+			"invalidBaseUrl": "URL base inválida",
+			"qdrantUrlRequired": "A URL do Qdrant é obrigatória",
+			"openaiApiKeyRequired": "A chave de API da OpenAI é obrigatória",
+			"modelSelectionRequired": "A seleção do modelo é obrigatória",
+			"apiKeyRequired": "A chave de API é obrigatória",
+			"modelIdRequired": "O ID do modelo é obrigatório",
+			"modelDimensionRequired": "A dimensão do modelo é obrigatória",
+			"geminiApiKeyRequired": "A chave de API do Gemini é obrigatória",
+			"ollamaBaseUrlRequired": "A URL base do Ollama é obrigatória",
+			"baseUrlRequired": "A URL base é obrigatória",
+			"modelDimensionMinValue": "A dimensão do modelo deve ser maior que 0"
+		},
 		"advancedConfigLabel": "Configuração Avançada",
 		"searchMinScoreLabel": "Limite de pontuação de busca",
 		"searchMinScoreDescription": "Pontuação mínima de similaridade (0.0-1.0) necessária para os resultados da busca. Valores mais baixos retornam mais resultados, mas podem ser menos relevantes. Valores mais altos retornam menos resultados, mas mais relevantes.",

+ 15 - 0
webview-ui/src/i18n/locales/ru/settings.json

@@ -98,6 +98,21 @@
 			"error": "Ошибка"
 		},
 		"close": "Закрыть",
+		"validation": {
+			"invalidQdrantUrl": "Неверный URL Qdrant",
+			"invalidOllamaUrl": "Неверный URL Ollama",
+			"invalidBaseUrl": "Неверный базовый URL",
+			"qdrantUrlRequired": "Требуется URL Qdrant",
+			"openaiApiKeyRequired": "Требуется ключ API OpenAI",
+			"modelSelectionRequired": "Требуется выбор модели",
+			"apiKeyRequired": "Требуется ключ API",
+			"modelIdRequired": "Требуется идентификатор модели",
+			"modelDimensionRequired": "Требуется размерность модели",
+			"geminiApiKeyRequired": "Требуется ключ API Gemini",
+			"ollamaBaseUrlRequired": "Требуется базовый URL Ollama",
+			"baseUrlRequired": "Требуется базовый URL",
+			"modelDimensionMinValue": "Размерность модели должна быть больше 0"
+		},
 		"advancedConfigLabel": "Расширенная конфигурация",
 		"searchMinScoreLabel": "Порог оценки поиска",
 		"searchMinScoreDescription": "Минимальный балл сходства (0.0-1.0), необходимый для результатов поиска. Более низкие значения возвращают больше результатов, но они могут быть менее релевантными. Более высокие значения возвращают меньше результатов, но более релевантных.",

+ 15 - 0
webview-ui/src/i18n/locales/tr/settings.json

@@ -98,6 +98,21 @@
 			"error": "Hata"
 		},
 		"close": "Kapat",
+		"validation": {
+			"invalidQdrantUrl": "Geçersiz Qdrant URL'si",
+			"invalidOllamaUrl": "Geçersiz Ollama URL'si",
+			"invalidBaseUrl": "Geçersiz temel URL'si",
+			"qdrantUrlRequired": "Qdrant URL'si gereklidir",
+			"openaiApiKeyRequired": "OpenAI API anahtarı gereklidir",
+			"modelSelectionRequired": "Model seçimi gereklidir",
+			"apiKeyRequired": "API anahtarı gereklidir",
+			"modelIdRequired": "Model kimliği gereklidir",
+			"modelDimensionRequired": "Model boyutu gereklidir",
+			"geminiApiKeyRequired": "Gemini API anahtarı gereklidir",
+			"ollamaBaseUrlRequired": "Ollama temel URL'si gereklidir",
+			"baseUrlRequired": "Temel URL'si gereklidir",
+			"modelDimensionMinValue": "Model boyutu 0'dan büyük olmalıdır"
+		},
 		"advancedConfigLabel": "Gelişmiş Yapılandırma",
 		"searchMinScoreLabel": "Arama Skoru Eşiği",
 		"searchMinScoreDescription": "Arama sonuçları için gereken minimum benzerlik puanı (0.0-1.0). Düşük değerler daha fazla sonuç döndürür ancak daha az alakalı olabilir. Yüksek değerler daha az ancak daha alakalı sonuçlar döndürür.",

+ 15 - 0
webview-ui/src/i18n/locales/vi/settings.json

@@ -98,6 +98,21 @@
 			"error": "Lỗi"
 		},
 		"close": "Đóng",
+		"validation": {
+			"invalidQdrantUrl": "URL Qdrant không hợp lệ",
+			"invalidOllamaUrl": "URL Ollama không hợp lệ",
+			"invalidBaseUrl": "URL cơ sở không hợp lệ",
+			"qdrantUrlRequired": "Yêu cầu URL Qdrant",
+			"openaiApiKeyRequired": "Yêu cầu khóa API OpenAI",
+			"modelSelectionRequired": "Yêu cầu chọn mô hình",
+			"apiKeyRequired": "Yêu cầu khóa API",
+			"modelIdRequired": "Yêu cầu ID mô hình",
+			"modelDimensionRequired": "Yêu cầu kích thước mô hình",
+			"geminiApiKeyRequired": "Yêu cầu khóa API Gemini",
+			"ollamaBaseUrlRequired": "Yêu cầu URL cơ sở Ollama",
+			"baseUrlRequired": "Yêu cầu URL cơ sở",
+			"modelDimensionMinValue": "Kích thước mô hình phải lớn hơn 0"
+		},
 		"advancedConfigLabel": "Cấu hình nâng cao",
 		"searchMinScoreLabel": "Ngưỡng điểm tìm kiếm",
 		"searchMinScoreDescription": "Điểm tương đồng tối thiểu (0.0-1.0) cần thiết cho kết quả tìm kiếm. Giá trị thấp hơn trả về nhiều kết quả hơn nhưng có thể kém liên quan hơn. Giá trị cao hơn trả về ít kết quả hơn nhưng có liên quan hơn.",

+ 15 - 0
webview-ui/src/i18n/locales/zh-CN/settings.json

@@ -98,6 +98,21 @@
 			"error": "错误"
 		},
 		"close": "关闭",
+		"validation": {
+			"invalidQdrantUrl": "无效的 Qdrant URL",
+			"invalidOllamaUrl": "无效的 Ollama URL",
+			"invalidBaseUrl": "无效的基础 URL",
+			"qdrantUrlRequired": "需要 Qdrant URL",
+			"openaiApiKeyRequired": "需要 OpenAI API 密钥",
+			"modelSelectionRequired": "需要选择模型",
+			"apiKeyRequired": "需要 API 密钥",
+			"modelIdRequired": "需要模型 ID",
+			"modelDimensionRequired": "需要模型维度",
+			"geminiApiKeyRequired": "需要 Gemini API 密钥",
+			"ollamaBaseUrlRequired": "需要 Ollama 基础 URL",
+			"baseUrlRequired": "需要基础 URL",
+			"modelDimensionMinValue": "模型维度必须大于 0"
+		},
 		"advancedConfigLabel": "高级配置",
 		"searchMinScoreLabel": "搜索分数阈值",
 		"searchMinScoreDescription": "搜索结果所需的最低相似度分数(0.0-1.0)。较低的值返回更多结果,但可能不太相关。较高的值返回较少但更相关的结果。",

+ 15 - 0
webview-ui/src/i18n/locales/zh-TW/settings.json

@@ -98,6 +98,21 @@
 			"error": "錯誤"
 		},
 		"close": "關閉",
+		"validation": {
+			"invalidQdrantUrl": "無效的 Qdrant URL",
+			"invalidOllamaUrl": "無效的 Ollama URL",
+			"invalidBaseUrl": "無效的基礎 URL",
+			"qdrantUrlRequired": "需要 Qdrant URL",
+			"openaiApiKeyRequired": "需要 OpenAI API 金鑰",
+			"modelSelectionRequired": "需要選擇模型",
+			"apiKeyRequired": "需要 API 金鑰",
+			"modelIdRequired": "需要模型 ID",
+			"modelDimensionRequired": "需要模型維度",
+			"geminiApiKeyRequired": "需要 Gemini API 金鑰",
+			"ollamaBaseUrlRequired": "需要 Ollama 基礎 URL",
+			"baseUrlRequired": "需要基礎 URL",
+			"modelDimensionMinValue": "模型維度必須大於 0"
+		},
 		"advancedConfigLabel": "進階設定",
 		"searchMinScoreLabel": "搜尋分數閾值",
 		"searchMinScoreDescription": "搜尋結果所需的最低相似度分數(0.0-1.0)。較低的值會傳回更多結果,但可能較不相關。較高的值會傳回較少但更相關的結果。",