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
@@ -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");
+8 -14
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;
}