mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
632b86cfbe
commit
21b64779e5
3 changed files with 227 additions and 3 deletions
74
src/core/webview/ActivityIndicator.ts
Normal file
74
src/core/webview/ActivityIndicator.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
135
src/core/webview/__tests__/ActivityIndicator.spec.ts
Normal file
135
src/core/webview/__tests__/ActivityIndicator.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue