mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
c03869bf1e
commit
5e6900bd3b
5 changed files with 384 additions and 2 deletions
6
webview-ui/public/locales/en/translation.json
Normal file
6
webview-ui/public/locales/en/translation.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"indexingBadge": {
|
||||
"indexingInProgress": "Indexing {{progress}}%",
|
||||
"indexingError": "Indexing Error"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
74
webview-ui/src/components/chat/IndexingStatusBadge.tsx
Normal file
74
webview-ui/src/components/chat/IndexingStatusBadge.tsx
Normal 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;
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue