fix(ai-chat): WYSIWYG Copy chat export + first-turn export (#160, #174) #165

Merged
vvzvlad merged 2 commits from fix/ai-chat-copy-chat-wysiwyg into develop 2026-06-25 03:54:35 +03:00

Closes #160. Closes #174.

Проблема (#160)

«Copy chat» строил Markdown из persisted-строк + живого «хвоста», который подмешивался ТОЛЬКО при isStreaming. При обрыве хода (Lost connection / dropped stream) isStreaming становился false → живой хвост отбрасывался, а частичный ответ ассистента (видимый на экране, чья строка часто так и не сохранилась) пропадал из выгрузки — оставались только сообщения пользователя.

Решение #160 (WYSIWYG)

  • buildChatMarkdown теперь live-first: живые (on-screen) сообщения становятся документом. Каждое матчится к persisted-строке по id для обогащения токенами/ошибкой/таймстампом; авторитетные usage/error, уже лежащие на живом сообщении (сервер кладёт usage на границе шага), приоритетнее строки. При пустом live — fallback на persisted-строки (старый формат сохранён). Пометка «still generating» — только на ассистенте, который реально является хвостом стрима (в окне submitted, где хвост — user-сообщение, предыдущий завершённый ответ больше не помечается).
  • Баннер (классифицированная ошибка / обрыв / ручной стоп) сплющивается в строку в ChatThread, зеркалится в liveStateRef рядом с messages/isStreaming и добавляется в конец выгрузки.
  • handleCopy маппит живые сообщения и передаёт live/rows/isStreaming/banner.

Проблема (#174)

Кнопка «Copy chat» была скрыта на САМОМ первом ходе нового, ещё не сохранённого чата: и гейт canExport, и ранний возврат handleCopy требовали activeChatId И persisted-строк (messageRows), которых на первом ходе ещё нет (он стримится или оборвался до сохранения любой строки). Это ровно тот edge-case, что был задокументирован как вне scope #160.

Решение #174

  • Гейт экспорта отвязан от persisted-состояния. ChatThread теперь репортит реактивный сигнал onLiveContentChange(messages.length > 0) — живой снапшот лежит в нереактивном liveStateRef, поэтому для ре-рендера кнопки нужен отдельный реактивный флаг. Родитель держит его в hasLiveContent.
  • canExport = hasLiveContent || (есть persisted-строки); ранний возврат handleCopy срабатывает только когда пусто И live, И rows.
  • handleCopy передаёт placeholder "unsaved" в качестве chat id, когда настоящего ещё нет; live-first билдер сериализует on-screen тред WYSIWYG.
  • Реактивность при переключении чатов: cleanup старого треда репортит false до эффекта нового маунта → корректный hasLiveContent, без stale-true для реально пустого чата. Идентичный setState — no-op, поэтому per-delta ре-рендеров нет.

Тесты

chat-markdown.test.ts: live/enrichment/fallback/banner + регресс на окно submitted + 2 новых теста на экспорт первого хода без persisted-базы (#174) — 28 зелёных. Полный набор ai-chat зелёный (185). tsc чисто, prettier --check чисто.

Ревью

#160: два прохода ревью-сабагента (первый нашёл реальный баг ложной пометки «генерируется» — исправлено + регресс-тест; повторный — APPROVE). #174: проход ревью-сабагента — APPROVE WITH SUGGESTIONS (поправлены кавычки "unsaved" под prettier; реактивность/отсутствие per-delta ре-рендеров/зависимости handleCopy — без замечаний).

🤖 Generated with Claude Code

Closes #160. Closes #174. ## Проблема (#160) «Copy chat» строил Markdown из persisted-строк + живого «хвоста», который подмешивался ТОЛЬКО при `isStreaming`. При обрыве хода (Lost connection / dropped stream) `isStreaming` становился false → живой хвост отбрасывался, а частичный ответ ассистента (видимый на экране, чья строка часто так и не сохранилась) пропадал из выгрузки — оставались только сообщения пользователя. ## Решение #160 (WYSIWYG) - **`buildChatMarkdown` теперь live-first**: живые (on-screen) сообщения становятся документом. Каждое матчится к persisted-строке по `id` для обогащения токенами/ошибкой/таймстампом; авторитетные `usage`/`error`, уже лежащие на живом сообщении (сервер кладёт usage на границе шага), приоритетнее строки. При пустом `live` — fallback на persisted-строки (старый формат сохранён). Пометка «still generating» — только на ассистенте, который реально является хвостом стрима (в окне submitted, где хвост — user-сообщение, предыдущий завершённый ответ больше не помечается). - **Баннер** (классифицированная ошибка / обрыв / ручной стоп) сплющивается в строку в `ChatThread`, зеркалится в `liveStateRef` рядом с messages/isStreaming и добавляется в конец выгрузки. - `handleCopy` маппит живые сообщения и передаёт `live`/`rows`/`isStreaming`/`banner`. ## Проблема (#174) Кнопка «Copy chat» была скрыта на САМОМ первом ходе нового, ещё не сохранённого чата: и гейт `canExport`, и ранний возврат `handleCopy` требовали `activeChatId` И persisted-строк (`messageRows`), которых на первом ходе ещё нет (он стримится или оборвался до сохранения любой строки). Это ровно тот edge-case, что был задокументирован как вне scope #160. ## Решение #174 - **Гейт экспорта отвязан от persisted-состояния.** `ChatThread` теперь репортит реактивный сигнал `onLiveContentChange(messages.length > 0)` — живой снапшот лежит в нереактивном `liveStateRef`, поэтому для ре-рендера кнопки нужен отдельный реактивный флаг. Родитель держит его в `hasLiveContent`. - `canExport = hasLiveContent || (есть persisted-строки)`; ранний возврат `handleCopy` срабатывает только когда пусто И live, И rows. - `handleCopy` передаёт placeholder `"unsaved"` в качестве chat id, когда настоящего ещё нет; live-first билдер сериализует on-screen тред WYSIWYG. - Реактивность при переключении чатов: cleanup старого треда репортит `false` до эффекта нового маунта → корректный `hasLiveContent`, без stale-true для реально пустого чата. Идентичный `setState` — no-op, поэтому per-delta ре-рендеров нет. ## Тесты `chat-markdown.test.ts`: live/enrichment/fallback/banner + регресс на окно submitted + 2 новых теста на экспорт первого хода без persisted-базы (#174) — 28 зелёных. Полный набор ai-chat зелёный (185). `tsc` чисто, `prettier --check` чисто. ## Ревью #160: два прохода ревью-сабагента (первый нашёл реальный баг ложной пометки «генерируется» — исправлено + регресс-тест; повторный — APPROVE). #174: проход ревью-сабагента — APPROVE WITH SUGGESTIONS (поправлены кавычки `"unsaved"` под prettier; реактивность/отсутствие per-delta ре-рендеров/зависимости `handleCopy` — без замечаний). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-24 15:03:24 +03:00
"Copy chat" built the Markdown from persisted rows plus a live tail that was
only included while isStreaming. When a turn was interrupted (dropped stream /
"Lost connection" banner) isStreaming flipped false, the live tail was dropped,
and the partial assistant reply visible on screen — whose row often never
persisted — vanished from the export, leaving only the user messages.

- buildChatMarkdown is now live-first: the on-screen `live` messages ARE the
  document. Each is matched to a persisted row by id to enrich it with token
  usage / error / timestamp; authoritative usage/error already on the live
  message win over the row. When `live` is empty it falls back to the persisted
  rows (old format preserved). Only the tail assistant is flagged "still
  generating", and only when it is genuinely the streaming tail — so the
  status==="submitted" window (tail is the user message) never mislabels the
  previous, completed answer.
- The on-screen banner (classified error / dropped connection / manual stop) is
  flattened to a string in ChatThread, mirrored into liveStateRef alongside the
  messages/isStreaming snapshot, and appended at the end of the export.
- handleCopy maps the live messages and passes live/rows/isStreaming/banner.

Tests: chat-markdown rewritten for the live/enrichment/fallback/banner paths and
the submitted-window regression (26); full ai-chat suite green (186). tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad added the bug label 2026-06-24 20:48:44 +03:00
Ghost force-pushed fix/ai-chat-copy-chat-wysiwyg from 039071852e to df81851eb3 2026-06-25 03:52:21 +03:00 Compare
Ghost changed title from fix(ai-chat): WYSIWYG Copy chat export keeps the on-screen partial reply (#160) to fix(ai-chat): WYSIWYG Copy chat export + first-turn export (#160, #174) 2026-06-25 03:52:53 +03:00
vvzvlad merged commit 176b0f575f into develop 2026-06-25 03:54:35 +03:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#165