Skip to content

Commit 5e6900b

Browse files
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.
1 parent c03869b commit 5e6900b

File tree

5 files changed

+384
-2
lines changed

5 files changed

+384
-2
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"indexingBadge": {
3+
"indexingInProgress": "Indexing {{progress}}%",
4+
"indexingError": "Indexing Error"
5+
}
6+
}

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
2+
// import useEffect and useState from react if not already imported - already imported
23
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
34
import debounce from "debounce"
45
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
@@ -41,6 +42,7 @@ import AutoApproveMenu from "./AutoApproveMenu"
4142
import SystemPromptWarning from "./SystemPromptWarning"
4243
import ProfileViolationWarning from "./ProfileViolationWarning"
4344
import { CheckpointWarning } from "./CheckpointWarning"
45+
import IndexingStatusBadge from "./IndexingStatusBadge"; // Added import
4446

4547
export interface ChatViewProps {
4648
isHidden: boolean
@@ -154,11 +156,42 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
154156
}),
155157
)
156158

159+
const [indexingStatus, setIndexingStatus] = useState({
160+
status: "Standby", // or "Idle" / "Initial"
161+
message: "",
162+
progress: 0,
163+
isVisible: false,
164+
});
165+
157166
const clineAskRef = useRef(clineAsk)
158167
useEffect(() => {
159168
clineAskRef.current = clineAsk
160169
}, [clineAsk])
161170

171+
useEffect(() => {
172+
vscode.postMessage({ type: "requestIndexingStatus" });
173+
174+
const handleMessage = (event: MessageEvent) => {
175+
if (event.data.type === "indexingStatusUpdate") {
176+
const { systemStatus, message, processedItems, totalItems } = event.data.values;
177+
const progress = totalItems > 0 ? Math.round((processedItems / totalItems) * 100) : 0;
178+
const isVisible = systemStatus === "Indexing" || systemStatus === "Error";
179+
180+
setIndexingStatus({
181+
status: systemStatus,
182+
message: message || "",
183+
progress: progress,
184+
isVisible: isVisible,
185+
});
186+
}
187+
};
188+
189+
window.addEventListener("message", handleMessage);
190+
return () => {
191+
window.removeEventListener("message", handleMessage);
192+
};
193+
}, []);
194+
162195
useEffect(() => {
163196
isMountedRef.current = true
164197
return () => {
@@ -1555,6 +1588,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
15551588
)}
15561589

15571590
<div id="roo-portal" />
1591+
<IndexingStatusBadge
1592+
status={indexingStatus.status}
1593+
message={indexingStatus.message}
1594+
progress={indexingStatus.progress}
1595+
isVisible={indexingStatus.isVisible}
1596+
/>
15581597
</div>
15591598
)
15601599
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React from "react";
2+
import { Trans } from "react-i18next";
3+
import { vscode } from "@src/utils/vscode";
4+
5+
interface IndexingStatusBadgeProps {
6+
status: string;
7+
message?: string;
8+
progress: number; // 0-100
9+
isVisible: boolean;
10+
}
11+
12+
const IndexingStatusBadge: React.FC<IndexingStatusBadgeProps> = ({
13+
status,
14+
message,
15+
progress,
16+
isVisible,
17+
}) => {
18+
if (!isVisible) {
19+
return null;
20+
}
21+
22+
const handleClick = () => {
23+
vscode.postMessage({
24+
type: "navigateTo",
25+
view: "settings",
26+
section: "codeIndex",
27+
});
28+
};
29+
30+
let badgeContent = null;
31+
let badgeColor = "";
32+
let pulseAnimation = false;
33+
34+
if (status === "Indexing") {
35+
badgeContent = (
36+
<>
37+
<div className="w-3 h-3 rounded-full bg-yellow-500 animate-pulse mr-2"></div>
38+
<Trans i18nKey="indexingBadge.indexingInProgress">
39+
Indexing {{ progress }}%
40+
</Trans>
41+
</>
42+
);
43+
badgeColor = "bg-yellow-500";
44+
pulseAnimation = true;
45+
} else if (status === "Error") {
46+
badgeContent = (
47+
<>
48+
<div className="w-3 h-3 rounded-full bg-red-500 mr-2"></div>
49+
<Trans i18nKey="indexingBadge.indexingError">Indexing Error</Trans>
50+
{message && <span className="ml-1">- {message}</span>}
51+
</>
52+
);
53+
badgeColor = "bg-red-500";
54+
} else {
55+
// Should not be visible for "Standby" or "Indexed"
56+
return null;
57+
}
58+
59+
return (
60+
<div
61+
className={`fixed bottom-4 right-4 p-2 rounded-md text-white text-xs font-medium cursor-pointer shadow-lg transition-opacity duration-300 ${
62+
isVisible ? "opacity-100" : "opacity-0"
63+
} ${badgeColor} ${pulseAnimation ? "animate-pulse" : ""}`}
64+
onClick={handleClick}
65+
title={message || status}
66+
>
67+
<div className="flex items-center">
68+
{badgeContent}
69+
</div>
70+
</div>
71+
);
72+
};
73+
74+
export default IndexingStatusBadge;

webview-ui/src/components/chat/__tests__/ChatView.test.tsx

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// npx jest src/components/chat/__tests__/ChatView.test.tsx
22

33
import React from "react"
4-
import { render, waitFor, act } from "@testing-library/react"
4+
import { render, waitFor, act, screen } from "@testing-library/react"
55
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
66

77
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
@@ -30,11 +30,27 @@ interface ExtensionState {
3030
}
3131

3232
// Mock vscode API
33+
const mockVscodePostMessage = jest.fn()
3334
jest.mock("@src/utils/vscode", () => ({
3435
vscode: {
35-
postMessage: jest.fn(),
36+
postMessage: mockVscodePostMessage,
3637
},
3738
}))
39+
// Mock i18n for IndexingStatusBadge
40+
jest.mock('react-i18next', () => ({
41+
...jest.requireActual('react-i18next'),
42+
Trans: ({ i18nKey, children }: { i18nKey: string, children: React.ReactNode[] | React.ReactNode }) => {
43+
if (i18nKey === "indexingBadge.indexingInProgress") {
44+
// Simulate interpolation for {{progress}}
45+
const progress = React.Children.toArray(children).find(child => typeof child === 'object' && child && 'progress' in child) as any;
46+
return <>Indexing {progress?.props?.progress}%</>;
47+
}
48+
if (i18nKey === "indexingBadge.indexingError") {
49+
return <>Indexing Error</>;
50+
}
51+
return <>{children}</>;
52+
},
53+
}));
3854

3955
// Mock use-sound hook
4056
const mockPlayFunction = jest.fn()
@@ -1072,3 +1088,140 @@ describe("ChatView - Focus Grabbing Tests", () => {
10721088
expect(mockFocus).toHaveBeenCalledTimes(FOCUS_CALLS_ON_INIT)
10731089
})
10741090
})
1091+
1092+
describe("ChatView - IndexingStatusBadge Integration", () => {
1093+
let mockAddEventListener = jest.fn();
1094+
let mockRemoveEventListener = jest.fn();
1095+
1096+
beforeEach(() => {
1097+
jest.clearAllMocks()
1098+
mockVscodePostMessage.mockClear(); // Use the specific mock for vscode
1099+
1100+
// Store original event listener methods
1101+
const originalAddEventListener = window.addEventListener;
1102+
const originalRemoveEventListener = window.removeEventListener;
1103+
1104+
// Mock event listener methods
1105+
mockAddEventListener = jest.fn();
1106+
mockRemoveEventListener = jest.fn();
1107+
window.addEventListener = mockAddEventListener;
1108+
window.removeEventListener = mockRemoveEventListener;
1109+
1110+
// Restore original event listener methods after each test
1111+
return () => {
1112+
window.addEventListener = originalAddEventListener;
1113+
window.removeEventListener = originalRemoveEventListener;
1114+
};
1115+
});
1116+
1117+
it("calls requestIndexingStatus on mount", () => {
1118+
renderChatView();
1119+
expect(mockVscodePostMessage).toHaveBeenCalledWith({ type: "requestIndexingStatus" });
1120+
});
1121+
1122+
it("shows IndexingStatusBadge when status is Indexing", async () => {
1123+
renderChatView();
1124+
1125+
// Simulate receiving indexingStatusUpdate message
1126+
act(() => {
1127+
const eventCallback = mockAddEventListener.mock.calls.find(call => call[0] === 'message')[1];
1128+
eventCallback({
1129+
data: {
1130+
type: "indexingStatusUpdate",
1131+
values: {
1132+
systemStatus: "Indexing",
1133+
message: "Processing files...",
1134+
processedItems: 10,
1135+
totalItems: 100,
1136+
},
1137+
},
1138+
});
1139+
});
1140+
1141+
await waitFor(() => {
1142+
expect(screen.getByText("Indexing 10%")).toBeInTheDocument();
1143+
const dot = screen.getByText('Indexing 10%').previousSibling as HTMLElement;
1144+
expect(dot).toHaveClass('bg-yellow-500');
1145+
});
1146+
});
1147+
1148+
it("shows IndexingStatusBadge with error message when status is Error", async () => {
1149+
renderChatView();
1150+
1151+
act(() => {
1152+
const eventCallback = mockAddEventListener.mock.calls.find(call => call[0] === 'message')[1];
1153+
eventCallback({
1154+
data: {
1155+
type: "indexingStatusUpdate",
1156+
values: {
1157+
systemStatus: "Error",
1158+
message: "Failed to index",
1159+
processedItems: 0,
1160+
totalItems: 0,
1161+
},
1162+
},
1163+
});
1164+
});
1165+
1166+
await waitFor(() => {
1167+
expect(screen.getByText("Indexing Error")).toBeInTheDocument();
1168+
expect(screen.getByText("- Failed to index")).toBeInTheDocument();
1169+
const dot = screen.getByText('Indexing Error').previousSibling as HTMLElement;
1170+
expect(dot).toHaveClass('bg-red-500');
1171+
});
1172+
});
1173+
1174+
it("does not show IndexingStatusBadge for Standby status", async () => {
1175+
const { container } = renderChatView();
1176+
act(() => {
1177+
const eventCallback = mockAddEventListener.mock.calls.find(call => call[0] === 'message')[1];
1178+
eventCallback({
1179+
data: {
1180+
type: "indexingStatusUpdate",
1181+
values: {
1182+
systemStatus: "Standby",
1183+
message: "",
1184+
processedItems: 0,
1185+
totalItems: 0,
1186+
},
1187+
},
1188+
});
1189+
});
1190+
1191+
await waitFor(() => {
1192+
// The badge itself returns null, so we check its absence.
1193+
// We look for a known child of the badge if it were visible.
1194+
expect(screen.queryByText(/Indexing/)).toBeNull();
1195+
expect(screen.queryByText(/Error/)).toBeNull();
1196+
});
1197+
});
1198+
1199+
it("does not show IndexingStatusBadge for Indexed status", async () => {
1200+
renderChatView();
1201+
act(() => {
1202+
const eventCallback = mockAddEventListener.mock.calls.find(call => call[0] === 'message')[1];
1203+
eventCallback({
1204+
data: {
1205+
type: "indexingStatusUpdate",
1206+
values: {
1207+
systemStatus: "Indexed",
1208+
message: "",
1209+
processedItems: 100,
1210+
totalItems: 100,
1211+
},
1212+
},
1213+
});
1214+
});
1215+
1216+
await waitFor(() => {
1217+
expect(screen.queryByText(/Indexing/)).toBeNull();
1218+
expect(screen.queryByText(/Error/)).toBeNull();
1219+
});
1220+
});
1221+
1222+
it("cleans up event listener on unmount", () => {
1223+
const { unmount } = renderChatView();
1224+
unmount();
1225+
expect(mockRemoveEventListener).toHaveBeenCalledWith("message", expect.any(Function));
1226+
});
1227+
});

0 commit comments

Comments
 (0)