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; -}