Addresses the 2nd PR #138 review (test debt + the Variant-B architecture ask). The new→persisted chat id lifecycle (mount key, both adoption paths, the history-load latch, the render-phase reconciler, onTurnFinished) is moved out of the 768-line window into a new useChatSession hook driven by a pure threadSessionReducer (reconcile/adopt), so adopt-vs-switch is one explicit dispatch point and the scattering the review flagged is gone (window: 768→~620). Tests (the blockers): - use-chat-session.test.tsx — hook-level locks incl. the #137 regression (adopts the authoritative streamed id 'A', NOT chats.items[0]='B' — fails on the old heuristic), the error-path fallback (arm/adopt/ambiguous/add+delete), the disarm-on-reconcile lock (a fallback armed then switched away must not be adopted by a late refetch), in-place-adopt-keeps-key vs external-switch-remount, and the waitingForHistory latch. - extractServerChatId (reading message.metadata.chatId) and newlyAddedChatIds extracted as pure helpers with unit tests; threadSessionReducer tested. Cleanups: single canonical #137 explanation in adopt-chat-id.ts (other sites reference it); fallback effect computes the set diff once; invalidate callbacks memoized; redundant invariant tests folded. Behavior preserved — re-verified live (z.ai glm-5.2): new-chat adopt + 2nd turn in the same row, no mid-conversation remount, two-tab race leak-free, switch to an existing chat reseeds full history, reload restores history. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
74 lines
2.9 KiB
TypeScript
74 lines
2.9 KiB
TypeScript
/**
|
|
* Pure transitions for the AI-chat thread's identity: the single source of
|
|
* truth tying ChatThread's mount key to the chat id that mounted thread holds.
|
|
*
|
|
* The window keeps exactly ONE of these in state. Consolidating the mount key
|
|
* and the live thread's chat id into one atomic value makes the "stale chat id
|
|
* vs key" state unrepresentable: every change goes through one of the explicit
|
|
* transitions below, so the key and chatId can never silently diverge.
|
|
*
|
|
* - `newThread`/`switchThread` produce a key that forces a remount (+ reseed):
|
|
* `newThread` for a brand-new (id-less) chat, `switchThread` for an existing
|
|
* one. The caller picks which based on whether there is a chat id.
|
|
* - `adoptThread` keeps the SAME key so a brand-new chat learns its real id
|
|
* WITHOUT remounting (the live useChat store, holding the just-finished turn,
|
|
* is preserved and the next turn sends the real chatId).
|
|
*
|
|
* `newThread` takes the session key from the impure `generateId()` at the call
|
|
* site so these stay pure and unit-testable.
|
|
*/
|
|
export type ThreadIdentity = { key: string; chatId: string | null };
|
|
|
|
/**
|
|
* A brand-new chat: a fresh session key and no chat id yet. `newKey` is
|
|
* supplied by the caller (generateId() is impure) so this stays pure/testable.
|
|
*/
|
|
export function newThread(newKey: string): ThreadIdentity {
|
|
return { key: newKey, chatId: null };
|
|
}
|
|
|
|
/**
|
|
* Switch to an EXISTING chat: the mount key becomes the chat id, forcing a
|
|
* remount + reseed from the persisted history. (A switch to a brand-new chat
|
|
* goes through `newThread` instead — there is no id to key on.)
|
|
*/
|
|
export function switchThread(chatId: string): ThreadIdentity {
|
|
return { key: chatId, chatId };
|
|
}
|
|
|
|
/**
|
|
* In-place adoption: a brand-new chat (`prev.chatId === null`) learns its real
|
|
* id WITHOUT remounting — keep the SAME key, set the chat id. If `prev` already
|
|
* has a chatId (not a new chat), this is a no-op (returns `prev`): adoption only
|
|
* applies to an as-yet-unadopted new thread.
|
|
*/
|
|
export function adoptThread(prev: ThreadIdentity, chatId: string): ThreadIdentity {
|
|
return prev.chatId === null ? { key: prev.key, chatId } : prev;
|
|
}
|
|
|
|
/**
|
|
* Thread-identity transitions as a reducer action. See `threadSessionReducer`.
|
|
*/
|
|
export type ThreadSessionAction =
|
|
| { type: "reconcile"; chatId: string | null; newKey: string }
|
|
| { type: "adopt"; chatId: string };
|
|
|
|
/**
|
|
* Single source of truth for thread-identity transitions. `reconcile` handles a
|
|
* genuine switch (user OR external atom write) -> remount; `adopt` moves a brand-
|
|
* new chat to its real id in place (no remount).
|
|
*/
|
|
export function threadSessionReducer(
|
|
state: ThreadIdentity,
|
|
action: ThreadSessionAction,
|
|
): ThreadIdentity {
|
|
switch (action.type) {
|
|
case "reconcile":
|
|
return action.chatId === null
|
|
? newThread(action.newKey)
|
|
: switchThread(action.chatId);
|
|
case "adopt":
|
|
return adoptThread(state, action.chatId);
|
|
}
|
|
}
|