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:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user