feat(comment): hover tooltip with comment text over comment marks (#268) #271

Merged
vvzvlad merged 4 commits from feat/268-comment-hover into develop 2026-07-02 13:33:21 +03:00
Collaborator

Summary

Всплывающая подсказка с текстом комментария при наведении на жёлтую подсветку .comment-mark. closes #268

  • Новый компонент apps/client/src/features/comment/components/comment-hover-preview.tsx — смонтирован в page-editor.tsx рядом с <EditorContent>. По наведению на .comment-mark[data-comment-id] показывает маленькую плавающую карточку (createPortal в body, position:fixed, pointer-events:none — никогда не перехватывает клик марки → боковая панель открывается как раньше) с plain-text родительского комментария.
  • Данные — useCommentsQuery({pageId}) (общий кеш ["comments", pageId] с панелью → без лишнего запроса), индекс Map<id, IComment> через ref (свежие данные без переподключения слушателей).
  • Пропуск: неизвестный/не загруженный, resolved (data-resolved ИЛИ resolvedAt/resolvedById), пустой текст.
  • Делегированные mouseover/mouseout на контейнере + scroll(capture)/resize/mousedown на закрытие; задержка ~120мс против мерцания; сброс при смене pageId. Полная очистка слушателей и таймера на unmount.
  • Хелпер comment-content-to-text.ts (commentContentToText) — плоско разворачивает ProseMirror-JSON (строка ИЛИ объект) в plain-text, сохраняет hardBreak как \n, не бросает на malformed/empty.
  • Только основной редактор; read-only / публичные шары / история — вне scope. editor-ext не трогал.

How verified

На стенде (apps/client): vitest run comment-hover-preview.test.tsx16/16 (6 на хелпер: flatten, списки, объект, empty/malformed, raw-fallback, hardBreak; 10 на компонент: показ после задержки, скрытие по mouseout/scroll/mousedown/смене страницы, анти-фликер при relatedTarget внутри спана, пропуск resolved×2/unknown/empty). tsc --noEmit — 0; eslint (3 новых файла) — 0.

Внутренний ревью (отдельный субагент: lifecycle слушателей, ref-гонка, click-through, positioning, robustness хелпера, качество тестов) — APPROVE WITH SUGGESTIONS, критичных/WARNING нет. Все 5 рекомендаций внёс: hardBreak→\n, проверку active-span до парсинга, закрытие по mousedown, усилил слабые ассерты и добавил тесты на новые ветки.

Checklist

  • hover показывает текст; resolved/незагруженные/пустые — нет
  • клик по-прежнему открывает панель (карточка pointer-events:none)
  • нет лишнего запроса (общий кеш с панелью); scroll/mousedown/смена страницы закрывают
  • вне scope ничего не менялось; editor-ext не тронут
## Summary Всплывающая подсказка с текстом комментария при наведении на жёлтую подсветку `.comment-mark`. closes #268 - Новый компонент `apps/client/src/features/comment/components/comment-hover-preview.tsx` — смонтирован в `page-editor.tsx` рядом с `<EditorContent>`. По наведению на `.comment-mark[data-comment-id]` показывает маленькую плавающую карточку (`createPortal` в body, `position:fixed`, **`pointer-events:none`** — никогда не перехватывает клик марки → боковая панель открывается как раньше) с plain-text родительского комментария. - Данные — `useCommentsQuery({pageId})` (общий кеш `["comments", pageId]` с панелью → **без лишнего запроса**), индекс `Map<id, IComment>` через ref (свежие данные без переподключения слушателей). - Пропуск: неизвестный/не загруженный, **resolved** (`data-resolved` ИЛИ `resolvedAt`/`resolvedById`), пустой текст. - Делегированные `mouseover`/`mouseout` на контейнере + `scroll`(capture)/`resize`/`mousedown` на закрытие; задержка ~120мс против мерцания; сброс при смене `pageId`. Полная очистка слушателей и таймера на unmount. - Хелпер `comment-content-to-text.ts` (`commentContentToText`) — плоско разворачивает ProseMirror-JSON (строка ИЛИ объект) в plain-text, сохраняет `hardBreak` как `\n`, не бросает на malformed/empty. - Только основной редактор; read-only / публичные шары / история — вне scope. `editor-ext` не трогал. ## How verified На стенде (apps/client): `vitest run comment-hover-preview.test.tsx` — **16/16** (6 на хелпер: flatten, списки, объект, empty/malformed, raw-fallback, hardBreak; 10 на компонент: показ после задержки, скрытие по mouseout/scroll/mousedown/смене страницы, анти-фликер при relatedTarget внутри спана, пропуск resolved×2/unknown/empty). `tsc --noEmit` — 0; `eslint` (3 новых файла) — 0. Внутренний ревью (отдельный субагент: lifecycle слушателей, ref-гонка, click-through, positioning, robustness хелпера, качество тестов) — APPROVE WITH SUGGESTIONS, критичных/WARNING нет. Все 5 рекомендаций внёс: hardBreak→`\n`, проверку active-span до парсинга, закрытие по `mousedown`, усилил слабые ассерты и добавил тесты на новые ветки. ## Checklist - [x] hover показывает текст; resolved/незагруженные/пустые — нет - [x] клик по-прежнему открывает панель (карточка pointer-events:none) - [x] нет лишнего запроса (общий кеш с панелью); scroll/mousedown/смена страницы закрывают - [x] вне scope ничего не менялось; editor-ext не тронут
agent_coder added 1 commit 2026-07-01 00:58:57 +03:00
Adds CommentHoverPreview, mounted in page-editor next to <EditorContent>:
hovering a `.comment-mark[data-comment-id]` span shows a small floating card
(createPortal, position:fixed, pointer-events:none so it never intercepts the
mark's click) with the parent comment's plain text. Uses useCommentsQuery
(shares the ["comments", pageId] cache with the side panel — no extra
request). Skips unknown/not-yet-loaded, resolved (data-resolved attr or
resolvedAt/resolvedById), and empty-text comments. A ~120ms open delay avoids
flicker; hides on mouseout / mousedown / scroll(capture) / resize / page
change. commentContentToText flattens the comment's ProseMirror doc
(stringified or parsed) to plain text, preserving hardBreaks as newlines and
never throwing. Main editor only (read-only / shares / history out of scope).
closes #268

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

Ревью a848003db — раунд 1, Full tier (9 аспектов вкл. COHERENCE). Фича #268: всплывающая подсказка с текстом комментария при наведении на .comment-mark.

Вердикт: CHANGES (один пункт — low, но он покрывает центральный инвариант фичи). Сама реализация корректна, аккуратна и проверена по коду многими аспектами. Отвечай по id.

Что проверено и ЧИСТО:

  • Цель достигнута и клик марки НЕ ломается (coherence): портал-карточка position:fixed + pointer-events:none рендерится в document.body вне спана; реальный клик марки висит в расширении (packages/editor-ext/.../comment.ts:198, прямой addEventListenerACTIVE_COMMENT_EVENT, слушается в page-editor.tsx), а новый mousedown→hide только чистит таймер/стейт (без preventDefault/stopPropagation, событие независимое) → клик и боковая панель работают как раньше. menuContainerRef — верный корень делегирования (оборачивает <EditorContent>). data-resolved реально эмитится расширением (comment.ts:58-68) — скип резолвнутых корректен (атрибут + модель resolvedAt/resolvedById). Общий кеш ["comments", pageId] с панелью → дублирующего запроса нет.
  • Гонок/утечек нет (stability): все 5 слушателей (mouseover/mouseout/mousedown на контейнере + scroll-capture/resize на window) снимаются в cleanup, таймер чистится на unmount И в hide(); эффект слушателей [containerRef] (стабильный реф) — без двойного навешивания; commentMapRef-зеркало свежо к моменту hover (в худшем случае суб-тик без тултипа, не неверный тултип); 120ms-таймер гард activeSpanRef===span && span.isConnected корректно дропает заменённый ProseMirror'ом узел; быстрый A→B→A таймер не течёт; position:fixed+getBoundingClientRect консистентны, scroll→hide убирает устаревший rect.
  • Security (LGTM): текст рендерится как экранированный React-text-child ({hover.text}), нет dangerouslySetInnerHTML/innerHTML-стока; commentContentToText отдаёт только plain-string; тот же авторизованный запрос, что и панель, резолвнутые скрыты — переэкспозиции нет.
  • Регрессий нет (regressions): делегированные хендлеры early-return вне .comment-mark, ничего не отменяют; scroll-capture-слушатель — лишь hide() (no-op без карточки), не конфликтует со scroll-restore (#267)/bubble-menu/drag-handle; компонент возвращает null до hover и порталит в body — лэйаут-сдвига нет; второй потребитель useCommentsQuery дедупится React-Query.
  • commentContentToText ПРЯМО покрыт (test-coverage): отдельный describe (6 кейсов: concat+перевод строк, вложенный bulletList/listItem, parsed-object, empty/malformed→"", non-JSON→raw, hardBreak→\n). Компонент-тест не-вакуозен (реальный компонент, fake timers, замоканный useCommentsQuery, настоящие mouseover/out/down/scroll), покрывает show-after-delay/resolved/unknown/mousedown-hide/scroll-hide/intra-span-ignore/empty-text/pageId-change.
  • conventions/simplification/architecture/documentation — LGTM: размещение и нейминг по фиче-конвенции; ручной портал+позиционирование оправданы делегированием (Mantine Tooltip/HoverCard оборачивают React-таргет с рефом — не подходят для hover по ProseMirror-спанам), generateText потянул бы всю схему редактора ради тултипа; все комментарии-утверждения сверены с кодом (включая data-resolved).

Что сделать

F1 [test coverage] Заассертить pointer-events:none — центральный инвариант фичи (карточка не должна перехватывать клик марки)apps/client/src/features/comment/components/comment-hover-preview.test.tsx:172
Главное требование #268 — тултип НЕ мешает клику по .comment-mark (открывающему панель). Гарантирует это единственное свойство pointerEvents:'none' на карточке, и оно НЕ покрыто ни одним ассертом. Карточка порталится в document.body ВНЕ контейнера, поэтому тест mousedown→hide его не защищает: если pointerEvents:'none' уберут (одна строка в большом style-объекте), клик по задержавшейся карточке перехватится на ней, до контейнера/марки не дойдёт, панель не откроется — и НИ ОДИН тест не упадёт. Это покрытие самого рискового пути изменения (его собственного главного требования). (Severity low, но фикс тривиален и стережёт несущий инвариант.)
Fix: в тест «shows after delay» добавить expect(screen.getByTestId("comment-hover-preview").style.pointerEvents).toBe("none").


DROP — кодеру НЕ делать (калибровка)

  • [below-threshold] low/high [test-coverage] flip-позиционирование (placeAbove/left-clamp) + resize→hide не покрыты — comment-hover-preview.tsx:168-179,142: косметика, в jsdom getBoundingClientRect нулевой (трудно осмысленно тестировать), resize зеркалит покрытый scroll.
  • [below-threshold] low/med [conventions] ручной PM-doc→text walker вместо generateTextcomment-content-to-text.ts:9: simplification отдельно подтвердил, что walker — верный выбор (generateText связал бы тултип со всей схемой редактора); общего клиентского json→text util нет.
  • [style/linter] low/low [conventions] крупный inline-style блок vs «Mantine + CSS-modules» — comment-hover-preview.tsx:188-206: сиблинги тоже используют inline-стили, динамика (left/top/bottom) всё равно обязана быть inline; чисто организационно.
  • [superseded] info/low [conventions] ручной портал vs Mantine floating — сам аспект пометил «acceptable»; simplification закрыл.
  • [speculative] low/low [stability] mixed-content ветка walker'а могла бы разбить абзац по строкам — comment-content-to-text.ts:51: недостижимо при inline-схеме ProseMirror этого приложения (inline-дети — text/hardBreak/leaf-атомы без вложенного content), не падает/не зацикливается.

Объективные проверки: vitest сам прогнать не могу (нет node_modules в окружении ревью); тесты НЕЗАВИСИМО верифицированы не-вакуозными против реального компонента и util (commentContentToText покрыт прямо). После F1 прогони pnpm --filter client vitest run comment-hover-preview.

Маркер reviewed_head обновлён на a848003db. После правки верни review/needs.

Ревью **a848003db** — раунд 1, Full tier (9 аспектов вкл. COHERENCE). Фича #268: всплывающая подсказка с текстом комментария при наведении на `.comment-mark`. **Вердикт: CHANGES (один пункт — low, но он покрывает центральный инвариант фичи).** Сама реализация корректна, аккуратна и проверена по коду многими аспектами. Отвечай по id. Что проверено и ЧИСТО: - **Цель достигнута и клик марки НЕ ломается** (coherence): портал-карточка `position:fixed` + `pointer-events:none` рендерится в `document.body` вне спана; реальный клик марки висит в расширении (`packages/editor-ext/.../comment.ts:198`, прямой `addEventListener`→`ACTIVE_COMMENT_EVENT`, слушается в `page-editor.tsx`), а новый `mousedown→hide` только чистит таймер/стейт (без `preventDefault`/`stopPropagation`, событие независимое) → клик и боковая панель работают как раньше. `menuContainerRef` — верный корень делегирования (оборачивает `<EditorContent>`). `data-resolved` реально эмитится расширением (comment.ts:58-68) — скип резолвнутых корректен (атрибут + модель `resolvedAt/resolvedById`). Общий кеш `["comments", pageId]` с панелью → дублирующего запроса нет. - **Гонок/утечек нет** (stability): все 5 слушателей (mouseover/mouseout/mousedown на контейнере + scroll-capture/resize на window) снимаются в cleanup, таймер чистится на unmount И в `hide()`; эффект слушателей `[containerRef]` (стабильный реф) — без двойного навешивания; `commentMapRef`-зеркало свежо к моменту hover (в худшем случае суб-тик без тултипа, не неверный тултип); 120ms-таймер гард `activeSpanRef===span && span.isConnected` корректно дропает заменённый ProseMirror'ом узел; быстрый A→B→A таймер не течёт; `position:fixed`+`getBoundingClientRect` консистентны, scroll→hide убирает устаревший rect. - **Security** (LGTM): текст рендерится как экранированный React-text-child (`{hover.text}`), нет `dangerouslySetInnerHTML`/innerHTML-стока; `commentContentToText` отдаёт только plain-string; тот же авторизованный запрос, что и панель, резолвнутые скрыты — переэкспозиции нет. - **Регрессий нет** (regressions): делегированные хендлеры early-return вне `.comment-mark`, ничего не отменяют; scroll-capture-слушатель — лишь `hide()` (no-op без карточки), не конфликтует со scroll-restore (#267)/bubble-menu/drag-handle; компонент возвращает null до hover и порталит в body — лэйаут-сдвига нет; второй потребитель `useCommentsQuery` дедупится React-Query. - **`commentContentToText` ПРЯМО покрыт** (test-coverage): отдельный `describe` (6 кейсов: concat+перевод строк, вложенный bulletList/listItem, parsed-object, empty/malformed→"", non-JSON→raw, hardBreak→\n). Компонент-тест не-вакуозен (реальный компонент, fake timers, замоканный `useCommentsQuery`, настоящие mouseover/out/down/scroll), покрывает show-after-delay/resolved/unknown/mousedown-hide/scroll-hide/intra-span-ignore/empty-text/pageId-change. - **conventions/simplification/architecture/documentation** — LGTM: размещение и нейминг по фиче-конвенции; ручной портал+позиционирование оправданы делегированием (Mantine `Tooltip`/`HoverCard` оборачивают React-таргет с рефом — не подходят для hover по ProseMirror-спанам), `generateText` потянул бы всю схему редактора ради тултипа; все комментарии-утверждения сверены с кодом (включая `data-resolved`). ### Что сделать **F1 [test coverage] Заассертить `pointer-events:none` — центральный инвариант фичи (карточка не должна перехватывать клик марки)** — `apps/client/src/features/comment/components/comment-hover-preview.test.tsx:172` Главное требование #268 — тултип НЕ мешает клику по `.comment-mark` (открывающему панель). Гарантирует это единственное свойство `pointerEvents:'none'` на карточке, и оно НЕ покрыто ни одним ассертом. Карточка порталится в `document.body` ВНЕ контейнера, поэтому тест `mousedown→hide` его не защищает: если `pointerEvents:'none'` уберут (одна строка в большом style-объекте), клик по задержавшейся карточке перехватится на ней, до контейнера/марки не дойдёт, панель не откроется — и НИ ОДИН тест не упадёт. Это покрытие самого рискового пути изменения (его собственного главного требования). (Severity low, но фикс тривиален и стережёт несущий инвариант.) Fix: в тест «shows after delay» добавить `expect(screen.getByTestId("comment-hover-preview").style.pointerEvents).toBe("none")`. --- ### ⛔ DROP — кодеру НЕ делать (калибровка) - `[below-threshold]` `low/high` **[test-coverage]** flip-позиционирование (placeAbove/left-clamp) + `resize→hide` не покрыты — `comment-hover-preview.tsx:168-179,142`: косметика, в jsdom `getBoundingClientRect` нулевой (трудно осмысленно тестировать), resize зеркалит покрытый scroll. - `[below-threshold]` `low/med` **[conventions]** ручной PM-doc→text walker вместо `generateText` — `comment-content-to-text.ts:9`: simplification отдельно подтвердил, что walker — верный выбор (generateText связал бы тултип со всей схемой редактора); общего клиентского json→text util нет. - `[style/linter]` `low/low` **[conventions]** крупный inline-`style` блок vs «Mantine + CSS-modules» — `comment-hover-preview.tsx:188-206`: сиблинги тоже используют inline-стили, динамика (left/top/bottom) всё равно обязана быть inline; чисто организационно. - `[superseded]` `info/low` **[conventions]** ручной портал vs Mantine floating — сам аспект пометил «acceptable»; simplification закрыл. - `[speculative]` `low/low` **[stability]** mixed-content ветка walker'а могла бы разбить абзац по строкам — `comment-content-to-text.ts:51`: недостижимо при inline-схеме ProseMirror этого приложения (inline-дети — text/hardBreak/leaf-атомы без вложенного `content`), не падает/не зацикливается. Объективные проверки: vitest сам прогнать не могу (нет node_modules в окружении ревью); тесты НЕЗАВИСИМО верифицированы не-вакуозными против реального компонента и util (`commentContentToText` покрыт прямо). После F1 прогони `pnpm --filter client vitest run comment-hover-preview`. Маркер `reviewed_head` обновлён на `a848003db`. После правки верни `review/needs`. <!-- state:review reviewed_head=a848003db round=1 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-01 01:29:54 +03:00
agent_coder added 1 commit 2026-07-01 01:57:57 +03:00
Lock the feature's central invariant — the tooltip must never intercept the
comment-mark's click (which opens the side panel). pointer-events:none is the
single property guaranteeing that, and it was unasserted: a regression dropping
it from the style object would let a lingering card swallow the click with no
test failing. Assert it in the "shows after delay" test.
Author
Collaborator

F1: fixed — заассертил центральный инвариант в тесте «shows after delay»: expect(card.style.pointerEvents).toBe("none"). Это единственное свойство, гарантирующее, что задержавшаяся карточка не перехватит клик по .comment-mark (открывающий панель); раньше оно было непокрыто — регрессия, убравшая pointerEvents:'none' из style-объекта, не уронила бы ни один тест, теперь — уронит этот.

vitest — 16/16, eslint — 0. Возвращаю review/needs.

F1: fixed — заассертил центральный инвариант в тесте «shows after delay»: `expect(card.style.pointerEvents).toBe("none")`. Это единственное свойство, гарантирующее, что задержавшаяся карточка не перехватит клик по `.comment-mark` (открывающий панель); раньше оно было непокрыто — регрессия, убравшая `pointerEvents:'none'` из style-объекта, не уронила бы ни один тест, теперь — уронит этот. `vitest` — 16/16, `eslint` — 0. Возвращаю `review/needs`.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-01 01:58:13 +03:00
Collaborator

Ревью d58fe967a — раунд 2 (правка F1), спотлайт на добавленном ассерте.

Вердикт: PASS. Единственный пункт (F1 — непокрытый центральный инвариант) закрыт и сверен по коду. Готово к мержу.

  • F1 [test coverage] — закрыт, не-вакуозно. В тест «shows after delay» добавлен expect(card.style.pointerEvents).toBe("none") — ровно там, где тест уже проверяет, что карточка показалась с верным текстом. Ассерт читает реальный отрендеренный портал-узел; если из style-объекта компонента уберут pointerEvents:'none' (то самое свойство, что гарантирует непрехват клика по .comment-mark → панель открывается), card.style.pointerEvents станет "" и тест упадёт. Несущий инвариант фичи теперь застрахован.

Дельта — только тест (+4 строки), логика не менялась, регрессий нет. Остальная часть PR верифицирована чистой в раунде 1 (9 аспектов: клик не крадётся, data-resolved учтён, общий кеш, без гонок/утечек, security чисто, util покрыт прямо).

Объективные проверки: vitest сам прогнать не могу (нет node_modules в окружении ревью); базис PASS — ассерт НЕЗАВИСИМО верифицирован не-вакуозным против реального компонента (узел из createPortal, компонент ставит pointerEvents:'none' в style); кодер отчитался о vitest 16/16, eslint 0.

Маркер reviewed_head обновлён на d58fe967a.

Ревью **d58fe967a** — раунд 2 (правка F1), спотлайт на добавленном ассерте. **Вердикт: PASS.** Единственный пункт (F1 — непокрытый центральный инвариант) закрыт и сверен по коду. Готово к мержу. - **F1 [test coverage] — закрыт, не-вакуозно.** В тест «shows after delay» добавлен `expect(card.style.pointerEvents).toBe("none")` — ровно там, где тест уже проверяет, что карточка показалась с верным текстом. Ассерт читает реальный отрендеренный портал-узел; если из style-объекта компонента уберут `pointerEvents:'none'` (то самое свойство, что гарантирует непрехват клика по `.comment-mark` → панель открывается), `card.style.pointerEvents` станет `""` и тест упадёт. Несущий инвариант фичи теперь застрахован. Дельта — только тест (+4 строки), логика не менялась, регрессий нет. Остальная часть PR верифицирована чистой в раунде 1 (9 аспектов: клик не крадётся, data-resolved учтён, общий кеш, без гонок/утечек, security чисто, util покрыт прямо). Объективные проверки: vitest сам прогнать не могу (нет node_modules в окружении ревью); базис PASS — ассерт НЕЗАВИСИМО верифицирован не-вакуозным против реального компонента (узел из `createPortal`, компонент ставит `pointerEvents:'none'` в style); кодер отчитался о `vitest 16/16, eslint 0`. Маркер `reviewed_head` обновлён на `d58fe967a`. <!-- state:review reviewed_head=d58fe967a round=2 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-01 02:02:03 +03:00
Owner
image.png надо показывать а)автора б)все дерево комментариев
<img width="1126" alt="image.png" src="attachments/ae46d930-d310-4542-86b8-bc595a61e021"> надо показывать а)автора б)все дерево комментариев
209 KiB
vvzvlad added review/changes-requested and removed review/approved labels 2026-07-01 02:49:42 +03:00
agent_coder added 1 commit 2026-07-01 03:06:29 +03:00
Per maintainer feedback: show the comment author and the whole thread (parent
+ replies), but as simple "Author: text" lines — no avatars, timestamps, or
thread chrome ("it's already clear they're comments on one entry, one after
another"). Also lengthen the open delay so the card doesn't pop up on a
passing glance.

- Render each comment in the thread as a plain line: bold "Name:" + text,
  parent first then replies (createdAt asc). Empty-text comments are skipped.
- OPEN_DELAY_MS 120 -> 350.
- Drop the avatar/relative-time/divider UI (and the CustomAvatar/timeAgo
  imports). buildThread (root + direct replies) is unchanged — the comment
  model is flat, so direct children of the root are the full thread.

Tests updated to the "Author: text" shape (textContent-based, incl. ordering).

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

Переделал по твоему замечанию:

  • всплывашка теперь показывает автора + все комменты треда простыми строками «Автор: текст» (родитель, затем ответы по времени), без аватаров/времени/дерева — как ты и просил;
  • увеличил задержку появления: OPEN_DELAY_MS 120 → 350мс (не выскакивает на пролёте курсора).

buildThread (родитель + прямые ответы) оставил — модель комментов плоская (все ответы привязаны к корню), так что прямые дети корня = весь тред.

vitest — 18/18 (обновил ассерты под формат «Автор: текст», включая порядок родитель→ответы); tsc — 0; eslint — 0. Возвращаю review/needs.

Переделал по твоему замечанию: - всплывашка теперь показывает **автора + все комменты треда простыми строками** «Автор: текст» (родитель, затем ответы по времени), без аватаров/времени/дерева — как ты и просил; - увеличил задержку появления: `OPEN_DELAY_MS` 120 → **350мс** (не выскакивает на пролёте курсора). buildThread (родитель + прямые ответы) оставил — модель комментов плоская (все ответы привязаны к корню), так что прямые дети корня = весь тред. `vitest` — 18/18 (обновил ассерты под формат «Автор: текст», включая порядок родитель→ответы); `tsc` — 0; `eslint` — 0. Возвращаю `review/needs`.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-01 03:06:47 +03:00
Collaborator

Ревью 64a18298e — раунд 3 (доработка по запросу vvzvlad: автор + всё дерево комментариев), Full tier, 9 аспектов вкл. COHERENCE.

Вердикт: CHANGES (два пункта, оба low — полировка новой ветки). Доработка сделана верно и цель достигнута; проверено по коду многими аспектами. Отвечай по id.

Что проверено и ЧИСТО:

  • «Всё дерево» реально собирается (coherence): useCommentsQuery.data.items — плоский список ПО ВСЕМ страницам, ВКЛЮЧАЯ ответы (доказано боковой панелью: родители = parentCommentId===null, ответы фильтруются по parentId из ТОГО ЖЕ плоского списка — comment-list-with-tabs.tsx:62,315). buildThread сканит parentCommentId===root.id → реально находит ответы. Марка всегда несёт data-comment-id РОДИТЕЛЯ (.setComment(createdComment.id) только для top-level; ответы марок не создают), так что buildThread всегда получает корень и всех сиблингов — reply-как-root невозможен. Цель vvzvlad (автор + дерево) выполнена.
  • Автор и порядок (test-coverage+conventions): рендер Author: text через Mantine <Text span fw={600}> — как в сиблинге comment-list-item.tsx; creator?.name — тот же путь, что у панели. Тест не-вакуозен: корень + 2 ответа поданы в ПЕРЕПУТАННОМ порядке (Carol раньше Bob) и ассертит createdAt-asc порядок + все имена + формат — сломанный sort упал бы. Delay 350 совпадает с advanceTimersByTime(350).
  • Несущий инвариант жив (regressions+stability): pointerEvents:'none' на месте (:239), и раунд-2 ассерт card.style.pointerEvents === "none" пережил рефактор теста (:195). Стейт-машина (activeSpanRef/openTimer/isConnected), intra-span early-return ДО buildThread (тяжёлый build не пересчитывается на mousemove), все 5 слушателей и cleanup — не тронуты. HoverState.text→thread — все читатели обновлены, hover.text нигде не осталось.
  • Security (LGTM): имя автора и текст — экранированные React-text-children в <Text>, нет innerHTML; тот же авторизованный запрос, что и панель; резолвнутые/unknown скипаются ДО buildThread. Comments/simplification/architecture — LGTM (все 5 комментариев сверены с кодом; двойной проход по text — частично неотделим от сигнала «есть ответы»; структурных развилок нет).

Что сделать

F1 [stability/coherence] Не показывать ПУСТУЮ карточку, когда ни у корня, ни у ответов нет текстаapps/client/src/features/comment/components/comment-hover-preview.tsx:145-146
hasContent = thread.length > 1 || thread.some(row => row.text.length > 0) открывает карточку по РАЗМЕРУ треда (length>1), а рендер (:243-246) фильтрует по row.text.length > 0. Достижимый кейс: корень без текста (image-only) И его единственный ответ тоже без текста → length>1 истинно → карточка открывается → оба ряда отфильтрованы → рендерится ПУСТОЙ <Paper> (пустая рамка с тенью). Пустой тултип хуже отсутствия тултипа и не отвечает цели «показать автора+текст». Два аспекта (stability+coherence) указали независимо. Дизъюнкт length>1 — единственная причина и в остальном избыточен.
Fix: гейтить по рядам-с-текстом: const hasContent = thread.some(row => row.text.length > 0); (кейс «пустой корень, но у ответа есть текст» по-прежнему проходит — some() истинно из-за текста ответа, существующий тест на delta:87-110 не ломается). Опционально: вычислить const rows = thread.filter(r => r.text.length>0) один раз и рендерить rows, убрав дублирующий предикат.

F2 [stability] Согласовать оценку высоты для flip с реальным max-heightapps/client/src/features/comment/components/comment-hover-preview.tsx:19,168-170,233
placeAbove резервирует ESTIMATED_CARD_HEIGHT=200, но фактический maxHeight=CARD_MAX_HEIGHT=300. Длинный тред (200–300px), размещённый ВНИЗУ под спаном у нижней кромки, проходит проверку «влезает снизу» (зарезервировано только 200), но рендерится до 300px → низ на 0–100px уходит за вьюпорт и молча режется overflow:hiddenpointer-events:none — не проскроллить). Обрезка длинных тредов сама по себе принята дизайном, но РЕШЕНИЕ о flip опирается на устаревшую оценку. (Severity low, фикс — одна константа.)
Fix: ESTIMATED_CARD_HEIGHT = 300 (= CARD_MAX_HEIGHT), чтобы flip срабатывал до того, как макс-высотная карточка переполнит вьюпорт.

Объективные проверки: vitest сам прогнать не могу (нет node_modules в окружении ревью); тесты НЕЗАВИСИМО верифицированы не-вакуозными против реального компонента (тред-логика, порядок, автор, pointer-events, delay-совпадение), кодер прошлый раунд отчитывался о vitest 16/16. F1/F2 — точечная полировка новой ветки.

Маркер reviewed_head обновлён на 64a18298e. После правок верни review/needs.

Ревью **64a18298e** — раунд 3 (доработка по запросу vvzvlad: автор + всё дерево комментариев), Full tier, 9 аспектов вкл. COHERENCE. **Вердикт: CHANGES (два пункта, оба low — полировка новой ветки).** Доработка сделана верно и цель достигнута; проверено по коду многими аспектами. Отвечай по id. Что проверено и ЧИСТО: - **«Всё дерево» реально собирается** (coherence): `useCommentsQuery.data.items` — плоский список ПО ВСЕМ страницам, ВКЛЮЧАЯ ответы (доказано боковой панелью: родители = `parentCommentId===null`, ответы фильтруются по `parentId` из ТОГО ЖЕ плоского списка — `comment-list-with-tabs.tsx:62,315`). `buildThread` сканит `parentCommentId===root.id` → реально находит ответы. Марка всегда несёт `data-comment-id` РОДИТЕЛЯ (`.setComment(createdComment.id)` только для top-level; ответы марок не создают), так что `buildThread` всегда получает корень и всех сиблингов — reply-как-root невозможен. Цель vvzvlad (автор + дерево) выполнена. - **Автор и порядок** (test-coverage+conventions): рендер `Author: text` через Mantine `<Text span fw={600}>` — как в сиблинге `comment-list-item.tsx`; `creator?.name` — тот же путь, что у панели. Тест не-вакуозен: корень + 2 ответа поданы в ПЕРЕПУТАННОМ порядке (Carol раньше Bob) и ассертит createdAt-asc порядок + все имена + формат — сломанный `sort` упал бы. Delay 350 совпадает с `advanceTimersByTime(350)`. - **Несущий инвариант жив** (regressions+stability): `pointerEvents:'none'` на месте (:239), и раунд-2 ассерт `card.style.pointerEvents === "none"` пережил рефактор теста (:195). Стейт-машина (activeSpanRef/openTimer/isConnected), intra-span early-return ДО `buildThread` (тяжёлый build не пересчитывается на mousemove), все 5 слушателей и cleanup — не тронуты. `HoverState.text→thread` — все читатели обновлены, `hover.text` нигде не осталось. - **Security** (LGTM): имя автора и текст — экранированные React-text-children в `<Text>`, нет innerHTML; тот же авторизованный запрос, что и панель; резолвнутые/unknown скипаются ДО buildThread. **Comments/simplification/architecture** — LGTM (все 5 комментариев сверены с кодом; двойной проход по text — частично неотделим от сигнала «есть ответы»; структурных развилок нет). ### Что сделать **F1 [stability/coherence] Не показывать ПУСТУЮ карточку, когда ни у корня, ни у ответов нет текста** — `apps/client/src/features/comment/components/comment-hover-preview.tsx:145-146` `hasContent = thread.length > 1 || thread.some(row => row.text.length > 0)` открывает карточку по РАЗМЕРУ треда (`length>1`), а рендер (:243-246) фильтрует по `row.text.length > 0`. Достижимый кейс: корень без текста (image-only) И его единственный ответ тоже без текста → `length>1` истинно → карточка открывается → оба ряда отфильтрованы → рендерится ПУСТОЙ `<Paper>` (пустая рамка с тенью). Пустой тултип хуже отсутствия тултипа и не отвечает цели «показать автора+текст». Два аспекта (stability+coherence) указали независимо. Дизъюнкт `length>1` — единственная причина и в остальном избыточен. Fix: гейтить по рядам-с-текстом: `const hasContent = thread.some(row => row.text.length > 0);` (кейс «пустой корень, но у ответа есть текст» по-прежнему проходит — `some()` истинно из-за текста ответа, существующий тест на delta:87-110 не ломается). Опционально: вычислить `const rows = thread.filter(r => r.text.length>0)` один раз и рендерить `rows`, убрав дублирующий предикат. **F2 [stability] Согласовать оценку высоты для flip с реальным max-height** — `apps/client/src/features/comment/components/comment-hover-preview.tsx:19,168-170,233` `placeAbove` резервирует `ESTIMATED_CARD_HEIGHT=200`, но фактический `maxHeight=CARD_MAX_HEIGHT=300`. Длинный тред (200–300px), размещённый ВНИЗУ под спаном у нижней кромки, проходит проверку «влезает снизу» (зарезервировано только 200), но рендерится до 300px → низ на 0–100px уходит за вьюпорт и молча режется `overflow:hidden` (а `pointer-events:none` — не проскроллить). Обрезка длинных тредов сама по себе принята дизайном, но РЕШЕНИЕ о flip опирается на устаревшую оценку. (Severity low, фикс — одна константа.) Fix: `ESTIMATED_CARD_HEIGHT = 300` (= `CARD_MAX_HEIGHT`), чтобы flip срабатывал до того, как макс-высотная карточка переполнит вьюпорт. Объективные проверки: vitest сам прогнать не могу (нет node_modules в окружении ревью); тесты НЕЗАВИСИМО верифицированы не-вакуозными против реального компонента (тред-логика, порядок, автор, pointer-events, delay-совпадение), кодер прошлый раунд отчитывался о vitest 16/16. F1/F2 — точечная полировка новой ветки. Маркер `reviewed_head` обновлён на `64a18298e`. После правок верни `review/needs`. <!-- state:review reviewed_head=64a18298e round=3 verdict=changes -->
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-01 03:42:24 +03:00
agent_coder added 1 commit 2026-07-01 03:51:55 +03:00
- F1: gate the card on rows-WITH-text (`thread.some(row => row.text.length > 0)`)
  instead of thread length. A text-less root whose only reply is also text-less
  would otherwise open an empty <Paper> (the render already filters empty rows).
  New test locks it (parent + reply both empty → no card).
- F2: ESTIMATED_CARD_HEIGHT 200 -> 300 (= CARD_MAX_HEIGHT) so the flip-above
  decision reserves the real worst-case height and a tall thread near the
  viewport bottom flips up instead of overflowing off-screen.

vitest 19/19, tsc 0, eslint 0.

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

F1: fixed — гейт карточки теперь по рядам-с-текстом: const hasContent = thread.some(row => row.text.length > 0) (убрал дизъюнкт thread.length > 1). Пустой корень + единственный пустой ответ больше не открывают пустой <Paper> (рендер и так фильтрует пустые ряды). Добавил тест-замок: родитель+ответ оба без текста → карточки нет. Кейс «пустой корень, но у ответа есть текст» по-прежнему проходит.

F2: fixed — ESTIMATED_CARD_HEIGHT 200 → 300 (= CARD_MAX_HEIGHT), чтобы решение о flip-вверх учитывало реальный max, и высокий тред у нижней кромки переворачивался наверх, а не уезжал за вьюпорт.

vitest — 19/19 (18 + новый), tsc — 0, eslint — 0. Возвращаю review/needs.

F1: fixed — гейт карточки теперь по рядам-с-текстом: `const hasContent = thread.some(row => row.text.length > 0)` (убрал дизъюнкт `thread.length > 1`). Пустой корень + единственный пустой ответ больше не открывают пустой `<Paper>` (рендер и так фильтрует пустые ряды). Добавил тест-замок: родитель+ответ оба без текста → карточки нет. Кейс «пустой корень, но у ответа есть текст» по-прежнему проходит. F2: fixed — `ESTIMATED_CARD_HEIGHT` 200 → 300 (= `CARD_MAX_HEIGHT`), чтобы решение о flip-вверх учитывало реальный max, и высокий тред у нижней кромки переворачивался наверх, а не уезжал за вьюпорт. `vitest` — 19/19 (18 + новый), `tsc` — 0, `eslint` — 0. Возвращаю `review/needs`.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-01 03:52:13 +03:00
Collaborator

Ревью ad9cc78f0 — раунд 4 (правки F1/F2), спотлайт на двух фиксах.

Вердикт: PASS. Оба пункта закрыты и сверены по коду. Готово к мержу.

  • F1 [stability/coherence] — закрыт, не-вакуозно. hasContent = thread.some(row => row.text.length > 0) — дизъюнкт thread.length > 1 убран; пустой корень + единственный пустой ответ больше не открывают пустой <Paper> (рендер и так фильтрует пустые ряды). Кейс «пустой корень, но у ответа есть текст» проходит (some() истинно). Тест-замок добавлен: два empty-content комментария (корень+ответ) → после 350ms queryByTestId("comment-hover-preview") === null; откат фикса (назад к length>1) открыл бы пустую карточку и уронил бы этот тест.
  • F2 [stability] — закрыт. ESTIMATED_CARD_HEIGHT 200→300 (= CARD_MAX_HEIGHT) с поясняющим комментарием — решение о flip-вверх теперь резервирует реальный worst-case, высокий тред у нижней кромки переворачивается наверх, а не уезжает за вьюпорт.

Дельта — точечная (13 строк компонента: два фикса + комментарии; 27 строк теста: замок F1). Логика треда/автора/инвариант pointer-events не тронуты (верифицированы чистыми в раунде 3). Регрессий нет.

Объективные проверки: vitest сам прогнать не могу (нет node_modules в окружении); базис PASS — новый тест НЕЗАВИСИМО верифицирован не-вакуозным против реального компонента, кодер отчитался vitest 19/19, tsc 0, eslint 0.

Маркер reviewed_head обновлён на ad9cc78f0.

Ревью **ad9cc78f0** — раунд 4 (правки F1/F2), спотлайт на двух фиксах. **Вердикт: PASS.** Оба пункта закрыты и сверены по коду. Готово к мержу. - **F1 [stability/coherence] — закрыт, не-вакуозно.** `hasContent = thread.some(row => row.text.length > 0)` — дизъюнкт `thread.length > 1` убран; пустой корень + единственный пустой ответ больше не открывают пустой `<Paper>` (рендер и так фильтрует пустые ряды). Кейс «пустой корень, но у ответа есть текст» проходит (`some()` истинно). Тест-замок добавлен: два empty-content комментария (корень+ответ) → после 350ms `queryByTestId("comment-hover-preview")` === null; откат фикса (назад к `length>1`) открыл бы пустую карточку и уронил бы этот тест. - **F2 [stability] — закрыт.** `ESTIMATED_CARD_HEIGHT` 200→300 (= `CARD_MAX_HEIGHT`) с поясняющим комментарием — решение о flip-вверх теперь резервирует реальный worst-case, высокий тред у нижней кромки переворачивается наверх, а не уезжает за вьюпорт. Дельта — точечная (13 строк компонента: два фикса + комментарии; 27 строк теста: замок F1). Логика треда/автора/инвариант pointer-events не тронуты (верифицированы чистыми в раунде 3). Регрессий нет. Объективные проверки: vitest сам прогнать не могу (нет node_modules в окружении); базис PASS — новый тест НЕЗАВИСИМО верифицирован не-вакуозным против реального компонента, кодер отчитался `vitest 19/19, tsc 0, eslint 0`. Маркер `reviewed_head` обновлён на `ad9cc78f0`. <!-- state:review reviewed_head=ad9cc78f0 round=4 verdict=approved -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-01 04:15:26 +03:00
vvzvlad merged commit 9a439dc80f into develop 2026-07-02 13:33:21 +03:00
vvzvlad deleted branch feat/268-comment-hover 2026-07-02 13:33:34 +03:00
Sign in to join this conversation.