From ab4940add9929cf10252dce6e8cb1d42f0c5ea5a Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Tue, 20 Oct 2026 22:12:47 +0200 Subject: [PATCH 1/3] fix(webview): don't block the first state post on task history loading getStateToPostToWebview awaited the task history scan even when the caller omitted task history, and webviewDidLaunch always posted the full state, so a large history left the sidebar on a spinner. Await the scan only when history is included; on launch, render without history first and post the full state once the scan completes. Fixes #2095 Co-Authored-By: Claude Sonnet 5.5 --- src/core/task-persistence/TaskHistoryStore.ts | 7 ++++ .../__tests__/TaskHistoryStore.spec.ts | 9 ++++ src/core/webview/ClineProvider.ts | 7 +++- .../webview/__tests__/ClineProvider.spec.ts | 10 +++++ .../__tests__/webviewMessageHandler.spec.ts | 41 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 11 ++++- 6 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/core/task-persistence/TaskHistoryStore.ts b/src/core/task-persistence/TaskHistoryStore.ts index 346d697d9a..54b6d80509 100644 --- a/src/core/task-persistence/TaskHistoryStore.ts +++ b/src/core/task-persistence/TaskHistoryStore.ts @@ -94,6 +94,12 @@ export class TaskHistoryStore { */ public readonly initialized: Promise private resolveInitialized!: () => void + private _isInitialized = false + + /** True once the initial scan has finished, so reads no longer need to wait on `initialized`. */ + get isInitialized(): boolean { + return this._isInitialized + } /** Periodic reconciliation interval in milliseconds. */ private static readonly RECONCILE_INTERVAL_MS = 5 * 60 * 1000 @@ -140,6 +146,7 @@ export class TaskHistoryStore { this.startPeriodicReconciliation() } finally { // Mark initialization as complete so callers awaiting `initialized` can proceed + this._isInitialized = true this.resolveInitialized() } } diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 3e277ac867..9d1472c808 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -53,6 +53,15 @@ describe("TaskHistoryStore", () => { }) describe("initialize()", () => { + it("reports isInitialized only after the scan completes", async () => { + expect(store.isInitialized).toBe(false) + + await store.initialize() + + expect(store.isInitialized).toBe(true) + await expect(store.initialized).resolves.toBeUndefined() + }) + it("initializes from empty state (no index, no task dirs)", async () => { await store.initialize() expect(store.getAll()).toEqual([]) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 28a42913f3..ffae7879e6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2743,8 +2743,11 @@ export class ClineProvider } async getStateToPostToWebview({ includeTaskHistory = true }: GetStateOptions = {}): Promise { - // Ensure the store is initialized before reading task history - await this.taskHistoryStore.initialized + // Only wait for the store when task history is requested, so a slow scan of a large + // history cannot keep the webview from receiving its first state. + if (includeTaskHistory) { + await this.taskHistoryStore.initialized + } const { apiConfiguration, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 93543435e2..60c1ead18f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2447,6 +2447,16 @@ describe("ClineProvider", () => { expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") }) + it("builds state without task history while the task history store is still loading", async () => { + await provider.resolveWebviewView(mockWebviewView) + // A scan that never finishes must not block a state that omits task history. + Object.defineProperty(provider.taskHistoryStore, "initialized", { value: new Promise(() => {}) }) + + const state = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + + expect(state.taskHistory).toEqual([]) + }) + it("saves current config when switching to mode without config", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 685a7b11c1..57e333ccd2 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -128,6 +128,8 @@ const mockClineProvider = { }, log: vi.fn(), postStateToWebview: vi.fn(), + postStateToWebviewWithoutTaskHistory: vi.fn(), + taskHistoryStore: { isInitialized: true, initialized: Promise.resolve() }, resolveWebviewThemeFixtureProbe: vi.fn(), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), @@ -3053,3 +3055,42 @@ describe("webviewMessageHandler - renameTaskWithId", () => { expect(vi.mocked(mockClineProvider.renameTaskWithId).mock.calls).toEqual(expectedCalls) }) }) + +describe("webviewMessageHandler - webviewDidLaunch task history", () => { + function setupLaunch(taskHistoryStore: { isInitialized: boolean; initialized: Promise }) { + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + const launchProvider = mockClineProvider as unknown as { + getMcpHub: ReturnType + providerSettingsManager: { listConfig: ReturnType } + taskHistoryStore: typeof taskHistoryStore + } + launchProvider.getMcpHub = vi.fn().mockReturnValue(undefined) + launchProvider.providerSettingsManager = { listConfig: vi.fn().mockResolvedValue(undefined) } + launchProvider.taskHistoryStore = taskHistoryStore + vi.mocked(mockClineProvider.postStateToWebview).mockClear() + vi.mocked(mockClineProvider.postStateToWebviewWithoutTaskHistory).mockClear() + } + + it("posts the full state right away once task history is loaded", async () => { + setupLaunch({ isInitialized: true, initialized: Promise.resolve() }) + + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" }) + + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + }) + + it("renders without waiting for a slow task history load, then posts the full state", async () => { + let finishLoading!: () => void + const initialized = new Promise((resolve) => (finishLoading = resolve)) + setupLaunch({ isInitialized: false, initialized }) + + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" }) + + expect(mockClineProvider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + + finishLoading() + await vi.waitFor(() => expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index bd7c589958..545897f1f4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -596,7 +596,16 @@ export const webviewMessageHandler = async ( const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) - await provider.postStateToWebview() + if (provider.taskHistoryStore.isInitialized) { + await provider.postStateToWebview() + } else { + // A large task history can take a long time to scan. Render the UI now and + // send the full state, including history, once the scan completes. + await provider.postStateToWebviewWithoutTaskHistory() + void provider.taskHistoryStore.initialized + .then(() => provider.postStateToWebview()) + .catch((err) => provider.log(`Task history state update error: ${err}`)) + } void provider.workspaceTracker ?.initializeFilePaths() .catch((err) => provider.log(`Workspace initialization error: ${err}`)) // Don't await. From 223d3edbf19c3f200ab0d9dc24f9c5080af6b601 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 25 Oct 2026 10:56:50 +0100 Subject: [PATCH 2/3] fix(webview): keep settings posts responsive during history scan and assert staged state Co-Authored-By: Claude Sonnet 5.5 --- .../__tests__/TaskHistoryStore.spec.ts | 20 +++++- .../ClineProvider.taskHistory.spec.ts | 68 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 8 ++- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts index 9d1472c808..b7f25836d8 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.spec.ts @@ -54,12 +54,30 @@ describe("TaskHistoryStore", () => { describe("initialize()", () => { it("reports isInitialized only after the scan completes", async () => { + // Hold the reconciliation scan so the in-flight state can be observed. + let releaseScan!: () => void + const scanGate = new Promise((resolve) => (releaseScan = resolve)) + const reconcile = store["reconcile"].bind(store) + store["reconcile"] = async (...args) => { + await scanGate + return reconcile(...args) + } + const settled = vi.fn() + void store.initialized.then(settled) + expect(store.isInitialized).toBe(false) - await store.initialize() + const initializing = store.initialize() + await Promise.resolve() + expect(store.isInitialized).toBe(false) + expect(settled).not.toHaveBeenCalled() + + releaseScan() + await initializing expect(store.isInitialized).toBe(true) await expect(store.initialized).resolves.toBeUndefined() + expect(settled).toHaveBeenCalledTimes(1) }) it("initializes from empty state (no index, no task dirs)", async () => { diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 30d635df4b..b1fa2dfed8 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -11,6 +11,7 @@ import { ContextProxy } from "../../config/ContextProxy" import { Task } from "../../task/Task" import { ProfileValidator } from "../../../shared/ProfileValidator" import { ClineProvider } from "../ClineProvider" +import { webviewMessageHandler } from "../webviewMessageHandler" // Mock setup vi.mock("p-wait-for", () => ({ @@ -926,6 +927,73 @@ describe("ClineProvider Task History Synchronization", () => { }) }) + describe("state posts while the task history scan is pending", () => { + const postedStates = () => + findCallsByType(mockPostMessage.mock.calls, "state").map(([message]) => message.state) + + // Makes the already-loaded store look like it is still scanning until `release` is called. + function holdTaskHistoryScan() { + const store = provider.taskHistoryStore + let resolveGate!: () => void + const gate = new Promise((resolve) => (resolveGate = resolve)) + store["_isInitialized"] = false + Object.defineProperty(store, "initialized", { value: gate }) + + return () => { + store["_isInitialized"] = true + resolveGate() + } + } + + it("launches without history, then posts the full state with history once the scan completes", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.updateTaskHistory(createHistoryItem({ id: "launch-task", task: "Launch task" }), { + broadcast: false, + }) + // The module-level WorkspaceTracker mock returns undefined; the launch handler chains `.catch` on it. + provider.workspaceTracker!.initializeFilePaths = vi.fn().mockResolvedValue(undefined) + const release = holdTaskHistoryScan() + mockPostMessage.mockClear() + + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + + expect(postedStates()).toHaveLength(1) + expect(postedStates()[0]).not.toHaveProperty("taskHistory") + + release() + await vi.waitFor(() => expect(postedStates()).toHaveLength(2)) + + expect(postedStates()[1].taskHistory).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "launch-task" })]), + ) + }) + + it.each([ + { scanComplete: false, expectHistory: false }, + { scanComplete: true, expectHistory: true }, + ])( + "updateSettings posts state with history: $expectHistory when the scan is complete: $scanComplete", + async ({ scanComplete, expectHistory }) => { + await provider.resolveWebviewView(mockWebviewView) + await provider.updateTaskHistory(createHistoryItem({ id: "settings-task", task: "Settings task" }), { + broadcast: false, + }) + if (!scanComplete) { + holdTaskHistoryScan() + } + mockPostMessage.mockClear() + + await webviewMessageHandler(provider, { + type: "updateSettings", + updatedSettings: { soundEnabled: true }, + }) + + expect(postedStates()).toHaveLength(1) + expect("taskHistory" in postedStates()[0]).toBe(expectHistory) + }, + ) + }) + describe("taskHistory write lock (mutex)", () => { it("serializes concurrent updateTaskHistory calls so no entries are lost", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 545897f1f4..bcdae5f7cb 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -879,7 +879,13 @@ export const webviewMessageHandler = async ( await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) } - await provider.postStateToWebview() + // While the task history scan runs, a full post would wait on it and stall the + // settings response. The full state follows once the scan completes (see webviewDidLaunch). + if (provider.taskHistoryStore.isInitialized) { + await provider.postStateToWebview() + } else { + await provider.postStateToWebviewWithoutTaskHistory() + } } break From bf3695acccc5c3f391f15bcf353b1284aa901902 Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 25 Oct 2026 11:16:39 +0100 Subject: [PATCH 3/3] test(webview): assert the posted task history contents in the updateSettings case Co-Authored-By: Claude Sonnet 5.5 --- .../webview/__tests__/ClineProvider.taskHistory.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index b1fa2dfed8..ab29cb7771 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -989,7 +989,14 @@ describe("ClineProvider Task History Synchronization", () => { }) expect(postedStates()).toHaveLength(1) - expect("taskHistory" in postedStates()[0]).toBe(expectHistory) + const state = postedStates()[0] + if (expectHistory) { + expect(state.taskHistory).toEqual( + expect.arrayContaining([expect.objectContaining({ id: "settings-task" })]), + ) + } else { + expect(state).not.toHaveProperty("taskHistory") + } }, ) })