Skip to content
Draft
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
74 changes: 74 additions & 0 deletions src/core/webview/ActivityIndicator.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
26 changes: 22 additions & 4 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 <most> 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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
135 changes: 135 additions & 0 deletions src/core/webview/__tests__/ActivityIndicator.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
Loading