fix(ui): release losing fallback payloads on composer adoption (#124391)

Global-scope composer fallback adoption coalesces sibling candidates
(bare-global, default-main, qualified-main) into one winner and deletes
the losers from chatComposerFallbackByScope — but never released their
attachment payload-store entries, unlike every clearChatComposerMemory-
Fallback caller. The dropped data URLs and object URLs leaked for the
pane's lifetime.

Release dropped candidates' payloads at the adoption site, retaining ids
still referenced by the live composer or any surviving fallback (the
same retention rule the pane-handoff owner uses).
This commit is contained in:
Peter Steinberger
2026-08-15 20:54:45 -07:00
committed by GitHub
parent c6ae99bb96
commit 9a3f3b7bf1
4 changed files with 135 additions and 8 deletions
@@ -87,6 +87,20 @@ export function releaseChatAttachmentPayloads(attachments: readonly ChatAttachme
}
}
/**
* Releases displaced attachments except ids still referenced by a retained
* owner (live composer, surviving fallbacks). Attachments are backups of
* composer state, so shared ids across owners are the norm — dropping one
* owner must never revoke another owner's payload.
*/
export function releaseDisplacedChatAttachmentPayloads(
displaced: readonly ChatAttachment[],
retained: ReadonlyArray<readonly ChatAttachment[]>,
): void {
const retainedIds = new Set(retained.flat().map((attachment) => attachment.id));
releaseChatAttachmentPayloads(displaced.filter((attachment) => !retainedIds.has(attachment.id)));
}
export function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
@@ -0,0 +1,105 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from "vitest";
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
import {
getChatAttachmentDataUrl,
registerChatAttachmentPayload,
} from "./attachment-payload-store.ts";
import { retainChatComposerMemoryFallback } from "./chat-composer-memory-fallback.ts";
import type { ChatComposerMemoryFallback, ChatPageHost } from "./chat-state-host.ts";
import { storedChatOutboxScopeKey } from "./composer-persistence.ts";
function storedAttachment(id: string, mimeType = "image/png"): ChatAttachment {
return registerChatAttachmentPayload({
attachment: { id, mimeType },
dataUrl: `data:${mimeType};base64,${id}`,
file: new File([id], id, { type: mimeType }),
});
}
function globalHost(
fallbacks: Record<string, ChatComposerMemoryFallback>,
attachments: ChatAttachment[] = [],
): ChatPageHost {
return {
agentsList: { defaultId: "main", mainKey: "main" },
assistantAgentId: "main",
chatAttachments: attachments,
chatComposerFallbackByScope: fallbacks,
hello: null,
sessionKey: "global",
settings: { gatewayUrl: "ws://example.test" },
} as unknown as ChatPageHost;
}
describe("chat composer memory fallback adoption", () => {
it("releases losing sibling fallback payloads when adoption drops them", () => {
const losing = storedAttachment("losing-sibling-attachment");
const winning = storedAttachment("winning-attachment");
const scope = { sessionKey: "global", agentId: "main" } as const;
const scopeKey = storedChatOutboxScopeKey(scope);
const bareGlobalKey = storedChatOutboxScopeKey({ sessionKey: "global" });
const host = globalHost({
[bareGlobalKey]: {
message: "older sibling draft",
attachments: [losing],
storageFailed: true,
sequence: 1,
},
[scopeKey]: {
message: "newest draft",
attachments: [winning],
storageFailed: true,
sequence: 2,
},
});
const ownership = retainChatComposerMemoryFallback(host, scope, {
message: "newest draft",
attachments: [winning],
});
expect(ownership).toEqual({ sequence: 2 });
expect(Object.keys(host.chatComposerFallbackByScope)).toEqual([scopeKey]);
// The losing sibling was dropped for good: its payload-store entry must be
// released with it, while the adopted fallback's payload stays available.
expect(getChatAttachmentDataUrl({ id: losing.id, mimeType: losing.mimeType })).toBeNull();
expect(getChatAttachmentDataUrl({ id: winning.id, mimeType: winning.mimeType })).toBe(
"data:image/png;base64,winning-attachment",
);
});
it("keeps payloads shared with the live composer when a sibling is dropped", () => {
const shared = storedAttachment("shared-with-composer");
const scope = { sessionKey: "global", agentId: "main" } as const;
const scopeKey = storedChatOutboxScopeKey(scope);
const bareGlobalKey = storedChatOutboxScopeKey({ sessionKey: "global" });
const host = globalHost(
{
[bareGlobalKey]: {
message: "older sibling draft",
attachments: [shared],
storageFailed: true,
sequence: 1,
},
[scopeKey]: {
message: "newest draft",
attachments: [],
storageFailed: true,
sequence: 2,
},
},
[shared],
);
retainChatComposerMemoryFallback(host, scope, {
message: "newest draft",
attachments: [],
});
expect(getChatAttachmentDataUrl({ id: shared.id, mimeType: shared.mimeType })).toBe(
"data:image/png;base64,shared-with-composer",
);
});
});
@@ -6,6 +6,7 @@ import {
resolveUiDefaultAgentId,
resolveUiKnownSelectedGlobalAgentId,
} from "../../lib/sessions/session-key.ts";
import { releaseDisplacedChatAttachmentPayloads } from "./attachment-payload-store.ts";
import type { ChatComposerMemoryFallback, ChatPageHost } from "./chat-state-host.ts";
import {
loadChatComposerCommittedDraftRevision,
@@ -106,6 +107,13 @@ function resolveChatComposerMemoryFallback(
delete nextFallbacks[candidate.scopeKey];
}
nextFallbacks[scopeKey] = adoptedFallback;
// Losing sibling fallbacks are dropped for good here; release their
// payload-store entries (like the pane-handoff owner does) or the data URLs
// leak for the pane's lifetime.
releaseDisplacedChatAttachmentPayloads(
candidates.flatMap((candidate) => candidate.fallback.attachments),
[state.chatAttachments, ...Object.values(nextFallbacks).map((f) => f.attachments)],
);
state.chatComposerFallbackByScope = nextFallbacks;
return { fallback: adoptedFallback, scopeKey };
}
@@ -1,6 +1,9 @@
import type { ApplicationContext } from "../../app/context.ts";
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
import { releaseChatAttachmentPayload } from "./attachment-payload-store.ts";
import {
releaseChatAttachmentPayload,
releaseDisplacedChatAttachmentPayloads,
} from "./attachment-payload-store.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
import { resolveStoredChatOutboxScope, storedChatOutboxScopeKey } from "./composer-persistence.ts";
import { panesOf, type ChatSplitLayout, visiblePanesOf } from "./split-layout.ts";
@@ -51,13 +54,10 @@ export function restorePaneStagedAttachments(
...restored.fallbacks,
...state.chatComposerFallbackByScope,
};
const retainedIds = new Set(state.chatAttachments.map((attachment) => attachment.id));
for (const fallback of Object.values(state.chatComposerFallbackByScope)) {
for (const attachment of fallback.attachments) {
retainedIds.add(attachment.id);
}
}
releaseAttachments(displaced, retainedIds);
releaseDisplacedChatAttachmentPayloads(displaced, [
state.chatAttachments,
...Object.values(state.chatComposerFallbackByScope).map((fallback) => fallback.attachments),
]);
}
export function preparePaneStagedAttachments(