fix(ui): catalog continuation silently dropped staged attachments (#123581)

A continuable catalog session exposes the full attachment surface
(file inputs, plus-menu, paste), and the send button counts staged
attachments as composed content — but continueCatalogSession required a
non-empty text draft and always handed off attachments: []. Draft+images
sent text only with the images silently vanishing (payloads leaked,
never released); images-only made the enabled send button do nothing at
all: the worst bug class, an action with no visible outcome and no
recorded reason.

Carry independently-owned attachment clones through the continuation
handoff, mirroring the new-session sibling
(chat-pane-session-creation.ts), and treat attachments as composed
content in the guard so attachment-only continuations send. Empty
draft with no attachments still no-ops.
This commit is contained in:
Peter Steinberger
2026-08-14 04:36:08 -07:00
committed by GitHub
parent ee6c2cb50d
commit 37a63fbf45
2 changed files with 78 additions and 4 deletions
@@ -79,6 +79,7 @@ function createTestChatPane(params: { client: GatewayBrowserClient; sessions: Se
const state = {
agentsList: null,
assistantAgentId: null,
chatAttachments: [],
chatError: null,
chatHistoryPagination: { hasMore: false },
chatLoading: false,
@@ -717,6 +718,70 @@ describe("chat pane catalog continuation lifecycle", () => {
expect(state.handleSendChat).not.toHaveBeenCalled();
});
it("carries staged attachments through the continuation handoff", async () => {
const request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:continued-attachments" });
const { key, pane, state } = createCatalogContinuationPane(request);
state.chatAttachments = [
{
id: "att-1",
mimeType: "image/png",
fileName: "shot.png",
dataUrl: "data:image/png;base64,AAAA",
},
];
await pane.continueCatalogSession(key);
const handoff = consumePaneSessionHandoff(
pane.context,
pane.paneId,
"agent:main:continued-attachments",
);
expect(handoff?.send).toBe(true);
expect(handoff?.attachments).toHaveLength(1);
expect(handoff?.attachments[0]).toMatchObject({
mimeType: "image/png",
fileName: "shot.png",
dataUrl: "data:image/png;base64,AAAA",
});
});
it("continues an attachment-only draft instead of silently ignoring the send", async () => {
const request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:attachment-only" });
const { key, pane, state } = createCatalogContinuationPane(request);
state.chatMessage = "";
state.chatAttachments = [
{
id: "att-2",
mimeType: "image/png",
fileName: "only.png",
dataUrl: "data:image/png;base64,BBBB",
},
];
await pane.continueCatalogSession(key);
expect(request).toHaveBeenCalledWith("sessions.catalog.continue", key);
const handoff = consumePaneSessionHandoff(
pane.context,
pane.paneId,
"agent:main:attachment-only",
);
expect(handoff?.send).toBe(true);
expect(handoff?.attachments).toHaveLength(1);
});
it("still ignores a continuation with no draft and no attachments", async () => {
const request = vi.fn();
const { key, pane, state } = createCatalogContinuationPane(request);
state.chatMessage = " ";
state.chatAttachments = [];
await pane.continueCatalogSession(key);
expect(request).not.toHaveBeenCalled();
});
it("does not stage or send a continuation rejected by its logical pane", async () => {
const request = vi.fn().mockResolvedValue({ sessionKey: "agent:main:rejected-continuation" });
const { key, pane, state } = createCatalogContinuationPane(request);
+13 -4
View File
@@ -17,7 +17,10 @@ import {
areUiSessionKeysEquivalent,
parseAgentSessionKey,
} from "../../lib/sessions/session-key.ts";
import { replaceChatAttachmentsFromEditor } from "./attachment-payload-store.ts";
import {
cloneChatAttachmentsForIndependentOwner,
replaceChatAttachmentsFromEditor,
} from "./attachment-payload-store.ts";
import type { ChatHistoryPagination } from "./chat-history-pagination.ts";
import {
loadChatHistory,
@@ -535,8 +538,14 @@ export abstract class ChatPaneHistory extends ChatPaneSession {
const scope = this.captureConnectionScope();
const state = scope?.state;
const client = scope?.client;
const draft = state?.chatMessage.trim();
if (!scope || !state || !client || !draft || !this.catalogSession?.canContinue) {
const draft = state?.chatMessage.trim() ?? "";
// Attachments count as composed content: an image-only continuation must
// send, not silently no-op while the send button looks live.
if (!scope || !state || !client || !this.catalogSession?.canContinue) {
return;
}
const attachments = cloneChatAttachmentsForIndependentOwner(state.chatAttachments);
if (!draft && attachments.length === 0) {
return;
}
const sourceSessionKey = state.sessionKey;
@@ -573,7 +582,7 @@ export abstract class ChatPaneHistory extends ChatPaneSession {
return;
}
preparePaneSessionHandoff(this.context, this.paneId, result.sessionKey, {
attachments: [],
attachments,
draft,
send: true,
});