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.
This commit is contained in:
google-labs-jules[bot] 2025-06-10 23:22:09 +00:00
parent c03869bf1e
commit 5e6900bd3b
5 changed files with 384 additions and 2 deletions

View file

@ -0,0 +1,6 @@
{
"indexingBadge": {
"indexingInProgress": "Indexing {{progress}}%",
"indexingError": "Indexing Error"
}
}

View file

@ -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<ChatViewRef, ChatViewPro
}),
)
const [indexingStatus, setIndexingStatus] = useState({
status: "Standby", // or "Idle" / "Initial"
message: "",
progress: 0,
isVisible: false,
});
const clineAskRef = useRef(clineAsk)
useEffect(() => {
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<ChatViewRef, ChatViewPro
)}
<div id="roo-portal" />
<IndexingStatusBadge
status={indexingStatus.status}
message={indexingStatus.message}
progress={indexingStatus.progress}
isVisible={indexingStatus.isVisible}
/>
</div>
)
}

View file

@ -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<IndexingStatusBadgeProps> = ({
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 = (
<>
<div className="w-3 h-3 rounded-full bg-yellow-500 animate-pulse mr-2"></div>
<Trans i18nKey="indexingBadge.indexingInProgress">
Indexing {{ progress }}%
</Trans>
</>
);
badgeColor = "bg-yellow-500";
pulseAnimation = true;
} else if (status === "Error") {
badgeContent = (
<>
<div className="w-3 h-3 rounded-full bg-red-500 mr-2"></div>
<Trans i18nKey="indexingBadge.indexingError">Indexing Error</Trans>
{message && <span className="ml-1">- {message}</span>}
</>
);
badgeColor = "bg-red-500";
} else {
// Should not be visible for "Standby" or "Indexed"
return null;
}
return (
<div
className={`fixed bottom-4 right-4 p-2 rounded-md text-white text-xs font-medium cursor-pointer shadow-lg transition-opacity duration-300 ${
isVisible ? "opacity-100" : "opacity-0"
} ${badgeColor} ${pulseAnimation ? "animate-pulse" : ""}`}
onClick={handleClick}
title={message || status}
>
<div className="flex items-center">
{badgeContent}
</div>
</div>
);
};
export default IndexingStatusBadge;

View file

@ -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));
});
});

View file

@ -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(<I18nextProvider i18n={i18n}>{component}</I18nextProvider>);
};
it('renders correctly when indexing', () => {
renderWithI18n(
<IndexingStatusBadge status="Indexing" progress={50} isVisible={true} />,
);
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(
<IndexingStatusBadge status="Error" message="Test Error" progress={0} isVisible={true} />,
);
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(
<IndexingStatusBadge status="Indexing" progress={50} isVisible={false} />,
);
expect(container.firstChild).toBeNull();
});
it('renders null for Standby status', () => {
const { container } = renderWithI18n(
<IndexingStatusBadge status="Standby" progress={0} isVisible={true} />,
);
expect(container.firstChild).toBeNull();
});
it('renders null for Indexed status', () => {
const { container } = renderWithI18n(
<IndexingStatusBadge status="Indexed" progress={100} isVisible={true} />,
);
expect(container.firstChild).toBeNull();
});
it('calls postMessage on click when indexing', () => {
renderWithI18n(
<IndexingStatusBadge status="Indexing" progress={50} isVisible={true} />,
);
// 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(
<IndexingStatusBadge status="Error" message="Test Error" progress={0} isVisible={true} />,
);
fireEvent.click(screen.getByText('Indexing Error').closest('div.fixed')!);
expect(mockPostMessage).toHaveBeenCalledWith({
type: 'navigateTo',
view: 'settings',
section: 'codeIndex',
});
});
});