Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions webview-ui/public/locales/en/translation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"indexingBadge": {
"indexingInProgress": "Indexing {{progress}}%",
"indexingError": "Indexing Error"
}
}
39 changes: 39 additions & 0 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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>
)
}
Expand Down
74 changes: 74 additions & 0 deletions webview-ui/src/components/chat/IndexingStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -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;
157 changes: 155 additions & 2 deletions webview-ui/src/components/chat/__tests__/ChatView.test.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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));
});
});
Loading
Loading