diff --git a/ui/src/pages/chat/chat-pane-attachment-handoff.test.ts b/ui/src/pages/chat/chat-pane-attachment-handoff.test.ts index 8e0ccc632523..0b78ea2c5241 100644 --- a/ui/src/pages/chat/chat-pane-attachment-handoff.test.ts +++ b/ui/src/pages/chat/chat-pane-attachment-handoff.test.ts @@ -5,6 +5,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { createChatAttachmentHandoff } from "../../app/chat-attachment-handoff.ts"; import type { ApplicationContext } from "../../app/context.ts"; import type { ChatAttachment } from "../../lib/chat/chat-types.ts"; +import type { SessionCapability } from "../../lib/sessions/index.ts"; import { getChatAttachmentDataUrl, registerChatAttachmentPayload, @@ -15,7 +16,9 @@ import { preparePaneStagedAttachments, restorePaneStagedAttachments, } from "./chat-pane-attachment-handoff.ts"; +import { createTestChatPane } from "./chat-pane.test-support.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; +import { resolveStoredChatOutboxScope, storedChatOutboxScopeKey } from "./composer-persistence.ts"; import type { ChatSplitLayout } from "./split-layout.ts"; function storedAttachment(id: string, mimeType = "image/png"): ChatAttachment { @@ -68,6 +71,54 @@ describe("staged chat attachment pane handoff", () => { expect(calls).toEqual(["discard", "clear"]); }); + it("does not restage a closed pane when its id is reused after disconnect", () => { + const owner = {} as GatewayBrowserClient; + const { pane, state: current } = createTestChatPane({ + client: owner, + sessions: {} as SessionCapability, + }); + pane.paneId = "p2"; + const fallback = storedAttachment("closed-fallback"); + current.chatComposerFallbackByScope = { + fallback: { + attachments: [fallback], + message: "closed pane draft", + sequence: 1, + storageFailed: false, + }, + }; + const root = { querySelectorAll: () => [pane] } as unknown as ParentNode; + const layout = { + columns: [ + { + id: "c1", + panes: [ + { id: "p1", sessionKey: "one" }, + { id: "p2", sessionKey: current.sessionKey }, + ], + paneWeights: [1, 1], + }, + ], + columnWeights: [1], + activePaneId: "p2", + } satisfies ChatSplitLayout; + const scopeKey = storedChatOutboxScopeKey( + resolveStoredChatOutboxScope(current, current.sessionKey), + ); + + closePaneStagedAttachments(pane.context, root, layout, pane.paneId); + pane.disconnectedCallback(); + + expect(getChatAttachmentDataUrl(fallback)).toBeNull(); + expect( + pane.context.chatAttachmentHandoff.consume({ + owner, + paneId: "p2", + scopeKey, + }), + ).toBeNull(); + }); + it("deduplicates current and fallback payload release", () => { const shared = storedAttachment("shared"); const fallback = storedAttachment("fallback", "application/pdf"); diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index 43758ca99857..e1da41d5495a 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -72,8 +72,12 @@ const COMPOSER_PREFILL_ATTENTION_CLASS = "agent-chat__input--prefill-attention"; export abstract class ChatPaneLifecycle extends ChatPaneBoard { private stagedAttachmentGatewayOwner: ChatAttachmentGatewayOwner = null; + private suppressStagedAttachmentHandoffOnDisconnect = false; public discardStagedAttachments(): void { + // Explicit pane disposal is terminal. The DOM disconnect that follows must + // not recreate an empty fallback handoff under a later reused pane id. + this.suppressStagedAttachmentHandoffOnDisconnect = true; discardStateStagedAttachments(this.state); } @@ -438,6 +442,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { override connectedCallback() { this.boardProviderLifecycleConnected = true; + this.suppressStagedAttachmentHandoffOnDisconnect = false; super.connectedCallback(); const mountGatewayOwner = this.context.gateway.snapshot.client; this.stagedAttachmentGatewayOwner = mountGatewayOwner; @@ -665,7 +670,7 @@ export abstract class ChatPaneLifecycle extends ChatPaneBoard { } override disconnectedCallback() { - if (this.state) { + if (this.state && !this.suppressStagedAttachmentHandoffOnDisconnect) { preparePaneStagedAttachments( this.context, this.paneId,