- {message.createdAt.toLocaleTimeString([], {
- hour: '2-digit',
- minute: '2-digit'
- })}
-
+ {!message.streaming && (
+
+ {message.createdAt.toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit'
+ })}
+
+ )}
);
})}
- {loading && (
+ {loading && !provisionalMessage && (
- {streamingText || streamingReasoning ? (
- <>
- {streamingReasoning && (
-
- )}
- {streamingText &&
}
- {!streamingText && (
-
...
- )}
- >
- ) : fileProcessing && fileProcessing.status === 'processing' ? (
+ {fileProcessing && fileProcessing.status === 'processing' ? (
{strings.input.processingFile}
{fileProcessing.progress != null && (
diff --git a/packages/client/src/hooks/useChatManagement.ts b/packages/client/src/hooks/useChatManagement.ts
index 7b3c44ae..542a0db5 100644
--- a/packages/client/src/hooks/useChatManagement.ts
+++ b/packages/client/src/hooks/useChatManagement.ts
@@ -318,13 +318,21 @@ export function useChatManagement({
}
}, [repository, setMessages]);
- /** Saves an AI response to storage and updates UI. */
+ /**
+ * Saves an AI response to storage and updates UI.
+ *
+ * `messageId` lets the caller choose the persisted id. The streaming answer
+ * is rendered under the id allocated at RUN_STARTED, so persisting the final
+ * answer under that same id lets React reuse the streaming bubble instead of
+ * mounting a new one.
+ */
const saveAIResponse = useCallback(async (
content: string,
displayMode?: MessageDisplayMode,
traceId?: string,
turnMessages?: PersistedMessage[],
- reasoningParts?: ReasoningPart[]
+ reasoningParts?: ReasoningPart[],
+ messageId?: string
): Promise => {
const currentChatIdValue = currentChatIdSnapshot.current;
const pendingChatIdValue = pendingChatIdSnapshot.current;
@@ -350,7 +358,7 @@ export function useChatManagement({
}
const finalMessage: PersistedMessage = {
- id: generateMessageId(),
+ id: messageId ?? generateMessageId(),
role: 'assistant',
content,
createdAt: new Date(),
diff --git a/packages/client/src/hooks/useServerEvents.streamingId.test.tsx b/packages/client/src/hooks/useServerEvents.streamingId.test.tsx
new file mode 100644
index 00000000..fb4581f7
--- /dev/null
+++ b/packages/client/src/hooks/useServerEvents.streamingId.test.tsx
@@ -0,0 +1,105 @@
+import { describe, it, expect, mock } from 'bun:test';
+import { renderHook, act } from '@testing-library/react';
+import { useServerEvents } from './useServerEvents';
+import { defaultStrings } from '../theme/strings';
+import { ErrorCode, EventType } from '../types';
+import type { UseToolSystemReturn } from './useToolSystem';
+import type { UseAIClient } from '../client';
+
+function makeToolSystem(): UseToolSystemReturn {
+ return {
+ aggregatedToolsRef: { current: {} },
+ executeToolCall: mock(async () => undefined),
+ storePendingToolCall: mock(),
+ handleApprovalRequest: mock(),
+ } as unknown as UseToolSystemReturn;
+}
+
+function makeClient(currentMessageContent: string) {
+ return {
+ messages: [],
+ currentMessageContent,
+ currentReasoningBlocks: [],
+ currentRunId: 'run-1',
+ finalizeRun: mock(() => {}),
+ currentToolCalls: new Map(),
+ } as unknown as UseAIClient;
+}
+
+/**
+ * The streaming answer and the persisted answer render under the same React
+ * key, so the id the answer will be saved with must be known while it streams.
+ */
+describe('useServerEvents — streaming message id', () => {
+ it('has no id before a run starts', () => {
+ const { result } = renderHook(() =>
+ useServerEvents({ toolSystem: makeToolSystem(), saveAIResponse: mock(async () => {}), strings: defaultStrings }),
+ );
+ expect(result.current.streamingMessageId).toBeNull();
+ });
+
+ it('assigns an id at RUN_STARTED and persists the answer under that id at RUN_FINISHED', async () => {
+ const saveAIResponse = mock(async () => {});
+ const { result } = renderHook(() =>
+ useServerEvents({ toolSystem: makeToolSystem(), saveAIResponse, strings: defaultStrings }),
+ );
+ const client = makeClient('Hello');
+
+ await act(async () => {
+ await result.current.handleServerEvent(client, { type: EventType.RUN_STARTED, threadId: 't', runId: 'run-1' });
+ });
+ const id = result.current.streamingMessageId;
+ expect(id).toEqual(expect.any(String));
+
+ await act(async () => {
+ await result.current.handleServerEvent(client, { type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'm', delta: 'Hello' });
+ await result.current.handleServerEvent(client, { type: EventType.RUN_FINISHED, threadId: 't', runId: 'run-1' });
+ });
+
+ expect(saveAIResponse).toHaveBeenCalledTimes(1);
+ const [content, displayMode, , , , messageId] = saveAIResponse.mock.calls[0];
+ expect(content).toBe('Hello');
+ expect(displayMode).toBeUndefined();
+ expect(messageId).toBe(id);
+ expect(result.current.streamingMessageId).toBeNull();
+ });
+
+ it('persists the partial answer under the streaming id when the user stops the run', async () => {
+ const saveAIResponse = mock(async () => {});
+ const { result } = renderHook(() =>
+ useServerEvents({ toolSystem: makeToolSystem(), saveAIResponse, strings: defaultStrings }),
+ );
+ const client = makeClient('Partial');
+
+ await act(async () => {
+ await result.current.handleServerEvent(client, { type: EventType.RUN_STARTED, threadId: 't', runId: 'run-1' });
+ });
+ const id = result.current.streamingMessageId;
+
+ await act(async () => {
+ await result.current.handleServerEvent(client, { type: EventType.RUN_ERROR, message: ErrorCode.ABORTED });
+ });
+
+ expect(saveAIResponse).toHaveBeenCalledTimes(2);
+ expect(saveAIResponse.mock.calls[0][5]).toBe(id);
+ // The abort notice is a separate bubble and must not reuse the answer's id.
+ expect(saveAIResponse.mock.calls[1][1]).toBe('info');
+ expect(saveAIResponse.mock.calls[1][5]).toBeUndefined();
+ });
+
+ it('uses a fresh id for each run', async () => {
+ const { result } = renderHook(() =>
+ useServerEvents({ toolSystem: makeToolSystem(), saveAIResponse: mock(async () => {}), strings: defaultStrings }),
+ );
+ const client = makeClient('');
+ await act(async () => {
+ await result.current.handleServerEvent(client, { type: EventType.RUN_STARTED, threadId: 't', runId: 'run-1' });
+ });
+ const first = result.current.streamingMessageId;
+ await act(async () => {
+ await result.current.handleServerEvent(client, { type: EventType.RUN_FINISHED, threadId: 't', runId: 'run-1' });
+ await result.current.handleServerEvent(client, { type: EventType.RUN_STARTED, threadId: 't', runId: 'run-2' });
+ });
+ expect(result.current.streamingMessageId).not.toBe(first);
+ });
+});
diff --git a/packages/client/src/hooks/useServerEvents.ts b/packages/client/src/hooks/useServerEvents.ts
index 6c8a293d..6c5b44f3 100644
--- a/packages/client/src/hooks/useServerEvents.ts
+++ b/packages/client/src/hooks/useServerEvents.ts
@@ -13,6 +13,7 @@ import type {
} from '../types';
import { EventType, ErrorCode, TOOL_APPROVAL_REQUEST } from '../types';
import type { UseAIClient } from '../client';
+import { generateMessageId } from '../providers/chatRepository/types';
import type { UseToolSystemReturn } from './useToolSystem';
import type { UseAIStrings } from '../theme';
import type { PersistedMessage, MessageDisplayMode } from '../providers/chatRepository/types';
@@ -22,7 +23,14 @@ export interface UseServerEventsOptions {
/** Tool system for executing tools and looking up tool metadata */
toolSystem: UseToolSystemReturn;
/** Saves an AI response to chat storage */
- saveAIResponse: (content: string, displayMode?: MessageDisplayMode, traceId?: string, turnMessages?: PersistedMessage[], reasoningParts?: ReasoningPart[]) => Promise;
+ saveAIResponse: (
+ content: string,
+ displayMode?: MessageDisplayMode,
+ traceId?: string,
+ turnMessages?: PersistedMessage[],
+ reasoningParts?: ReasoningPart[],
+ messageId?: string,
+ ) => Promise;
/** UI strings for error messages and tool execution fallbacks */
strings: UseAIStrings;
}
@@ -38,6 +46,14 @@ export interface UseServerEventsReturn {
setLoading: React.Dispatch>;
/** Current streaming text from the AI response */
streamingText: string;
+ /**
+ * Id the streaming answer will be persisted under, allocated at RUN_STARTED
+ * and cleared when the run ends. The chat panel renders the streaming answer
+ * as a provisional message with this id, so the persisted answer replaces it
+ * under the same React key.
+ * @example "msg_1723972800000_k3j9x2a"
+ */
+ streamingMessageId: string | null;
/** Clear streaming text (e.g., when starting a new message) */
clearStreamingText: () => void;
/** Currently executing tool info for UI display, or null */
@@ -81,6 +97,9 @@ export function useServerEvents({
const [loading, setLoading] = useState(false);
const [streamingText, setStreamingText] = useState('');
const [streamingReasoning, setStreamingReasoning] = useState('');
+ const [streamingMessageId, setStreamingMessageId] = useState(null);
+ // Mirror of streamingMessageId for the stable event handler.
+ const streamingMessageIdRef = useRef(null);
const streamingChatIdRef = useRef(null);
// Mirror of `loading` for use from stable callbacks (handleDisconnect) where
@@ -144,7 +163,7 @@ export function useServerEvents({
// races and the second save clobbers the first — dropping the turn's
// tool context (only surfaces after reload, since in-memory state uses a
// functional setMessages update that keeps both).
- await saveAIResponseRef.current(content, undefined, opts.traceId, turnMessages, reasoningParts);
+ await saveAIResponseRef.current(content, undefined, opts.traceId, turnMessages, reasoningParts, streamingMessageIdRef.current ?? undefined);
await saveAIResponseRef.current(notice, 'info');
} else {
// No trailing text: skip the empty placeholder bubble. Attach
@@ -157,13 +176,15 @@ export function useServerEvents({
// RUN_FINISHED: only persist when the AI produced a final text response.
if (content) {
- await saveAIResponseRef.current(content, undefined, opts.traceId, turnMessages, reasoningParts);
+ await saveAIResponseRef.current(content, undefined, opts.traceId, turnMessages, reasoningParts, streamingMessageIdRef.current ?? undefined);
}
}, []);
const resetRunUiState = useCallback(() => {
setStreamingText('');
setStreamingReasoning('');
+ setStreamingMessageId(null);
+ streamingMessageIdRef.current = null;
streamingChatIdRef.current = null;
// Clear executingTool in case TOOL_CALL_END was never received
// (e.g., stream truncated by token limit, or aborted mid-tool).
@@ -183,6 +204,9 @@ export function useServerEvents({
runIdAtRunStartRef.current = client.currentRunId ?? undefined;
hasTextFromPriorStepRef.current = false;
setStreamingReasoning('');
+ const messageId = generateMessageId();
+ streamingMessageIdRef.current = messageId;
+ setStreamingMessageId(messageId);
} else if (event.type === EventType.REASONING_MESSAGE_START) {
// Add paragraph separator between reasoning from different steps
setStreamingReasoning(prev => prev ? prev + '\n\n' : prev);
@@ -282,12 +306,8 @@ export function useServerEvents({
const message = strs.errors[ErrorCode.CONNECTION_LOST] || strs.errors[ErrorCode.UNKNOWN_ERROR];
saveAIResponseRef.current(message, 'error');
- setStreamingText('');
- setStreamingReasoning('');
- streamingChatIdRef.current = null;
- setExecutingTool(null);
- setLoading(false);
- }, []);
+ resetRunUiState();
+ }, [resetRunUiState]);
// Compute display value for UI
const executingTool = executingToolRaw ? {
@@ -298,6 +318,7 @@ export function useServerEvents({
loading,
setLoading,
streamingText,
+ streamingMessageId,
clearStreamingText,
executingTool,
streamingChatIdRef,
diff --git a/packages/client/src/providers/useAIProvider.tsx b/packages/client/src/providers/useAIProvider.tsx
index 08ebde52..005922b8 100644
--- a/packages/client/src/providers/useAIProvider.tsx
+++ b/packages/client/src/providers/useAIProvider.tsx
@@ -769,6 +769,8 @@ export function UseAIProvider({
? serverEvents.streamingText : '';
const effectiveStreamingReasoning = serverEvents.streamingChatIdRef.current === chatManagement.displayedChatId
? serverEvents.streamingReasoning : '';
+ const effectiveStreamingMessageId = serverEvents.streamingChatIdRef.current === chatManagement.displayedChatId
+ ? serverEvents.streamingMessageId : null;
const chatUIContextValue: ChatUIContextValue = {
connected,
@@ -778,6 +780,7 @@ export function UseAIProvider({
messages,
streamingText: effectiveStreamingText,
streamingReasoning: effectiveStreamingReasoning,
+ streamingMessageId: effectiveStreamingMessageId,
suggestions: promptState.aggregatedSuggestions,
fileUploadConfig,
fileProcessing: fileProcessingState,
@@ -833,6 +836,7 @@ export function UseAIProvider({
connected,
streamingText: effectiveStreamingText,
streamingReasoning: effectiveStreamingReasoning,
+ streamingMessageId: effectiveStreamingMessageId,
currentChatId: chatManagement.displayedChatId,
onNewChat: chatManagement.createNewChat,
onLoadChat: chatManagement.loadChat,