feat(comment): Undo в тосте резолва (reopen) + сортировка Resolved по времени резолва (#542) #548

Merged
vvzvlad merged 2 commits from feat/542-resolve-undo into develop 2026-07-12 17:36:10 +03:00
Collaborator

Summary

  1. Undo-кнопка «Отмена» прямо в success-тосте резолва комментария (Undo-паттерн как у удаления страницы): один клик возвращает комментарий в открытые. 2. Resolved-вкладка сортируется по времени резолва (новые сверху) вместо порядка создания. closes #542.

Ключевые решения

  • Тост с Undo — только на резолв (variables.resolved===true), в onSuccess (после подтверждения сервером, НЕ onMutate). Reopen — обычный тост без Undo.
  • Guard от двойного клика: замкнутый per-toast let done=false — reopen шлётся ровно один раз (notifications.hide асинхронный, кнопка кликабельна кадр-два).
  • Доступ к editor/мутации из замыкания тоста (исходный CommentListItem к клику уже размонтирован): editorRef (живой pageEditorAtom, обновляется каждый рендер) + mutationRef (объявлен до useMutation, присвоен после, читается в момент клика — цикла нет). Снятие инлайн-метки под !isDestroyed+try/catch (тост живёт 10с, панель может закрыться).
  • 404-ветка (терминальная, до generic-отката): комментарий реально удалён → removeCommentFromCache + unsetComment (безусловно, иначе висячая .comment-mark) + нейтральное Comment no longer exists; НЕ откатывать (фантом в Resolved не воскресает), НЕ показывать Undo.
  • Направленная copy ошибки: Failed to re-open comment для reopen vs Failed to resolve comment для резолва (было всегда «resolve»).
  • Сортировка Resolved — копией, new Date(x.resolvedAt).getTime() DESC (обязательно new Date(): тип Date, но в рантайме ISO-строка из axios-JSON/WS — сырой .getTime() даёт NaN/throw). Open не тронут.
  • RESOLVE_UNDO_AUTOCLOSE_MS = 10000.

How verified (на стенде)

  • Клиент comment suite 104/104 (7 файлов). Новый comment-query.resolve-undo.test.tsx (6): Undo только на резолв + autoClose=10000 (крит 1,2,5); guard шлёт reopen ровно раз + скрывает тост + снимает метку (крит 3); 404 → drop из кэша + unsetComment + нейтрал, без отката/Undo (крит 4); 404 при editor=null не падает (крит 7); направленная copy (крит 6). comment-list-with-tabs.test.tsx +4: ISO-string DESC (крит 8), Date-instance, без мутации входа, стабильность равных меток; Open не тронут (крит 9). tsc 0.
  • Внутренний цикл: 1 проход + адверсариальный ревью с мутациями (снять guard → двойной reopen краснеет; сырой .getTime() → ISO-sort краснеет; 404 data-integrity зелёный) → APPROVE.
  • i18n: 2 новых ключа (Comment no longer exists, Failed to re-open comment) в en-US + ru-RU. node_modules 0.

Осознанная граница scope / SUGGESTION

  • Undo НЕ откатывает применённый suggested-edit — это существующая семантика reopen (Undo лишь ещё одна точка входа), нового состояния не вводит.
  • SUGGESTION из ревью: логику снятия метки (404 + Undo) можно вынести в локальный хелпер — косметика, на корректность не влияет.
  • Только видимые в браузере пиксели тоста / серверная конвергенция метки через COMMENT_MARK_UPDATE — вне юнит-контракта (тесты проверяют проводку/эффекты, не рендер Mantine).

Checklist

  • критерии приёмки 1-9 из #542 выполнены
  • серверных изменений нет (reopen-эндпоинт и 404-контракт уже есть); вне scope ничего; node_modules нет
## Summary 1. **Undo-кнопка «Отмена» прямо в success-тосте резолва комментария** (Undo-паттерн как у удаления страницы): один клик возвращает комментарий в открытые. 2. **Resolved-вкладка сортируется по времени резолва (новые сверху)** вместо порядка создания. closes #542. ## Ключевые решения - Тост с Undo — только на резолв (`variables.resolved===true`), в **`onSuccess`** (после подтверждения сервером, НЕ `onMutate`). Reopen — обычный тост без Undo. - **Guard от двойного клика**: замкнутый per-toast `let done=false` — reopen шлётся ровно один раз (`notifications.hide` асинхронный, кнопка кликабельна кадр-два). - Доступ к editor/мутации из замыкания тоста (исходный `CommentListItem` к клику уже размонтирован): `editorRef` (живой `pageEditorAtom`, обновляется каждый рендер) + `mutationRef` (объявлен до `useMutation`, присвоен после, читается в момент клика — цикла нет). Снятие инлайн-метки под `!isDestroyed`+try/catch (тост живёт 10с, панель может закрыться). - **404-ветка (терминальная, до generic-отката)**: комментарий реально удалён → `removeCommentFromCache` + `unsetComment` (безусловно, иначе висячая `.comment-mark`) + нейтральное `Comment no longer exists`; НЕ откатывать (фантом в Resolved не воскресает), НЕ показывать Undo. - Направленная copy ошибки: `Failed to re-open comment` для reopen vs `Failed to resolve comment` для резолва (было всегда «resolve»). - Сортировка Resolved — **копией**, `new Date(x.resolvedAt).getTime()` DESC (обязательно `new Date()`: тип `Date`, но в рантайме ISO-строка из axios-JSON/WS — сырой `.getTime()` даёт NaN/throw). Open не тронут. - `RESOLVE_UNDO_AUTOCLOSE_MS = 10000`. ## How verified (на стенде) - Клиент comment suite **104/104** (7 файлов). Новый `comment-query.resolve-undo.test.tsx` (6): Undo только на резолв + autoClose=10000 (крит 1,2,5); guard шлёт reopen ровно раз + скрывает тост + снимает метку (крит 3); 404 → drop из кэша + `unsetComment` + нейтрал, без отката/Undo (крит 4); 404 при editor=null не падает (крит 7); направленная copy (крит 6). `comment-list-with-tabs.test.tsx` +4: ISO-string DESC (крит 8), Date-instance, без мутации входа, стабильность равных меток; Open не тронут (крит 9). `tsc` 0. - Внутренний цикл: 1 проход + адверсариальный ревью с мутациями (снять guard → двойной reopen краснеет; сырой `.getTime()` → ISO-sort краснеет; 404 data-integrity зелёный) → APPROVE. - i18n: 2 новых ключа (`Comment no longer exists`, `Failed to re-open comment`) в en-US + ru-RU. node_modules 0. ## Осознанная граница scope / SUGGESTION - Undo НЕ откатывает применённый suggested-edit — это существующая семантика reopen (Undo лишь ещё одна точка входа), нового состояния не вводит. - SUGGESTION из ревью: логику снятия метки (404 + Undo) можно вынести в локальный хелпер — косметика, на корректность не влияет. - Только видимые в браузере пиксели тоста / серверная конвергенция метки через `COMMENT_MARK_UPDATE` — вне юнит-контракта (тесты проверяют проводку/эффекты, не рендер Mantine). ## Checklist - [x] критерии приёмки 1-9 из #542 выполнены - [x] серверных изменений нет (reopen-эндпоинт и 404-контракт уже есть); вне scope ничего; node_modules нет
agent_coder added 1 commit 2026-07-12 05:51:23 +03:00
Part 1 (#542): add an inline Undo (reopen) button to the resolve success
toast in useResolveCommentMutation. Undo re-invokes the same mutation via a
self-ref (declared before useMutation, read at click time — no cycle) and
clears the inline comment mark through an editor ref (survives the originating
CommentListItem unmounting). A closured `done` flag guards a fast double-click
(notifications.hide is async). Reopen keeps the plain toast (no Undo).

onError gains a terminal 404 branch: drop the comment from the cache and
unsetComment its orphaned mark (no rollback → no phantom in Resolved, neutral
"Comment no longer exists"). The generic-failure copy is now directional
(resolve vs. re-open). Autoclose policy const RESOLVE_UNDO_AUTOCLOSE_MS=10000.

Part 2: sort the Resolved tab by resolvedAt DESC via an exported
sortResolvedByResolvedAt helper; coerce with new Date(...) because resolvedAt
is an ISO string at runtime (Date only in the optimistic window).

i18n: add "Failed to re-open comment" and "Comment no longer exists" to
en-US/ru-RU. Tests: 6 resolve-undo cases + 4 sort cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-12 05:51:25 +03:00
Collaborator

Ревью — Undo в тосте резолва + сортировка Resolved (#542). Вердикт: changes-requested

Работа аккуратная: toast-closure переживает unmount CommentListItem корректно (RQ v5 зовёт onSuccess/onError даже после unmount observer; editorRef/mutationRef живы, читаются в момент клика — без stale-closure/цикла), double-click guard (done синхронно) и sort ([...resolved].sort — КОПИЯ, не мутирует RQ-кэш; DESC, стабильна, callers пре-фильтруют truthy resolvedAt) — оба залочены мутацией. i18n в обоих локалях, authz сервер перепроверяет. Regressions/test-coverage/conventions/security/simplification — LGTM. Эскалаций нет.

Открыто:

  • F1 — Undo чистит inline-марку РАНЬШЕ подтверждения reopen → расхождение doc↔панель на фейле reopen.
📋 F1 — детали

F1 [warning · stability/coherence] eager mark-clear vs откат onError — apps/client/src/features/comment/queries/comment-query.ts:534-541

Undo-onClick чистит inline-марку СРАЗУ (ed.commands.setCommentResolved(commentId, false), :537) в тот же момент, что запускает reopen-мутацию — ДО того как известен результат reopen. Если reopen падает non-404 (5xx/сеть): onError (:462-466) откатывает RQ-кэш обратно в resolved (коммент остаётся в Resolved-табе), НО марка в ProseMirror/collab-доке уже перевёрнута в resolved=false и задиспатчена. Итог: документ показывает активный (unresolved) хайлайт для коммента, который панель числит resolved, и сервер ничего не коммитнул (500) → нечему примирить; марка живёт в доке, не в comments-query, поэтому рефетч комментов НЕ лечит (и, будучи collab-шагом, может разъехаться и у других клиентов).

Асимметрично с устоявшимся БЕЗОПАСНЫМ паттерном comment-list-item.tsx:108-118 handleResolveComment: там await mutateAsync() и setCommentResolved ТОЛЬКО после успеха → на фейле марка не тронута. Триггер: любой network/5xx на reopen после клика Undo. Это прямой ответ на крюкс «reopen и mark-clear происходят, но НЕ остаются консистентны на фейле».

Fix: убрать eager-блок setCommentResolved(commentId,false) из onClick (:534-541); чистить марку в reopen-ветке onSuccess — в if (!variables.resolved) (:493): прочитать editorRef.current, если есть и !isDestroyeded.commands.setCommentResolved(variables.commentId,false) в try/catch (зеркалит 404-ветку :445-452). Это привязывает смену марки к серверному подтверждению, откат-на-фейле бесплатно. Обычный кнопочный reopen и так ставит марку сам — лишний вызов там идемпотентный no-op. Добавь тест: reopen ПАДАЕТ → марка НЕ оставлена очищенной (doc↔панель консистентны).

9 аспектов (2 агента + мой разбор крюкса). Гейт: client vitest 19/19 (double-click guard + sort — мутации краснеют). Дерево после мутаций чисто. База develop 03eafa6c. кодеру НЕ делать: unmount не прогнан литерально через unmount() (рисковый путь всё равно драйвится; дельта — библиотечное поведение RQ) + null-editorRef degrade-ветка не покрыта — below-threshold, но добавь во второй тест при фиксе F1.

## Ревью — Undo в тосте резолва + сортировка Resolved (#542). Вердикт: **changes-requested** Работа **аккуратная**: toast-closure переживает unmount `CommentListItem` корректно (RQ v5 зовёт onSuccess/onError даже после unmount observer; `editorRef`/`mutationRef` живы, читаются в момент клика — без stale-closure/цикла), double-click guard (`done` синхронно) и sort (`[...resolved].sort` — КОПИЯ, не мутирует RQ-кэш; DESC, стабильна, callers пре-фильтруют truthy resolvedAt) — оба залочены мутацией. i18n в обоих локалях, authz сервер перепроверяет. Regressions/test-coverage/conventions/security/simplification — LGTM. Эскалаций нет. Открыто: - **F1 — Undo чистит inline-марку РАНЬШЕ подтверждения reopen → расхождение doc↔панель на фейле reopen.** <details> <summary>📋 F1 — детали</summary> ### F1 [warning · stability/coherence] eager mark-clear vs откат onError — `apps/client/src/features/comment/queries/comment-query.ts:534-541` Undo-onClick чистит inline-марку СРАЗУ (`ed.commands.setCommentResolved(commentId, false)`, `:537`) в тот же момент, что запускает reopen-мутацию — ДО того как известен результат reopen. Если reopen падает non-404 (5xx/сеть): `onError` (`:462-466`) откатывает RQ-кэш обратно в resolved (коммент остаётся в Resolved-табе), НО марка в ProseMirror/collab-доке уже перевёрнута в `resolved=false` и задиспатчена. Итог: документ показывает активный (unresolved) хайлайт для коммента, который панель числит resolved, и сервер ничего не коммитнул (500) → нечему примирить; марка живёт в доке, не в comments-query, поэтому рефетч комментов НЕ лечит (и, будучи collab-шагом, может разъехаться и у других клиентов). Асимметрично с устоявшимся БЕЗОПАСНЫМ паттерном `comment-list-item.tsx:108-118` `handleResolveComment`: там `await mutateAsync()` и `setCommentResolved` ТОЛЬКО после успеха → на фейле марка не тронута. Триггер: любой network/5xx на reopen после клика Undo. Это прямой ответ на крюкс «reopen и mark-clear происходят, но НЕ остаются консистентны на фейле». **Fix:** убрать eager-блок `setCommentResolved(commentId,false)` из onClick (`:534-541`); чистить марку в reopen-ветке `onSuccess` — в `if (!variables.resolved)` (`:493`): прочитать `editorRef.current`, если есть и `!isDestroyed` — `ed.commands.setCommentResolved(variables.commentId,false)` в try/catch (зеркалит 404-ветку `:445-452`). Это привязывает смену марки к серверному подтверждению, откат-на-фейле бесплатно. Обычный кнопочный reopen и так ставит марку сам — лишний вызов там идемпотентный no-op. Добавь тест: reopen ПАДАЕТ → марка НЕ оставлена очищенной (doc↔панель консистентны). <sub>9 аспектов (2 агента + мой разбор крюкса). Гейт: client vitest 19/19 (double-click guard + sort — мутации краснеют). Дерево после мутаций чисто. База develop `03eafa6c`. ⛔ кодеру НЕ делать: unmount не прогнан литерально через `unmount()` (рисковый путь всё равно драйвится; дельта — библиотечное поведение RQ) + null-editorRef degrade-ветка не покрыта — below-threshold, но добавь во второй тест при фиксе F1.</sub> </details>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-12 06:44:44 +03:00
agent_coder added 1 commit 2026-07-12 06:56:27 +03:00
Move the inline ProseMirror comment-mark clear out of the Undo toast's
onClick and into the reopen mutation's onSuccess branch. Clearing it
eagerly flipped the collab mark to unresolved before the reopen result
was known: on a non-404 failure the RQ cache rolls back to resolved but
the doc kept an active highlight the panel treats as resolved, and a
comment refetch can't heal a divergence that lives in the collab doc.
Mirrors the 404 branch's editor-liveness guard/try-catch and the safe
await-then-mark pattern in comment-list-item. The button-triggered
reopen already sets the mark, so the onSuccess call is an idempotent
no-op there.

Tests: reopen failure (500) leaves the mark untouched; null editorRef
on the success path degrades gracefully.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Collaborator

Ответ на ревью (F1)

F1 [warning · stability/coherence] — eager mark-clear vs откат onError — закрыто.

Причина: Undo-onClick чистил inline-марку СРАЗУ (ed.commands.setCommentResolved(commentId,false)) в момент клика, ДО подтверждения reopen. При non-404 фейле (5xx/сеть) onError откатывал RQ-кэш обратно в resolved, но марка в ProseMirror/collab-доке уже была перевёрнута — документ показывал активный хайлайт для коммента, который панель числит resolved, и рефетч комментов это не лечил (марка живёт в доке, не в comments-query).

Фикс (comment-query.ts): eager-блок удалён из onClick целиком (остались только триггер reopen-мутации, guard двойного клика done и notifications.hide). Очистка марки перенесена в reopen-ветку onSuccess — внутри if (!variables.resolved): читаем editorRef.current, проверяем !isDestroyed, вызываем setCommentResolved(variables.commentId,false) в try/catch — структурно идентично 404-ветке. Теперь смена марки привязана к серверному подтверждению: на пути ошибки марка не трогается вовсе, откат бесплатен, doc↔панель консистентны. На пути resolve (variables.resolved===true) не срабатывает — ошибочного снятия марки при резолве нет. Обычный кнопочный reopen и так ставит марку сам → лишний вызов идемпотентный no-op (конвергентная транзакция).

Тесты (comment-query.resolve-undo.test.tsx, +2):

  1. reopen через Undo ПАДАЕТ (500) → setCommentResolved(...,false) НЕ вызван, RQ-кэш откатывается в resolved (ассерт на факт-невызова, не на отсутствие тоста).
  2. null editorRef на success-пути → graceful no-op (без throw, reopen-тост показан, кэш reopened).
    Существующий тест «марка снята при УСПЕШНОМ reopen» по-прежнему зелёный (теперь через onSuccess).

Верификация: comment-feature suite 106/106 (resolve-undo 8/8, было 6). Мутация (вернуть eager-clear в onClick) → краснеет ровно fail-path тест; восстановлено → зелено. Скоуп коммита: comment-query.ts + тест, 0 node_modules. Ветка на уровне develop (0 позади), конфликтов нет.

внутренний цикл: 1 проход внутреннего ревью на этот фикс — APPROVE, изоляция подтверждена мутацией, happy-path и guard'ы двойного клика/hide сохранены.

Новый head: d36021a1.

## Ответ на ревью (F1) **F1 [warning · stability/coherence] — eager mark-clear vs откат onError — закрыто.** Причина: Undo-onClick чистил inline-марку СРАЗУ (`ed.commands.setCommentResolved(commentId,false)`) в момент клика, ДО подтверждения reopen. При non-404 фейле (5xx/сеть) `onError` откатывал RQ-кэш обратно в `resolved`, но марка в ProseMirror/collab-доке уже была перевёрнута — документ показывал активный хайлайт для коммента, который панель числит resolved, и рефетч комментов это не лечил (марка живёт в доке, не в comments-query). Фикс (`comment-query.ts`): eager-блок удалён из onClick целиком (остались только триггер reopen-мутации, guard двойного клика `done` и `notifications.hide`). Очистка марки перенесена в reopen-ветку `onSuccess` — внутри `if (!variables.resolved)`: читаем `editorRef.current`, проверяем `!isDestroyed`, вызываем `setCommentResolved(variables.commentId,false)` в try/catch — структурно идентично 404-ветке. Теперь смена марки привязана к серверному подтверждению: на пути ошибки марка не трогается вовсе, откат бесплатен, doc↔панель консистентны. На пути resolve (`variables.resolved===true`) не срабатывает — ошибочного снятия марки при резолве нет. Обычный кнопочный reopen и так ставит марку сам → лишний вызов идемпотентный no-op (конвергентная транзакция). Тесты (`comment-query.resolve-undo.test.tsx`, +2): 1. reopen через Undo ПАДАЕТ (500) → `setCommentResolved(...,false)` НЕ вызван, RQ-кэш откатывается в resolved (ассерт на факт-невызова, не на отсутствие тоста). 2. null `editorRef` на success-пути → graceful no-op (без throw, reopen-тост показан, кэш reopened). Существующий тест «марка снята при УСПЕШНОМ reopen» по-прежнему зелёный (теперь через onSuccess). **Верификация:** comment-feature suite **106/106** (resolve-undo 8/8, было 6). Мутация (вернуть eager-clear в onClick) → краснеет ровно fail-path тест; восстановлено → зелено. Скоуп коммита: `comment-query.ts` + тест, 0 node_modules. Ветка на уровне develop (0 позади), конфликтов нет. **внутренний цикл:** 1 проход внутреннего ревью на этот фикс — APPROVE, изоляция подтверждена мутацией, happy-path и guard'ы двойного клика/hide сохранены. Новый head: `d36021a1`.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 06:56:53 +03:00
Collaborator

Ре-ревью — resolve Undo/sort (#542), раунд 2. Вердикт: approved

F1 (расхождение doc↔панель на фейле reopen) закрыт, проверил адверсариально:

  • Eager mark-clear убран из onClick — теперь клик только mutate({resolved:false}) + notifications.hide, с комментом «mark cleared in onSuccess, NOT here — clearing eagerly would desync doc from panel if reopen fails».
  • Марка чистится в onSuccess reopen-ветке (comment-query.ts:499-502, if(!variables.resolved)setCommentResolved(commentId,false) с editor-liveness guard + try/catch) — ПОСЛЕ серверного подтверждения. Кнопочный reopen уже ставит марку сам → идемпотентный no-op.
  • Тест на фейл-путь добавлен: «reopen via Undo FAILS (non-404) → inline mark is NOT left cleared (doc↔panel stay consistent)». Мутация-локк: вернул eager-clear в onClick (пре-фикс баг) → ровно этот тест краснеет. Не вакуумен.

Гейт: client vitest 21/21 (было 19 + 2 фейл-теста). Дерево после мутаций чисто. На фейле reopen кэш и марка теперь остаются консистентны. Готово к мержу.

## Ре-ревью — resolve Undo/sort (#542), раунд 2. Вердикт: **approved** ✅ F1 (расхождение doc↔панель на фейле reopen) закрыт, проверил адверсариально: - **Eager mark-clear убран из onClick** — теперь клик только `mutate({resolved:false})` + `notifications.hide`, с комментом «mark cleared in onSuccess, NOT here — clearing eagerly would desync doc from panel if reopen fails». - **Марка чистится в onSuccess reopen-ветке** (`comment-query.ts:499-502`, `if(!variables.resolved)` → `setCommentResolved(commentId,false)` с editor-liveness guard + try/catch) — ПОСЛЕ серверного подтверждения. Кнопочный reopen уже ставит марку сам → идемпотентный no-op. - **Тест на фейл-путь добавлен:** «reopen via Undo FAILS (non-404) → inline mark is NOT left cleared (doc↔panel stay consistent)». **Мутация-локк:** вернул eager-clear в onClick (пре-фикс баг) → ровно этот тест краснеет. Не вакуумен. **Гейт:** client vitest **21/21** (было 19 + 2 фейл-теста). Дерево после мутаций чисто. На фейле reopen кэш и марка теперь остаются консистентны. Готово к мержу.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 07:07:40 +03:00
vvzvlad merged commit 490d7965a1 into develop 2026-07-12 17:36:10 +03:00
Sign in to join this conversation.