fix(ai-chat): adopt the server-returned chat id, not the newest in the list

A brand-new chat (activeChatId === null) had no way to learn the id of the row
the server created: the SSE stream never returned it, so the client adopted the
NEWEST chat in the per-user list (chats.items[0]). With two tabs open, a second
tab creating a chat at ~the same time made its row the newest, so the first tab
adopted the wrong id — its later turns persisted into the other chat and the
agent rebuilt history from it (commands leaked between chats), while the live UI
still showed the original conversation. (#137)

The server now attaches the authoritative chatId to the streamed assistant
message via the AI SDK messageMetadata on the 'start' part, so it reaches the
client on the first chunk. The client reads message.metadata.chatId in useChat's
onFinish and adopts that id in place (no remount, so the live turn and the
thread's chatIdRef follow the real id and the next turn targets the right chat).
The chats.items[0] guess and the adoptNewChat ref are removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-22 23:46:50 +03:00
parent 2d7f85fccb
commit 1858a5800d
3 changed files with 67 additions and 53 deletions

View File

@@ -58,8 +58,12 @@ interface ChatThreadProps {
* forwarded to MessageList. Absent => the generic "AI agent". */
assistantName?: string;
/** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void;
* a new chat, adopts the freshly created chat id. `serverChatId` is the
* authoritative id the server attached to the streamed assistant message
* metadata (see the server's `messageMetadata`); the parent adopts THIS for a
* new chat instead of guessing the newest chat in the list (fixes the two-tab
* adoption race, #137). Undefined on a failed turn that produced no metadata. */
onTurnFinished: (serverChatId?: string) => void;
/** Parent-owned ref that this thread keeps updated with its live useChat
* snapshot (full message list + streaming flag), so the header's
* "Copy chat" export can include the in-progress, not-yet-persisted
@@ -246,8 +250,14 @@ export default function ChatThread({
// sending after the user hit Stop — or blindly retrying after a failure —
// would be wrong, so on Stop/disconnect/error the queue is left intact for
// the user to decide.
onFinish: ({ isAbort, isDisconnect, isError }) => {
onTurnFinished();
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
// The server attaches the authoritative chatId to the streamed assistant
// message metadata (see `messageMetadata` in ai-chat.service.ts). Forward it
// so the parent adopts the REAL created chat id for a new chat, rather than
// guessing the newest chat in the list (which races a second tab — #137).
const serverChatId = (message?.metadata as { chatId?: string } | undefined)
?.chatId;
onTurnFinished(serverChatId);
// Show a neutral "stopped" marker for an aborted turn; the red error banner
// (via `error`) already covers isError, and a clean finish clears any marker.
if (isError) setStopNotice(null);