feat: Add indexing status badge to chat view (#4429) (#4532)

Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com>
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
Hannes Rudolph 2025-06-12 19:56:57 -06:00 committed by GitHub
parent 5f29348519
commit bde7a7bf2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 615 additions and 10 deletions

View file

@ -18,6 +18,20 @@ import { Mode } from "./modes"
import { RouterModels } from "./api"
import { MarketplaceItem } from "../services/marketplace/types"
// Indexing status types
export interface IndexingStatus {
systemStatus: string
message?: string
processedItems: number
totalItems: number
currentItemUnit?: string
}
export interface IndexingStatusUpdateMessage {
type: "indexingStatusUpdate"
values: IndexingStatus
}
export interface LanguageModelChatSelector {
vendor?: string
family?: string

View file

@ -26,6 +26,7 @@ import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import { VolumeX, Pin, Check } from "lucide-react"
import { IconButton } from "./IconButton"
import { IndexingStatusDot } from "./IndexingStatusBadge"
import { cn } from "@/lib/utils"
import { usePromptHistory } from "./hooks/usePromptHistory"
@ -78,6 +79,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
togglePinnedApiConfig,
taskHistory,
clineMessages,
codebaseIndexConfig,
} = useExtensionState()
// Find the ID and display text for the currently selected API configuration
@ -1171,6 +1173,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
{codebaseIndexConfig?.codebaseIndexEnabled && <IndexingStatusDot />}
<IconButton
iconClass={isEnhancingPrompt ? "codicon-loading" : "codicon-sparkle"}
title={t("chat:enhancePrompt")}

View file

@ -0,0 +1,158 @@
import React, { useState, useEffect, useMemo } from "react"
import { cn } from "@src/lib/utils"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useTooltip } from "@/hooks/useTooltip"
import type { IndexingStatus, IndexingStatusUpdateMessage } from "@roo/ExtensionMessage"
interface IndexingStatusDotProps {
className?: string
}
export const IndexingStatusDot: React.FC<IndexingStatusDotProps> = ({ className }) => {
const { t } = useAppTranslation()
const { showTooltip, handleMouseEnter, handleMouseLeave, cleanup } = useTooltip({ delay: 300 })
const [isHovered, setIsHovered] = useState(false)
const [indexingStatus, setIndexingStatus] = useState<IndexingStatus>({
systemStatus: "Standby",
processedItems: 0,
totalItems: 0,
currentItemUnit: "items",
})
useEffect(() => {
// Request initial indexing status
vscode.postMessage({ type: "requestIndexingStatus" })
// Set up message listener for status updates
const handleMessage = (event: MessageEvent<IndexingStatusUpdateMessage>) => {
if (event.data.type === "indexingStatusUpdate") {
const status = event.data.values
setIndexingStatus(status)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
cleanup()
}
}, [cleanup])
// Calculate progress percentage with memoization
const progressPercentage = useMemo(
() =>
indexingStatus.totalItems > 0
? Math.round((indexingStatus.processedItems / indexingStatus.totalItems) * 100)
: 0,
[indexingStatus.processedItems, indexingStatus.totalItems],
)
// Get tooltip text with internationalization
const getTooltipText = () => {
switch (indexingStatus.systemStatus) {
case "Standby":
return t("chat:indexingStatus.ready")
case "Indexing":
return t("chat:indexingStatus.indexing", { percentage: progressPercentage })
case "Indexed":
return t("chat:indexingStatus.indexed")
case "Error":
return t("chat:indexingStatus.error")
default:
return t("chat:indexingStatus.status")
}
}
// Navigate to settings when clicked
const handleClick = () => {
window.postMessage(
{
type: "action",
action: "settingsButtonClicked",
values: { section: "experimental" },
},
"*",
)
}
const handleMouseEnterButton = () => {
setIsHovered(true)
handleMouseEnter()
}
const handleMouseLeaveButton = () => {
setIsHovered(false)
handleMouseLeave()
}
// Get status color classes based on status and hover state
const getStatusColorClass = () => {
const statusColors = {
Standby: {
default: "bg-vscode-descriptionForeground/40",
hover: "bg-vscode-descriptionForeground/60",
},
Indexing: {
default: "bg-yellow-500/40 animate-pulse",
hover: "bg-yellow-500 animate-pulse",
},
Indexed: {
default: "bg-green-500/40",
hover: "bg-green-500",
},
Error: {
default: "bg-red-500/40",
hover: "bg-red-500",
},
}
const colors = statusColors[indexingStatus.systemStatus as keyof typeof statusColors] || statusColors.Standby
return isHovered ? colors.hover : colors.default
}
return (
<div className={cn("relative inline-block", className)}>
<button
onClick={handleClick}
onMouseEnter={handleMouseEnterButton}
onMouseLeave={handleMouseLeaveButton}
className={cn(
"flex items-center justify-center w-7 h-7 rounded-md",
"bg-transparent hover:bg-vscode-list-hoverBackground",
"cursor-pointer transition-all duration-200",
"opacity-85 hover:opacity-100 relative",
)}
aria-label={getTooltipText()}>
{/* Status dot */}
<span
className={cn(
"inline-block w-2 h-2 rounded-full relative z-10 transition-colors duration-200",
getStatusColorClass(),
)}
/>
</button>
{showTooltip && (
<div
className={cn(
"absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2",
"px-2 py-1 text-xs font-medium text-vscode-foreground",
"bg-vscode-editor-background border border-vscode-panel-border",
"rounded shadow-lg whitespace-nowrap z-50",
)}
role="tooltip">
{getTooltipText()}
<div
className={cn(
"absolute top-full left-1/2 transform -translate-x-1/2",
"w-0 h-0 border-l-4 border-r-4 border-t-4",
"border-l-transparent border-r-transparent border-t-vscode-panel-border",
)}
/>
</div>
)}
</div>
)
}

View file

@ -126,6 +126,9 @@ describe("ChatTextArea", () => {
render(<ChatTextArea {...defaultProps} inputValue="" />)
// Clear any calls from component initialization (e.g., IndexingStatusBadge)
mockPostMessage.mockClear()
const enhanceButton = getEnhancePromptButton()
fireEvent.click(enhanceButton)

View file

@ -0,0 +1,278 @@
import React from "react"
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"
import { IndexingStatusDot } from "../IndexingStatusBadge"
import { vscode } from "@src/utils/vscode"
// Mock i18n setup to prevent initialization errors
jest.mock("@/i18n/setup", () => ({
__esModule: true,
default: {
use: jest.fn().mockReturnThis(),
init: jest.fn().mockReturnThis(),
addResourceBundle: jest.fn(),
language: "en",
changeLanguage: jest.fn(),
},
loadTranslations: jest.fn(),
}))
// Mock react-i18next
jest.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, params?: any) => {
const translations: Record<string, string> = {
"indexingStatus.ready": "Index ready",
"indexingStatus.indexing": params?.progress !== undefined ? `Indexing ${params.progress}%` : "Indexing",
"indexingStatus.error": "Index error",
"indexingStatus.indexed": "Indexed",
"indexingStatus.tooltip.ready": "The codebase index is ready for use",
"indexingStatus.tooltip.indexing":
params?.progress !== undefined
? `Indexing in progress: ${params.progress}% complete`
: "Indexing in progress",
"indexingStatus.tooltip.error": "An error occurred during indexing",
"indexingStatus.tooltip.indexed": "Codebase has been successfully indexed",
"indexingStatus.tooltip.clickToSettings": "Click to open indexing settings",
}
return translations[key] || key
},
i18n: {
language: "en",
changeLanguage: jest.fn(),
},
}),
initReactI18next: {
type: "3rdParty",
init: jest.fn(),
},
}))
// Mock vscode API
jest.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: jest.fn(),
},
}))
// Mock the useTooltip hook
jest.mock("@/hooks/useTooltip", () => ({
useTooltip: jest.fn(() => ({
showTooltip: false,
handleMouseEnter: jest.fn(),
handleMouseLeave: jest.fn(),
cleanup: jest.fn(),
})),
}))
// Mock the ExtensionStateContext
jest.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
language: "en",
}),
ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
// Mock TranslationContext to provide t function directly
jest.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, params?: any) => {
// Remove namespace prefix if present
const cleanKey = key.includes(":") ? key.split(":")[1] : key
const translations: Record<string, string> = {
"indexingStatus.ready": "Index ready",
"indexingStatus.indexing":
params?.percentage !== undefined ? `Indexing ${params.percentage}%` : "Indexing",
"indexingStatus.error": "Index error",
"indexingStatus.indexed": "Indexed",
"indexingStatus.tooltip.ready": "The codebase index is ready for use",
"indexingStatus.tooltip.indexing":
params?.percentage !== undefined
? `Indexing in progress: ${params.percentage}% complete`
: "Indexing in progress",
"indexingStatus.tooltip.error": "An error occurred during indexing",
"indexingStatus.tooltip.indexed": "Codebase has been successfully indexed",
"indexingStatus.tooltip.clickToSettings": "Click to open indexing settings",
}
return translations[cleanKey] || cleanKey
},
}),
}))
describe("IndexingStatusDot", () => {
const renderComponent = (props = {}) => {
return render(<IndexingStatusDot {...props} />)
}
beforeEach(() => {
jest.clearAllMocks()
})
it("renders the status dot", () => {
renderComponent()
const button = screen.getByRole("button")
expect(button).toBeInTheDocument()
})
it("shows standby status by default", () => {
renderComponent()
const button = screen.getByRole("button")
expect(button).toHaveAttribute("aria-label", "Index ready")
})
it("posts settingsButtonClicked message when clicked", () => {
// Mock window.postMessage
const postMessageSpy = jest.spyOn(window, "postMessage")
renderComponent()
const button = screen.getByRole("button")
fireEvent.click(button)
expect(postMessageSpy).toHaveBeenCalledWith(
{
type: "action",
action: "settingsButtonClicked",
values: { section: "experimental" },
},
"*",
)
postMessageSpy.mockRestore()
})
it("requests indexing status on mount", () => {
renderComponent()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "requestIndexingStatus",
})
})
it("updates status when receiving indexingStatusUpdate message", async () => {
renderComponent()
// Simulate receiving an indexing status update
const event = new MessageEvent("message", {
data: {
type: "indexingStatusUpdate",
values: {
systemStatus: "Indexing",
processedItems: 50,
totalItems: 100,
currentItemUnit: "files",
},
},
})
act(() => {
window.dispatchEvent(event)
})
await waitFor(() => {
const button = screen.getByRole("button")
expect(button).toHaveAttribute("aria-label", "Indexing 50%")
})
})
it("shows error status correctly", async () => {
renderComponent()
// Simulate error status
const event = new MessageEvent("message", {
data: {
type: "indexingStatusUpdate",
values: {
systemStatus: "Error",
processedItems: 0,
totalItems: 0,
currentItemUnit: "files",
},
},
})
act(() => {
window.dispatchEvent(event)
})
await waitFor(() => {
const button = screen.getByRole("button")
expect(button).toHaveAttribute("aria-label", "Index error")
})
})
it("shows indexed status correctly", async () => {
renderComponent()
// Simulate indexed status
const event = new MessageEvent("message", {
data: {
type: "indexingStatusUpdate",
values: {
systemStatus: "Indexed",
processedItems: 100,
totalItems: 100,
currentItemUnit: "files",
},
},
})
act(() => {
window.dispatchEvent(event)
})
await waitFor(() => {
const button = screen.getByRole("button")
expect(button).toHaveAttribute("aria-label", "Indexed")
})
})
it("cleans up event listener on unmount", () => {
const { unmount } = renderComponent()
const removeEventListenerSpy = jest.spyOn(window, "removeEventListener")
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith("message", expect.any(Function))
})
it("calculates progress percentage correctly", async () => {
renderComponent()
// Test various progress scenarios
const testCases = [
{ processed: 0, total: 100, expected: 0 },
{ processed: 25, total: 100, expected: 25 },
{ processed: 33, total: 100, expected: 33 },
{ processed: 100, total: 100, expected: 100 },
{ processed: 0, total: 0, expected: 0 },
]
for (const testCase of testCases) {
const event = new MessageEvent("message", {
data: {
type: "indexingStatusUpdate",
values: {
systemStatus: "Indexing",
processedItems: testCase.processed,
totalItems: testCase.total,
currentItemUnit: "files",
},
},
})
act(() => {
window.dispatchEvent(event)
})
await waitFor(() => {
const button = screen.getByRole("button")
expect(button).toHaveAttribute("aria-label", `Indexing ${testCase.expected}%`)
})
}
})
})

View file

@ -40,16 +40,7 @@ interface CodeIndexSettingsProps {
areSettingsCommitted: boolean
}
interface IndexingStatusUpdateMessage {
type: "indexingStatusUpdate"
values: {
systemStatus: string
message?: string
processedItems: number
totalItems: number
currentItemUnit?: string
}
}
import type { IndexingStatusUpdateMessage } from "@roo/ExtensionMessage"
export const CodeIndexSettings: React.FC<CodeIndexSettingsProps> = ({
codebaseIndexModels,

View file

@ -0,0 +1,39 @@
import { useState, useCallback, useRef } from "react"
interface UseTooltipOptions {
delay?: number
}
export const useTooltip = (options: UseTooltipOptions = {}) => {
const { delay = 300 } = options
const [showTooltip, setShowTooltip] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleMouseEnter = useCallback(() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => setShowTooltip(true), delay)
}, [delay])
const handleMouseLeave = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setShowTooltip(false)
}, [])
// Cleanup on unmount
const cleanup = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [])
return {
showTooltip,
handleMouseEnter,
handleMouseLeave,
cleanup,
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Denegar tot"
}
},
"indexingStatus": {
"ready": "Índex preparat",
"indexing": "Indexant {{percentage}}%",
"indexed": "Indexat",
"error": "Error d'índex",
"status": "Estat de l'índex"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Alle ablehnen"
}
},
"indexingStatus": {
"ready": "Index bereit",
"indexing": "Indizierung {{percentage}}%",
"indexed": "Indiziert",
"error": "Index-Fehler",
"status": "Index-Status"
}
}

View file

@ -279,5 +279,12 @@
"description": "Roo has reached the auto-approved limit of {{count}} API request(s). Would you like to reset the count and proceed with the task?",
"button": "Reset and Continue"
}
},
"indexingStatus": {
"ready": "Index ready",
"indexing": "Indexing {{percentage}}%",
"indexed": "Indexed",
"error": "Index error",
"status": "Index status"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Denegar todo"
}
},
"indexingStatus": {
"ready": "Índice listo",
"indexing": "Indexando {{percentage}}%",
"indexed": "Indexado",
"error": "Error de índice",
"status": "Estado del índice"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Tout refuser"
}
},
"indexingStatus": {
"ready": "Index prêt",
"indexing": "Indexation {{percentage}}%",
"indexed": "Indexé",
"error": "Erreur d'index",
"status": "Statut de l'index"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "सभी अस्वीकार करें"
}
},
"indexingStatus": {
"ready": "इंडेक्स तैयार",
"indexing": "इंडेक्सिंग {{percentage}}%",
"indexed": "इंडेक्स किया गया",
"error": "इंडेक्स त्रुटि",
"status": "इंडेक्स स्थिति"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Nega tutto"
}
},
"indexingStatus": {
"ready": "Indice pronto",
"indexing": "Indicizzazione {{percentage}}%",
"indexed": "Indicizzato",
"error": "Errore indice",
"status": "Stato indice"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "すべて拒否"
}
},
"indexingStatus": {
"ready": "インデックス準備完了",
"indexing": "インデックス作成中 {{percentage}}%",
"indexed": "インデックス作成済み",
"error": "インデックスエラー",
"status": "インデックス状態"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "모두 거부"
}
},
"indexingStatus": {
"ready": "인덱스 준비됨",
"indexing": "인덱싱 중 {{percentage}}%",
"indexed": "인덱싱 완료",
"error": "인덱스 오류",
"status": "인덱스 상태"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Alles weigeren"
}
},
"indexingStatus": {
"ready": "Index gereed",
"indexing": "Indexeren {{percentage}}%",
"indexed": "Geïndexeerd",
"error": "Index fout",
"status": "Index status"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Odrzuć wszystko"
}
},
"indexingStatus": {
"ready": "Indeks gotowy",
"indexing": "Indeksowanie {{percentage}}%",
"indexed": "Zaindeksowane",
"error": "Błąd indeksu",
"status": "Status indeksu"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Negar tudo"
}
},
"indexingStatus": {
"ready": "Índice pronto",
"indexing": "Indexando {{percentage}}%",
"indexed": "Indexado",
"error": "Erro do índice",
"status": "Status do índice"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Отклонить все"
}
},
"indexingStatus": {
"ready": "Индекс готов",
"indexing": "Индексация {{percentage}}%",
"indexed": "Проиндексировано",
"error": "Ошибка индекса",
"status": "Статус индекса"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Tümünü Reddet"
}
},
"indexingStatus": {
"ready": "İndeks hazır",
"indexing": "İndeksleniyor {{percentage}}%",
"indexed": "İndekslendi",
"error": "İndeks hatası",
"status": "İndeks durumu"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "Từ chối tất cả"
}
},
"indexingStatus": {
"ready": "Chỉ mục sẵn sàng",
"indexing": "Đang lập chỉ mục {{percentage}}%",
"indexed": "Đã lập chỉ mục",
"error": "Lỗi chỉ mục",
"status": "Trạng thái chỉ mục"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "全部拒绝"
}
},
"indexingStatus": {
"ready": "索引就绪",
"indexing": "索引中 {{percentage}}%",
"indexed": "已索引",
"error": "索引错误",
"status": "索引状态"
}
}

View file

@ -279,5 +279,12 @@
"deny": {
"title": "全部拒絕"
}
},
"indexingStatus": {
"ready": "索引就緒",
"indexing": "索引中 {{percentage}}%",
"indexed": "已索引",
"error": "索引錯誤",
"status": "索引狀態"
}
}