From 5e6900bd3baaf8a97adaa0fd2e7a1c82a05a3ddc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 23:22:09 +0000 Subject: [PATCH] feat: Add indexing status badge to chat view Implements an indexing status badge in the chat view that displays the current status of codebase indexing. The badge provides you with real-time feedback on indexing progress and errors without needing to navigate to the settings page. Key changes: - Created `IndexingStatusBadge.tsx`: A new component to display the badge. - Shows "Indexing X%" with a yellow pulsing dot for active indexing. - Shows "Indexing Error" with a red dot for errors. - Hidden for "Standby" or successful "Indexed" states. - Clicking the badge navigates you to the Codebase Indexing settings. - Integrated `IndexingStatusBadge` into `ChatView.tsx`: - `ChatView` now listens for `indexingStatusUpdate` messages from the extension. - The badge is rendered in the bottom-right corner of the chat view. - Added i18n translations for new badge text. - Added unit tests for `IndexingStatusBadge` and integration tests in `ChatView.test.tsx` to ensure correct behavior and visibility based on indexing status. --- webview-ui/public/locales/en/translation.json | 6 + webview-ui/src/components/chat/ChatView.tsx | 39 +++++ .../components/chat/IndexingStatusBadge.tsx | 74 +++++++++ .../chat/__tests__/ChatView.test.tsx | 157 +++++++++++++++++- .../__tests__/IndexingStatusBadge.test.tsx | 110 ++++++++++++ 5 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 webview-ui/public/locales/en/translation.json create mode 100644 webview-ui/src/components/chat/IndexingStatusBadge.tsx create mode 100644 webview-ui/src/components/chat/__tests__/IndexingStatusBadge.test.tsx diff --git a/webview-ui/public/locales/en/translation.json b/webview-ui/public/locales/en/translation.json new file mode 100644 index 0000000000..6d472352f1 --- /dev/null +++ b/webview-ui/public/locales/en/translation.json @@ -0,0 +1,6 @@ +{ + "indexingBadge": { + "indexingInProgress": "Indexing {{progress}}%", + "indexingError": "Indexing Error" + } +} diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 52a3026e8a..2b24e39725 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1,4 +1,5 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" +// import useEffect and useState from react if not already imported - already imported import { useDeepCompareEffect, useEvent, useMount } from "react-use" import debounce from "debounce" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" @@ -41,6 +42,7 @@ import AutoApproveMenu from "./AutoApproveMenu" import SystemPromptWarning from "./SystemPromptWarning" import ProfileViolationWarning from "./ProfileViolationWarning" import { CheckpointWarning } from "./CheckpointWarning" +import IndexingStatusBadge from "./IndexingStatusBadge"; // Added import export interface ChatViewProps { isHidden: boolean @@ -154,11 +156,42 @@ const ChatViewComponent: React.ForwardRefRenderFunction { clineAskRef.current = clineAsk }, [clineAsk]) + useEffect(() => { + vscode.postMessage({ type: "requestIndexingStatus" }); + + const handleMessage = (event: MessageEvent) => { + if (event.data.type === "indexingStatusUpdate") { + const { systemStatus, message, processedItems, totalItems } = event.data.values; + const progress = totalItems > 0 ? Math.round((processedItems / totalItems) * 100) : 0; + const isVisible = systemStatus === "Indexing" || systemStatus === "Error"; + + setIndexingStatus({ + status: systemStatus, + message: message || "", + progress: progress, + isVisible: isVisible, + }); + } + }; + + window.addEventListener("message", handleMessage); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + useEffect(() => { isMountedRef.current = true return () => { @@ -1555,6 +1588,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction + ) } diff --git a/webview-ui/src/components/chat/IndexingStatusBadge.tsx b/webview-ui/src/components/chat/IndexingStatusBadge.tsx new file mode 100644 index 0000000000..3067456039 --- /dev/null +++ b/webview-ui/src/components/chat/IndexingStatusBadge.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Trans } from "react-i18next"; +import { vscode } from "@src/utils/vscode"; + +interface IndexingStatusBadgeProps { + status: string; + message?: string; + progress: number; // 0-100 + isVisible: boolean; +} + +const IndexingStatusBadge: React.FC = ({ + status, + message, + progress, + isVisible, +}) => { + if (!isVisible) { + return null; + } + + const handleClick = () => { + vscode.postMessage({ + type: "navigateTo", + view: "settings", + section: "codeIndex", + }); + }; + + let badgeContent = null; + let badgeColor = ""; + let pulseAnimation = false; + + if (status === "Indexing") { + badgeContent = ( + <> +
+ + Indexing {{ progress }}% + + + ); + badgeColor = "bg-yellow-500"; + pulseAnimation = true; + } else if (status === "Error") { + badgeContent = ( + <> +
+ Indexing Error + {message && - {message}} + + ); + badgeColor = "bg-red-500"; + } else { + // Should not be visible for "Standby" or "Indexed" + return null; + } + + return ( +
+
+ {badgeContent} +
+
+ ); +}; + +export default IndexingStatusBadge; diff --git a/webview-ui/src/components/chat/__tests__/ChatView.test.tsx b/webview-ui/src/components/chat/__tests__/ChatView.test.tsx index 5b618378c4..072b49835a 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.test.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.test.tsx @@ -1,7 +1,7 @@ // npx jest src/components/chat/__tests__/ChatView.test.tsx import React from "react" -import { render, waitFor, act } from "@testing-library/react" +import { render, waitFor, act, screen } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" @@ -30,11 +30,27 @@ interface ExtensionState { } // Mock vscode API +const mockVscodePostMessage = jest.fn() jest.mock("@src/utils/vscode", () => ({ vscode: { - postMessage: jest.fn(), + postMessage: mockVscodePostMessage, }, })) +// Mock i18n for IndexingStatusBadge +jest.mock('react-i18next', () => ({ + ...jest.requireActual('react-i18next'), + Trans: ({ i18nKey, children }: { i18nKey: string, children: React.ReactNode[] | React.ReactNode }) => { + if (i18nKey === "indexingBadge.indexingInProgress") { + // Simulate interpolation for {{progress}} + const progress = React.Children.toArray(children).find(child => typeof child === 'object' && child && 'progress' in child) as any; + return <>Indexing {progress?.props?.progress}%; + } + if (i18nKey === "indexingBadge.indexingError") { + return <>Indexing Error; + } + return <>{children}; + }, +})); // Mock use-sound hook const mockPlayFunction = jest.fn() @@ -1072,3 +1088,140 @@ describe("ChatView - Focus Grabbing Tests", () => { expect(mockFocus).toHaveBeenCalledTimes(FOCUS_CALLS_ON_INIT) }) }) + +describe("ChatView - IndexingStatusBadge Integration", () => { + let mockAddEventListener = jest.fn(); + let mockRemoveEventListener = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks() + mockVscodePostMessage.mockClear(); // Use the specific mock for vscode + + // Store original event listener methods + const originalAddEventListener = window.addEventListener; + const originalRemoveEventListener = window.removeEventListener; + + // Mock event listener methods + mockAddEventListener = jest.fn(); + mockRemoveEventListener = jest.fn(); + window.addEventListener = mockAddEventListener; + window.removeEventListener = mockRemoveEventListener; + + // Restore original event listener methods after each test + return () => { + window.addEventListener = originalAddEventListener; + window.removeEventListener = originalRemoveEventListener; + }; + }); + + it("calls requestIndexingStatus on mount", () => { + renderChatView(); + expect(mockVscodePostMessage).toHaveBeenCalledWith({ type: "requestIndexingStatus" }); + }); + + it("shows IndexingStatusBadge when status is Indexing", async () => { + renderChatView(); + + // Simulate receiving indexingStatusUpdate message + act(() => { + const eventCallback = mockAddEventListener.mock.calls.find(call => call[0] === 'message')[1]; + eventCallback({ + data: { + type: "indexingStatusUpdate", + values: { + systemStatus: "Indexing", + message: "Processing files...", + processedItems: 10, + totalItems: 100, + }, + }, + }); + }); + + await waitFor(() => { + expect(screen.getByText("Indexing 10%")).toBeInTheDocument(); + const dot = screen.getByText('Indexing 10%').previousSibling as HTMLElement; + expect(dot).toHaveClass('bg-yellow-500'); + }); + }); + + it("shows IndexingStatusBadge with error message when status is Error", async () => { + renderChatView(); + + act(() => { + const eventCallback = mockAddEventListener.mock.calls.find(call => call[0] === 'message')[1]; + eventCallback({ + data: { + type: "indexingStatusUpdate", + values: { + systemStatus: "Error", + message: "Failed to index", + processedItems: 0, + totalItems: 0, + }, + }, + }); + }); + + await waitFor(() => { + expect(screen.getByText("Indexing Error")).toBeInTheDocument(); + expect(screen.getByText("- Failed to index")).toBeInTheDocument(); + const dot = screen.getByText('Indexing Error').previousSibling as HTMLElement; + expect(dot).toHaveClass('bg-red-500'); + }); + }); + + it("does not show IndexingStatusBadge for Standby status", async () => { + const { container } = renderChatView(); + act(() => { + const eventCallback = mockAddEventListener.mock.calls.find(call => call[0] === 'message')[1]; + eventCallback({ + data: { + type: "indexingStatusUpdate", + values: { + systemStatus: "Standby", + message: "", + processedItems: 0, + totalItems: 0, + }, + }, + }); + }); + + await waitFor(() => { + // The badge itself returns null, so we check its absence. + // We look for a known child of the badge if it were visible. + expect(screen.queryByText(/Indexing/)).toBeNull(); + expect(screen.queryByText(/Error/)).toBeNull(); + }); + }); + + it("does not show IndexingStatusBadge for Indexed status", async () => { + renderChatView(); + act(() => { + const eventCallback = mockAddEventListener.mock.calls.find(call => call[0] === 'message')[1]; + eventCallback({ + data: { + type: "indexingStatusUpdate", + values: { + systemStatus: "Indexed", + message: "", + processedItems: 100, + totalItems: 100, + }, + }, + }); + }); + + await waitFor(() => { + expect(screen.queryByText(/Indexing/)).toBeNull(); + expect(screen.queryByText(/Error/)).toBeNull(); + }); + }); + + it("cleans up event listener on unmount", () => { + const { unmount } = renderChatView(); + unmount(); + expect(mockRemoveEventListener).toHaveBeenCalledWith("message", expect.any(Function)); + }); +}); diff --git a/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.test.tsx b/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.test.tsx new file mode 100644 index 0000000000..a49d6449ad --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/IndexingStatusBadge.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import IndexingStatusBadge from '../IndexingStatusBadge'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../../i18n/i18n'; // Adjusted path + +const mockPostMessage = jest.fn(); +jest.mock('@src/utils/vscode', () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})); + +describe('IndexingStatusBadge', () => { + beforeEach(() => { + mockPostMessage.mockClear(); + // Mock i18n instance if not already configured for tests + if (!i18n.isInitialized) { + i18n.init({ + lng: 'en', + fallbackLng: 'en', + resources: { + en: { + translation: { + indexingBadge: { + indexingInProgress: 'Indexing {{progress}}%', + indexingError: 'Indexing Error', + }, + }, + }, + }, + interpolation: { + escapeValue: false, // React already safes from xss + }, + }); + } + }); + + const renderWithI18n = (component: React.ReactElement) => { + return render({component}); + }; + + it('renders correctly when indexing', () => { + renderWithI18n( + , + ); + expect(screen.getByText('Indexing 50%')).toBeInTheDocument(); + // Check for yellow dot (presence of a div with bg-yellow-500) + const dot = screen.getByText('Indexing 50%').previousSibling as HTMLElement; + expect(dot).toHaveClass('bg-yellow-500'); + expect(dot).toHaveClass('animate-pulse'); + }); + + it('renders correctly on error', () => { + renderWithI18n( + , + ); + expect(screen.getByText('Indexing Error')).toBeInTheDocument(); + expect(screen.getByText('- Test Error')).toBeInTheDocument(); + // Check for red dot (presence of a div with bg-red-500) + const dot = screen.getByText('Indexing Error').previousSibling as HTMLElement; + expect(dot).toHaveClass('bg-red-500'); + }); + + it('renders null if not visible', () => { + const { container } = renderWithI18n( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders null for Standby status', () => { + const { container } = renderWithI18n( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders null for Indexed status', () => { + const { container } = renderWithI18n( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('calls postMessage on click when indexing', () => { + renderWithI18n( + , + ); + // Find the clickable div (parent of the text) + fireEvent.click(screen.getByText('Indexing 50%').closest('div.fixed')!); + expect(mockPostMessage).toHaveBeenCalledWith({ + type: 'navigateTo', + view: 'settings', + section: 'codeIndex', + }); + }); + + it('calls postMessage on click when error', () => { + renderWithI18n( + , + ); + fireEvent.click(screen.getByText('Indexing Error').closest('div.fixed')!); + expect(mockPostMessage).toHaveBeenCalledWith({ + type: 'navigateTo', + view: 'settings', + section: 'codeIndex', + }); + }); +});