From 1baa1fc8a1e5cf11585759d3bedcbbbc7cd5a0ff Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 28 May 2026 12:38:41 +0100 Subject: [PATCH 1/2] feat(mobile): show prompt in chat thread immediately on submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports desktop PR PostHog/code#2310 to the React Native app. The user's submitted prompt now appears in the chat thread the instant they tap send, instead of waiting for the cloud run to start and the SSE stream to deliver the canonical echo. Mechanism mirrors the desktop one: - A keyed `pendingTaskPromptStore` (Zustand) holds the optimistic prompt. `NewTaskScreen` sets it under a transient UUID the moment the user submits, then `move`s it onto the real task id as soon as `createTask` returns. - `TaskSessionView` accepts an `optimisticUserMessage` prop and appends it as the newest message, dedup'd against any matching real `user_message_chunk` that has already arrived. - `TaskDetailScreen` reads the pending prompt for the current task id and clears it once the live session echoes the same text back. - The full-screen loading overlay is suppressed while an optimistic prompt is showing — the user sees their own text plus the connecting/thinking strip instead of a blank spinner. - The terminal-resume path (`handleSendAfterTerminal`) also writes to the store so the user's text remains visible across the disconnect/reconnect gap. Tests: new unit tests for the store (set/get/move/clear/attachments) and two new `TaskSessionView` rendering tests for the optimistic-echo path (renders when no SSE echo, suppressed when one matches). Generated-By: PostHog Code Task-Id: d59fa139-b382-4fcc-b6fd-9282bbc406e2 --- apps/mobile/src/app/task/[id].tsx | 51 +++++++++++- apps/mobile/src/app/task/index.tsx | 29 ++++++- .../tasks/components/TaskSessionView.test.tsx | 51 ++++++++++++ .../tasks/components/TaskSessionView.tsx | 34 +++++++- .../stores/pendingTaskPromptStore.test.ts | 61 ++++++++++++++ .../tasks/stores/pendingTaskPromptStore.ts | 81 +++++++++++++++++++ 6 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts create mode 100644 apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 2d0c494dd..a19ba7354 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -31,6 +31,10 @@ import { } from "@/features/tasks/composer/options"; import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer"; import { taskKeys } from "@/features/tasks/hooks/useTasks"; +import { + pendingTaskPromptStoreApi, + usePendingTaskPrompt, +} from "@/features/tasks/stores/pendingTaskPromptStore"; import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { Task } from "@/features/tasks/types"; @@ -91,6 +95,27 @@ export default function TaskDetailScreen() { const session = taskId ? getSessionForTask(taskId) : undefined; + // Optimistic echo set by the new-task screen (or the terminal-resume path + // below) so the user's prompt appears in the thread immediately, before + // the live session catches up. + const optimisticPrompt = usePendingTaskPrompt(taskId); + + // Clear the echo once the canonical user_message_chunk with matching text + // arrives via SSE — `TaskSessionView` also dedups visually, but clearing + // the store frees it for the next submit. + useEffect(() => { + if (!taskId || !optimisticPrompt) return; + const matched = session?.events.some( + (e) => + e.type === "session_update" && + e.notification?.update?.sessionUpdate === "user_message_chunk" && + e.notification.update.content?.text === optimisticPrompt.promptText, + ); + if (matched) { + pendingTaskPromptStoreApi.clear(taskId); + } + }, [taskId, optimisticPrompt, session?.events]); + // Per-task composer pill values. Persisted in taskStore so reopening the // task keeps the user's choices; defaults fall back to the same constants // the new-task composer uses. @@ -216,6 +241,18 @@ export default function TaskDetailScreen() { const handleSendAfterTerminal = useCallback( async (text: string, attachments: PendingAttachment[]) => { if (!taskId || !task) return; + // Optimistically echo into the chat before tearing down the old session + // and waiting for the resume run's SSE stream to come up. + const echoAttachments = attachments.map((a) => ({ + kind: a.kind, + uri: a.uri, + fileName: a.fileName, + mimeType: a.mimeType, + })); + pendingTaskPromptStoreApi.set(taskId, { + promptText: text, + attachments: echoAttachments.length > 0 ? echoAttachments : undefined, + }); try { setRetrying(true); disconnectFromTask(taskId); @@ -241,6 +278,7 @@ export default function TaskDetailScreen() { updateTaskInCache(updatedTask); } catch (err) { log.error("Failed to send after terminal", err); + pendingTaskPromptStoreApi.clear(taskId); setRetrying(false); Alert.alert( "Failed to send", @@ -388,7 +426,10 @@ export default function TaskDetailScreen() { !!session && session.status === "connecting" && session.events.length === 0; - const showLoading = loading || isHistoryLoading; + // Suppress the full-screen overlay when we have an optimistic prompt to + // show — the user just submitted and seeing their own text + a connecting + // indicator is friendlier than a blank spinner. + const showLoading = (loading || isHistoryLoading) && !optimisticPrompt; const showAutomationContext = fromAutomation === "1" || task?.origin_product === "automation"; const automationContextLabel = @@ -471,6 +512,14 @@ export default function TaskDetailScreen() { } onOpenTask={handleOpenTask} onSendPermissionResponse={handleSendPermissionResponse} + optimisticUserMessage={ + optimisticPrompt + ? { + text: optimisticPrompt.promptText, + attachments: optimisticPrompt.attachments, + } + : undefined + } contentContainerStyle={{ paddingTop: 8, paddingBottom: diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index 62aab442e..da4c28dcf 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -62,6 +62,10 @@ import { Pill } from "@/features/tasks/composer/Pill"; import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline"; import { SelectSheet } from "@/features/tasks/composer/SelectSheet"; import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations"; +import { + generatePendingTaskKey, + pendingTaskPromptStoreApi, +} from "@/features/tasks/stores/pendingTaskPromptStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; import type { CreateTaskOptions, @@ -255,8 +259,27 @@ export default function NewTaskScreen() { setCreating(true); + // Echo the prompt into the chat thread the moment the user taps send. + // The key is transient until `createTask` returns the real task id, at + // which point we `move` it so the detail screen can pick it up. + const pendingKey = generatePendingTaskKey(); + const trimmedPrompt = prompt.trim(); + const echoAttachments = attachments.map((a) => ({ + kind: a.kind, + uri: a.uri, + fileName: a.fileName, + mimeType: a.mimeType, + })); + pendingTaskPromptStoreApi.set(pendingKey, { + promptText: trimmedPrompt, + attachments: echoAttachments.length > 0 ? echoAttachments : undefined, + }); + + // Tracks where the optimistic echo currently lives so the catch block + // can clear the correct key regardless of how far the flow got. + let currentPendingKey = pendingKey; + try { - const trimmedPrompt = prompt.trim(); // The task description is plain text (it shows up as the task title and // in metadata). Attachments only enter the agent prompt via the cloud // payload below. @@ -284,6 +307,9 @@ export default function NewTaskScreen() { : {}), } as CreateTaskOptions); + pendingTaskPromptStoreApi.move(pendingKey, task.id); + currentPendingKey = task.id; + const pendingUserMessage = attachments.length > 0 ? serializeCloudPrompt( @@ -310,6 +336,7 @@ export default function NewTaskScreen() { router.replace(`/task/${task.id}`); } catch (creationError) { log.error("Failed to create task", creationError); + pendingTaskPromptStoreApi.clear(currentPendingKey); } finally { setCreating(false); } diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx index ad35c3aa9..b28d7547d 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx @@ -51,7 +51,58 @@ vi.mock("./PlanApprovalCard", () => ({ createElement("PlanApprovalCard", props), })); +function renderTaskSessionView( + props: Parameters[0], +): ReturnType { + let renderer!: ReturnType; + act(() => { + renderer = create(createElement(TaskSessionView, props)); + }); + return renderer; +} + +function findHumanMessages(renderer: ReturnType) { + // vi.mock'd `HumanMessage` is rendered as the literal string `"HumanMessage"` + // (an intrinsic), so node.type is a string at runtime even though the type + // says ElementType. + return renderer.root.findAll( + (node) => (node.type as unknown as string) === "HumanMessage", + ); +} + describe("TaskSessionView", () => { + it("renders the optimistic user message when no SSE echo has arrived", () => { + const renderer = renderTaskSessionView({ + events: [], + optimisticUserMessage: { text: "Ship it" }, + }); + + const humans = findHumanMessages(renderer); + expect(humans).toHaveLength(1); + expect(humans[0].props.content).toBe("Ship it"); + }); + + it("suppresses the optimistic echo once the real user message lands", () => { + const renderer = renderTaskSessionView({ + events: [ + { + type: "session_update" as const, + ts: 1, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Ship it" }, + }, + }, + }, + ], + optimisticUserMessage: { text: "Ship it" }, + }); + + const humans = findHumanMessages(renderer); + expect(humans).toHaveLength(1); + }); + it("keeps question tools pending after the run goes idle", () => { const events = [ { diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 1b0050333..23395c19a 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -41,6 +41,11 @@ interface PermissionResponseArgs { displayText: string; } +interface OptimisticUserMessage { + text: string; + attachments?: SessionNotificationAttachment[]; +} + interface TaskSessionViewProps { events: SessionEvent[]; pendingPermissions?: Record; @@ -52,6 +57,11 @@ interface TaskSessionViewProps { onOpenTask?: (taskId: string) => void; onSendPermissionResponse?: (args: PermissionResponseArgs) => void; contentContainerStyle?: object; + // Renders a user message at the bottom of the thread before the SSE echo + // arrives — for the gap between submit and the live session catching up. + // Suppressed automatically once a real user_message_chunk with matching + // text appears in `events`. + optimisticUserMessage?: OptimisticUserMessage; } interface ToolData { @@ -792,6 +802,7 @@ export function TaskSessionView({ onOpenTask, onSendPermissionResponse, contentContainerStyle, + optimisticUserMessage, }: TaskSessionViewProps) { const processorRef = useRef(createProcessorState()); const prevEventsRef = useRef(events); @@ -838,9 +849,30 @@ export function TaskSessionView({ } prevAgentActive.current = agentActive; + // Append the optimistic user echo (if any) as the newest message, unless a + // real `user` message with matching text has already arrived via SSE. The + // dedup keeps the transition seamless when the canonical copy lands. + const messagesWithOptimistic = useMemo(() => { + if (!optimisticUserMessage) return messages; + const alreadyEchoed = messages.some( + (m) => m.type === "user" && m.content === optimisticUserMessage.text, + ); + if (alreadyEchoed) return messages; + const optimistic: ParsedMessage = { + id: "optimistic-user", + type: "user", + content: optimisticUserMessage.text, + attachments: optimisticUserMessage.attachments, + }; + return [...messages, optimistic]; + }, [messages, optimisticUserMessage]); + // Inverted FlatList renders data[0] at the visual bottom. // Reverse so newest messages are at index 0 = bottom. - const reversedMessages = useMemo(() => [...messages].reverse(), [messages]); + const reversedMessages = useMemo( + () => [...messagesWithOptimistic].reverse(), + [messagesWithOptimistic], + ); const themeColors = useThemeColors(); const flatListRef = useRef(null); const hasPendingQuestion = useMemo( diff --git a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts new file mode 100644 index 000000000..e41d6dbee --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + pendingTaskPromptStoreApi, + usePendingTaskPromptStore, +} from "./pendingTaskPromptStore"; + +describe("pendingTaskPromptStore", () => { + beforeEach(() => { + usePendingTaskPromptStore.setState({ byKey: {} }); + }); + + it("stores prompts keyed by an arbitrary id", () => { + pendingTaskPromptStoreApi.set("uuid-1", { + promptText: "Fix the login bug", + }); + + expect(pendingTaskPromptStoreApi.get("uuid-1")).toEqual({ + promptText: "Fix the login bug", + }); + }); + + it("moves a prompt from a transient key to the real task id", () => { + pendingTaskPromptStoreApi.set("uuid-1", { promptText: "Do the thing" }); + pendingTaskPromptStoreApi.move("uuid-1", "task-123"); + + expect(pendingTaskPromptStoreApi.get("uuid-1")).toBeUndefined(); + expect(pendingTaskPromptStoreApi.get("task-123")).toEqual({ + promptText: "Do the thing", + }); + }); + + it("ignores move when the source key has no prompt", () => { + pendingTaskPromptStoreApi.move("missing", "task-999"); + expect(pendingTaskPromptStoreApi.get("task-999")).toBeUndefined(); + }); + + it("clears prompts", () => { + pendingTaskPromptStoreApi.set("task-42", { promptText: "Hi" }); + pendingTaskPromptStoreApi.clear("task-42"); + expect(pendingTaskPromptStoreApi.get("task-42")).toBeUndefined(); + }); + + it("preserves attachments through move", () => { + pendingTaskPromptStoreApi.set("uuid-1", { + promptText: "Look at this", + attachments: [ + { + kind: "image", + uri: "file://x.png", + fileName: "x.png", + mimeType: "image/png", + }, + ], + }); + pendingTaskPromptStoreApi.move("uuid-1", "task-7"); + + expect(pendingTaskPromptStoreApi.get("task-7")?.attachments).toHaveLength( + 1, + ); + }); +}); diff --git a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts new file mode 100644 index 000000000..f4491a58d --- /dev/null +++ b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts @@ -0,0 +1,81 @@ +import { create } from "zustand"; +import type { SessionNotificationAttachment } from "../types"; + +/** + * Optimistic chat-thread echo for a prompt the user just submitted but whose + * canonical SSE copy hasn't landed yet. Keyed first by a transient UUID (set + * the instant the user taps send, before any task ID is known) and then + * `move`d onto the real task ID once `createTask` returns. + * + * Pure UI state — not persisted. Cleared as soon as the live session echoes + * the matching `user_message_chunk` back. + */ +export interface PendingTaskPrompt { + promptText: string; + attachments?: SessionNotificationAttachment[]; +} + +interface PendingTaskPromptState { + byKey: Record; + set: (key: string, prompt: PendingTaskPrompt) => void; + move: (fromKey: string, toKey: string) => void; + clear: (key: string) => void; +} + +export const usePendingTaskPromptStore = create( + (set) => ({ + byKey: {}, + set: (key, prompt) => + set((state) => ({ byKey: { ...state.byKey, [key]: prompt } })), + move: (fromKey, toKey) => + set((state) => { + const value = state.byKey[fromKey]; + if (!value) return state; + const { [fromKey]: _removed, ...rest } = state.byKey; + return { byKey: { ...rest, [toKey]: value } }; + }), + clear: (key) => + set((state) => { + if (!(key in state.byKey)) return state; + const { [key]: _removed, ...rest } = state.byKey; + return { byKey: rest }; + }), + }), +); + +export function usePendingTaskPrompt( + key: string | undefined | null, +): PendingTaskPrompt | undefined { + return usePendingTaskPromptStore((s) => (key ? s.byKey[key] : undefined)); +} + +/** + * Non-reactive accessors so non-component code (screens, async flows) can + * mutate the store without going through hooks. Mirrors the desktop + * `pendingTaskPromptStoreApi` shape. + */ +export const pendingTaskPromptStoreApi = { + set(key: string, prompt: PendingTaskPrompt): void { + usePendingTaskPromptStore.getState().set(key, prompt); + }, + get(key: string): PendingTaskPrompt | undefined { + return usePendingTaskPromptStore.getState().byKey[key]; + }, + move(fromKey: string, toKey: string): void { + usePendingTaskPromptStore.getState().move(fromKey, toKey); + }, + clear(key: string): void { + usePendingTaskPromptStore.getState().clear(key); + }, +}; + +export function generatePendingTaskKey(): string { + const cryptoObj = + typeof globalThis !== "undefined" + ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto + : undefined; + if (cryptoObj?.randomUUID) { + return cryptoObj.randomUUID(); + } + return `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} From d31c5a08eef84e1885e5cbc3d5c12961a9c9f995 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Thu, 28 May 2026 12:59:12 +0100 Subject: [PATCH 2/2] fix(mobile): gate optimistic-echo dedup on submit timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Greptile review feedback on the dedup logic. Previously the optimistic-echo dedup matched against any historical user message with identical text, so resubmitting the same prompt ("Continue", "Continue", ...) would suppress the new optimistic bubble immediately — the prior message was treated as the canonical echo for the new submission. Adds a `setAt: number` submit-time timestamp to `PendingTaskPrompt` and gates both the in-view dedup (TaskSessionView) and the store-clear effect (TaskDetailScreen) on `event.ts >= setAt`. A text-identical historical turn now correctly leaves the new optimistic echo in place until its own canonical SSE copy arrives. Tests: collapses the two prior optimistic-echo cases into a single `it.each` table and adds two edge cases: - text-identical historical turn → optimistic still renders - non-matching SSE text → optimistic still renders Generated-By: PostHog Code Task-Id: d59fa139-b382-4fcc-b6fd-9282bbc406e2 --- apps/mobile/src/app/task/[id].tsx | 9 ++- apps/mobile/src/app/task/index.tsx | 1 + .../tasks/components/TaskSessionView.test.tsx | 66 +++++++++++++------ .../tasks/components/TaskSessionView.tsx | 14 +++- .../stores/pendingTaskPromptStore.test.ts | 14 +++- .../tasks/stores/pendingTaskPromptStore.ts | 5 ++ 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index a19ba7354..1148ac9f8 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -102,14 +102,17 @@ export default function TaskDetailScreen() { // Clear the echo once the canonical user_message_chunk with matching text // arrives via SSE — `TaskSessionView` also dedups visually, but clearing - // the store frees it for the next submit. + // the store frees it for the next submit. Only events with `ts >= setAt` + // qualify so a text-identical historical turn (e.g. resubmitting + // "Continue") doesn't drop the echo before the real copy lands. useEffect(() => { if (!taskId || !optimisticPrompt) return; const matched = session?.events.some( (e) => e.type === "session_update" && e.notification?.update?.sessionUpdate === "user_message_chunk" && - e.notification.update.content?.text === optimisticPrompt.promptText, + e.notification.update.content?.text === optimisticPrompt.promptText && + (e.ts ?? 0) >= optimisticPrompt.setAt, ); if (matched) { pendingTaskPromptStoreApi.clear(taskId); @@ -252,6 +255,7 @@ export default function TaskDetailScreen() { pendingTaskPromptStoreApi.set(taskId, { promptText: text, attachments: echoAttachments.length > 0 ? echoAttachments : undefined, + setAt: Date.now(), }); try { setRetrying(true); @@ -517,6 +521,7 @@ export default function TaskDetailScreen() { ? { text: optimisticPrompt.promptText, attachments: optimisticPrompt.attachments, + setAt: optimisticPrompt.setAt, } : undefined } diff --git a/apps/mobile/src/app/task/index.tsx b/apps/mobile/src/app/task/index.tsx index da4c28dcf..47081bfed 100644 --- a/apps/mobile/src/app/task/index.tsx +++ b/apps/mobile/src/app/task/index.tsx @@ -273,6 +273,7 @@ export default function NewTaskScreen() { pendingTaskPromptStoreApi.set(pendingKey, { promptText: trimmedPrompt, attachments: echoAttachments.length > 0 ? echoAttachments : undefined, + setAt: Date.now(), }); // Tracks where the optimistic echo currently lives so the catch block diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx index b28d7547d..b89b8ceb7 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx @@ -71,36 +71,62 @@ function findHumanMessages(renderer: ReturnType) { } describe("TaskSessionView", () => { - it("renders the optimistic user message when no SSE echo has arrived", () => { - const renderer = renderTaskSessionView({ + function userMessageEvent(text: string, ts: number) { + return { + type: "session_update" as const, + ts, + notification: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text }, + }, + }, + }; + } + + const SUBMIT_TS = 1000; + + it.each([ + { + name: "no SSE echo yet → optimistic renders", events: [], - optimisticUserMessage: { text: "Ship it" }, + expectedCount: 1, + }, + { + name: "matching SSE chunk after submit → optimistic suppressed", + events: [userMessageEvent("Ship it", SUBMIT_TS + 5)], + expectedCount: 1, + }, + { + name: "text-identical historical turn → optimistic still renders", + // Same text but ts predates submit — a prior "Ship it" message shouldn't + // cause the new optimistic echo to be deduped. + events: [userMessageEvent("Ship it", SUBMIT_TS - 1000)], + expectedCount: 2, + }, + { + name: "non-matching SSE text → optimistic still renders", + events: [userMessageEvent("Different text", SUBMIT_TS + 5)], + expectedCount: 2, + }, + ])("optimistic echo: $name", ({ events, expectedCount }) => { + const renderer = renderTaskSessionView({ + events, + optimisticUserMessage: { text: "Ship it", setAt: SUBMIT_TS }, }); - const humans = findHumanMessages(renderer); - expect(humans).toHaveLength(1); - expect(humans[0].props.content).toBe("Ship it"); + expect(findHumanMessages(renderer)).toHaveLength(expectedCount); }); - it("suppresses the optimistic echo once the real user message lands", () => { + it("optimistic echo carries the submitted text into the rendered bubble", () => { const renderer = renderTaskSessionView({ - events: [ - { - type: "session_update" as const, - ts: 1, - notification: { - update: { - sessionUpdate: "user_message_chunk", - content: { type: "text", text: "Ship it" }, - }, - }, - }, - ], - optimisticUserMessage: { text: "Ship it" }, + events: [], + optimisticUserMessage: { text: "Ship it", setAt: SUBMIT_TS }, }); const humans = findHumanMessages(renderer); expect(humans).toHaveLength(1); + expect(humans[0].props.content).toBe("Ship it"); }); it("keeps question tools pending after the run goes idle", () => { diff --git a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx index 23395c19a..fb4c23ed1 100644 --- a/apps/mobile/src/features/tasks/components/TaskSessionView.tsx +++ b/apps/mobile/src/features/tasks/components/TaskSessionView.tsx @@ -44,6 +44,10 @@ interface PermissionResponseArgs { interface OptimisticUserMessage { text: string; attachments?: SessionNotificationAttachment[]; + // Submit-time epoch ms. Dedup only fires against user messages whose `ts` + // is at or after this — protects against a text-identical historical turn + // suppressing the new optimistic echo. + setAt: number; } interface TaskSessionViewProps { @@ -850,12 +854,16 @@ export function TaskSessionView({ prevAgentActive.current = agentActive; // Append the optimistic user echo (if any) as the newest message, unless a - // real `user` message with matching text has already arrived via SSE. The - // dedup keeps the transition seamless when the canonical copy lands. + // real `user` message with matching text *and a ts at or after submit time* + // has already arrived via SSE. Gating on `ts` prevents a text-identical + // historical turn from suppressing a freshly-submitted echo. const messagesWithOptimistic = useMemo(() => { if (!optimisticUserMessage) return messages; const alreadyEchoed = messages.some( - (m) => m.type === "user" && m.content === optimisticUserMessage.text, + (m) => + m.type === "user" && + m.content === optimisticUserMessage.text && + (m.ts ?? 0) >= optimisticUserMessage.setAt, ); if (alreadyEchoed) return messages; const optimistic: ParsedMessage = { diff --git a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts index e41d6dbee..fa4cab91f 100644 --- a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.test.ts @@ -12,20 +12,26 @@ describe("pendingTaskPromptStore", () => { it("stores prompts keyed by an arbitrary id", () => { pendingTaskPromptStoreApi.set("uuid-1", { promptText: "Fix the login bug", + setAt: 1000, }); expect(pendingTaskPromptStoreApi.get("uuid-1")).toEqual({ promptText: "Fix the login bug", + setAt: 1000, }); }); it("moves a prompt from a transient key to the real task id", () => { - pendingTaskPromptStoreApi.set("uuid-1", { promptText: "Do the thing" }); + pendingTaskPromptStoreApi.set("uuid-1", { + promptText: "Do the thing", + setAt: 1000, + }); pendingTaskPromptStoreApi.move("uuid-1", "task-123"); expect(pendingTaskPromptStoreApi.get("uuid-1")).toBeUndefined(); expect(pendingTaskPromptStoreApi.get("task-123")).toEqual({ promptText: "Do the thing", + setAt: 1000, }); }); @@ -35,7 +41,10 @@ describe("pendingTaskPromptStore", () => { }); it("clears prompts", () => { - pendingTaskPromptStoreApi.set("task-42", { promptText: "Hi" }); + pendingTaskPromptStoreApi.set("task-42", { + promptText: "Hi", + setAt: 1000, + }); pendingTaskPromptStoreApi.clear("task-42"); expect(pendingTaskPromptStoreApi.get("task-42")).toBeUndefined(); }); @@ -43,6 +52,7 @@ describe("pendingTaskPromptStore", () => { it("preserves attachments through move", () => { pendingTaskPromptStoreApi.set("uuid-1", { promptText: "Look at this", + setAt: 1000, attachments: [ { kind: "image", diff --git a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts index f4491a58d..e7face63f 100644 --- a/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts +++ b/apps/mobile/src/features/tasks/stores/pendingTaskPromptStore.ts @@ -13,6 +13,11 @@ import type { SessionNotificationAttachment } from "../types"; export interface PendingTaskPrompt { promptText: string; attachments?: SessionNotificationAttachment[]; + // Submit-time epoch ms. Consumers compare event `ts` against this so the + // echo is only deduped against `user_message_chunk`s that arrived *after* + // submit — protects against text-identical historical turns (e.g. a user + // submitting "Continue" twice in a row) hiding the new optimistic echo. + setAt: number; } interface PendingTaskPromptState {