From fc10c79e2653e5e653fe8dc2d1364faaa0529e0e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 24 Jul 2026 11:52:31 +0200 Subject: [PATCH] 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. --- docs/floating-panels.md | 7 +++ packages/app/src/agent-stream/view.tsx | 2 +- packages/app/src/composer/actions.test.ts | 20 +++++++ packages/app/src/composer/actions.ts | 52 +++++++++++-------- .../src/composer/attachments/workspace.tsx | 14 ++++- packages/app/src/composer/index.tsx | 5 ++ .../app/src/hooks/use-keyboard-shift-style.ts | 18 ++++++- packages/app/src/timeline/turn-time.test.ts | 30 +++++++++++ packages/app/src/timeline/turn-time.ts | 22 +++----- 9 files changed, 132 insertions(+), 38 deletions(-) diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 7b47b23aa..d646dc609 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -129,6 +129,13 @@ lockstep, no re-measurement needed. Do not call can briefly report a stale nonzero height with closed progress, and the shared provider is where that is normalized. +The provider also reconciles iOS from the controller's native `onEnd` event. +The controller's stock iOS shared values update at move start and during an +interactive move, but not at the terminal event, so JS contention can otherwise +leave the last height/progress pair stuck in either the open or closed state. +Keep that terminal reconciliation on the UI thread; a later focus or blur must +not be required to repair the offset. + Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only to refresh the snapshot if the keyboard was mid-transition when the popover opened. diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 3ff24bcc8..421fed73c 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -876,7 +876,7 @@ const AgentStreamViewComponent = forwardRef renderPendingPermissionsNode({ diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index facc6a909..8b577fcd7 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -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(); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 382d9f775..af3d123d7 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -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 { diff --git a/packages/app/src/composer/attachments/workspace.tsx b/packages/app/src/composer/attachments/workspace.tsx index 0beff1fb5..2e9882a49 100644 --- a/packages/app/src/composer/attachments/workspace.tsx +++ b/packages/app/src/composer/attachments/workspace.tsx @@ -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, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c7e9cc38a..f753a04d4 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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, diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/hooks/use-keyboard-shift-style.ts index a07ce56c3..63799c678 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/hooks/use-keyboard-shift-style.ts @@ -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({ diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index 575817b5e..d63d2de7d 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -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"); diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index 575f52ef6..435694524 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -9,6 +9,7 @@ export interface TurnTiming { export interface StreamTurnTiming { byAssistantId: Map; runningStartedAt: Date | null; + isActive: boolean; } export function deriveStreamTurnTiming(params: { @@ -18,6 +19,8 @@ export function deriveStreamTurnTiming(params: { }): StreamTurnTiming { const byAssistantId = new Map(); 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; -}