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
This commit is contained in:
Roo Code 2026-01-12 03:02:45 +00:00
parent 632b86cfbe
commit 21b64779e5
3 changed files with 227 additions and 3 deletions

View file

@ -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<void>((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()
}
}

View file

@ -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<Task, Array<() => void>> = new WeakMap()
private currentWorkspacePath: string | undefined
private activityIndicator: ActivityIndicator
private recentTasksCache?: string[]
private pendingOperations: Map<string, PendingEditOperation> = 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 <most> 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)

View file

@ -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<void>) => {
return task({}).then(() => {
// Promise resolved
})
})
// We need to capture the resolve function
mockWithProgress.mockImplementation((_options: any, task: () => Promise<void>) => {
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()
})
})
})