From 21b64779e584d4cf826410ae606bb939f17e58e5 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 12 Jan 2026 03:02:45 +0000 Subject: [PATCH 1/4] feat: add activity indicator using withProgress API This implements the "Agent is working" indicator feature for the Roo Code sidebar icon using VSCode's withProgress API with a view-specific location. The indicator shows a spinning progress icon when a task is actively processing. Key changes: - New ActivityIndicator class managing progress indicator state - Integration with ClineProvider task lifecycle events - Comprehensive unit tests (12 tests passing) Closes #10625 --- src/core/webview/ActivityIndicator.ts | 74 ++++++++++ src/core/webview/ClineProvider.ts | 21 ++- .../__tests__/ActivityIndicator.spec.ts | 135 ++++++++++++++++++ 3 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 src/core/webview/ActivityIndicator.ts create mode 100644 src/core/webview/__tests__/ActivityIndicator.spec.ts 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..5add8caa23 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,6 +211,9 @@ 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) => { @@ -216,9 +221,12 @@ export class ClineProvider // 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 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 +257,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 +631,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() + }) + }) +}) From 55d8cd30e75b3c6e98562dd2f9df6cdf209c4f40 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 12 Jan 2026 03:24:59 +0000 Subject: [PATCH 2/4] fix: show activity indicator on TaskStarted event, not just TaskActive The activity indicator was only showing when transitioning from an idle state back to active (TaskActive event), but TaskActive was not emitted when a task first started - it was only emitted after the user responded to an ask after the task went idle. This fix shows the activity indicator when TaskStarted fires, which happens when the task loop begins. This ensures the indicator shows immediately when the user starts a task or the agent begins processing. --- src/core/webview/ClineProvider.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5add8caa23..26f7d32065 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -220,7 +220,10 @@ export class ClineProvider this.emit(RooCodeEventName.TaskCreated, instance) // Create named listener functions so we can remove them later. - const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId) + 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) From 6ab93f08bea0753fa8aa58c2f27ffa78b8cf4a3b Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 12 Jan 2026 08:47:43 +0000 Subject: [PATCH 3/4] feat: add tooltip text to activity indicator progress Adds "Roo is working..." tooltip text to the progress indicator on the sidebar icon. This provides accessible context when hovering over the progress indicator. Also adds a test to verify the tooltip text is included in the progress options. --- src/core/webview/ActivityIndicator.ts | 1 + src/core/webview/__tests__/ActivityIndicator.spec.ts | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/core/webview/ActivityIndicator.ts b/src/core/webview/ActivityIndicator.ts index 20e161cd4e..3dccb4a4d6 100644 --- a/src/core/webview/ActivityIndicator.ts +++ b/src/core/webview/ActivityIndicator.ts @@ -32,6 +32,7 @@ export class ActivityIndicator { vscode.window.withProgress( { location: { viewId: ActivityIndicator.VIEW_ID }, + title: "Roo is working...", }, () => { return new Promise((resolve) => { diff --git a/src/core/webview/__tests__/ActivityIndicator.spec.ts b/src/core/webview/__tests__/ActivityIndicator.spec.ts index 97188aa67c..f625d8720e 100644 --- a/src/core/webview/__tests__/ActivityIndicator.spec.ts +++ b/src/core/webview/__tests__/ActivityIndicator.spec.ts @@ -36,6 +36,17 @@ describe("ActivityIndicator", () => { ) }) + it("should include tooltip text in progress options", () => { + indicator.show() + + expect(mockWithProgress).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Roo is working...", + }), + expect.any(Function), + ) + }) + it("should only call withProgress once when shown multiple times", () => { indicator.show() indicator.show() From 8ff6355582314ff27fb9ef015f4cb0a9883facc7 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 12 Jan 2026 09:34:48 +0000 Subject: [PATCH 4/4] revert: remove unsupported tooltip from activity indicator The VSCode extensions API does not support tooltips for withProgress view indicators. Confirmed by manual testing that the title property has no visible effect. --- src/core/webview/ActivityIndicator.ts | 1 - src/core/webview/__tests__/ActivityIndicator.spec.ts | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/src/core/webview/ActivityIndicator.ts b/src/core/webview/ActivityIndicator.ts index 3dccb4a4d6..20e161cd4e 100644 --- a/src/core/webview/ActivityIndicator.ts +++ b/src/core/webview/ActivityIndicator.ts @@ -32,7 +32,6 @@ export class ActivityIndicator { vscode.window.withProgress( { location: { viewId: ActivityIndicator.VIEW_ID }, - title: "Roo is working...", }, () => { return new Promise((resolve) => { diff --git a/src/core/webview/__tests__/ActivityIndicator.spec.ts b/src/core/webview/__tests__/ActivityIndicator.spec.ts index f625d8720e..97188aa67c 100644 --- a/src/core/webview/__tests__/ActivityIndicator.spec.ts +++ b/src/core/webview/__tests__/ActivityIndicator.spec.ts @@ -36,17 +36,6 @@ describe("ActivityIndicator", () => { ) }) - it("should include tooltip text in progress options", () => { - indicator.show() - - expect(mockWithProgress).toHaveBeenCalledWith( - expect.objectContaining({ - title: "Roo is working...", - }), - expect.any(Function), - ) - }) - it("should only call withProgress once when shown multiple times", () => { indicator.show() indicator.show()