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

feat: Add indexing status badge to chat view (#4429) (#4532)

Co-authored-by: Daniel <[email protected]>
Co-authored-by: Daniel Riccio <[email protected]>
Hannes Rudolph 6 месяцев назад
Родитель
Сommit
bde7a7bf2a

+ 14 - 0
src/shared/ExtensionMessage.ts

@@ -18,6 +18,20 @@ import { Mode } from "./modes"
 import { RouterModels } from "./api"
 import { MarketplaceItem } from "../services/marketplace/types"
 
+// Indexing status types
+export interface IndexingStatus {
+	systemStatus: string
+	message?: string
+	processedItems: number
+	totalItems: number
+	currentItemUnit?: string
+}
+
+export interface IndexingStatusUpdateMessage {
+	type: "indexingStatusUpdate"
+	values: IndexingStatus
+}
+
 export interface LanguageModelChatSelector {
 	vendor?: string
 	family?: string

+ 3 - 0
webview-ui/src/components/chat/ChatTextArea.tsx

@@ -26,6 +26,7 @@ import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
 import ContextMenu from "./ContextMenu"
 import { VolumeX, Pin, Check } from "lucide-react"
 import { IconButton } from "./IconButton"
+import { IndexingStatusDot } from "./IndexingStatusBadge"
 import { cn } from "@/lib/utils"
 import { usePromptHistory } from "./hooks/usePromptHistory"
 
@@ -78,6 +79,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 			togglePinnedApiConfig,
 			taskHistory,
 			clineMessages,
+			codebaseIndexConfig,
 		} = useExtensionState()
 
 		// Find the ID and display text for the currently selected API configuration
@@ -1171,6 +1173,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
 					</div>
 
 					<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
+						{codebaseIndexConfig?.codebaseIndexEnabled && <IndexingStatusDot />}
 						<IconButton
 							iconClass={isEnhancingPrompt ? "codicon-loading" : "codicon-sparkle"}
 							title={t("chat:enhancePrompt")}

+ 158 - 0
webview-ui/src/components/chat/IndexingStatusBadge.tsx

@@ -0,0 +1,158 @@
+import React, { useState, useEffect, useMemo } from "react"
+import { cn } from "@src/lib/utils"
+import { vscode } from "@src/utils/vscode"
+import { useAppTranslation } from "@/i18n/TranslationContext"
+import { useTooltip } from "@/hooks/useTooltip"
+import type { IndexingStatus, IndexingStatusUpdateMessage } from "@roo/ExtensionMessage"
+
+interface IndexingStatusDotProps {
+	className?: string
+}
+
+export const IndexingStatusDot: React.FC<IndexingStatusDotProps> = ({ className }) => {
+	const { t } = useAppTranslation()
+	const { showTooltip, handleMouseEnter, handleMouseLeave, cleanup } = useTooltip({ delay: 300 })
+	const [isHovered, setIsHovered] = useState(false)
+
+	const [indexingStatus, setIndexingStatus] = useState<IndexingStatus>({
+		systemStatus: "Standby",
+		processedItems: 0,
+		totalItems: 0,
+		currentItemUnit: "items",
+	})
+
+	useEffect(() => {
+		// Request initial indexing status
+		vscode.postMessage({ type: "requestIndexingStatus" })
+
+		// Set up message listener for status updates
+		const handleMessage = (event: MessageEvent<IndexingStatusUpdateMessage>) => {
+			if (event.data.type === "indexingStatusUpdate") {
+				const status = event.data.values
+				setIndexingStatus(status)
+			}
+		}
+
+		window.addEventListener("message", handleMessage)
+
+		return () => {
+			window.removeEventListener("message", handleMessage)
+			cleanup()
+		}
+	}, [cleanup])
+
+	// Calculate progress percentage with memoization
+	const progressPercentage = useMemo(
+		() =>
+			indexingStatus.totalItems > 0
+				? Math.round((indexingStatus.processedItems / indexingStatus.totalItems) * 100)
+				: 0,
+		[indexingStatus.processedItems, indexingStatus.totalItems],
+	)
+
+	// Get tooltip text with internationalization
+	const getTooltipText = () => {
+		switch (indexingStatus.systemStatus) {
+			case "Standby":
+				return t("chat:indexingStatus.ready")
+			case "Indexing":
+				return t("chat:indexingStatus.indexing", { percentage: progressPercentage })
+			case "Indexed":
+				return t("chat:indexingStatus.indexed")
+			case "Error":
+				return t("chat:indexingStatus.error")
+			default:
+				return t("chat:indexingStatus.status")
+		}
+	}
+
+	// Navigate to settings when clicked
+	const handleClick = () => {
+		window.postMessage(
+			{
+				type: "action",
+				action: "settingsButtonClicked",
+				values: { section: "experimental" },
+			},
+			"*",
+		)
+	}
+
+	const handleMouseEnterButton = () => {
+		setIsHovered(true)
+		handleMouseEnter()
+	}
+
+	const handleMouseLeaveButton = () => {
+		setIsHovered(false)
+		handleMouseLeave()
+	}
+
+	// Get status color classes based on status and hover state
+	const getStatusColorClass = () => {
+		const statusColors = {
+			Standby: {
+				default: "bg-vscode-descriptionForeground/40",
+				hover: "bg-vscode-descriptionForeground/60",
+			},
+			Indexing: {
+				default: "bg-yellow-500/40 animate-pulse",
+				hover: "bg-yellow-500 animate-pulse",
+			},
+			Indexed: {
+				default: "bg-green-500/40",
+				hover: "bg-green-500",
+			},
+			Error: {
+				default: "bg-red-500/40",
+				hover: "bg-red-500",
+			},
+		}
+
+		const colors = statusColors[indexingStatus.systemStatus as keyof typeof statusColors] || statusColors.Standby
+		return isHovered ? colors.hover : colors.default
+	}
+
+	return (
+		<div className={cn("relative inline-block", className)}>
+			<button
+				onClick={handleClick}
+				onMouseEnter={handleMouseEnterButton}
+				onMouseLeave={handleMouseLeaveButton}
+				className={cn(
+					"flex items-center justify-center w-7 h-7 rounded-md",
+					"bg-transparent hover:bg-vscode-list-hoverBackground",
+					"cursor-pointer transition-all duration-200",
+					"opacity-85 hover:opacity-100 relative",
+				)}
+				aria-label={getTooltipText()}>
+				{/* Status dot */}
+				<span
+					className={cn(
+						"inline-block w-2 h-2 rounded-full relative z-10 transition-colors duration-200",
+						getStatusColorClass(),
+					)}
+				/>
+			</button>
+			{showTooltip && (
+				<div
+					className={cn(
+						"absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2",
+						"px-2 py-1 text-xs font-medium text-vscode-foreground",
+						"bg-vscode-editor-background border border-vscode-panel-border",
+						"rounded shadow-lg whitespace-nowrap z-50",
+					)}
+					role="tooltip">
+					{getTooltipText()}
+					<div
+						className={cn(
+							"absolute top-full left-1/2 transform -translate-x-1/2",
+							"w-0 h-0 border-l-4 border-r-4 border-t-4",
+							"border-l-transparent border-r-transparent border-t-vscode-panel-border",
+						)}
+					/>
+				</div>
+			)}
+		</div>
+	)
+}

+ 3 - 0
webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx

@@ -126,6 +126,9 @@ describe("ChatTextArea", () => {
 
 			render(<ChatTextArea {...defaultProps} inputValue="" />)
 
+			// Clear any calls from component initialization (e.g., IndexingStatusBadge)
+			mockPostMessage.mockClear()
+
 			const enhanceButton = getEnhancePromptButton()
 			fireEvent.click(enhanceButton)
 

+ 278 - 0
webview-ui/src/components/chat/__tests__/IndexingStatusBadge.test.tsx

@@ -0,0 +1,278 @@
+import React from "react"
+import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
+import { IndexingStatusDot } from "../IndexingStatusBadge"
+import { vscode } from "@src/utils/vscode"
+
+// Mock i18n setup to prevent initialization errors
+jest.mock("@/i18n/setup", () => ({
+	__esModule: true,
+	default: {
+		use: jest.fn().mockReturnThis(),
+		init: jest.fn().mockReturnThis(),
+		addResourceBundle: jest.fn(),
+		language: "en",
+		changeLanguage: jest.fn(),
+	},
+	loadTranslations: jest.fn(),
+}))
+
+// Mock react-i18next
+jest.mock("react-i18next", () => ({
+	useTranslation: () => ({
+		t: (key: string, params?: any) => {
+			const translations: Record<string, string> = {
+				"indexingStatus.ready": "Index ready",
+				"indexingStatus.indexing": params?.progress !== undefined ? `Indexing ${params.progress}%` : "Indexing",
+				"indexingStatus.error": "Index error",
+				"indexingStatus.indexed": "Indexed",
+				"indexingStatus.tooltip.ready": "The codebase index is ready for use",
+				"indexingStatus.tooltip.indexing":
+					params?.progress !== undefined
+						? `Indexing in progress: ${params.progress}% complete`
+						: "Indexing in progress",
+				"indexingStatus.tooltip.error": "An error occurred during indexing",
+				"indexingStatus.tooltip.indexed": "Codebase has been successfully indexed",
+				"indexingStatus.tooltip.clickToSettings": "Click to open indexing settings",
+			}
+			return translations[key] || key
+		},
+		i18n: {
+			language: "en",
+			changeLanguage: jest.fn(),
+		},
+	}),
+	initReactI18next: {
+		type: "3rdParty",
+		init: jest.fn(),
+	},
+}))
+
+// Mock vscode API
+jest.mock("@src/utils/vscode", () => ({
+	vscode: {
+		postMessage: jest.fn(),
+	},
+}))
+
+// Mock the useTooltip hook
+jest.mock("@/hooks/useTooltip", () => ({
+	useTooltip: jest.fn(() => ({
+		showTooltip: false,
+		handleMouseEnter: jest.fn(),
+		handleMouseLeave: jest.fn(),
+		cleanup: jest.fn(),
+	})),
+}))
+
+// Mock the ExtensionStateContext
+jest.mock("@/context/ExtensionStateContext", () => ({
+	useExtensionState: () => ({
+		version: "1.0.0",
+		clineMessages: [],
+		taskHistory: [],
+		shouldShowAnnouncement: false,
+		language: "en",
+	}),
+	ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+}))
+
+// Mock TranslationContext to provide t function directly
+jest.mock("@/i18n/TranslationContext", () => ({
+	useAppTranslation: () => ({
+		t: (key: string, params?: any) => {
+			// Remove namespace prefix if present
+			const cleanKey = key.includes(":") ? key.split(":")[1] : key
+
+			const translations: Record<string, string> = {
+				"indexingStatus.ready": "Index ready",
+				"indexingStatus.indexing":
+					params?.percentage !== undefined ? `Indexing ${params.percentage}%` : "Indexing",
+				"indexingStatus.error": "Index error",
+				"indexingStatus.indexed": "Indexed",
+				"indexingStatus.tooltip.ready": "The codebase index is ready for use",
+				"indexingStatus.tooltip.indexing":
+					params?.percentage !== undefined
+						? `Indexing in progress: ${params.percentage}% complete`
+						: "Indexing in progress",
+				"indexingStatus.tooltip.error": "An error occurred during indexing",
+				"indexingStatus.tooltip.indexed": "Codebase has been successfully indexed",
+				"indexingStatus.tooltip.clickToSettings": "Click to open indexing settings",
+			}
+			return translations[cleanKey] || cleanKey
+		},
+	}),
+}))
+
+describe("IndexingStatusDot", () => {
+	const renderComponent = (props = {}) => {
+		return render(<IndexingStatusDot {...props} />)
+	}
+
+	beforeEach(() => {
+		jest.clearAllMocks()
+	})
+
+	it("renders the status dot", () => {
+		renderComponent()
+		const button = screen.getByRole("button")
+		expect(button).toBeInTheDocument()
+	})
+
+	it("shows standby status by default", () => {
+		renderComponent()
+		const button = screen.getByRole("button")
+		expect(button).toHaveAttribute("aria-label", "Index ready")
+	})
+
+	it("posts settingsButtonClicked message when clicked", () => {
+		// Mock window.postMessage
+		const postMessageSpy = jest.spyOn(window, "postMessage")
+
+		renderComponent()
+
+		const button = screen.getByRole("button")
+		fireEvent.click(button)
+
+		expect(postMessageSpy).toHaveBeenCalledWith(
+			{
+				type: "action",
+				action: "settingsButtonClicked",
+				values: { section: "experimental" },
+			},
+			"*",
+		)
+
+		postMessageSpy.mockRestore()
+	})
+
+	it("requests indexing status on mount", () => {
+		renderComponent()
+
+		expect(vscode.postMessage).toHaveBeenCalledWith({
+			type: "requestIndexingStatus",
+		})
+	})
+
+	it("updates status when receiving indexingStatusUpdate message", async () => {
+		renderComponent()
+
+		// Simulate receiving an indexing status update
+		const event = new MessageEvent("message", {
+			data: {
+				type: "indexingStatusUpdate",
+				values: {
+					systemStatus: "Indexing",
+					processedItems: 50,
+					totalItems: 100,
+					currentItemUnit: "files",
+				},
+			},
+		})
+
+		act(() => {
+			window.dispatchEvent(event)
+		})
+
+		await waitFor(() => {
+			const button = screen.getByRole("button")
+			expect(button).toHaveAttribute("aria-label", "Indexing 50%")
+		})
+	})
+
+	it("shows error status correctly", async () => {
+		renderComponent()
+
+		// Simulate error status
+		const event = new MessageEvent("message", {
+			data: {
+				type: "indexingStatusUpdate",
+				values: {
+					systemStatus: "Error",
+					processedItems: 0,
+					totalItems: 0,
+					currentItemUnit: "files",
+				},
+			},
+		})
+
+		act(() => {
+			window.dispatchEvent(event)
+		})
+
+		await waitFor(() => {
+			const button = screen.getByRole("button")
+			expect(button).toHaveAttribute("aria-label", "Index error")
+		})
+	})
+
+	it("shows indexed status correctly", async () => {
+		renderComponent()
+
+		// Simulate indexed status
+		const event = new MessageEvent("message", {
+			data: {
+				type: "indexingStatusUpdate",
+				values: {
+					systemStatus: "Indexed",
+					processedItems: 100,
+					totalItems: 100,
+					currentItemUnit: "files",
+				},
+			},
+		})
+
+		act(() => {
+			window.dispatchEvent(event)
+		})
+
+		await waitFor(() => {
+			const button = screen.getByRole("button")
+			expect(button).toHaveAttribute("aria-label", "Indexed")
+		})
+	})
+
+	it("cleans up event listener on unmount", () => {
+		const { unmount } = renderComponent()
+		const removeEventListenerSpy = jest.spyOn(window, "removeEventListener")
+
+		unmount()
+
+		expect(removeEventListenerSpy).toHaveBeenCalledWith("message", expect.any(Function))
+	})
+
+	it("calculates progress percentage correctly", async () => {
+		renderComponent()
+
+		// Test various progress scenarios
+		const testCases = [
+			{ processed: 0, total: 100, expected: 0 },
+			{ processed: 25, total: 100, expected: 25 },
+			{ processed: 33, total: 100, expected: 33 },
+			{ processed: 100, total: 100, expected: 100 },
+			{ processed: 0, total: 0, expected: 0 },
+		]
+
+		for (const testCase of testCases) {
+			const event = new MessageEvent("message", {
+				data: {
+					type: "indexingStatusUpdate",
+					values: {
+						systemStatus: "Indexing",
+						processedItems: testCase.processed,
+						totalItems: testCase.total,
+						currentItemUnit: "files",
+					},
+				},
+			})
+
+			act(() => {
+				window.dispatchEvent(event)
+			})
+
+			await waitFor(() => {
+				const button = screen.getByRole("button")
+				expect(button).toHaveAttribute("aria-label", `Indexing ${testCase.expected}%`)
+			})
+		}
+	})
+})

+ 1 - 10
webview-ui/src/components/settings/CodeIndexSettings.tsx

@@ -40,16 +40,7 @@ interface CodeIndexSettingsProps {
 	areSettingsCommitted: boolean
 }
 
-interface IndexingStatusUpdateMessage {
-	type: "indexingStatusUpdate"
-	values: {
-		systemStatus: string
-		message?: string
-		processedItems: number
-		totalItems: number
-		currentItemUnit?: string
-	}
-}
+import type { IndexingStatusUpdateMessage } from "@roo/ExtensionMessage"
 
 export const CodeIndexSettings: React.FC<CodeIndexSettingsProps> = ({
 	codebaseIndexModels,

+ 39 - 0
webview-ui/src/hooks/useTooltip.ts

@@ -0,0 +1,39 @@
+import { useState, useCallback, useRef } from "react"
+
+interface UseTooltipOptions {
+	delay?: number
+}
+
+export const useTooltip = (options: UseTooltipOptions = {}) => {
+	const { delay = 300 } = options
+	const [showTooltip, setShowTooltip] = useState(false)
+	const timeoutRef = useRef<NodeJS.Timeout | null>(null)
+
+	const handleMouseEnter = useCallback(() => {
+		if (timeoutRef.current) clearTimeout(timeoutRef.current)
+		timeoutRef.current = setTimeout(() => setShowTooltip(true), delay)
+	}, [delay])
+
+	const handleMouseLeave = useCallback(() => {
+		if (timeoutRef.current) {
+			clearTimeout(timeoutRef.current)
+			timeoutRef.current = null
+		}
+		setShowTooltip(false)
+	}, [])
+
+	// Cleanup on unmount
+	const cleanup = useCallback(() => {
+		if (timeoutRef.current) {
+			clearTimeout(timeoutRef.current)
+			timeoutRef.current = null
+		}
+	}, [])
+
+	return {
+		showTooltip,
+		handleMouseEnter,
+		handleMouseLeave,
+		cleanup,
+	}
+}

+ 7 - 0
webview-ui/src/i18n/locales/ca/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Denegar tot"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Índex preparat",
+		"indexing": "Indexant {{percentage}}%",
+		"indexed": "Indexat",
+		"error": "Error d'índex",
+		"status": "Estat de l'índex"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/de/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Alle ablehnen"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Index bereit",
+		"indexing": "Indizierung {{percentage}}%",
+		"indexed": "Indiziert",
+		"error": "Index-Fehler",
+		"status": "Index-Status"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/en/chat.json

@@ -279,5 +279,12 @@
 			"description": "Roo has reached the auto-approved limit of {{count}} API request(s). Would you like to reset the count and proceed with the task?",
 			"button": "Reset and Continue"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Index ready",
+		"indexing": "Indexing {{percentage}}%",
+		"indexed": "Indexed",
+		"error": "Index error",
+		"status": "Index status"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/es/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Denegar todo"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Índice listo",
+		"indexing": "Indexando {{percentage}}%",
+		"indexed": "Indexado",
+		"error": "Error de índice",
+		"status": "Estado del índice"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/fr/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Tout refuser"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Index prêt",
+		"indexing": "Indexation {{percentage}}%",
+		"indexed": "Indexé",
+		"error": "Erreur d'index",
+		"status": "Statut de l'index"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/hi/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "सभी अस्वीकार करें"
 		}
+	},
+	"indexingStatus": {
+		"ready": "इंडेक्स तैयार",
+		"indexing": "इंडेक्सिंग {{percentage}}%",
+		"indexed": "इंडेक्स किया गया",
+		"error": "इंडेक्स त्रुटि",
+		"status": "इंडेक्स स्थिति"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/it/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Nega tutto"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Indice pronto",
+		"indexing": "Indicizzazione {{percentage}}%",
+		"indexed": "Indicizzato",
+		"error": "Errore indice",
+		"status": "Stato indice"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/ja/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "すべて拒否"
 		}
+	},
+	"indexingStatus": {
+		"ready": "インデックス準備完了",
+		"indexing": "インデックス作成中 {{percentage}}%",
+		"indexed": "インデックス作成済み",
+		"error": "インデックスエラー",
+		"status": "インデックス状態"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/ko/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "모두 거부"
 		}
+	},
+	"indexingStatus": {
+		"ready": "인덱스 준비됨",
+		"indexing": "인덱싱 중 {{percentage}}%",
+		"indexed": "인덱싱 완료",
+		"error": "인덱스 오류",
+		"status": "인덱스 상태"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/nl/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Alles weigeren"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Index gereed",
+		"indexing": "Indexeren {{percentage}}%",
+		"indexed": "Geïndexeerd",
+		"error": "Index fout",
+		"status": "Index status"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/pl/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Odrzuć wszystko"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Indeks gotowy",
+		"indexing": "Indeksowanie {{percentage}}%",
+		"indexed": "Zaindeksowane",
+		"error": "Błąd indeksu",
+		"status": "Status indeksu"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/pt-BR/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Negar tudo"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Índice pronto",
+		"indexing": "Indexando {{percentage}}%",
+		"indexed": "Indexado",
+		"error": "Erro do índice",
+		"status": "Status do índice"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/ru/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Отклонить все"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Индекс готов",
+		"indexing": "Индексация {{percentage}}%",
+		"indexed": "Проиндексировано",
+		"error": "Ошибка индекса",
+		"status": "Статус индекса"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/tr/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Tümünü Reddet"
 		}
+	},
+	"indexingStatus": {
+		"ready": "İndeks hazır",
+		"indexing": "İndeksleniyor {{percentage}}%",
+		"indexed": "İndekslendi",
+		"error": "İndeks hatası",
+		"status": "İndeks durumu"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/vi/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "Từ chối tất cả"
 		}
+	},
+	"indexingStatus": {
+		"ready": "Chỉ mục sẵn sàng",
+		"indexing": "Đang lập chỉ mục {{percentage}}%",
+		"indexed": "Đã lập chỉ mục",
+		"error": "Lỗi chỉ mục",
+		"status": "Trạng thái chỉ mục"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/zh-CN/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "全部拒绝"
 		}
+	},
+	"indexingStatus": {
+		"ready": "索引就绪",
+		"indexing": "索引中 {{percentage}}%",
+		"indexed": "已索引",
+		"error": "索引错误",
+		"status": "索引状态"
 	}
 }

+ 7 - 0
webview-ui/src/i18n/locales/zh-TW/chat.json

@@ -279,5 +279,12 @@
 		"deny": {
 			"title": "全部拒絕"
 		}
+	},
+	"indexingStatus": {
+		"ready": "索引就緒",
+		"indexing": "索引中 {{percentage}}%",
+		"indexed": "已索引",
+		"error": "索引錯誤",
+		"status": "索引狀態"
 	}
 }