From 650a8f0fafa6122e370b1be04ef6e4d012bd0e2d Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 29 Jul 2026 22:37:37 +0200 Subject: [PATCH] fix(collab): detach reconciled loader on idle (cherry picked from commit d675de815644a5b02358fa90d83e5cd5c78141d2) --- packages/coding-agent/src/collab/guest.ts | 2 ++ .../test/collab/guest-idle-reconciler.test.ts | 20 +++++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/collab/guest.ts b/packages/coding-agent/src/collab/guest.ts index 09baae0df..db8aa640b 100644 --- a/packages/coding-agent/src/collab/guest.ts +++ b/packages/coding-agent/src/collab/guest.ts @@ -85,6 +85,7 @@ interface PendingSnapshot { /** Minimal context surface the idle-state reconciler mutates. */ export interface GuestIdleReconcilerCtx { statusLine: { markActivityEnd: () => void }; + statusContainer: Pick; loadingAnimation: { stop: () => void } | undefined; } @@ -106,6 +107,7 @@ export function reconcileGuestIdleHostState(ctx: GuestIdleReconcilerCtx, isStrea if (ctx.loadingAnimation) { ctx.loadingAnimation.stop(); ctx.loadingAnimation = undefined; + ctx.statusContainer.disposeChildren(); } } diff --git a/packages/coding-agent/test/collab/guest-idle-reconciler.test.ts b/packages/coding-agent/test/collab/guest-idle-reconciler.test.ts index bc58e27a1..2d0613344 100644 --- a/packages/coding-agent/test/collab/guest-idle-reconciler.test.ts +++ b/packages/coding-agent/test/collab/guest-idle-reconciler.test.ts @@ -39,16 +39,20 @@ interface Fixture { ctx: GuestIdleReconcilerCtx; markActivityEnd: Mock<() => void>; loaderStop: Mock<() => void>; + visibleChildren: object[]; } function makeCtx(hasLoader: boolean): Fixture { const markActivityEnd: Mock<() => void> = mock(() => {}); const loaderStop: Mock<() => void> = mock(() => {}); + const loader = { stop: loaderStop }; + const visibleChildren: object[] = hasLoader ? [loader] : []; const ctx: GuestIdleReconcilerCtx = { statusLine: { markActivityEnd }, - loadingAnimation: hasLoader ? { stop: loaderStop } : undefined, + statusContainer: { disposeChildren: () => visibleChildren.splice(0) }, + loadingAnimation: hasLoader ? loader : undefined, }; - return { ctx, markActivityEnd, loaderStop }; + return { ctx, markActivityEnd, loaderStop, visibleChildren }; } function makeSession(): ConstructorParameters[0] { @@ -87,12 +91,13 @@ function makeSession(): ConstructorParameters[0] { describe("reconcileGuestIdleHostState", () => { it("closes the active-time window and stops the loader when the host reports idle", () => { - const { ctx, markActivityEnd, loaderStop } = makeCtx(true); + const { ctx, markActivityEnd, loaderStop, visibleChildren } = makeCtx(true); reconcileGuestIdleHostState(ctx, false); expect(markActivityEnd).toHaveBeenCalledTimes(1); expect(loaderStop).toHaveBeenCalledTimes(1); // Loader is cleared so a second reconciliation does not re-stop it. expect(ctx.loadingAnimation).toBeUndefined(); + expect(visibleChildren).toEqual([]); }); it("is a no-op while the host is still streaming so live turns keep the meter open", () => { @@ -135,6 +140,7 @@ describe("reconcileGuestSnapshotHostState", () => { const ensureLoadingAnimation = mock(() => {}); const ctx: GuestSnapshotActivityReconcilerCtx = { statusLine, + statusContainer: { disposeChildren: () => {} }, loadingAnimation: undefined, ensureLoadingAnimation, autoCompactionLoader: undefined, @@ -157,6 +163,7 @@ describe("reconcileGuestSnapshotHostState", () => { const ensureLoadingAnimation = mock(() => {}); const ctx: GuestSnapshotActivityReconcilerCtx = { statusLine, + statusContainer: { disposeChildren: () => {} }, loadingAnimation: undefined, ensureLoadingAnimation, autoCompactionLoader: undefined, @@ -179,7 +186,10 @@ describe("reconcileGuestSnapshotHostState", () => { }); const ctx: GuestSnapshotActivityReconcilerCtx & { statusContainer: { clear: () => void } } = { statusLine: new StatusLineComponent(makeSession()), - statusContainer: { clear: () => visibleChildren.splice(0) }, + statusContainer: { + clear: () => visibleChildren.splice(0), + disposeChildren: () => visibleChildren.splice(0), + }, loadingAnimation: undefined, ensureLoadingAnimation, autoCompactionLoader: @@ -201,6 +211,7 @@ describe("reconcileGuestSnapshotHostState", () => { const ensureLoadingAnimation = mock(() => {}); const ctx: GuestSnapshotActivityReconcilerCtx = { statusLine: new StatusLineComponent(makeSession()), + statusContainer: { disposeChildren: () => {} }, loadingAnimation: undefined, ensureLoadingAnimation, autoCompactionLoader: undefined, @@ -214,6 +225,7 @@ describe("reconcileGuestSnapshotHostState", () => { const ensureLoadingAnimation = mock(() => {}); const ctx: GuestSnapshotActivityReconcilerCtx = { statusLine: new StatusLineComponent(makeSession()), + statusContainer: { disposeChildren: () => {} }, loadingAnimation: undefined, ensureLoadingAnimation, autoCompactionLoader: {} as GuestSnapshotActivityReconcilerCtx["autoCompactionLoader"],