[bug][ai-chat] Нельзя скопировать незавершённое сообщение: кнопка «Copy chat» скрыта во время первого стрима нового чата #174

Closed
opened 2026-06-24 21:06:00 +03:00 by Ghost · 0 comments

Кратко

Кнопку «Copy chat» нельзя использовать для незавершённого сообщения. В самый частый момент — пока идёт первый стрим ответа в новом чате (агент ещё думает/вызывает инструменты, активна красная кнопка Stop) — иконка копирования в шапке окна вообще не показывается, хотя на экране уже есть и сообщение пользователя, и ответ агента (текст плана, блоки «Ran tool …», размышления).

Нужно: разрешить копирование того, что показано на экране, даже когда turn ещё не завершён и чат ещё не сохранён.

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

  1. Открыть AI-chat, начать новый чат, отправить первый запрос (агент отвечает стримингом с вызовами инструментов).
  2. Пока ответ ещё генерируется (Stop активен), посмотреть на шапку окна.

Ожидается: доступна кнопка «Copy chat», по нажатию в буфер попадает то, что видно на экране (сообщение пользователя + частичный ответ ассистента с текстом и блоками инструментов, помеченный как генерирующийся).

Фактически: кнопки «Copy chat» в шапке нет вообще. Она появляется только после завершения первого хода.

Корень проблемы

Видимость кнопки и сам экспорт завязаны на persisted-состояние, а не на то, что реально отрисовано:

  • Кнопка рендерится только при canExportai-chat-window.tsx#L510:
    // ai-chat-window.tsx:234
    const canExport = !!activeChatId && !!messageRows && messageRows.length > 0;
    
  • handleCopy дополнительно выходит по тому же условию — ai-chat-window.tsx#L252:
    if (!activeChatId || !messageRows || messageRows.length === 0) return;
    

А то, что на экране, рендерится из живого in-memory треда useChat, а не из messageRows:

Для первого, ещё не завершённого хода нового чата оба слагаемых canExport ложны:

Итог: во время первого стрима контент существует только в live-треде → canExport === false → кнопка скрыта, а handleCopy всё равно сделал бы no-op.

Связь с #160

Это ровно тот «смежный момент вне scope», который отмечен в #160 (раздел «Заметки и риски»): canExport требует messageRows.length > 0, поэтому для первого хода нового чата кнопка скрыта. #160 чинит потерю содержимого при экспорте оборванного хода уже существующего чата; этот issue — про видимость и работу кнопки для незавершённого хода, когда persisted-базы ещё нет вовсе. Решения пересекаются (live-first построение Markdown), поэтому делать желательно поверх/вместе с #160.

Предлагаемое решение (направление)

  1. Развязать гейт экспорта с persisted-состоянием: показывать кнопку, когда есть видимый контент в live-треде (есть хотя бы одно сообщение), независимо от activeChatId/messageRows. Оставить кнопку скрытой только для по-настоящему пустого чата (экран выбора роли, ноль сообщений).
  2. handleCopy: когда persisted-базы нет (activeChatId === null или messageRows пуст), строить Markdown только из live-треда (live-first), переиспользуя WYSIWYG-логику из #160. chatId может быть null/плейсхолдер, title может отсутствовать; помечать ассистентский ответ как генерирующийся (generating).
  3. Не ломать обычный путь завершённого чата: без дублей (дедуп live vs persisted по id, как сейчас), токены/таймстемпы сохраняются.

Подводный камень реактивности: liveStateRef — это ref, его изменение само по себе не вызывает ре-рендер шапки. Во время стрима шапка и так перерисовывается из-за liveTurnTokens (~8 Гц), но чтобы кнопка стабильно появлялась/исчезала, для гейта нужен реактивный сигнал «есть live-контент» (отдельный state/проп из chat-thread), а не чтение liveStateRef.current напрямую.

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

Критерии приёмки

  • Во время первого ещё не завершённого хода нового чата (нет activeChatId, нет persisted-строк) кнопка «Copy chat» видна.
  • По нажатию в буфер попадает то, что на экране: сообщение пользователя + частичный ответ ассистента (текст/план + блоки инструментов), помеченный как генерирующийся.
  • Для по-настоящему пустого чата (ноль сообщений, экран выбора роли) кнопка по-прежнему скрыта.
  • Нормально завершённый чат экспортируется без регрессий: без дублей, токены/таймстемпы на месте.
  • apps/clientpnpm test зелёный; добавлены тесты на копирование незавершённого хода.
## Кратко Кнопку **«Copy chat»** нельзя использовать для **незавершённого** сообщения. В самый частый момент — пока идёт **первый стрим ответа в новом чате** (агент ещё думает/вызывает инструменты, активна красная кнопка Stop) — иконка копирования в шапке окна **вообще не показывается**, хотя на экране уже есть и сообщение пользователя, и ответ агента (текст плана, блоки «Ran tool …», размышления). Нужно: разрешить копирование того, что показано на экране, даже когда turn ещё не завершён и чат ещё не сохранён. ## Воспроизведение 1. Открыть AI-chat, начать **новый** чат, отправить первый запрос (агент отвечает стримингом с вызовами инструментов). 2. Пока ответ ещё генерируется (Stop активен), посмотреть на шапку окна. **Ожидается:** доступна кнопка «Copy chat», по нажатию в буфер попадает то, что видно на экране (сообщение пользователя + частичный ответ ассистента с текстом и блоками инструментов, помеченный как генерирующийся). **Фактически:** кнопки «Copy chat» в шапке нет вообще. Она появляется только после завершения первого хода. ## Корень проблемы Видимость кнопки и сам экспорт завязаны на **persisted-состояние**, а не на то, что реально отрисовано: - Кнопка рендерится только при `canExport` — [ai-chat-window.tsx#L510](apps/client/src/features/ai-chat/components/ai-chat-window.tsx#L510): ```tsx // ai-chat-window.tsx:234 const canExport = !!activeChatId && !!messageRows && messageRows.length > 0; ``` - `handleCopy` дополнительно выходит по тому же условию — [ai-chat-window.tsx#L252](apps/client/src/features/ai-chat/components/ai-chat-window.tsx#L252): ```tsx if (!activeChatId || !messageRows || messageRows.length === 0) return; ``` А то, что на экране, рендерится из **живого** in-memory треда `useChat`, а не из `messageRows`: - `MessageList` отрисовывает live `messages` — [chat-thread.tsx#L423-L425](apps/client/src/features/ai-chat/components/chat-thread.tsx#L423-L425); - этот тред зеркалится в `liveStateRef` всегда — [chat-thread.tsx#L318-L323](apps/client/src/features/ai-chat/components/chat-thread.tsx#L318-L323). Для первого, ещё не завершённого хода нового чата оба слагаемых `canExport` ложны: - `activeChatId === null` до конца хода — реальный id «усыновляется» только в `onTurnFinished` ([use-chat-session.ts#L119-L129](apps/client/src/features/ai-chat/hooks/use-chat-session.ts#L119-L129), канон — [adopt-chat-id.ts](apps/client/src/features/ai-chat/utils/adopt-chat-id.ts)); - `messageRows` берётся из `useAiChatMessagesQuery(activeChatId ?? undefined)` ([ai-chat-window.tsx#L148-L149](apps/client/src/features/ai-chat/components/ai-chat-window.tsx#L148-L149)) и рефетчится тоже только в `onTurnFinished` ([use-chat-session.ts#L146-L148](apps/client/src/features/ai-chat/hooks/use-chat-session.ts#L146-L148)). **Итог:** во время первого стрима контент существует только в live-треде → `canExport === false` → кнопка скрыта, а `handleCopy` всё равно сделал бы no-op. ## Связь с #160 Это ровно тот «смежный момент вне scope», который отмечен в #160 (раздел «Заметки и риски»): `canExport` требует `messageRows.length > 0`, поэтому для первого хода нового чата кнопка скрыта. #160 чинит **потерю содержимого** при экспорте оборванного хода уже существующего чата; этот issue — про **видимость и работу** кнопки для незавершённого хода, когда persisted-базы ещё нет вовсе. Решения пересекаются (live-first построение Markdown), поэтому делать желательно поверх/вместе с #160. ## Предлагаемое решение (направление) 1. Развязать гейт экспорта с persisted-состоянием: показывать кнопку, когда есть видимый контент в live-треде (есть хотя бы одно сообщение), независимо от `activeChatId`/`messageRows`. Оставить кнопку скрытой только для по-настоящему пустого чата (экран выбора роли, ноль сообщений). 2. `handleCopy`: когда persisted-базы нет (`activeChatId === null` или `messageRows` пуст), строить Markdown **только** из live-треда (live-first), переиспользуя WYSIWYG-логику из #160. `chatId` может быть `null`/плейсхолдер, `title` может отсутствовать; помечать ассистентский ответ как генерирующийся (`generating`). 3. Не ломать обычный путь завершённого чата: без дублей (дедуп live vs persisted по id, как сейчас), токены/таймстемпы сохраняются. **Подводный камень реактивности:** `liveStateRef` — это `ref`, его изменение само по себе не вызывает ре-рендер шапки. Во время стрима шапка и так перерисовывается из-за `liveTurnTokens` (~8 Гц), но чтобы кнопка стабильно появлялась/исчезала, для гейта нужен реактивный сигнал «есть live-контент» (отдельный state/проп из `chat-thread`), а не чтение `liveStateRef.current` напрямую. ## Затронутые файлы - [apps/client/src/features/ai-chat/components/ai-chat-window.tsx](apps/client/src/features/ai-chat/components/ai-chat-window.tsx) — `canExport`/гейт кнопки + `handleCopy` (ветка без persisted-базы). - [apps/client/src/features/ai-chat/utils/chat-markdown.ts](apps/client/src/features/ai-chat/utils/chat-markdown.ts) — построение из live-треда при `chatId === null` (пересечение с #160). - [apps/client/src/features/ai-chat/components/chat-thread.tsx](apps/client/src/features/ai-chat/components/chat-thread.tsx) — пробросить реактивный признак наличия live-контента наверх. - [apps/client/src/features/ai-chat/utils/chat-markdown.test.ts](apps/client/src/features/ai-chat/utils/chat-markdown.test.ts) — тесты на копирование незавершённого хода без persisted-базы. ## Критерии приёмки - [ ] Во время первого ещё не завершённого хода нового чата (нет `activeChatId`, нет persisted-строк) кнопка «Copy chat» **видна**. - [ ] По нажатию в буфер попадает то, что на экране: сообщение пользователя + частичный ответ ассистента (текст/план + блоки инструментов), помеченный как генерирующийся. - [ ] Для по-настоящему пустого чата (ноль сообщений, экран выбора роли) кнопка по-прежнему скрыта. - [ ] Нормально завершённый чат экспортируется без регрессий: без дублей, токены/таймстемпы на месте. - [ ] `apps/client` → `pnpm test` зелёный; добавлены тесты на копирование незавершённого хода.
Ghost added the bug label 2026-06-24 21:06:00 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#174