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:
Mohamed Boudra
2026-07-24 11:52:31 +02:00
parent 4b5551d61c
commit fc10c79e26
9 changed files with 132 additions and 38 deletions

View File

@@ -876,7 +876,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = context.status === "running";
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -9,7 +9,10 @@ import {
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import {
useGenericKeyboardHandler,
useReanimatedKeyboardAnimation,
} from "react-native-keyboard-controller";
import {
useAnimatedStyle,
useDerivedValue,
@@ -40,6 +43,19 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
bottomInset.value = insets.bottom;
}, [bottomInset, insets.bottom]);
useGenericKeyboardHandler(
{
onEnd: (event) => {
"worklet";
if (isIos) {
keyboardHeight.value = -event.height;
keyboardProgress.value = event.progress;
}
},
},
[isIos, keyboardHeight, keyboardProgress],
);
const shift = useDerivedValue(() => {
"worklet";
return resolveKeyboardShift({

View File

@@ -22,6 +22,36 @@ function assistant(id: string, timestamp: Date): StreamItem {
}
describe("deriveStreamTurnTiming", () => {
it("reserves a running footer for an optimistic prompt before the host starts the turn", () => {
const optimisticPrompt = {
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
optimistic: true as const,
};
const timing = deriveStreamTurnTiming({
agentStatus: "idle",
tail: [],
head: [optimisticPrompt],
});
assert.equal(timing.isActive, true);
});
it("does not start elapsed time from an optimistic prompt", () => {
const optimisticPrompt = {
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
optimistic: true as const,
};
const timing = deriveStreamTurnTiming({
agentStatus: "running",
tail: [],
head: [optimisticPrompt],
});
assert.equal(timing.runningStartedAt, null);
});
it("uses the last user message as the running turn start", () => {
const firstUserAt = new Date("2026-05-15T00:00:00.000Z");
const secondUserAt = new Date("2026-05-15T00:01:00.000Z");

View File

@@ -9,6 +9,7 @@ export interface TurnTiming {
export interface StreamTurnTiming {
byAssistantId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
isActive: boolean;
}
export function deriveStreamTurnTiming(params: {
@@ -18,6 +19,8 @@ export function deriveStreamTurnTiming(params: {
}): StreamTurnTiming {
const byAssistantId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null;
let currentAuthoritativeUserAt: Date | null = null;
let currentUserIsOptimistic = false;
let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = [];
@@ -39,6 +42,8 @@ export function deriveStreamTurnTiming(params: {
if (item.kind === "user_message") {
flushCompletedTurn();
currentUserAt = item.timestamp;
currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp;
currentUserIsOptimistic = item.optimistic === true;
currentLastItemAt = null;
currentAssistantIds = [];
return;
@@ -59,10 +64,8 @@ export function deriveStreamTurnTiming(params: {
visitItem(item);
}
const runningStartedAt =
params.agentStatus === "running"
? (findLastUserMessageTimestamp(params.head) ?? currentUserAt)
: null;
const isRunning = params.agentStatus === "running";
const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null;
if (params.agentStatus !== "running") {
flushCompletedTurn();
}
@@ -70,15 +73,6 @@ export function deriveStreamTurnTiming(params: {
return {
byAssistantId,
runningStartedAt,
isActive: isRunning || currentUserIsOptimistic,
};
}
function findLastUserMessageTimestamp(items: StreamItem[]): Date | null {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
if (item?.kind === "user_message") {
return item.timestamp;
}
}
return null;
}