fix(app): synchronize chat submission and keyboard state
Render optimistic turn feedback before host acknowledgement and roll it back on rejection. Reconcile iOS keyboard offsets from the native transition end event so JS contention cannot leave the composer displaced.
This commit is contained in:
@@ -333,6 +333,26 @@ describe("pickAndPersistImages", () => {
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("removes the optimistic prompt when the host rejects it", async () => {
|
||||
const rejection = new Error("Host rejected prompt");
|
||||
const client = createFakeSendClient({ rejection });
|
||||
const stream = createFakeStream();
|
||||
|
||||
await expect(
|
||||
dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "rejected prompt",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
}),
|
||||
).rejects.toBe(rejection);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
expect(stream.tail.get("agent") ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
|
||||
@@ -186,40 +186,50 @@ export async function dispatchComposerAgentMessage(
|
||||
images: wirePayload.images,
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
const rollbackOptimisticMessage = appendUserMessageToStream(
|
||||
input.agentId,
|
||||
userMessage,
|
||||
input.stream,
|
||||
);
|
||||
try {
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackOptimisticMessage();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
agentId: string,
|
||||
userMessage: UserMessageItem,
|
||||
stream: AgentStreamWriter,
|
||||
): void {
|
||||
): () => void {
|
||||
const result = appendOptimisticUserMessageToStream({
|
||||
tail: stream.getTail(agentId) ?? [],
|
||||
head: stream.getHead(agentId) ?? [],
|
||||
message: userMessage,
|
||||
placement: "active-head",
|
||||
});
|
||||
if (result.changedHead) {
|
||||
stream.setHead((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.head);
|
||||
return next;
|
||||
const write = result.changedHead ? stream.setHead : stream.setTail;
|
||||
const items = result.changedHead ? result.head : result.tail;
|
||||
write((prev) => new Map(prev).set(agentId, items));
|
||||
|
||||
return () => {
|
||||
write((prev) => {
|
||||
const current = prev.get(agentId);
|
||||
if (!current) return prev;
|
||||
const nextItems = current.filter(
|
||||
(item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic,
|
||||
);
|
||||
if (nextItems.length === current.length) return prev;
|
||||
return new Map(prev).set(agentId, nextItems);
|
||||
});
|
||||
}
|
||||
if (result.changedTail) {
|
||||
stream.setTail((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, result.tail);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageInput {
|
||||
|
||||
@@ -45,6 +45,7 @@ interface ComposerWorkspaceAttachmentBinding {
|
||||
buildOutgoingAttachments: (normalAttachments: UserComposerAttachment[]) => ComposerAttachment[];
|
||||
removeAttachment: (input: RemoveWorkspaceAttachmentInput) => boolean;
|
||||
openAttachment: (input: OpenWorkspaceAttachmentInput) => boolean;
|
||||
beginSubmit: (attachments: readonly ComposerAttachment[]) => void;
|
||||
clearSentAttachments: (attachments: readonly ComposerAttachment[]) => void;
|
||||
completeSubmit: (input: CompleteSubmitInput) => void;
|
||||
resetSuppression: () => void;
|
||||
@@ -196,12 +197,22 @@ function useWorkspaceAttachmentBinding({
|
||||
setSuppressedKeys([]);
|
||||
}, []);
|
||||
|
||||
const beginSubmit = useCallback((attachments: readonly ComposerAttachment[]) => {
|
||||
const keys = attachments.filter(isWorkspaceAttachment).map(getAttachmentKey);
|
||||
if (keys.length === 0) return;
|
||||
setSuppressedKeys((current) => {
|
||||
const next = new Set(current);
|
||||
for (const key of keys) next.add(key);
|
||||
return next.size === current.length ? current : Array.from(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const completeSubmit = useCallback(
|
||||
({ result, outgoingAttachments }: CompleteSubmitInput) => {
|
||||
if (result === "submitted") {
|
||||
clearSentAttachments(outgoingAttachments);
|
||||
}
|
||||
if (result === "queued" || result === "submitted") {
|
||||
if (result === "queued" || result === "submitted" || result === "failed") {
|
||||
resetSuppression();
|
||||
}
|
||||
},
|
||||
@@ -213,6 +224,7 @@ function useWorkspaceAttachmentBinding({
|
||||
buildOutgoingAttachments,
|
||||
removeAttachment,
|
||||
openAttachment,
|
||||
beginSubmit,
|
||||
clearSentAttachments,
|
||||
completeSubmit,
|
||||
resetSuppression,
|
||||
|
||||
@@ -1095,6 +1095,7 @@ export function Composer({
|
||||
buildOutgoingAttachments,
|
||||
removeAttachment,
|
||||
openAttachment,
|
||||
beginSubmit,
|
||||
clearSentAttachments,
|
||||
completeSubmit,
|
||||
resetSuppression,
|
||||
@@ -1372,6 +1373,9 @@ export function Composer({
|
||||
queueMessage(queuedText, queuedAttachments);
|
||||
},
|
||||
submitMessage: async ({ message: submitText, attachments: submitAttachments }) => {
|
||||
if (submitBehavior !== "preserve-and-lock") {
|
||||
beginSubmit(submitAttachments);
|
||||
}
|
||||
await submitMessage(submitText, submitAttachments);
|
||||
},
|
||||
clearDraft,
|
||||
@@ -1393,6 +1397,7 @@ export function Composer({
|
||||
},
|
||||
[
|
||||
allowEmptySubmit,
|
||||
beginSubmit,
|
||||
clearDraft,
|
||||
completeSubmit,
|
||||
hasExternalContent,
|
||||
|
||||
Reference in New Issue
Block a user