Skip to content

Commit fb49197

Browse files
authored
feat: Optimistically render conversation items before subscription confirms (#1209)
Adds a generic optimisticItems mechanism to the session store so any conversation item type can be displayed immediately before the real event arrives from the main process. Currently used for user messages, but the OptimisticItem union is designed to be extended for future use cases. 1. Add OptimisticItem type and optimisticItems array to AgentSession store 2. Append an optimistic user message when a prompt is sent, before the server round-trip 3. Clear optimistic items when the real session/prompt event arrives or on error 4. Merge optimistic items into the conversation view between confirmed items and queued messages 5. Add useOptimisticItemsForTask hook and store setters (appendOptimisticItem, clearOptimisticItems, appendEventAndClearOptimisticItems)
1 parent 83bb341 commit fb49197

5 files changed

Lines changed: 87 additions & 11 deletions

File tree

apps/code/src/renderer/features/sessions/components/ConversationView.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
sessionStoreSetters,
3+
useOptimisticItemsForTask,
34
usePendingPermissionsForTask,
45
useQueuedMessagesForTask,
56
} from "@features/sessions/stores/sessionStore";
@@ -68,6 +69,7 @@ export function ConversationView({
6869
const pendingPermissions = usePendingPermissionsForTask(taskId ?? "");
6970
const pendingPermissionsCount = pendingPermissions.size;
7071
const queuedMessages = useQueuedMessagesForTask(taskId);
72+
const optimisticItems = useOptimisticItemsForTask(taskId);
7173

7274
const queuedItems = useMemo<Extract<ConversationItem, { type: "queued" }>[]>(
7375
() =>
@@ -79,13 +81,13 @@ export function ConversationView({
7981
[queuedMessages],
8082
);
8183

82-
const items = useMemo<ConversationItem[]>(
83-
() =>
84-
queuedItems.length > 0
85-
? [...conversationItems, ...queuedItems]
86-
: conversationItems,
87-
[conversationItems, queuedItems],
88-
);
84+
const items = useMemo<ConversationItem[]>(() => {
85+
const result: ConversationItem[] = [
86+
...conversationItems,
87+
...optimisticItems,
88+
];
89+
return queuedItems.length > 0 ? [...result, ...queuedItems] : result;
90+
}, [conversationItems, optimisticItems, queuedItems]);
8991

9092
const handleScrollStateChange = useCallback((isAtBottom: boolean) => {
9193
setShowScrollButton(!isAtBottom);

apps/code/src/renderer/features/sessions/hooks/useSession.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type Adapter,
1111
type AgentSession,
1212
getConfigOptionByCategory,
13+
type OptimisticItem,
1314
type QueuedMessage,
1415
useSessionStore,
1516
} from "../stores/sessionStore";
@@ -98,6 +99,17 @@ export const useQueuedMessagesForTask = (
9899
});
99100
};
100101

102+
export const useOptimisticItemsForTask = (
103+
taskId: string | undefined,
104+
): OptimisticItem[] => {
105+
return useSessionStore((s) => {
106+
if (!taskId) return [];
107+
const taskRunId = s.taskIdIndex[taskId];
108+
if (!taskRunId) return [];
109+
return s.sessions[taskRunId]?.optimisticItems ?? [];
110+
});
111+
};
112+
101113
// --- Config Option Hooks ---
102114

103115
/** Get a config option by category for a task */

apps/code/src/renderer/features/sessions/service/service.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ const mockSessionStoreSetters = vi.hoisted(() => ({
5050
getSessionByTaskId: vi.fn(),
5151
getSessions: vi.fn(() => ({})),
5252
clearAll: vi.fn(),
53+
appendOptimisticItem: vi.fn(),
54+
clearOptimisticItems: vi.fn(),
55+
replaceOptimisticWithEvent: vi.fn(),
5356
}));
5457

5558
vi.mock("@features/sessions/stores/sessionStore", () => ({
@@ -205,6 +208,7 @@ const createMockSession = (
205208
promptStartedAt: null,
206209
pendingPermissions: new Map(),
207210
messageQueue: [],
211+
optimisticItems: [],
208212
...overrides,
209213
});
210214

apps/code/src/renderer/features/sessions/service/service.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,15 @@ export class SessionService {
819819
const session = sessionStoreSetters.getSessions()[taskRunId];
820820
if (!session) return;
821821

822-
sessionStoreSetters.appendEvents(taskRunId, [acpMsg]);
822+
const isUserPromptEcho =
823+
isJsonRpcRequest(acpMsg.message) &&
824+
acpMsg.message.method === "session/prompt";
825+
826+
if (isUserPromptEcho) {
827+
sessionStoreSetters.replaceOptimisticWithEvent(taskRunId, acpMsg);
828+
} else {
829+
sessionStoreSetters.appendEvents(taskRunId, [acpMsg]);
830+
}
823831
this.updatePromptStateFromEvents(taskRunId, [acpMsg]);
824832

825833
const msg = acpMsg.message;
@@ -997,7 +1005,7 @@ export class SessionService {
9971005
prompt_length_chars: promptText.length,
9981006
});
9991007

1000-
return this.sendLocalPrompt(session, blocks);
1008+
return this.sendLocalPrompt(session, blocks, promptText);
10011009
}
10021010

10031011
/**
@@ -1044,7 +1052,7 @@ export class SessionService {
10441052
});
10451053

10461054
try {
1047-
return await this.sendLocalPrompt(session, blocks);
1055+
return await this.sendLocalPrompt(session, blocks, combinedText);
10481056
} catch (error) {
10491057
// Log that queued messages were lost due to send failure
10501058
log.error("Failed to send queued messages, messages lost", {
@@ -1059,18 +1067,24 @@ export class SessionService {
10591067
private async sendLocalPrompt(
10601068
session: AgentSession,
10611069
blocks: ContentBlock[],
1070+
promptText: string,
10621071
): Promise<{ stopReason: string }> {
10631072
sessionStoreSetters.updateSession(session.taskRunId, {
10641073
isPromptPending: true,
10651074
promptStartedAt: Date.now(),
10661075
});
10671076

1077+
sessionStoreSetters.appendOptimisticItem(session.taskRunId, {
1078+
type: "user_message",
1079+
content: promptText,
1080+
timestamp: Date.now(),
1081+
});
1082+
10681083
try {
10691084
const result = await trpcClient.agent.prompt.mutate({
10701085
sessionId: session.taskRunId,
10711086
prompt: blocks,
10721087
});
1073-
// Clear pending state on success
10741088
sessionStoreSetters.updateSession(session.taskRunId, {
10751089
isPromptPending: false,
10761090
promptStartedAt: null,
@@ -1082,6 +1096,8 @@ export class SessionService {
10821096
const errorDetails = (error as { data?: { details?: string } }).data
10831097
?.details;
10841098

1099+
sessionStoreSetters.clearOptimisticItems(session.taskRunId);
1100+
10851101
if (isFatalSessionError(errorMessage, errorDetails)) {
10861102
log.error("Fatal prompt error, setting session to error state", {
10871103
taskRunId: session.taskRunId,
@@ -2144,6 +2160,7 @@ export class SessionService {
21442160
promptStartedAt: null,
21452161
pendingPermissions: new Map(),
21462162
messageQueue: [],
2163+
optimisticItems: [],
21472164
};
21482165
}
21492166

apps/code/src/renderer/features/sessions/stores/sessionStore.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ export type TaskRunStatus =
2828
| "failed"
2929
| "cancelled";
3030

31+
export type OptimisticItem = {
32+
type: "user_message";
33+
id: string;
34+
content: string;
35+
timestamp: number;
36+
};
37+
3138
export interface AgentSession {
3239
taskRunId: string;
3340
taskId: string;
@@ -63,6 +70,7 @@ export interface AgentSession {
6370
cloudBranch?: string | null;
6471
/** Number of session/prompt events to skip from polled logs (set during resume) */
6572
skipPolledPromptCount?: number;
73+
optimisticItems: OptimisticItem[];
6674
}
6775

6876
// --- Config Option Helpers ---
@@ -191,6 +199,7 @@ export {
191199
useConfigOptionForTask,
192200
useModeConfigOptionForTask,
193201
useModelConfigOptionForTask,
202+
useOptimisticItemsForTask,
194203
usePendingPermissionsForTask,
195204
useQueuedMessagesForTask,
196205
useSessionForTask,
@@ -334,6 +343,38 @@ export const sessionStoreSetters = {
334343
return result;
335344
},
336345

346+
appendOptimisticItem: (
347+
taskRunId: string,
348+
item: Omit<OptimisticItem, "id">,
349+
): void => {
350+
const id = `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
351+
useSessionStore.setState((state) => {
352+
const session = state.sessions[taskRunId];
353+
if (session) {
354+
session.optimisticItems.push({ ...item, id });
355+
}
356+
});
357+
},
358+
359+
clearOptimisticItems: (taskRunId: string): void => {
360+
useSessionStore.setState((state) => {
361+
const session = state.sessions[taskRunId];
362+
if (session) {
363+
session.optimisticItems = [];
364+
}
365+
});
366+
},
367+
368+
replaceOptimisticWithEvent: (taskRunId: string, event: AcpMessage): void => {
369+
useSessionStore.setState((state) => {
370+
const session = state.sessions[taskRunId];
371+
if (session) {
372+
session.events.push(event);
373+
session.optimisticItems = [];
374+
}
375+
});
376+
},
377+
337378
/** O(1) lookup using taskIdIndex */
338379
getSessionByTaskId: (taskId: string): AgentSession | undefined => {
339380
const state = useSessionStore.getState();

0 commit comments

Comments
 (0)