Review of #158 (Request changes) — core logic verified correct; addressed the test-coverage + localization items: 1. i18n pluralization: the token-count keys were called with {count} but had one form, so ru-RU always rendered the genitive ("1 токенов"). Added _one/_other (en) and _one/_few/_many (ru: токен/токена/токенов) for both "Thinking… · {{count}} tokens" and "Thinking · {{count}} tokens"; de-duped the PR-added duplicate "Thinking" key. Call sites unchanged. 2. ReasoningBlock: new reasoning-block.test.tsx (4 branches: authoritative count wins / estimate fallback / header-only when count-but-no-text / body render). 3. Reasoning-token attribution: extracted the #151 anti-double-count rule into a pure `reasoningTokensForPart(message)` (single reasoning part -> authoritative turn total; multiple/none -> undefined so each estimates). message-item uses it; removed the now-dead lastReasoningIndex reduce (review #5). Unit-tested. 6. adopt-chat-id.ts: refreshed 3 stale `chatStreamStartMetadata` -> `chatStreamMetadata` comment references. 7. chat-markdown.test.ts: assert the export footer's `reasoning: N` line appears when reasoningTokens>0 and is absent at 0/undefined. Skipped optional #4 (mantine useThrottledCallback): the manual throttle has two distinct exit paths (turn-end revert-to-null + the captured-total trailing emit) with no guarding test; remapping risks the streaming behavior — non-blocking. Client tsc clean; ai-chat suite green (171 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
71 lines
3.2 KiB
TypeScript
71 lines
3.2 KiB
TypeScript
/**
|
|
* Pure helpers for adopting a brand-new chat's authoritative server id.
|
|
*
|
|
* ============================ CANONICAL #137 NOTE ============================
|
|
* This docblock is the single authoritative explanation of the new-chat id
|
|
* adoption design and the #137 two-tab race it fixes. Other call sites
|
|
* (use-chat-session.ts, the server's `chatStreamMetadata`) reference here
|
|
* rather than restating it.
|
|
*
|
|
* When a user sends the first turn of a BRAND-NEW chat, the client has no chat
|
|
* id yet (`activeChatId === null`). The server creates the row and the client
|
|
* must "adopt" that row's real id so the SECOND turn targets the same chat.
|
|
*
|
|
* The OLD heuristic adopted `items[0]` — the newest chat in the refetched list.
|
|
* That races a second tab: if another tab created a chat in the same moment,
|
|
* its row could be `items[0]`, so this tab would adopt the SIBLING chat and
|
|
* leak its later turns into it (#137). We adopt by IDENTITY instead, two ways:
|
|
*
|
|
* PRIMARY path: the server streams the real chat id on the assistant message
|
|
* metadata's `start` part (see `chatStreamMetadata` server-side);
|
|
* `extractServerChatId` reads it off the finished message and
|
|
* `resolveAdoptedChatId` turns it into the id to adopt for a new chat. This is
|
|
* authoritative and immune to the race.
|
|
*
|
|
* FALLBACK path (only when a new chat's first turn errors BEFORE the `start`
|
|
* chunk, so no metadata id ever reached the client): adopt the single chat that
|
|
* NEWLY appeared in the per-user list relative to a pre-refetch snapshot —
|
|
* `newlyAddedChatIds` (the fallback effect adopts only when exactly one id is
|
|
* new). This is unambiguous and does not race a second tab the way the old
|
|
* "newest chat in the list" guess did.
|
|
* ============================================================================
|
|
*/
|
|
|
|
/**
|
|
* Resolve the id to adopt from the server-streamed metadata. Returns
|
|
* `serverChatId` only for a brand-new chat (`activeChatId === null`) that
|
|
* received a truthy id; otherwise null (existing chat, or no id streamed).
|
|
*/
|
|
export function resolveAdoptedChatId(
|
|
activeChatId: string | null,
|
|
serverChatId: string | null | undefined,
|
|
): string | null {
|
|
return activeChatId === null && serverChatId ? serverChatId : null;
|
|
}
|
|
|
|
/**
|
|
* Read the authoritative server chat id off a finished assistant message. The
|
|
* server attaches it as `message.metadata.chatId` on the `start` part (see
|
|
* `chatStreamMetadata`). Returns it only when it is a string; undefined for
|
|
* a missing message, missing metadata, or a non-string `chatId`.
|
|
*/
|
|
export function extractServerChatId(
|
|
message: { metadata?: unknown } | undefined,
|
|
): string | undefined {
|
|
const m = message?.metadata as { chatId?: string } | undefined;
|
|
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
|
}
|
|
|
|
/**
|
|
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
|
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
|
* chat must not read as multiple from a duplicate.
|
|
*/
|
|
export function newlyAddedChatIds(
|
|
beforeIds: readonly string[],
|
|
afterIds: readonly string[],
|
|
): Set<string> {
|
|
const before = new Set(beforeIds);
|
|
return new Set(afterIds.filter((id) => !before.has(id)));
|
|
}
|