diff --git a/src/core/webview/ActivityIndicator.ts b/src/core/webview/ActivityIndicator.ts new file mode 100644 index 0000000000..20e161cd4e --- /dev/null +++ b/src/core/webview/ActivityIndicator.ts @@ -0,0 +1,74 @@ +import * as vscode from "vscode" +import { Package } from "../../shared/package" + +/** + * Manages the activity indicator (spinning progress icon) on the Roo Code sidebar icon. + * + * Uses VSCode's `window.withProgress` API with a view-specific location to show + * a progress indicator on the activity bar icon when Roo is working on a task. + * + * This provides visual feedback to users even when they've navigated away from + * the Roo Code sidebar to other views like Explorer or Source Control. + */ +export class ActivityIndicator { + private static readonly VIEW_ID = `${Package.name}.SidebarProvider` + + private isIndicatorActive = false + private resolveProgressPromise: (() => void) | null = null + + /** + * Shows the activity indicator on the sidebar icon. + * If already showing, this is a no-op. + * + * The indicator will continue showing until `hide()` is called. + */ + public show(): void { + if (this.isIndicatorActive) { + return + } + + this.isIndicatorActive = true + + vscode.window.withProgress( + { + location: { viewId: ActivityIndicator.VIEW_ID }, + }, + () => { + return new Promise((resolve) => { + this.resolveProgressPromise = resolve + }) + }, + ) + } + + /** + * Hides the activity indicator on the sidebar icon. + * If not currently showing, this is a no-op. + */ + public hide(): void { + if (!this.isIndicatorActive) { + return + } + + this.isIndicatorActive = false + + if (this.resolveProgressPromise) { + this.resolveProgressPromise() + this.resolveProgressPromise = null + } + } + + /** + * Returns whether the activity indicator is currently showing. + */ + public isActive(): boolean { + return this.isIndicatorActive + } + + /** + * Disposes the activity indicator, hiding it if active. + */ + public dispose(): void { + this.hide() + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e86316a258..26f7d32065 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -102,6 +102,7 @@ import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" +import { ActivityIndicator } from "./ActivityIndicator" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -146,6 +147,7 @@ export class ClineProvider private taskCreationCallback: (task: Task) => void private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined + private activityIndicator: ActivityIndicator private recentTasksCache?: string[] private pendingOperations: Map = new Map() @@ -209,16 +211,25 @@ export class ClineProvider this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) + // Initialize activity indicator for sidebar icon + this.activityIndicator = new ActivityIndicator() + // Forward task events to the provider. // We do something fairly similar for the IPC-based API. this.taskCreationCallback = (instance: Task) => { this.emit(RooCodeEventName.TaskCreated, instance) // Create named listener functions so we can remove them later. - const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId) - const onTaskCompleted = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => + const onTaskStarted = () => { + this.activityIndicator.show() + this.emit(RooCodeEventName.TaskStarted, instance.taskId) + } + const onTaskCompleted = (taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage) => { + this.activityIndicator.hide() this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) + } const onTaskAborted = async () => { + this.activityIndicator.hide() this.emit(RooCodeEventName.TaskAborted, instance.taskId) try { @@ -249,10 +260,16 @@ export class ClineProvider } const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId) const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId) - const onTaskActive = (taskId: string) => this.emit(RooCodeEventName.TaskActive, taskId) + const onTaskActive = (taskId: string) => { + this.activityIndicator.show() + this.emit(RooCodeEventName.TaskActive, taskId) + } const onTaskInteractive = (taskId: string) => this.emit(RooCodeEventName.TaskInteractive, taskId) const onTaskResumable = (taskId: string) => this.emit(RooCodeEventName.TaskResumable, taskId) - const onTaskIdle = (taskId: string) => this.emit(RooCodeEventName.TaskIdle, taskId) + const onTaskIdle = (taskId: string) => { + this.activityIndicator.hide() + this.emit(RooCodeEventName.TaskIdle, taskId) + } const onTaskPaused = (taskId: string) => this.emit(RooCodeEventName.TaskPaused, taskId) const onTaskUnpaused = (taskId: string) => this.emit(RooCodeEventName.TaskUnpaused, taskId) const onTaskSpawned = (taskId: string) => this.emit(RooCodeEventName.TaskSpawned, taskId) @@ -617,6 +634,7 @@ export class ClineProvider this.skillsManager = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() + this.activityIndicator?.dispose() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) diff --git a/src/core/webview/__tests__/ActivityIndicator.spec.ts b/src/core/webview/__tests__/ActivityIndicator.spec.ts new file mode 100644 index 0000000000..97188aa67c --- /dev/null +++ b/src/core/webview/__tests__/ActivityIndicator.spec.ts @@ -0,0 +1,135 @@ +import { ActivityIndicator } from "../ActivityIndicator" +import * as vscode from "vscode" + +// Mock vscode.window.withProgress +const mockWithProgress = vi.fn() +vi.mock("vscode", () => ({ + window: { + withProgress: (...args: any[]) => mockWithProgress(...args), + }, +})) + +describe("ActivityIndicator", () => { + let indicator: ActivityIndicator + + beforeEach(() => { + vi.clearAllMocks() + indicator = new ActivityIndicator() + }) + + afterEach(() => { + indicator.dispose() + }) + + describe("show", () => { + it("should call withProgress when shown", () => { + indicator.show() + + expect(mockWithProgress).toHaveBeenCalledTimes(1) + expect(mockWithProgress).toHaveBeenCalledWith( + expect.objectContaining({ + location: expect.objectContaining({ + viewId: expect.stringContaining("SidebarProvider"), + }), + }), + expect.any(Function), + ) + }) + + it("should only call withProgress once when shown multiple times", () => { + indicator.show() + indicator.show() + indicator.show() + + expect(mockWithProgress).toHaveBeenCalledTimes(1) + }) + + it("should set isActive to true", () => { + expect(indicator.isActive()).toBe(false) + indicator.show() + expect(indicator.isActive()).toBe(true) + }) + }) + + describe("hide", () => { + it("should resolve the progress promise when hidden", () => { + let capturedResolve: (() => void) | null = null + + mockWithProgress.mockImplementation((_options: any, task: (progress: any) => Promise) => { + return task({}).then(() => { + // Promise resolved + }) + }) + + // We need to capture the resolve function + mockWithProgress.mockImplementation((_options: any, task: () => Promise) => { + const promise = task() + // The promise is created inside the task function + return promise + }) + + indicator.show() + indicator.hide() + + expect(indicator.isActive()).toBe(false) + }) + + it("should be a no-op when not active", () => { + // Calling hide when not active should not throw + expect(() => indicator.hide()).not.toThrow() + expect(indicator.isActive()).toBe(false) + }) + + it("should set isActive to false", () => { + indicator.show() + expect(indicator.isActive()).toBe(true) + + indicator.hide() + expect(indicator.isActive()).toBe(false) + }) + + it("should allow showing again after hiding", () => { + indicator.show() + expect(mockWithProgress).toHaveBeenCalledTimes(1) + + indicator.hide() + expect(indicator.isActive()).toBe(false) + + indicator.show() + expect(mockWithProgress).toHaveBeenCalledTimes(2) + }) + }) + + describe("isActive", () => { + it("should return false initially", () => { + expect(indicator.isActive()).toBe(false) + }) + + it("should return true after show", () => { + indicator.show() + expect(indicator.isActive()).toBe(true) + }) + + it("should return false after hide", () => { + indicator.show() + indicator.hide() + expect(indicator.isActive()).toBe(false) + }) + }) + + describe("dispose", () => { + it("should hide the indicator when disposed", () => { + indicator.show() + expect(indicator.isActive()).toBe(true) + + indicator.dispose() + expect(indicator.isActive()).toBe(false) + }) + + it("should be safe to call multiple times", () => { + indicator.show() + indicator.dispose() + expect(() => indicator.dispose()).not.toThrow() + }) + }) +})