[bug][ai-chat] «New chat» во время стрима первого ответа не сбрасывает чат, а лишь убирает бейдж роли #161

Closed
opened 2026-06-24 14:24:31 +03:00 by claude_code · 0 comments
Collaborator

Симптом

Если создать чат со скиллом-ролью (например «Корректор») и, пока агент ещё
печатает первый ответ
(статус Thinking…), нажать New chat, то:

  • из шапки окна пропадает бейдж роли «Корректор»;
  • но сам чат и сессия остаются прежними — стрим продолжается, история не
    очищается, новый пустой чат не открывается.

Реальный сброс к новому чату происходит только если нажать New chat после
того, как агент закончил ответ
.

То есть «New chat» во время стрима первого хода работает как «снять бейдж роли»,
а не как «начать новый чат».

Воспроизведение

  1. Открыть окно AI-чата на странице.
  2. На пустом новом чате выбрать карточку роли (например «Корректор») — агент
    авто-стартует первый ход.
  3. Пока идёт стрим (Thinking…), нажать New chat.
  4. Наблюдать: бейдж роли исчез, но чат/стрим тот же.

Ветка на момент анализа: develop @ e9702434.

Первопричина

Перемонтирование (сброс) треда чата управляется не самой кнопкой, а
reconciler-ом в фазе рендера внутри useChatSession
(apps/client/src/features/ai-chat/hooks/use-chat-session.ts, ~стр. 187–195):

// remount происходит ТОЛЬКО когда расходятся activeChatId и thread.chatId
if (activeChatId !== thread.chatId) {
  pendingNewChatRef.current = null;
  dispatch({ type: "reconcile", chatId: activeChatId, newKey: `new-${generateId()}` });
}

Кнопка New chat (apps/client/src/features/ai-chat/components/ai-chat-window.tsx,
startNewChat, ~стр. 206–213) меняет только атомы:

const startNewChat = useCallback((): void => {
  cancelPendingAdoption();
  setActiveChatId(null);     // <-- в этом и проблема
  setHistoryOpen(false);
  setDraft("");
  setSelectedRoleId(null);   // именно это убирает бейдж роли
}, [...]);

Ключевой момент: усыновление (adoption) реального id чата происходит только в
конце хода
— в onTurnFinished (вызывается из useChat.onFinish). Пока идёт
стрим первого хода нового чата:

  • activeChatId === null (id ещё не усыновлён);
  • thread.chatId === null.

Поэтому setActiveChatId(null) в startNewChat — это no-op (значение и так
null), reconciler не срабатывает (null !== null ложно), тред не
перемонтируется
. Меняется только selectedRoleId → null, из-за чего исчезает
бейдж роли (currentRole в ai-chat-window.tsx, ~стр. 240–246).

Почему «после конца работы агента» работает: к этому моменту adoption уже
выставил activeChatId = <реальный id> (не null). Тогда setActiveChatId(null)
действительно меняет значение id → null, reconciler видит расхождение и
перемонтирует тред с новым ключом → открывается чистый новый чат.

Предлагаемая правка (дизайн)

1. Принудительный свежий тред

Перестать опираться на «изменение activeChatId» как на единственный триггер
перемонтирования. Добавить в useChatSession метод, который безусловно
создаёт новый тред (новый mount-key, chatId: null), и вызывать его из
startNewChat:

// use-chat-session.ts
const startFreshThread = useCallback(() => {
  pendingNewChatRef.current = null; // снять любой армированный fallback
  dispatch({ type: "reconcile", chatId: null, newKey: `new-${generateId()}` });
}, []);
// ...и вернуть его из хука
// ai-chat-window.tsx, startNewChat
startFreshThread();        // всегда даёт чистый тред, даже если activeChatId уже null
setActiveChatId(null);
setSelectedRoleId(null);
// ...

Так как новый ключ всегда отличается, React перемонтирует ChatThread (новый
пустой useChat), а render-phase reconciler уже не вмешивается, потому что после
диспатча activeChatId (null) === thread.chatId (null).

2. Защита от «переусыновления» брошенного стрима (обязательно)

Здесь есть подводный камень. Хук @ai-sdk/react (3.0.209) не прерывает стрим
при размонтировании
: у useChat нет cleanup, который зовёт stop(), а
коллбеки проксируются через callbacksRef, поэтому onFinish старого
(брошенного) хода срабатывает даже после unmount и вызывает родительский
onTurnFinished(serverChatId). Поскольку после New chat activeChatId снова
null, основной путь усыновления
(resolveAdoptedChatId(null, serverChatId) в adopt-chat-id.ts) усыновит
брошенный чат и выдернет пользователя обратно
в только что покинутый чат.

Решение — сделать усыновление «привязанным к треду»: передавать в
onTurnFinished ключ завершившегося треда и усыновлять/армировать fallback
только если он совпадает с текущим смонтированным thread.key:

  • ChatThread получает проп threadKey (то же значение, что и React key) и
    прокидывает его в оба вызова onTurnFinishedonFinish и onError).
  • В useChatSession держать threadKeyRef (обновляется каждый рендер) и в
    onTurnFinished:
    • isCurrentThread = finishingThreadKey === undefined || finishingThreadKey === threadKeyRef.current;
    • усыновлять (primary) и армировать fallback только при isCurrentThread;
    • брошенный тред (isCurrentThread === false) делает лишь
      onInvalidateChatList() (чтобы покинутый чат появился в истории), без
      adoption и без onInvalidateChatMessages.

Это сохраняет «счастливый путь» (in-place adoption того же треда: ключ совпадает),
но не даёт фоновому завершению брошенного хода вернуть пользователя.

Затронутые файлы

  • apps/client/src/features/ai-chat/hooks/use-chat-session.tsstartFreshThread,
    threadKeyRef, thread-aware-гард в onTurnFinished, расширение
    UseChatSessionResult.
  • apps/client/src/features/ai-chat/components/ai-chat-window.tsx — вызов
    startFreshThread в startNewChat, проброс threadKey в ChatThread.
  • apps/client/src/features/ai-chat/components/chat-thread.tsx — приём пропа
    threadKey и его проброс в onTurnFinishedonFinish и onError).
  • Тесты: use-chat-session.test.tsx (новые кейсы: New chat во время стрима
    перемонтирует тред; брошенный тред не усыновляется), при необходимости
    thread-identity.test.ts.

Замечания / границы правки

  • Правка сознательно не меняет поведение «стрим брошенного хода продолжается
    на сервере в фоне» — это согласуется с текущим поведением переключения чатов на
    лету (selectChat), где useChat тоже не прерывает запрос при unmount. Если
    отдельно потребуется реально останавливать фоновый ход при New chat (например,
    чтобы «Корректор» перестал править документ после ухода) — это отдельная задача
    про abort стрима при размонтировании.
  • Гард по threadKey обратносовместим: если ключ не передан (undefined), вызов
    трактуется как «текущий тред» — старые тесты/вызовы не ломаются.
## Симптом Если создать чат со скиллом-ролью (например «Корректор») и, **пока агент ещё печатает первый ответ** (статус `Thinking…`), нажать **New chat**, то: - из шапки окна пропадает бейдж роли «Корректор»; - но **сам чат и сессия остаются прежними** — стрим продолжается, история не очищается, новый пустой чат не открывается. Реальный сброс к новому чату происходит **только если нажать New chat после того, как агент закончил ответ**. То есть «New chat» во время стрима первого хода работает как «снять бейдж роли», а не как «начать новый чат». ## Воспроизведение 1. Открыть окно AI-чата на странице. 2. На пустом новом чате выбрать карточку роли (например «Корректор») — агент авто-стартует первый ход. 3. Пока идёт стрим (`Thinking…`), нажать **New chat**. 4. Наблюдать: бейдж роли исчез, но чат/стрим тот же. Ветка на момент анализа: `develop` @ `e9702434`. ## Первопричина Перемонтирование (сброс) треда чата управляется не самой кнопкой, а **reconciler-ом в фазе рендера** внутри `useChatSession` (`apps/client/src/features/ai-chat/hooks/use-chat-session.ts`, ~стр. 187–195): ```ts // remount происходит ТОЛЬКО когда расходятся activeChatId и thread.chatId if (activeChatId !== thread.chatId) { pendingNewChatRef.current = null; dispatch({ type: "reconcile", chatId: activeChatId, newKey: `new-${generateId()}` }); } ``` Кнопка New chat (`apps/client/src/features/ai-chat/components/ai-chat-window.tsx`, `startNewChat`, ~стр. 206–213) меняет только атомы: ```ts const startNewChat = useCallback((): void => { cancelPendingAdoption(); setActiveChatId(null); // <-- в этом и проблема setHistoryOpen(false); setDraft(""); setSelectedRoleId(null); // именно это убирает бейдж роли }, [...]); ``` Ключевой момент: **усыновление (adoption) реального id чата происходит только в конце хода** — в `onTurnFinished` (вызывается из `useChat.onFinish`). Пока идёт стрим первого хода нового чата: - `activeChatId === null` (id ещё не усыновлён); - `thread.chatId === null`. Поэтому `setActiveChatId(null)` в `startNewChat` — это **no-op** (значение и так `null`), reconciler не срабатывает (`null !== null` ложно), тред **не перемонтируется**. Меняется только `selectedRoleId → null`, из-за чего исчезает бейдж роли (`currentRole` в `ai-chat-window.tsx`, ~стр. 240–246). **Почему «после конца работы агента» работает:** к этому моменту adoption уже выставил `activeChatId = <реальный id>` (не `null`). Тогда `setActiveChatId(null)` действительно меняет значение `id → null`, reconciler видит расхождение и перемонтирует тред с новым ключом → открывается чистый новый чат. ## Предлагаемая правка (дизайн) ### 1. Принудительный свежий тред Перестать опираться на «изменение `activeChatId`» как на единственный триггер перемонтирования. Добавить в `useChatSession` метод, который **безусловно** создаёт новый тред (новый mount-key, `chatId: null`), и вызывать его из `startNewChat`: ```ts // use-chat-session.ts const startFreshThread = useCallback(() => { pendingNewChatRef.current = null; // снять любой армированный fallback dispatch({ type: "reconcile", chatId: null, newKey: `new-${generateId()}` }); }, []); // ...и вернуть его из хука ``` ```ts // ai-chat-window.tsx, startNewChat startFreshThread(); // всегда даёт чистый тред, даже если activeChatId уже null setActiveChatId(null); setSelectedRoleId(null); // ... ``` Так как новый ключ всегда отличается, React перемонтирует `ChatThread` (новый пустой `useChat`), а render-phase reconciler уже не вмешивается, потому что после диспатча `activeChatId (null) === thread.chatId (null)`. ### 2. Защита от «переусыновления» брошенного стрима (обязательно) Здесь есть подводный камень. Хук `@ai-sdk/react` (3.0.209) **не прерывает стрим при размонтировании**: у `useChat` нет cleanup, который зовёт `stop()`, а коллбеки проксируются через `callbacksRef`, поэтому `onFinish` старого (брошенного) хода **срабатывает даже после unmount** и вызывает родительский `onTurnFinished(serverChatId)`. Поскольку после New chat `activeChatId` снова `null`, основной путь усыновления (`resolveAdoptedChatId(null, serverChatId)` в `adopt-chat-id.ts`) **усыновит брошенный чат и выдернет пользователя обратно** в только что покинутый чат. Решение — сделать усыновление «привязанным к треду»: передавать в `onTurnFinished` ключ завершившегося треда и усыновлять/армировать fallback только если он совпадает с текущим смонтированным `thread.key`: - `ChatThread` получает проп `threadKey` (то же значение, что и React `key`) и прокидывает его в оба вызова `onTurnFinished` (в `onFinish` и `onError`). - В `useChatSession` держать `threadKeyRef` (обновляется каждый рендер) и в `onTurnFinished`: - `isCurrentThread = finishingThreadKey === undefined || finishingThreadKey === threadKeyRef.current`; - усыновлять (primary) и армировать fallback **только** при `isCurrentThread`; - брошенный тред (`isCurrentThread === false`) делает лишь `onInvalidateChatList()` (чтобы покинутый чат появился в истории), без adoption и без `onInvalidateChatMessages`. Это сохраняет «счастливый путь» (in-place adoption того же треда: ключ совпадает), но не даёт фоновому завершению брошенного хода вернуть пользователя. ### Затронутые файлы - `apps/client/src/features/ai-chat/hooks/use-chat-session.ts` — `startFreshThread`, `threadKeyRef`, thread-aware-гард в `onTurnFinished`, расширение `UseChatSessionResult`. - `apps/client/src/features/ai-chat/components/ai-chat-window.tsx` — вызов `startFreshThread` в `startNewChat`, проброс `threadKey` в `ChatThread`. - `apps/client/src/features/ai-chat/components/chat-thread.tsx` — приём пропа `threadKey` и его проброс в `onTurnFinished` (в `onFinish` и `onError`). - Тесты: `use-chat-session.test.tsx` (новые кейсы: New chat во время стрима перемонтирует тред; брошенный тред не усыновляется), при необходимости `thread-identity.test.ts`. ### Замечания / границы правки - Правка сознательно **не меняет** поведение «стрим брошенного хода продолжается на сервере в фоне» — это согласуется с текущим поведением переключения чатов на лету (`selectChat`), где `useChat` тоже не прерывает запрос при unmount. Если отдельно потребуется реально останавливать фоновый ход при New chat (например, чтобы «Корректор» перестал править документ после ухода) — это отдельная задача про abort стрима при размонтировании. - Гард по `threadKey` обратносовместим: если ключ не передан (`undefined`), вызов трактуется как «текущий тред» — старые тесты/вызовы не ломаются.
claude_code added the bug label 2026-06-24 14:24:31 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#161