Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions ui/src/lib/acp-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,13 +302,21 @@ export function applySessionUpdate(
transcript.thinkingStartTime = 0;
transcript.thinkingElapsedSeconds = 0;

// ACP is the source of truth for durable chat transcript messages. The
// chat page should not append a separate local user bubble for the same
// prompt; it should wait for this echoed user_message_chunk instead.
if (historical) {
appendHistoricalText(transcript, 'user', text, (update.historyMessageId || update.messageId) as string);
} else {
appendStreamingText(transcript, 'user', text);
// Check for an optimistic user message to upgrade instead of duplicating.
const optimisticIdx = findLiveReplayCandidateIndex(transcript, 'user', text);
if (optimisticIdx !== -1) {
const optimistic = transcript.messages[optimisticIdx];
const messageKey = String((update.historyMessageId || update.messageId) || '').trim();
if (messageKey) optimistic._historyMessageId = messageKey;
const textBlock = optimistic.blocks.find((b) => b.type === 'text');
if (textBlock) textBlock.text = text;
optimistic.streaming = false;
} else {
appendStreamingText(transcript, 'user', text);
}
}
return null;
}
Expand Down
12 changes: 12 additions & 0 deletions ui/src/lib/use-chat-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface UseChatConnectionResult {
status: string;
permissionQueue: PermissionEntry[];
sendPrompt: (text: string) => Promise<void>;
appendOptimisticMessage: (text: string) => void;
cancelPrompt: () => void;
shiftPermissionQueue: () => void;
}
Expand Down Expand Up @@ -94,6 +95,16 @@ export function useChatConnection({
setStatus('Completed');
}, []);

const appendOptimisticMessage = useCallback((text: string) => {
const session = transcriptSessionRef.current;
session.transcript.messages.push({
role: 'user',
blocks: [{ type: 'text', text }],
streaming: false,
});
syncTranscript(session.transcript);
}, [syncTranscript]);

const cancelPrompt = useCallback(() => {
clientRef.current?.cancelPrompt();
}, []);
Expand Down Expand Up @@ -413,6 +424,7 @@ export function useChatConnection({
status,
permissionQueue,
sendPrompt,
appendOptimisticMessage,
cancelPrompt,
shiftPermissionQueue,
};
Expand Down
24 changes: 14 additions & 10 deletions ui/src/pages/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export function ChatPage() {
status,
permissionQueue,
sendPrompt,
appendOptimisticMessage,
cancelPrompt,
shiftPermissionQueue,
} = useChatConnection({
Expand Down Expand Up @@ -320,9 +321,18 @@ export function ChatPage() {
? ''
: buildFallbackConversationTitle(text);

// ACP owns durable transcript entries, including the echoed user prompt.
// Keep send feedback in ephemeral UI state and wait for ACP to write the
// real message so the transcript cannot diverge or duplicate.
// Show the user bubble immediately (optimistic). The server echo
// will be deduplicated via findLiveReplayCandidateIndex in acp-transcript.
appendOptimisticMessage(text);

// Clear composer and draft immediately so the input feels responsive.
if (activeConversationId && activeSpritzName) {
clearChatDraft(activeSpritzName, activeConversationId);
if (selectedConversationRef.current?.metadata?.name === activeConversationId) {
setComposerText('');
}
}

try {
await sendPrompt(text);
if (activeConversationId && fallbackTitle) {
Expand All @@ -333,12 +343,6 @@ export function ChatPage() {
body: JSON.stringify({ title: fallbackTitle }),
}).catch(() => {});
}
if (activeConversationId && activeSpritzName) {
clearChatDraft(activeSpritzName, activeConversationId);
if (selectedConversationRef.current?.metadata?.name === activeConversationId) {
setComposerText('');
}
}
} catch (err) {
if (activeConversationId && activeSpritzName) {
writeChatDraft(activeSpritzName, activeConversationId, previousComposerText);
Expand All @@ -349,7 +353,7 @@ export function ChatPage() {
toast.error(err instanceof Error ? err.message : 'Failed to send message.');
}
},
[applyConversationTitle, composerText, name, sendPrompt],
[appendOptimisticMessage, applyConversationTitle, composerText, name, sendPrompt],
);

const handleSelectConversation = useCallback((conv: ConversationInfo) => {
Expand Down
Loading