fix: preserve fallback attachment packages across remounts

This commit is contained in:
Shakker
2026-08-10 11:33:32 +02:00
parent 33bee61f2c
commit 607f50bece
9 changed files with 108 additions and 50 deletions
+19 -7
View File
@@ -59,12 +59,15 @@ describe("chat attachment route handoff", () => {
paneId: "p1",
scopeKey: "agent:main:one",
attachments: staged,
fallbacks: {},
});
const consumed = handoff.consume({ owner, paneId: "p1", scopeKey: "agent:main:one" });
expect(consumed).toEqual(staged);
expect(consumed).not.toBe(staged);
expect(consumed?.every((attachment, index) => attachment === staged[index])).toBe(true);
expect(consumed?.attachments).toEqual(staged);
expect(consumed?.attachments).not.toBe(staged);
expect(consumed?.attachments.every((attachment, index) => attachment === staged[index])).toBe(
true,
);
expect(handoff.consume({ owner, paneId: "p1", scopeKey: "agent:main:one" })).toBeNull();
for (const attachment of ordinary) {
expect(getChatAttachmentDataUrl(attachment)).not.toBeNull();
@@ -89,6 +92,7 @@ describe("chat attachment route handoff", () => {
paneId: "p1",
scopeKey: "agent:main:one",
attachments: [annotation],
fallbacks: {},
});
expect(
@@ -108,10 +112,16 @@ describe("chat attachment route handoff", () => {
const oversized = Array.from({ length: 33 }, (_, index) =>
storedAttachment(`oversized-${index}`, "image/png", false),
);
handoff.prepare({ owner, paneId: "oversized", scopeKey: "oversized", attachments: oversized });
expect(handoff.consume({ owner, paneId: "oversized", scopeKey: "oversized" })).toEqual(
oversized,
);
handoff.prepare({
owner,
paneId: "oversized",
scopeKey: "oversized",
attachments: oversized,
fallbacks: {},
});
expect(
handoff.consume({ owner, paneId: "oversized", scopeKey: "oversized" })?.attachments,
).toEqual(oversized);
expect(getChatAttachmentDataUrl(oversized[32]!)).not.toBeNull();
const annotations = Array.from({ length: 33 }, (_, index) =>
@@ -123,6 +133,7 @@ describe("chat attachment route handoff", () => {
paneId: `p${index}`,
scopeKey: `scope-${index}`,
attachments: [annotation],
fallbacks: {},
}),
);
@@ -144,6 +155,7 @@ describe("chat attachment route handoff", () => {
paneId: "p1",
scopeKey: "agent:main:one",
attachments: [annotation],
fallbacks: {},
});
expect(getChatAttachmentDataUrl(annotation)).toBeNull();
+38 -12
View File
@@ -1,4 +1,4 @@
import type { ChatAttachment } from "../lib/chat/chat-types.ts";
import type { ChatAttachment, ChatComposerMemoryFallback } from "../lib/chat/chat-types.ts";
import { releaseChatAttachmentPayloads } from "../pages/chat/attachment-payload-store.ts";
import type { ApplicationChatAttachmentHandoff } from "./context.ts";
@@ -10,6 +10,7 @@ type PendingChatAttachmentHandoff = {
owner: NonNullable<Parameters<ApplicationChatAttachmentHandoff["prepare"]>[0]["owner"]>;
scopeKey: string;
attachments: ChatAttachment[];
fallbacks: Record<string, ChatComposerMemoryFallback>;
};
export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff {
@@ -18,6 +19,18 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff
const release = (attachments: readonly ChatAttachment[] = []) =>
releaseChatAttachmentPayloads(attachments);
const releaseHandoff = (handoff: PendingChatAttachmentHandoff | undefined) => {
if (!handoff) {
return;
}
const byId = new Map(handoff.attachments.map((attachment) => [attachment.id, attachment]));
for (const fallback of Object.values(handoff.fallbacks)) {
for (const attachment of fallback.attachments) {
byId.set(attachment.id, attachment);
}
}
release([...byId.values()]);
};
const take = (paneId: string) => {
const handoff = pending.get(paneId);
if (handoff) {
@@ -27,26 +40,39 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff
};
return {
prepare: ({ owner, paneId, scopeKey, attachments }) => {
prepare: ({ owner, paneId, scopeKey, attachments, fallbacks }) => {
const previous = take(paneId);
if (attachments.length === 0) {
release(previous?.attachments);
const fallbackEntries = Object.entries(fallbacks);
if (attachments.length === 0 && fallbackEntries.length === 0) {
releaseHandoff(previous);
return;
}
const retainedIds = new Set(attachments.map((attachment) => attachment.id));
release(previous?.attachments.filter((attachment) => !retainedIds.has(attachment.id)));
releaseHandoff(previous);
if (!owner || disposed) {
release(attachments);
for (const fallback of Object.values(fallbacks)) {
release(fallback.attachments);
}
return;
}
pending.set(paneId, { owner, scopeKey, attachments: [...attachments] });
pending.set(paneId, {
owner,
scopeKey,
attachments: [...attachments],
fallbacks: Object.fromEntries(
fallbackEntries.map(([key, fallback]) => [
key,
{ ...fallback, attachments: [...fallback.attachments] },
]),
),
});
// Route handoffs normally consume immediately. Bounds make abandoned
// split panes release their packages instead of leaking for the tab lifetime.
for (const oldestPaneId of pending.keys()) {
if (pending.size <= MAX_PENDING_CHAT_ATTACHMENT_ENTRIES) {
break;
}
release(take(oldestPaneId)?.attachments);
releaseHandoff(take(oldestPaneId));
}
},
consume: ({ owner, paneId, scopeKey }) => {
@@ -54,16 +80,16 @@ export function createChatAttachmentHandoff(): ApplicationChatAttachmentHandoff
// Reusing a pane id with another session or Gateway is terminal for the
// old owner; keeping it would allow a later remount to recover stale evidence.
if (match?.owner === owner && match.scopeKey === scopeKey) {
return match.attachments;
return { attachments: match.attachments, fallbacks: match.fallbacks };
}
release(match?.attachments);
releaseHandoff(match);
return null;
},
clearPane: (paneId) => release(take(paneId)?.attachments),
clearPane: (paneId) => releaseHandoff(take(paneId)),
dispose: () => {
disposed = true;
for (const handoff of pending.values()) {
release(handoff.attachments);
releaseHandoff(handoff);
}
pending.clear();
},
+11 -3
View File
@@ -4,7 +4,7 @@ import type { RouteId } from "../app-route-paths.ts";
import type { AgentIdentityCapability } from "../lib/agents/identity.ts";
import type { AgentCapability } from "../lib/agents/index.ts";
import type { ChannelCapability } from "../lib/channels/index.ts";
import type { ChatAttachment } from "../lib/chat/chat-types.ts";
import type { ChatAttachment, ChatComposerMemoryFallback } from "../lib/chat/chat-types.ts";
import type { RuntimeConfigCapability } from "../lib/config/index.ts";
import type { SessionCapability } from "../lib/sessions/index.ts";
import type { WorkboardCapability } from "../lib/workboard/capability.ts";
@@ -80,8 +80,16 @@ type ChatAttachmentHandoffKey = {
};
export type ApplicationChatAttachmentHandoff = {
prepare(handoff: ChatAttachmentHandoffKey & { attachments: readonly ChatAttachment[] }): void;
consume(handoff: ChatAttachmentHandoffKey): ChatAttachment[] | null;
prepare(
handoff: ChatAttachmentHandoffKey & {
attachments: readonly ChatAttachment[];
fallbacks: Readonly<Record<string, ChatComposerMemoryFallback>>;
},
): void;
consume(handoff: ChatAttachmentHandoffKey): {
attachments: ChatAttachment[];
fallbacks: Record<string, ChatComposerMemoryFallback>;
} | null;
clearPane(paneId: string): void;
dispose(): void;
};
+13
View File
@@ -24,6 +24,19 @@ export type ChatAttachment = {
browserAnnotation?: BrowserAnnotationAttachment;
};
export type ChatComposerDraftRetry = {
expectedDraftRevision: number;
draftRevision: number;
};
export type ChatComposerMemoryFallback = {
message: string;
attachments: ChatAttachment[];
storageFailed: boolean;
draftRetry?: ChatComposerDraftRetry;
sequence: number;
};
export type ChatQueueSkillWorkshopRevision = {
proposalId: string;
agentId?: string;
@@ -29,13 +29,6 @@ function releaseAttachments(
}
}
function releaseFallbackAttachments(state: ChatPageHost, retainedIds = new Set<string>()): void {
const releasedIds = new Set<string>();
for (const fallback of Object.values(state.chatComposerFallbackByScope)) {
releaseAttachments(fallback.attachments, retainedIds, releasedIds);
}
}
export function restorePaneStagedAttachments(
context: ApplicationContext,
paneId: string,
@@ -49,8 +42,12 @@ export function restorePaneStagedAttachments(
const currentIds = new Set(state.chatAttachments.map((attachment) => attachment.id));
state.chatAttachments = [
...state.chatAttachments,
...restored.filter((attachment) => !currentIds.has(attachment.id)),
...restored.attachments.filter((attachment) => !currentIds.has(attachment.id)),
];
state.chatComposerFallbackByScope = {
...restored.fallbacks,
...state.chatComposerFallbackByScope,
};
}
export function preparePaneStagedAttachments(
@@ -63,8 +60,8 @@ export function preparePaneStagedAttachments(
context.chatAttachmentHandoff.prepare({
...handoffKey(paneId, state, owner),
attachments,
fallbacks: state.chatComposerFallbackByScope,
});
releaseFallbackAttachments(state, new Set(attachments.map((attachment) => attachment.id)));
}
export function discardStateStagedAttachments(state: ChatPageHost | undefined): void {
@@ -108,17 +108,24 @@ describe("staged attachment composer adoption", () => {
pane.disconnectedCallback();
expect(getChatAttachmentDataUrl(shared)).not.toBeNull();
expect(getChatAttachmentDataUrl(fallback)).toBeNull();
expect(getChatAttachmentDataUrl(fallback)).not.toBeNull();
expect(getChatAttachmentDataUrl(ordinary)).not.toBeNull();
const transferred = pane.context.chatAttachmentHandoff.consume({
owner,
paneId: pane.paneId,
scopeKey,
});
expect(transferred).toEqual([shared, ordinary]);
expect(transferred?.[0]).toBe(shared);
expect(transferred?.[1]).toBe(ordinary);
expect(transferred?.attachments).toEqual([shared, ordinary]);
expect(transferred?.attachments[0]).toBe(shared);
expect(transferred?.attachments[1]).toBe(ordinary);
expect(transferred?.fallbacks.fallback).toMatchObject({
message: "",
sequence: 1,
storageFailed: false,
});
expect(transferred?.fallbacks.fallback?.attachments).toEqual([shared, fallback, ordinary]);
releaseChatAttachmentPayload(shared.id);
releaseChatAttachmentPayload(fallback.id);
releaseChatAttachmentPayload(ordinary.id);
});
+2 -9
View File
@@ -8,7 +8,7 @@ import type {
import type { ApplicationContext } from "../../app/context.ts";
import type { UiSettings } from "../../app/settings.ts";
import type { ImageLightboxItem } from "../../components/image-lightbox.ts";
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
import type { ChatComposerMemoryFallback } from "../../lib/chat/chat-types.ts";
import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts";
import type { ChatState } from "./chat-history.ts";
import type { ChatRealtimeState } from "./chat-realtime.ts";
@@ -18,7 +18,6 @@ import type { ChatProps } from "./chat-view.ts";
import type { BackgroundTasksHost } from "./components/chat-background-tasks.ts";
import type { SessionWorkspaceHost } from "./components/chat-session-workspace.ts";
import type { SidebarContent } from "./components/chat-sidebar.ts";
import type { ChatComposerDraftRetry } from "./composer-persistence.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./input-history.ts";
import type { RenderLifecycle } from "./render-lifecycle.ts";
import type { PendingChatAbort } from "./run-lifecycle.ts";
@@ -32,13 +31,7 @@ import type {
WaitingApprovalStatus,
} from "./tool-stream.ts";
export type ChatComposerMemoryFallback = {
message: string;
attachments: ChatAttachment[];
storageFailed: boolean;
draftRetry?: ChatComposerDraftRetry;
sequence: number;
};
export type { ChatComposerMemoryFallback } from "../../lib/chat/chat-types.ts";
export type ChatPageHost = ChatHost &
ChatState &
+2 -1
View File
@@ -4478,11 +4478,12 @@ describe("chat attachment picker", () => {
paneId: "p1",
scopeKey: "agent:main:one",
attachments,
fallbacks: {},
});
attachments = expectDefined(
handoff.consume({ owner, paneId: "p1", scopeKey: "agent:main:one" }),
"restored attachments",
);
).attachments;
expect(attachments).toHaveLength(1);
expect(attachments[0]).toBe(original);
+6 -5
View File
@@ -1,4 +1,8 @@
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
import type {
ChatAttachment,
ChatComposerDraftRetry,
ChatQueueItem,
} from "../../lib/chat/chat-types.ts";
import {
INTERRUPTED_SETTINGS_WAIT_ERROR,
MAX_STORED_QUEUE_ITEMS,
@@ -66,10 +70,7 @@ type RestoreOptions = {
sessionKey?: string;
};
export type ChatComposerDraftRetry = {
expectedDraftRevision: number;
draftRevision: number;
};
export type { ChatComposerDraftRetry } from "../../lib/chat/chat-types.ts";
type ChatComposerPersistStatus = "persisted" | "conflict" | "storage-failed";