Skip to content

Commit 32ebc98

Browse files
authored
feat(mobile): show prompt in chat thread immediately on submit (#2407)
1 parent d3ead8f commit 32ebc98

6 files changed

Lines changed: 359 additions & 3 deletions

File tree

apps/mobile/src/app/task/[id].tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ import {
3232
} from "@/features/tasks/composer/options";
3333
import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer";
3434
import { taskKeys } from "@/features/tasks/hooks/useTasks";
35+
import {
36+
pendingTaskPromptStoreApi,
37+
usePendingTaskPrompt,
38+
} from "@/features/tasks/stores/pendingTaskPromptStore";
3539
import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore";
3640
import { useTaskStore } from "@/features/tasks/stores/taskStore";
3741
import type { Task } from "@/features/tasks/types";
@@ -92,6 +96,30 @@ export default function TaskDetailScreen() {
9296

9397
const session = taskId ? getSessionForTask(taskId) : undefined;
9498

99+
// Optimistic echo set by the new-task screen (or the terminal-resume path
100+
// below) so the user's prompt appears in the thread immediately, before
101+
// the live session catches up.
102+
const optimisticPrompt = usePendingTaskPrompt(taskId);
103+
104+
// Clear the echo once the canonical user_message_chunk with matching text
105+
// arrives via SSE — `TaskSessionView` also dedups visually, but clearing
106+
// the store frees it for the next submit. Only events with `ts >= setAt`
107+
// qualify so a text-identical historical turn (e.g. resubmitting
108+
// "Continue") doesn't drop the echo before the real copy lands.
109+
useEffect(() => {
110+
if (!taskId || !optimisticPrompt) return;
111+
const matched = session?.events.some(
112+
(e) =>
113+
e.type === "session_update" &&
114+
e.notification?.update?.sessionUpdate === "user_message_chunk" &&
115+
e.notification.update.content?.text === optimisticPrompt.promptText &&
116+
(e.ts ?? 0) >= optimisticPrompt.setAt,
117+
);
118+
if (matched) {
119+
pendingTaskPromptStoreApi.clear(taskId);
120+
}
121+
}, [taskId, optimisticPrompt, session?.events]);
122+
95123
// Per-task composer pill values. Persisted in taskStore so reopening the
96124
// task keeps the user's choices; defaults fall back to the same constants
97125
// the new-task composer uses.
@@ -217,6 +245,19 @@ export default function TaskDetailScreen() {
217245
const handleSendAfterTerminal = useCallback(
218246
async (text: string, attachments: PendingAttachment[]) => {
219247
if (!taskId || !task) return;
248+
// Optimistically echo into the chat before tearing down the old session
249+
// and waiting for the resume run's SSE stream to come up.
250+
const echoAttachments = attachments.map((a) => ({
251+
kind: a.kind,
252+
uri: a.uri,
253+
fileName: a.fileName,
254+
mimeType: a.mimeType,
255+
}));
256+
pendingTaskPromptStoreApi.set(taskId, {
257+
promptText: text,
258+
attachments: echoAttachments.length > 0 ? echoAttachments : undefined,
259+
setAt: Date.now(),
260+
});
220261
try {
221262
setRetrying(true);
222263
disconnectFromTask(taskId);
@@ -242,6 +283,7 @@ export default function TaskDetailScreen() {
242283
updateTaskInCache(updatedTask);
243284
} catch (err) {
244285
log.error("Failed to send after terminal", err);
286+
pendingTaskPromptStoreApi.clear(taskId);
245287
setRetrying(false);
246288
Alert.alert(
247289
"Failed to send",
@@ -390,7 +432,10 @@ export default function TaskDetailScreen() {
390432
!!session &&
391433
session.status === "connecting" &&
392434
session.events.length === 0;
393-
const showLoading = loading || isHistoryLoading;
435+
// Suppress the full-screen overlay when we have an optimistic prompt to
436+
// show — the user just submitted and seeing their own text + a connecting
437+
// indicator is friendlier than a blank spinner.
438+
const showLoading = (loading || isHistoryLoading) && !optimisticPrompt;
394439
const showAutomationContext =
395440
fromAutomation === "1" || task?.origin_product === "automation";
396441
const automationContextLabel =
@@ -473,6 +518,15 @@ export default function TaskDetailScreen() {
473518
}
474519
onOpenTask={handleOpenTask}
475520
onSendPermissionResponse={handleSendPermissionResponse}
521+
optimisticUserMessage={
522+
optimisticPrompt
523+
? {
524+
text: optimisticPrompt.promptText,
525+
attachments: optimisticPrompt.attachments,
526+
setAt: optimisticPrompt.setAt,
527+
}
528+
: undefined
529+
}
476530
contentContainerStyle={{
477531
paddingTop: 8,
478532
paddingBottom:

apps/mobile/src/app/task/index.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ import { Pill } from "@/features/tasks/composer/Pill";
6262
import { RepositoryPickerInline } from "@/features/tasks/composer/RepositoryPickerInline";
6363
import { SelectSheet } from "@/features/tasks/composer/SelectSheet";
6464
import { useUserIntegrations } from "@/features/tasks/hooks/useUserIntegrations";
65+
import {
66+
generatePendingTaskKey,
67+
pendingTaskPromptStoreApi,
68+
} from "@/features/tasks/stores/pendingTaskPromptStore";
6569
import { useTaskStore } from "@/features/tasks/stores/taskStore";
6670
import type {
6771
CreateTaskOptions,
@@ -263,8 +267,28 @@ export default function NewTaskScreen() {
263267

264268
setCreating(true);
265269

270+
// Echo the prompt into the chat thread the moment the user taps send.
271+
// The key is transient until `createTask` returns the real task id, at
272+
// which point we `move` it so the detail screen can pick it up.
273+
const pendingKey = generatePendingTaskKey();
274+
const trimmedPrompt = prompt.trim();
275+
const echoAttachments = attachments.map((a) => ({
276+
kind: a.kind,
277+
uri: a.uri,
278+
fileName: a.fileName,
279+
mimeType: a.mimeType,
280+
}));
281+
pendingTaskPromptStoreApi.set(pendingKey, {
282+
promptText: trimmedPrompt,
283+
attachments: echoAttachments.length > 0 ? echoAttachments : undefined,
284+
setAt: Date.now(),
285+
});
286+
287+
// Tracks where the optimistic echo currently lives so the catch block
288+
// can clear the correct key regardless of how far the flow got.
289+
let currentPendingKey = pendingKey;
290+
266291
try {
267-
const trimmedPrompt = prompt.trim();
268292
// The task description is plain text (it shows up as the task title and
269293
// in metadata). Attachments only enter the agent prompt via the cloud
270294
// payload below.
@@ -292,6 +316,9 @@ export default function NewTaskScreen() {
292316
: {}),
293317
} as CreateTaskOptions);
294318

319+
pendingTaskPromptStoreApi.move(pendingKey, task.id);
320+
currentPendingKey = task.id;
321+
295322
const pendingUserMessage =
296323
attachments.length > 0
297324
? serializeCloudPrompt(
@@ -318,6 +345,7 @@ export default function NewTaskScreen() {
318345
router.replace(`/task/${task.id}`);
319346
} catch (creationError) {
320347
log.error("Failed to create task", creationError);
348+
pendingTaskPromptStoreApi.clear(currentPendingKey);
321349
} finally {
322350
setCreating(false);
323351
}

apps/mobile/src/features/tasks/components/TaskSessionView.test.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,84 @@ vi.mock("./PlanApprovalCard", () => ({
5151
createElement("PlanApprovalCard", props),
5252
}));
5353

54+
function renderTaskSessionView(
55+
props: Parameters<typeof TaskSessionView>[0],
56+
): ReturnType<typeof create> {
57+
let renderer!: ReturnType<typeof create>;
58+
act(() => {
59+
renderer = create(createElement(TaskSessionView, props));
60+
});
61+
return renderer;
62+
}
63+
64+
function findHumanMessages(renderer: ReturnType<typeof create>) {
65+
// vi.mock'd `HumanMessage` is rendered as the literal string `"HumanMessage"`
66+
// (an intrinsic), so node.type is a string at runtime even though the type
67+
// says ElementType.
68+
return renderer.root.findAll(
69+
(node) => (node.type as unknown as string) === "HumanMessage",
70+
);
71+
}
72+
5473
describe("TaskSessionView", () => {
74+
function userMessageEvent(text: string, ts: number) {
75+
return {
76+
type: "session_update" as const,
77+
ts,
78+
notification: {
79+
update: {
80+
sessionUpdate: "user_message_chunk",
81+
content: { type: "text", text },
82+
},
83+
},
84+
};
85+
}
86+
87+
const SUBMIT_TS = 1000;
88+
89+
it.each([
90+
{
91+
name: "no SSE echo yet → optimistic renders",
92+
events: [],
93+
expectedCount: 1,
94+
},
95+
{
96+
name: "matching SSE chunk after submit → optimistic suppressed",
97+
events: [userMessageEvent("Ship it", SUBMIT_TS + 5)],
98+
expectedCount: 1,
99+
},
100+
{
101+
name: "text-identical historical turn → optimistic still renders",
102+
// Same text but ts predates submit — a prior "Ship it" message shouldn't
103+
// cause the new optimistic echo to be deduped.
104+
events: [userMessageEvent("Ship it", SUBMIT_TS - 1000)],
105+
expectedCount: 2,
106+
},
107+
{
108+
name: "non-matching SSE text → optimistic still renders",
109+
events: [userMessageEvent("Different text", SUBMIT_TS + 5)],
110+
expectedCount: 2,
111+
},
112+
])("optimistic echo: $name", ({ events, expectedCount }) => {
113+
const renderer = renderTaskSessionView({
114+
events,
115+
optimisticUserMessage: { text: "Ship it", setAt: SUBMIT_TS },
116+
});
117+
118+
expect(findHumanMessages(renderer)).toHaveLength(expectedCount);
119+
});
120+
121+
it("optimistic echo carries the submitted text into the rendered bubble", () => {
122+
const renderer = renderTaskSessionView({
123+
events: [],
124+
optimisticUserMessage: { text: "Ship it", setAt: SUBMIT_TS },
125+
});
126+
127+
const humans = findHumanMessages(renderer);
128+
expect(humans).toHaveLength(1);
129+
expect(humans[0].props.content).toBe("Ship it");
130+
});
131+
55132
it("keeps question tools pending after the run goes idle", () => {
56133
const events = [
57134
{

apps/mobile/src/features/tasks/components/TaskSessionView.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ interface PermissionResponseArgs {
4141
displayText: string;
4242
}
4343

44+
interface OptimisticUserMessage {
45+
text: string;
46+
attachments?: SessionNotificationAttachment[];
47+
// Submit-time epoch ms. Dedup only fires against user messages whose `ts`
48+
// is at or after this — protects against a text-identical historical turn
49+
// suppressing the new optimistic echo.
50+
setAt: number;
51+
}
52+
4453
interface TaskSessionViewProps {
4554
events: SessionEvent[];
4655
pendingPermissions?: Record<string, CloudPendingPermissionRequest>;
@@ -52,6 +61,11 @@ interface TaskSessionViewProps {
5261
onOpenTask?: (taskId: string) => void;
5362
onSendPermissionResponse?: (args: PermissionResponseArgs) => void;
5463
contentContainerStyle?: object;
64+
// Renders a user message at the bottom of the thread before the SSE echo
65+
// arrives — for the gap between submit and the live session catching up.
66+
// Suppressed automatically once a real user_message_chunk with matching
67+
// text appears in `events`.
68+
optimisticUserMessage?: OptimisticUserMessage;
5569
}
5670

5771
interface ToolData {
@@ -792,6 +806,7 @@ export function TaskSessionView({
792806
onOpenTask,
793807
onSendPermissionResponse,
794808
contentContainerStyle,
809+
optimisticUserMessage,
795810
}: TaskSessionViewProps) {
796811
const processorRef = useRef(createProcessorState());
797812
const prevEventsRef = useRef(events);
@@ -838,9 +853,34 @@ export function TaskSessionView({
838853
}
839854
prevAgentActive.current = agentActive;
840855

856+
// Append the optimistic user echo (if any) as the newest message, unless a
857+
// real `user` message with matching text *and a ts at or after submit time*
858+
// has already arrived via SSE. Gating on `ts` prevents a text-identical
859+
// historical turn from suppressing a freshly-submitted echo.
860+
const messagesWithOptimistic = useMemo(() => {
861+
if (!optimisticUserMessage) return messages;
862+
const alreadyEchoed = messages.some(
863+
(m) =>
864+
m.type === "user" &&
865+
m.content === optimisticUserMessage.text &&
866+
(m.ts ?? 0) >= optimisticUserMessage.setAt,
867+
);
868+
if (alreadyEchoed) return messages;
869+
const optimistic: ParsedMessage = {
870+
id: "optimistic-user",
871+
type: "user",
872+
content: optimisticUserMessage.text,
873+
attachments: optimisticUserMessage.attachments,
874+
};
875+
return [...messages, optimistic];
876+
}, [messages, optimisticUserMessage]);
877+
841878
// Inverted FlatList renders data[0] at the visual bottom.
842879
// Reverse so newest messages are at index 0 = bottom.
843-
const reversedMessages = useMemo(() => [...messages].reverse(), [messages]);
880+
const reversedMessages = useMemo(
881+
() => [...messagesWithOptimistic].reverse(),
882+
[messagesWithOptimistic],
883+
);
844884
const themeColors = useThemeColors();
845885
const flatListRef = useRef<FlatList>(null);
846886
const hasPendingQuestion = useMemo(
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
pendingTaskPromptStoreApi,
4+
usePendingTaskPromptStore,
5+
} from "./pendingTaskPromptStore";
6+
7+
describe("pendingTaskPromptStore", () => {
8+
beforeEach(() => {
9+
usePendingTaskPromptStore.setState({ byKey: {} });
10+
});
11+
12+
it("stores prompts keyed by an arbitrary id", () => {
13+
pendingTaskPromptStoreApi.set("uuid-1", {
14+
promptText: "Fix the login bug",
15+
setAt: 1000,
16+
});
17+
18+
expect(pendingTaskPromptStoreApi.get("uuid-1")).toEqual({
19+
promptText: "Fix the login bug",
20+
setAt: 1000,
21+
});
22+
});
23+
24+
it("moves a prompt from a transient key to the real task id", () => {
25+
pendingTaskPromptStoreApi.set("uuid-1", {
26+
promptText: "Do the thing",
27+
setAt: 1000,
28+
});
29+
pendingTaskPromptStoreApi.move("uuid-1", "task-123");
30+
31+
expect(pendingTaskPromptStoreApi.get("uuid-1")).toBeUndefined();
32+
expect(pendingTaskPromptStoreApi.get("task-123")).toEqual({
33+
promptText: "Do the thing",
34+
setAt: 1000,
35+
});
36+
});
37+
38+
it("ignores move when the source key has no prompt", () => {
39+
pendingTaskPromptStoreApi.move("missing", "task-999");
40+
expect(pendingTaskPromptStoreApi.get("task-999")).toBeUndefined();
41+
});
42+
43+
it("clears prompts", () => {
44+
pendingTaskPromptStoreApi.set("task-42", {
45+
promptText: "Hi",
46+
setAt: 1000,
47+
});
48+
pendingTaskPromptStoreApi.clear("task-42");
49+
expect(pendingTaskPromptStoreApi.get("task-42")).toBeUndefined();
50+
});
51+
52+
it("preserves attachments through move", () => {
53+
pendingTaskPromptStoreApi.set("uuid-1", {
54+
promptText: "Look at this",
55+
setAt: 1000,
56+
attachments: [
57+
{
58+
kind: "image",
59+
uri: "file://x.png",
60+
fileName: "x.png",
61+
mimeType: "image/png",
62+
},
63+
],
64+
});
65+
pendingTaskPromptStoreApi.move("uuid-1", "task-7");
66+
67+
expect(pendingTaskPromptStoreApi.get("task-7")?.attachments).toHaveLength(
68+
1,
69+
);
70+
});
71+
});

0 commit comments

Comments
 (0)