From d542fc58ae7a11c65cb977f4d297b3ad97df32f8 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 12 Jul 2026 19:30:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(comments):=20=D0=B8=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=80=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D0=B9=D0=BD=D0=B0=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B5=D0=B2=20(=D0=BE=D0=B1=D0=B5=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BA=D0=B8)=20(#561)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the NewDesign/CommentsPanel prototype onto the entire live comment panel, both card types (agent suggested-edit + human/agent thread) on the new visual, reusing existing mutations/gates/editor/menu/resolve/anchor-nav (not forked), zero backend changes. New: agent-edit-card, severity.ts, group-agent-runs.ts. Delete only the CommentsPanel prototype (TimeWorkedModal/PageHistoryModal belong to #566/#568 and are deleted by their own PRs). Co-Authored-By: Claude Opus 4.8 (1M context) --- NewDesign/CommentsPanel.tsx | 460 ------------------ .../components/agent-edit-card.test.tsx | 187 +++++++ .../comment/components/agent-edit-card.tsx | 400 +++++++++++++++ .../components/comment-list-item.test.tsx | 119 ----- .../comment/components/comment-list-item.tsx | 174 ++----- .../components/comment-list-with-tabs.tsx | 144 +++++- .../comment/components/comment.module.css | 47 -- .../comment/utils/group-agent-runs.test.ts | 95 ++++ .../comment/utils/group-agent-runs.ts | 76 +++ .../features/comment/utils/severity.test.ts | 47 ++ .../src/features/comment/utils/severity.ts | 44 ++ 11 files changed, 998 insertions(+), 795 deletions(-) delete mode 100644 NewDesign/CommentsPanel.tsx create mode 100644 apps/client/src/features/comment/components/agent-edit-card.test.tsx create mode 100644 apps/client/src/features/comment/components/agent-edit-card.tsx create mode 100644 apps/client/src/features/comment/utils/group-agent-runs.test.ts create mode 100644 apps/client/src/features/comment/utils/group-agent-runs.ts create mode 100644 apps/client/src/features/comment/utils/severity.test.ts create mode 100644 apps/client/src/features/comment/utils/severity.ts diff --git a/NewDesign/CommentsPanel.tsx b/NewDesign/CommentsPanel.tsx deleted file mode 100644 index 3e1f2d67..00000000 --- a/NewDesign/CommentsPanel.tsx +++ /dev/null @@ -1,460 +0,0 @@ -/** - * CommentsPanel — редизайн панели комментариев с фокусом на агентские правки. - * Mantine v7. Узкая колонка ~340–400px, светлая/тёмная темы. - * - * Реализованные решения (см. README.md): - * - Дифф-первая карточка: цитата = старая строка диффа (без тройного дублирования) - * - Группировка серии прогона под одной шапкой ("Корректор · 28 правок") - * - Пакетное "Accept all" на фронте (по одной под капотом) с полоской прогресса - * - Метки важности/категории парсятся клиентом из тегов "[Корректура][Существенно]" - * - Фильтры по важности/категории - * - Безопасный Dismiss через undo-тост (удаление откладывается на клиенте) - * - Состояния: pending / applied / dismissed / conflict (потерян якорь) - * - Крайние диффы: вставка (␣), удаление, одна буква, дефис→тире, длинный абзац - * - Человеческий тред не деградирует (та же система, другое наполнение) - * - Вкладки Open/Resolved сохранены - */ -import { useMemo, useState, useCallback, useRef } from 'react'; -import { - Box, Group, Stack, Text, Badge, Button, ActionIcon, Tabs, Progress, - Avatar, Tooltip, ScrollArea, useMantineColorScheme, useMantineTheme, -} from '@mantine/core'; -import { notifications } from '@mantine/notifications'; - -/* ─────────────────────────── Типы данных ─────────────────────────── */ - -export type Severity = 'critical' | 'major' | 'minor'; -export type Category = string; // "Корректура" | "Факт" | "Стиль" | … - -/** Один сегмент интра-диффа: изменённый фрагмент подсвечивается. */ -export interface DiffSegment { - text: string; - changed: boolean; -} - -/** Правка: сервер уже отдаёт посегментную разметку обеих строк. */ -export interface SuggestedEdit { - before: DiffSegment[]; // "было" (пустой массив ⇒ чистая вставка) - after: DiffSegment[]; // "стало" (пустой массив ⇒ чистое удаление) -} - -export type CommentStatus = 'pending' | 'applied' | 'dismissed' | 'conflict'; - -export interface Comment { - id: string; - runId?: string; // id прогона агента — для группировки серии - authorName: string; // "Корректор" | "Нарратор" | имя человека - authorKind: 'agent' | 'human'; - triggeredBy?: string; // кто запустил агента ("vvzvlad") - createdAtLabel: string; // "15 ч", "2 дн" — форматируется вызывающим кодом - /** Сырой текст комментария от агента, метки в квадратных скобках. */ - bodyRaw: string; - edit?: SuggestedEdit; // есть ⇒ агентская правка; нет ⇒ обычный тред - status: CommentStatus; - replyCount?: number; - anchorLost?: boolean; // сервер ответил конфликтом якоря -} - -export interface CommentsPanelProps { - comments: Comment[]; - /** Применить одну правку. Резолвит тред на сервере. */ - onApply: (id: string) => Promise; - /** Жёсткое удаление на сервере (для треда без ответов). Вызывается ПОСЛЕ окна undo. */ - onDismiss: (id: string) => Promise; - /** Резолв обычного треда. */ - onResolve?: (id: string) => Promise; - /** Клик по карточке/цитате — скролл документа к якорю и подсветка. */ - onNavigateToAnchor: (id: string) => void; - onClose?: () => void; - /** Мс до фактического удаления после Dismiss (окно undo). По умолчанию 5000. */ - dismissUndoMs?: number; -} - -/* ───────────────────── Клиентский парсинг меток ───────────────────── */ - -const SEVERITY_WORDS: Record = { - 'существенно': 'major', 'критично': 'critical', 'критическая': 'critical', - 'незначительно': 'minor', 'мелко': 'minor', 'major': 'major', - 'minor': 'minor', 'critical': 'critical', -}; - -interface ParsedBody { - category?: Category; - severity: Severity; - text: string; // тело без скобочных тегов -} - -/** "[Корректура] [Существенно] Пробел…" → {category, severity, text}. */ -export function parseBody(raw: string): ParsedBody { - const tags: string[] = []; - const text = raw.replace(/\[([^\]]+)\]/g, (_, t) => { tags.push(t.trim()); return ''; }).trim(); - let severity: Severity = 'minor'; - let category: Category | undefined; - for (const t of tags) { - const sv = SEVERITY_WORDS[t.toLowerCase()]; - if (sv) severity = sv; else if (!category) category = t; - } - return { category, severity, text }; -} - -const SEV_COLOR: Record = { - critical: 'red', major: 'orange', minor: 'gray', -}; - -/* ──────────────────────────── Дифф ──────────────────────────── */ - -/** Делает невидимые символы видимыми в подсветке (пробел, таб). */ -function visibleWhitespace(s: string): string { - return s.replace(/ /g, '␣').replace(/\t/g, '⇥'); -} - -function DiffLine({ segments, kind }: { segments: DiffSegment[]; kind: 'del' | 'ins' }) { - const theme = useMantineTheme(); - const { colorScheme } = useMantineColorScheme(); - const dark = colorScheme === 'dark'; - const isDel = kind === 'del'; - const sign = isDel ? '−' : '+'; - const signColor = isDel ? theme.colors.red[dark ? 5 : 6] : theme.colors.green[dark ? 5 : 6]; - const baseColor = isDel - ? (dark ? theme.colors.gray[5] : theme.colors.gray[6]) - : (dark ? theme.colors.gray[1] : theme.colors.dark[9]); - const markBg = isDel - ? (dark ? 'rgba(224,49,49,.22)' : '#ffe3e3') - : (dark ? 'rgba(47,158,68,.22)' : '#d3f9d8'); - const markFg = isDel - ? (dark ? theme.colors.red[3] : theme.colors.red[8]) - : (dark ? theme.colors.green[3] : theme.colors.green[9]); - - return ( - - {sign} - - {segments.map((seg, i) => - seg.changed ? ( - - {visibleWhitespace(seg.text)} - - ) : ( - {seg.text} - ) - )} - - - ); -} - -function DiffBlock({ edit }: { edit: SuggestedEdit }) { - const pureInsert = edit.before.length === 0; - const pureDelete = edit.after.length === 0; - return ( - - {!pureInsert && } - {!pureDelete && } - - ); -} - -/* ──────────────────────── Карточка правки ──────────────────────── */ - -function EditCard({ c, onApply, onDismiss, onNavigateToAnchor, dismissUndoMs = 5000 }: { - c: Comment; - onApply: CommentsPanelProps['onApply']; - onDismiss: CommentsPanelProps['onDismiss']; - onNavigateToAnchor: CommentsPanelProps['onNavigateToAnchor']; - dismissUndoMs?: number; -}) { - const parsed = useMemo(() => parseBody(c.bodyRaw), [c.bodyRaw]); - const [busy, setBusy] = useState(false); - const timer = useRef(); - - const apply = useCallback(async () => { - setBusy(true); - try { await onApply(c.id); } finally { setBusy(false); } - }, [c.id, onApply]); - - // Безопасный Dismiss: прячем сразу, удаляем на сервере после окна undo. - const dismiss = useCallback(() => { - let undone = false; - const nid = notifications.show({ - message: 'Edit dismissed', - color: 'gray', - autoClose: dismissUndoMs, - withCloseButton: false, - // Кнопка "Вернуть" — см. README про кастомный рендер экшена. - }); - timer.current = window.setTimeout(() => { - if (!undone) onDismiss(c.id); - }, dismissUndoMs); - // undo вызывается из UI: clearTimeout(timer.current); undone = true; - return { nid, cancel: () => { undone = true; clearTimeout(timer.current); } }; - }, [c.id, onDismiss, dismissUndoMs]); - - const conflict = c.status === 'conflict' || c.anchorLost; - - return ( - onNavigateToAnchor(c.id)} - style={(t) => ({ - cursor: 'pointer', - borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`, - })} - > - - {c.edit && } - - {conflict && ( - ({ background: t.colorScheme === 'dark' ? 'rgba(230,180,20,.12)' : '#fff9db', borderRadius: 7 })}> - - Text changed — this edit can’t be applied - - )} - - {parsed.text && !conflict && ( - {parsed.text} - )} - - e.stopPropagation()}> - ({ flex: 'none', borderRadius: '50%', background: t.colors[SEV_COLOR[parsed.severity]][6] })} /> - - {[parsed.category, parsed.severity === 'major' ? 'Major' : parsed.severity === 'critical' ? 'Critical' : null].filter(Boolean).join(' · ')} - - - {c.status === 'applied' ? ( - ✓ Applied - ) : c.status === 'dismissed' ? ( - Dismissed - ) : conflict ? ( - - ) : ( - <> - - - - )} - - - - ); -} - -/* ──────────────────────── Человеческий тред ──────────────────────── */ - -function HumanThread({ c, onResolve, onNavigateToAnchor }: { - c: Comment; onResolve?: CommentsPanelProps['onResolve']; onNavigateToAnchor: CommentsPanelProps['onNavigateToAnchor']; -}) { - const parsed = useMemo(() => parseBody(c.bodyRaw), [c.bodyRaw]); - return ( - ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}> - - - {c.authorName[0]} - {c.authorName} - · {c.triggeredBy ? `${c.triggeredBy} · ` : ''}{c.createdAtLabel} - - - onResolve?.(c.id)}>✓ - - - {parsed.text} - - {c.replyCount ? {c.replyCount} replies : null} - - - - - - ); -} - -/* ──────────────────── Шапка серии + прогресс ──────────────────── */ - -function RunHeader({ runComments, onAcceptAll, progress }: { - runComments: Comment[]; - onAcceptAll: () => void; - progress?: { done: number; total: number } | null; -}) { - const head = runComments[0]; - const majors = runComments.filter((c) => parseBody(c.bodyRaw).severity !== 'minor').length; - const applied = runComments.filter((c) => c.status === 'applied').length; - return ( - - ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}> - - {head.authorName[0]} - {head.triggeredBy && ( - - )} - - - {head.authorName} - · {head.triggeredBy} · {head.createdAtLabel} - - - {runComments.length} edits · {majors} major · {applied} applied - - - - {!progress && } - - {progress && ( - ({ background: t.colorScheme === 'dark' ? 'rgba(47,158,68,.08)' : '#f8fbf9', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}> - - Applying {progress.done} of {progress.total}… - - - - - - )} - - ); -} - -/* ──────────────────────────── Панель ──────────────────────────── */ - -/** Роль-автор для фильтра: у агентов — имя роли, у людей — «Пользователь». */ -function roleOf(c: Comment): string { - return c.authorKind === 'human' ? 'Пользователь' : c.authorName; -} - -export function CommentsPanel(props: CommentsPanelProps) { - const { comments, onApply, onDismiss, onResolve, onNavigateToAnchor, onClose, dismissUndoMs } = props; - const [tab, setTab] = useState<'open' | 'resolved'>('open'); - const [roleFilter, setRoleFilter] = useState(null); - const [progress, setProgress] = useState>({}); - - const open = comments.filter((c) => c.status === 'pending' || c.status === 'conflict'); - const resolved = comments.filter((c) => c.status === 'applied' || c.status === 'dismissed'); - const list = tab === 'open' ? open : resolved; - - const filtered = list.filter((c) => !roleFilter || roleOf(c) === roleFilter); - - // Роли и счётчики для чипов-фильтров (только роли: Корректор / Фактчекер / Пользователь …). - const roles = useMemo(() => { - const order: string[] = []; - const count: Record = {}; - for (const c of list) { - const r = roleOf(c); - if (!(r in count)) { count[r] = 0; order.push(r); } - count[r]++; - } - return order.map((r) => ({ role: r, count: count[r] })); - }, [list]); - - // Группировка агентских правок по runId; человеческие треды — по одному. - const groups = useMemo(() => { - const map = new Map(); - const singles: Comment[] = []; - for (const c of filtered) { - if (c.authorKind === 'agent' && c.runId && c.edit) { - if (!map.has(c.runId)) map.set(c.runId, []); - map.get(c.runId)!.push(c); - } else singles.push(c); - } - return { runs: [...map.entries()], singles }; - }, [filtered]); - - // Пакетное "Accept all" — по одной на фронте, обновляем прогресс. - const acceptAll = useCallback(async (runId: string, items: Comment[]) => { - const minor = items.filter((c) => parseBody(c.bodyRaw).severity === 'minor' && c.status === 'pending'); - for (let i = 0; i < minor.length; i++) { - setProgress((p) => ({ ...p, [runId]: { done: i, total: minor.length } })); - try { await onApply(minor[i].id); } catch { /* конфликт — пропускаем, копим для сводки */ } - } - setProgress((p) => { const n = { ...p }; delete n[runId]; return n; }); - notifications.show({ color: 'green', message: `${minor.length} applied` }); - }, [onApply]); - - return ( - - {/* Вкладки — статусная ось. Open/Resolved сохранены. */} - ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}> - setTab(v as 'open' | 'resolved')} variant="pills"> - - {open.length}}>Open - {resolved.length}}>Resolved - - - - {onClose && } - - - {/* Фильтры-чипы: только по ролям (Корректор / Фактчекер / Пользователь). */} - {tab === 'open' && roles.length > 1 && ( - ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}> - - setRoleFilter(null)} label={`All ${list.length}`} solid /> - {roles.map(({ role, count }) => ( - setRoleFilter(roleFilter === role ? null : role)} label={`${role} ${count}`} /> - ))} - - - )} - - {/* Лента */} - - {filtered.length === 0 ? ( - - ) : ( - <> - {groups.runs.map(([runId, items]) => ( - - acceptAll(runId, items)} /> - {items.map((c) => ( - - ))} - - ))} - {groups.singles.map((c) => ( - - {c.edit - ? - : } - - ))} - - )} - - - ); -} - -/* ─────────────────────── Мелкие компоненты ─────────────────────── */ - -function FilterChip({ label, active, onClick, dot, solid }: { - label: string; active: boolean; onClick: () => void; dot?: string; solid?: boolean; -}) { - return ( - - ); -} - -function EmptyState({ tab }: { tab: 'open' | 'resolved' }) { - const isOpen = tab === 'open'; - return ( - - {isOpen ? '✓' : '◌'} - {isOpen ? 'All caught up' : 'Nothing here yet'} - - {isOpen ? 'No edits waiting on you.' : 'Applied and closed items will appear here.'} - - - ); -} - -export default CommentsPanel; diff --git a/apps/client/src/features/comment/components/agent-edit-card.test.tsx b/apps/client/src/features/comment/components/agent-edit-card.test.tsx new file mode 100644 index 00000000..f1722670 --- /dev/null +++ b/apps/client/src/features/comment/components/agent-edit-card.test.tsx @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { IComment } from "@/features/comment/types/comment.types"; + +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. + +// The suggestion mutations reach react-query/network — stub them so the card +// renders in isolation. We assert the Apply/Dismiss gating and that the click +// hands the {commentId, pageId} pair to the existing mutation unchanged. +const applyMutateAsync = vi.fn(); +const dismissMutateAsync = vi.fn(); +vi.mock("@/features/comment/queries/comment-query", () => ({ + useApplySuggestionMutation: () => ({ + mutateAsync: applyMutateAsync, + isPending: false, + }), + useDismissSuggestionMutation: () => ({ + mutateAsync: dismissMutateAsync, + isPending: false, + }), +})); + +// CommentContentView -> mention-view -> page-query/share-query pull in the app +// entry (createRoot) as a side effect; stub the queries so the card renders in +// isolation. +vi.mock("@/features/page/queries/page-query.ts", () => ({ + usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }), +})); +vi.mock("@/features/share/queries/share-query.ts", () => ({ + useSharePageQuery: () => ({ data: undefined }), +})); +// space-query.ts -> main.tsx (createRoot) is a module side effect reached via the +// mention view; stub it so importing the card is side-effect free. +vi.mock("@/features/space/queries/space-query.ts", () => ({ + useSpaceQuery: () => ({ data: undefined }), + useGetSpaceBySlugQuery: () => ({ data: undefined }), +})); + +import AgentEditCard from "./agent-edit-card"; + +const body = (text: string) => + JSON.stringify({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }); + +const edit = (over?: Partial): IComment => + ({ + id: "c-1", + content: body("[Существенно] tighten the wording"), + creatorId: "user-1", + pageId: "page-1", + workspaceId: "ws-1", + createdAt: new Date(), + createdSource: "agent", + aiChatId: "chat-1", + agent: { name: "Corrector", emoji: "✏️", avatarUrl: null }, + launcher: { name: "Alice", avatarUrl: null }, + creator: { id: "user-1", name: "Corrector", avatarUrl: null } as any, + selection: "old wording here", + suggestedText: "new wording here", + ...over, + }) as IComment; + +function renderCard( + comment: IComment, + canEdit = true, + canComment = true, + userSpaceRole?: string, +) { + return render( + + + , + ); +} + +describe("AgentEditCard — suggested edit diff + Apply (#315)", () => { + it("renders the было→стало diff and an Apply button when canEdit, not applied/resolved", () => { + const { container } = renderCard(edit(), true); + // Both diff lines are present (old struck-through, new added). + expect(container.textContent).toContain("old wording here"); + expect(container.textContent).toContain("new wording here"); + // Diff line signs (aria-hidden) present for a replacement. + expect(container.textContent).toContain("−"); + expect(container.textContent).toContain("+"); + expect(screen.getByRole("button", { name: "Apply" })).toBeDefined(); + }); + + it("hides Apply when canEdit is false (still shows the diff)", () => { + const { container } = renderCard(edit(), false); + expect(container.textContent).toContain("new wording here"); + expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); + }); + + it("hides Apply once the thread is resolved", () => { + renderCard(edit({ resolvedAt: new Date() }), true); + expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); + }); + + it("hides Apply once suggestionAppliedAt is set", () => { + renderCard(edit({ suggestionAppliedAt: new Date() }), true); + expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); + }); + + it("calls the apply mutation with {commentId, pageId} on click", () => { + applyMutateAsync.mockClear(); + renderCard(edit(), true); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(applyMutateAsync).toHaveBeenCalledWith({ + commentId: "c-1", + pageId: "page-1", + }); + }); + + it("a pure insertion (empty selection) hides the removed line", () => { + const { container } = renderCard( + edit({ selection: "", suggestedText: "added text" }), + true, + ); + // No "−" del sign — nothing was removed. + expect(container.textContent).not.toContain("−"); + expect(container.textContent).toContain("+"); + }); + + it("a pure deletion (empty suggestedText) hides the added line", () => { + const { container } = renderCard( + edit({ selection: "removed text", suggestedText: "" }), + true, + ); + expect(container.textContent).not.toContain("+"); + expect(container.textContent).toContain("−"); + }); +}); + +describe("AgentEditCard — Dismiss gate (#329/#338)", () => { + it("shows Dismiss alongside Apply for an admin who can edit/comment", () => { + renderCard(edit(), true, true, "admin"); + expect(screen.getByRole("button", { name: "Apply" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined(); + }); + + it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => { + renderCard(edit(), false, true, "admin"); + expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); + expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined(); + }); + + it("hides Dismiss for a non-owner non-admin (mirrors server 403, #338 F5)", () => { + renderCard(edit(), false, true, "member"); + expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull(); + }); + + it("hides Dismiss when the viewer cannot comment", () => { + renderCard(edit(), false, false, "admin"); + expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull(); + }); + + it("calls the dismiss mutation with {commentId, pageId} on click", () => { + dismissMutateAsync.mockClear(); + renderCard(edit(), true, true, "admin"); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(dismissMutateAsync).toHaveBeenCalledWith({ + commentId: "c-1", + pageId: "page-1", + }); + }); +}); + +describe("AgentEditCard — provenance", () => { + it("renders the agent avatar stack (provenance) for the edit author (#300)", () => { + renderCard(edit(), true); + // The agent role name is shown by the provenance stack. + expect(screen.getAllByText("Corrector").length).toBeGreaterThan(0); + }); + + // The owner-or-admin gate uses the currentUser atom; clear localStorage so a + // previous test's seed never leaks into the non-owner assertions above. + beforeEach(() => localStorage.clear()); + afterEach(() => localStorage.clear()); +}); diff --git a/apps/client/src/features/comment/components/agent-edit-card.tsx b/apps/client/src/features/comment/components/agent-edit-card.tsx new file mode 100644 index 00000000..8aecd0b0 --- /dev/null +++ b/apps/client/src/features/comment/components/agent-edit-card.tsx @@ -0,0 +1,400 @@ +import React, { useMemo } from "react"; +import { + Box, + Button, + Group, + Stack, + Text, + useMantineColorScheme, + useMantineTheme, +} from "@mantine/core"; +import { useAtom } from "jotai"; +import { useTranslation } from "react-i18next"; +import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx"; +import CommentContentView from "@/features/comment/components/comment-content-view"; +import { IComment } from "@/features/comment/types/comment.types"; +import { + canShowApply, + canShowDismiss, + computeSuggestionDiff, + Segment, +} from "@/features/comment/utils/suggestion"; +import { commentContentToText } from "@/features/comment/utils/comment-content-to-text"; +import { + isSignificant, + parseSeverity, + Severity, +} from "@/features/comment/utils/severity"; +import { + useApplySuggestionMutation, + useDismissSuggestionMutation, +} from "@/features/comment/queries/comment-query"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { useTimeAgo } from "@/hooks/use-time-ago"; + +// Scroll the document to this comment's inline mark and flash it — the same +// anchor navigation the old panel used (handleCommentClick). Used both by a card +// click and by an explicit "Go to text" affordance. +function scrollToCommentMark(commentId: string) { + const el = document.querySelector( + `.comment-mark[data-comment-id="${commentId}"]`, + ); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + el.classList.add("comment-highlight"); + setTimeout(() => el.classList.remove("comment-highlight"), 3000); + } +} + +// Make otherwise-invisible edited whitespace legible inside a highlighted diff +// fragment (a pure-space insertion/deletion would otherwise look like nothing +// changed). +function visibleWhitespace(s: string): string { + return s.replace(/ /g, "␣").replace(/\t/g, "⇥"); +} + +// One line of the intraline diff. `kind==="del"` is the old ("было") line drawn +// red with a strike-through; `kind==="ins"` is the new ("стало") line drawn +// green. Only the `changed` segments carry the emphasised mark background — the +// common segments read as plain context (git/GitHub-style, #331). +function DiffLine({ + segments, + kind, +}: { + segments: Segment[]; + kind: "del" | "ins"; +}) { + const theme = useMantineTheme(); + const { colorScheme } = useMantineColorScheme(); + const dark = colorScheme === "dark"; + const isDel = kind === "del"; + const sign = isDel ? "−" : "+"; + const signColor = isDel + ? theme.colors.red[dark ? 5 : 6] + : theme.colors.green[dark ? 5 : 6]; + const baseColor = isDel + ? dark + ? theme.colors.gray[5] + : theme.colors.gray[6] + : dark + ? theme.colors.gray[1] + : theme.colors.dark[9]; + const markBg = isDel + ? dark + ? "rgba(224,49,49,.22)" + : "#ffe3e3" + : dark + ? "rgba(47,158,68,.22)" + : "#d3f9d8"; + const markFg = isDel + ? dark + ? theme.colors.red[3] + : theme.colors.red[8] + : dark + ? theme.colors.green[3] + : theme.colors.green[9]; + + return ( + + + {sign} + + + {segments.map((seg, i) => + seg.changed ? ( + + {visibleWhitespace(seg.text)} + + ) : ( + + {seg.text} + + ), + )} + + + ); +} + +// The "было → стало" block. A pure insertion (empty `before`) hides the old +// line; a pure deletion (empty `after`) hides the new line. +function DiffBlock({ before, after }: { before: Segment[]; after: Segment[] }) { + const pureInsert = before.length === 0; + const pureDelete = after.length === 0; + return ( + + {!pureInsert && } + {!pureDelete && } + + ); +} + +const SEV_DOT: Record = { + critical: { color: "red", shade: 6 }, + major: { color: "orange", shade: 6 }, + minor: { color: "gray", shade: 5 }, + // A neutral, deliberately faint dot — NOT the minor grey — so an untagged or + // verdict-only edit never reads as a graded severity. + unknown: { color: "gray", shade: 3 }, +}; + +function SeverityDot({ severity }: { severity: Severity }) { + return ( + ({ + flex: "none", + borderRadius: "50%", + background: t.colors[SEV_DOT[severity].color][SEV_DOT[severity].shade], + })} + /> + ); +} + +interface AgentEditCardProps { + comment: IComment; + canComment: boolean; + canEdit?: boolean; + userSpaceRole?: string; + // Whether to render the per-card agent avatar + timestamp. A card inside a + // collapsed run omits it (the RunHeader carries the one provenance line); + // a standalone card shows it (#300 provenance must be visible on the card). + showProvenance?: boolean; +} + +// Type A — the diff-first agent suggested-edit card. It carries NO TipTap editor +// (a perf win vs. the old row, #340); the body renders through the static +// CommentContentView. Apply/Dismiss reuse the existing mutations and gates +// verbatim — the reskin changes presentation only, not the 409/404/400 toast +// semantics or the authz gating. +function AgentEditCard({ + comment, + canComment, + canEdit, + userSpaceRole, + showProvenance = true, +}: AgentEditCardProps) { + const { t } = useTranslation(); + const [currentUser] = useAtom(currentUserAtom); + const applySuggestionMutation = useApplySuggestionMutation(); + const dismissSuggestionMutation = useDismissSuggestionMutation(); + const createdAtAgo = useTimeAgo(comment.createdAt); + + const diff = useMemo( + () => + computeSuggestionDiff(comment.selection ?? "", comment.suggestedText ?? ""), + [comment.selection, comment.suggestedText], + ); + + const severity = useMemo( + () => parseSeverity(commentContentToText(comment.content)), + [comment.content], + ); + + // Owner-or-space-admin gate (#338), mirrored from the old row so we never + // render a Dismiss the server would 403. + const isOwnerOrAdmin = + currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin"; + + const showApply = canShowApply(comment, canEdit); + const showDismiss = canShowDismiss(comment, canComment, isOwnerOrAdmin); + const pending = + applySuggestionMutation.isPending || dismissSuggestionMutation.isPending; + + const handleApply = async () => { + try { + await applySuggestionMutation.mutateAsync({ + commentId: comment.id, + pageId: comment.pageId, + }); + } catch (error) { + // Errors (incl. 409 "text changed") surface via the mutation's onError. + console.error("Failed to apply suggestion:", error); + } + }; + + const handleDismiss = async () => { + try { + await dismissSuggestionMutation.mutateAsync({ + commentId: comment.id, + pageId: comment.pageId, + }); + } catch (error) { + // Idempotent 404 is reconciled to success in the mutation's onError. + console.error("Failed to dismiss suggestion:", error); + } + }; + + const sevLabel = + severity === "critical" + ? t("Critical") + : severity === "major" + ? t("Major") + : null; + + return ( + scrollToCommentMark(comment.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + scrollToCommentMark(comment.id); + } + }} + style={{ cursor: "pointer" }} + > + + {showProvenance && comment.agent && ( + e.stopPropagation()} + > + + + {createdAtAgo} + + + )} + + + + {/* Agent rationale — rendered through the static ProseMirror view, not + restructured into a flat string. */} + + + + + e.stopPropagation()}> + + {sevLabel && ( + + {sevLabel} + + )} + + {showDismiss && ( + + )} + {showApply && ( + + )} + + + + ); +} + +// Series header for a collapsed agent run: ONE provenance line for N edits, +// with a "N edits · M major" tally. There is intentionally NO "Accept all" +// button / progress / Stop (rejected by product) and NO "K applied" counter +// (uncomputable after the #329 hard-delete). Purely visual. +export function RunHeader({ comments }: { comments: IComment[] }) { + const { t } = useTranslation(); + const head = comments[0]; + const createdAtAgo = useTimeAgo(head?.createdAt); + + const majors = useMemo( + () => + comments.filter((c) => + isSignificant(parseSeverity(commentContentToText(c.content))), + ).length, + [comments], + ); + + if (!head) return null; + + return ( + + + {head.agent ? ( + + ) : ( + + {head.creator?.name} + + )} + + {createdAtAgo} + + + + {t("{{count}} edits", { count: comments.length })} + {majors > 0 && ( + <> + {" · "} + + {t("{{count}} major", { count: majors })} + + + )} + + + ); +} + +export default React.memo(AgentEditCard); diff --git a/apps/client/src/features/comment/components/comment-list-item.test.tsx b/apps/client/src/features/comment/components/comment-list-item.test.tsx index 5b217520..ce90c477 100644 --- a/apps/client/src/features/comment/components/comment-list-item.test.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.test.tsx @@ -156,125 +156,6 @@ describe("CommentListItem — agent avatar stack", () => { // only guards the insertion gate (agent → stack, user → no stack). }); -describe("CommentListItem — suggested edit (#315)", () => { - const suggestion = (over?: Partial): IComment => - baseComment({ - selection: "old wording here", - suggestedText: "new wording here", - ...over, - }); - - it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => { - const { container } = renderItem(suggestion(), true); - // Old text appears as the selection quote (a single unsplit Text node). - expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0); - // The new line is now rendered as per-fragment spans (intraline diff, #331), - // so it is no longer a single text node — assert the concatenated content. - expect(container.textContent).toContain("new wording here"); - // Apply button is present. - expect(screen.getByRole("button", { name: "Apply" })).toBeDefined(); - // No Applied badge yet. - expect(screen.queryByText("Applied")).toBeNull(); - }); - - it("hides the Apply button when canEdit is false", () => { - const { container } = renderItem(suggestion(), false); - // Diff still renders (as per-fragment spans, #331)... - expect(container.textContent).toContain("new wording here"); - // ...but no Apply button. - expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); - }); - - it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => { - renderItem(suggestion({ suggestionAppliedAt: new Date() }), true); - expect(screen.getByText("Applied")).toBeDefined(); - expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); - }); - - it("hides the Apply button once the thread is resolved", () => { - renderItem(suggestion({ resolvedAt: new Date() }), true); - expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); - }); - - it("calls the apply mutation when the Apply button is clicked", () => { - applyMutateAsync.mockClear(); - renderItem(suggestion(), true); - fireEvent.click(screen.getByRole("button", { name: "Apply" })); - expect(applyMutateAsync).toHaveBeenCalledWith({ - commentId: "c-1", - pageId: "page-1", - }); - }); - - it("does not render the diff block for a reply (child) comment", () => { - renderItem( - suggestion({ parentCommentId: "c-0" }), - true, - ); - expect(screen.queryByText("new wording here")).toBeNull(); - expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); - }); -}); - -describe("CommentListItem — dismiss suggestion (#329)", () => { - const suggestion = (over?: Partial): IComment => - baseComment({ - selection: "old wording here", - suggestedText: "new wording here", - ...over, - }); - - // A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate - // regardless of who authored the comment; the tests below use it as the lever - // since the currentUser atom is unseeded (null) in this harness. - it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => { - renderItem(suggestion(), true, true, "admin"); - expect(screen.getByRole("button", { name: "Apply" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined(); - }); - - it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => { - renderItem(suggestion(), false, true, "admin"); - expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); - expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined(); - }); - - it("hides Dismiss when the viewer cannot comment", () => { - renderItem(suggestion(), false, false, "admin"); - expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull(); - expect(screen.queryByRole("button", { name: "Apply" })).toBeNull(); - }); - - it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => { - // canComment=true but NOT a space admin and NOT the comment owner (the - // currentUser atom is null while the comment is authored by user-1), so the - // server would 403 a dismiss — the button must not be shown at all. - renderItem(suggestion(), false, true, "member"); - expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull(); - }); - - it("hides Dismiss once the thread is resolved", () => { - renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin"); - expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull(); - }); - - it("hides Dismiss (shows the Applied badge) once applied", () => { - renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin"); - expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull(); - expect(screen.getByText("Applied")).toBeDefined(); - }); - - it("calls the dismiss mutation when the Dismiss button is clicked", () => { - dismissMutateAsync.mockClear(); - renderItem(suggestion(), true, true, "admin"); - fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); - expect(dismissMutateAsync).toHaveBeenCalledWith({ - commentId: "c-1", - pageId: "page-1", - }); - }); -}); - describe("canShowApply predicate", () => { const c = (over?: Partial): IComment => ({ suggestedText: "x", ...over }) as IComment; diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index 543ed1a1..12f7a74b 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -1,6 +1,6 @@ -import { Group, Text, Box, Badge, Button } from "@mantine/core"; +import { Group, Text, Box } from "@mantine/core"; import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx"; -import React, { useMemo, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import classes from "./comment.module.css"; import { useAtom, useAtomValue } from "jotai"; import { useTimeAgo } from "@/hooks/use-time-ago"; @@ -12,18 +12,11 @@ import CommentMenu from "@/features/comment/components/comment-menu"; import ResolveComment from "@/features/comment/components/resolve-comment"; import { useHover } from "@mantine/hooks"; import { - useApplySuggestionMutation, useDeleteCommentMutation, - useDismissSuggestionMutation, useResolveCommentMutation, useUpdateCommentMutation, } from "@/features/comment/queries/comment-query"; import { IComment } from "@/features/comment/types/comment.types"; -import { - canShowApply, - canShowDismiss, - computeSuggestionDiff, -} from "@/features/comment/utils/suggestion"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; import { useTranslation } from "react-i18next"; @@ -32,13 +25,21 @@ interface CommentListItemProps { comment: IComment; pageId: string; canComment: boolean; - // Real page-edit permission (page.permissions.canEdit) — gates the suggestion - // "Apply" button. Distinct from `canComment`, which may be looser (viewers - // allowed to comment cannot apply edits). + // Real page-edit permission (page.permissions.canEdit). Kept on the props for + // parity with the container's wiring even though the thread row itself no + // longer renders the suggestion Apply button (that moved to AgentEditCard). canEdit?: boolean; userSpaceRole?: string; } +// Type B — the thread ROW. Renders a single human OR agent-without-edit comment +// in the redesigned visual: provenance avatar, author + timeago, hover-revealed +// resolve + edit/delete menu, the anchored selection quote, and the body through +// the static CommentContentView (or the inline TipTap editor while editing). It +// is used for both a top-level thread comment and, recursively, each reply row. +// ALL wiring (update/delete/resolve mutations, owner/admin gate, anchor nav) is +// the same logic the old row carried — only the presentation changed, and the +// agent suggested-edit block was lifted out into AgentEditCard. function CommentListItem({ comment, pageId, @@ -55,29 +56,20 @@ function CommentListItem({ const updateCommentMutation = useUpdateCommentMutation(); const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const resolveCommentMutation = useResolveCommentMutation(); - const applySuggestionMutation = useApplySuggestionMutation(); - const dismissSuggestionMutation = useDismissSuggestionMutation(); const [currentUser] = useAtom(currentUserAtom); const createdAtAgo = useTimeAgo(comment.createdAt); - // Intraline "before -> after" diff (#331) for a suggested edit: only the - // fragments that actually changed get emphasised inside the red/green block, - // instead of striking through / greening the whole line. Memoised on the - // (selection, suggestedText) pair so it recomputes only when they change. - const suggestionDiff = useMemo( - () => - comment.suggestedText != null - ? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText) - : null, - [comment.selection, comment.suggestedText], - ); + // `canEdit`/`pageId` are threaded through for wiring parity with the container; + // the thread row does not itself gate on them (Apply lives on AgentEditCard). + void canEdit; + void pageId; - // Owner-or-space-admin gate (#338): mirrors the server authz for both the - // comment menu (edit/delete) and the suggestion Dismiss button, so we never - // render an action the server will 403. + // Owner-or-space-admin gate (#338): mirrors the server authz for the comment + // menu (edit/delete), so we never render an action the server will 403. const isOwnerOrAdmin = currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin"; + const isAgent = comment.createdSource === "agent" && !!comment.agent; async function handleUpdateComment() { try { @@ -121,34 +113,9 @@ function CommentListItem({ } } - async function handleApplySuggestion() { - try { - await applySuggestionMutation.mutateAsync({ - commentId: comment.id, - pageId: comment.pageId, - }); - } catch (error) { - // Errors surface via the mutation's onError notification (incl. 409). - console.error("Failed to apply suggestion:", error); - } - } - - async function handleDismissSuggestion() { - try { - await dismissSuggestionMutation.mutateAsync({ - commentId: comment.id, - pageId: comment.pageId, - }); - } catch (error) { - // Idempotent races are reconciled to success in the mutation's onError; - // anything else surfaces there as a notification. - console.error("Failed to dismiss suggestion:", error); - } - } - - function handleCommentClick(comment: IComment) { + function handleCommentClick(target: IComment) { const el = document.querySelector( - `.comment-mark[data-comment-id="${comment.id}"]`, + `.comment-mark[data-comment-id="${target.id}"]`, ); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); @@ -169,10 +136,10 @@ function CommentListItem({ return ( - - {comment.createdSource === "agent" && comment.agent ? ( + + {isAgent ? ( )} -
+
- {comment.createdSource === "agent" && comment.agent ? ( + {isAgent ? ( <> - {comment.agent.name} + {comment.agent!.name} {comment.launcher && ( <> @@ -262,87 +229,6 @@ function CommentListItem({ )} - {/* Suggested-edit (#315): "было → стало" diff for a top-level comment - carrying a suggestion. Old text struck-through/red, new text green. */} - {!comment.parentCommentId && comment.suggestedText && ( - - {comment.selection && ( - // Old line: read as removed as a whole (line-through/red); only the - // changed fragments carry the extra intraline emphasis. - - {suggestionDiff?.old.map((segment, index) => ( - - {segment.text} - - ))} - - )} - - {suggestionDiff?.new.map((segment, index) => ( - - {segment.text} - - ))} - - - {comment.suggestionAppliedAt ? ( - - {t("Applied")} - - ) : ( - (canShowApply(comment, canEdit) || - canShowDismiss(comment, canComment, isOwnerOrAdmin)) && ( - - {canShowApply(comment, canEdit) && ( - - )} - {/* Dismiss ("Не применять", #329): removes the suggestion - without changing the page text. Gated on canComment. */} - {canShowDismiss(comment, canComment, isOwnerOrAdmin) && ( - - )} - - ) - )} - - )} - {!isEditing ? ( ) : ( @@ -350,7 +236,9 @@ function CommentListItem({ { editContentRef.current = newContent; }} + onUpdate={(newContent: any) => { + editContentRef.current = newContent; + }} onSave={handleUpdateComment} autofocus={true} /> diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index 73075348..2b390224 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -5,7 +5,7 @@ import { Center, Divider, Group, - Paper, + Box, Stack, Tabs, Badge, @@ -14,6 +14,9 @@ import { Tooltip, } from "@mantine/core"; import CommentListItem from "@/features/comment/components/comment-list-item"; +import AgentEditCard, { + RunHeader, +} from "@/features/comment/components/agent-edit-card"; import { useCommentsQuery, useCreateCommentMutation, @@ -22,6 +25,10 @@ import CommentEditor from "@/features/comment/components/comment-editor"; import CommentActions from "@/features/comment/components/comment-actions"; import { useFocusWithin } from "@mantine/hooks"; import { IComment } from "@/features/comment/types/comment.types.ts"; +import { + groupAgentRuns, + CommentRenderUnit, +} from "@/features/comment/utils/group-agent-runs"; import { usePageMetaQuery } from "@/features/page/queries/page-query.ts"; import { extractPageSlugId } from "@/lib"; import { useTranslation } from "react-i18next"; @@ -69,6 +76,32 @@ export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] { ); } +// The redesigned card shell: a rounded, bordered surface on the panel body. Both +// a thread card and an agent-run group live inside one of these. +function PanelCard({ + children, + ...rest +}: { + children: React.ReactNode; + [key: string]: unknown; +}) { + return ( + + {children} + + ); +} + function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { const { t } = useTranslation(); const { pageSlug } = useParams(); @@ -90,6 +123,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { canEdit || (space?.settings?.comments?.allowViewerComments === true); + const userSpaceRole = space?.membership?.role; + // Separate active and resolved comments const { activeComments, resolvedComments } = useMemo(() => { if (!comments?.items) { @@ -113,6 +148,17 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { }; }, [comments]); + // Collapse each tab's top-level list into render units (a lone comment or a + // collapsed agent run). Purely visual — the underlying data is untouched. + const activeUnits = useMemo( + () => groupAgentRuns(activeComments), + [activeComments], + ); + const resolvedUnits = useMemo( + () => groupAgentRuns(resolvedComments), + [resolvedComments], + ); + // Index replies by their parent once, instead of an O(n^2) filter per thread. // The map ref changes on any comments update, so MemoizedChildComments re-runs // (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items. @@ -168,56 +214,104 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { [createCommentAsync, page?.id], ); - const renderComments = useCallback( - (comment: IComment) => ( - -
- ( + <> + {isEdit ? ( + + ) : ( + + + + )} + + -
+ {!comment.resolvedAt && canComment && ( - <> + - + )} -
+ ), [ childrenByParent, handleAddReply, page?.id, - space?.membership?.role, + userSpaceRole, canComment, canEdit, ], ); + // Render one collapsed unit: a standalone card (thread or lone edit) or an + // agent-run group (one RunHeader over N stacked edit cards). + const renderUnit = useCallback( + (unit: CommentRenderUnit) => { + if (unit.kind === "single") { + const c = unit.comment; + const isEdit = + c.createdSource === "agent" && + c.suggestedText != null && + !c.parentCommentId; + return ( + + {renderCommentSubtree(c, isEdit, true)} + + ); + } + + // A collapsed agent run: one header, then each edit card (provenance + // suppressed on the cards — the header carries the single provenance line). + return ( + + + {unit.comments.map((c) => ( + + {renderCommentSubtree(c, true, false)} + + ))} + + ); + }, + [renderCommentSubtree], + ); + if (isCommentsLoading) { return <>; } @@ -226,8 +320,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { return
{t("Error loading comments.")}
; } - const totalComments = activeComments.length + resolvedComments.length; - const pageCommentInput = canComment ? ( ) : ( - activeComments.map(renderComments) + activeUnits.map(renderUnit) )} @@ -347,7 +439,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) { ) : ( - resolvedComments.map(renderComments) + resolvedUnits.map(renderUnit) )}
diff --git a/apps/client/src/features/comment/components/comment.module.css b/apps/client/src/features/comment/components/comment.module.css index 0f6e4c5e..36362338 100644 --- a/apps/client/src/features/comment/components/comment.module.css +++ b/apps/client/src/features/comment/components/comment.module.css @@ -21,53 +21,6 @@ box-sizing: border-box; } -/* Suggested-edit (#315) "было → стало" diff block. */ -.suggestionBlock { - margin-top: 8px; - margin-left: 6px; - padding: 6px; - border-radius: var(--mantine-radius-sm); - border: 1px solid var(--mantine-color-default-border); - overflow-wrap: break-word; - word-break: break-word; - max-width: 100%; - box-sizing: border-box; - display: flex; - flex-direction: column; - align-items: flex-start; -} - -.suggestionOld { - text-decoration: line-through; - color: var(--mantine-color-red-7); - background: var(--mantine-color-red-light); - border-radius: 2px; - padding: 1px 3px; -} - -.suggestionNew { - color: var(--mantine-color-green-9); - background: var(--mantine-color-green-light); - border-radius: 2px; - padding: 1px 3px; - margin-top: 4px; -} - -/* Intraline diff (#331): the fragment that actually changed within the - red "before" / green "after" block. It inherits the surrounding red/green - framing and adds a stronger tint plus bold weight so the eye lands on the - changed letters/words (git/GitHub-style) rather than the whole line. The - container's line-through (old) / green (new) still marks the full line. */ -.suggestionChanged { - /* Stronger tint of the surrounding red/green so the changed fragment pops - within the block. `currentColor` follows the parent's red (old) or green - (new) text colour. No `text-decoration` here on purpose: the old block's - inherited line-through must survive on the changed letters too. */ - background: color-mix(in srgb, currentColor 22%, transparent); - border-radius: 2px; - font-weight: 700; -} - .commentEditor { &[data-editable][data-surface="muted"] .ProseMirror:not(.focused) { diff --git a/apps/client/src/features/comment/utils/group-agent-runs.test.ts b/apps/client/src/features/comment/utils/group-agent-runs.test.ts new file mode 100644 index 00000000..6e87767d --- /dev/null +++ b/apps/client/src/features/comment/utils/group-agent-runs.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { groupAgentRuns, runKey, GROUP_MIN } from "./group-agent-runs"; +import { IComment } from "@/features/comment/types/comment.types"; + +const editComment = (id: string, over?: Partial): IComment => + ({ + id, + createdSource: "agent", + aiChatId: "chat-1", + agent: { name: "Corrector" }, + suggestedText: "new text", + parentCommentId: null, + ...over, + }) as unknown as IComment; + +const human = (id: string): IComment => + ({ id, createdSource: "user", parentCommentId: null }) as unknown as IComment; + +describe("runKey", () => { + it("keys a groupable agent edit on aiChatId + agent.name", () => { + expect(runKey(editComment("a"))).toBe("chat-1:Corrector"); + }); + + it("is null for an external MCP agent (aiChatId null)", () => { + expect(runKey(editComment("a", { aiChatId: null }))).toBeNull(); + }); + + it("is null for a non-edit agent comment (no suggestedText)", () => { + expect(runKey(editComment("a", { suggestedText: null }))).toBeNull(); + }); + + it("is null for a reply (has parentCommentId)", () => { + expect(runKey(editComment("a", { parentCommentId: "p" }))).toBeNull(); + }); + + it("is null for a human comment", () => { + expect(runKey(human("a"))).toBeNull(); + }); +}); + +describe("groupAgentRuns", () => { + it("collapses >= GROUP_MIN same chat+role edits into one run at the first position", () => { + const units = groupAgentRuns([ + editComment("e1"), + editComment("e2"), + editComment("e3"), + ]); + expect(units).toHaveLength(1); + expect(units[0].kind).toBe("run"); + if (units[0].kind === "run") { + expect(units[0].key).toBe("chat-1:Corrector"); + expect(units[0].comments.map((c) => c.id)).toEqual(["e1", "e2", "e3"]); + } + expect(GROUP_MIN).toBe(2); + }); + + it("renders a lone edit as a single (below the threshold)", () => { + const units = groupAgentRuns([editComment("e1")]); + expect(units).toHaveLength(1); + expect(units[0].kind).toBe("single"); + }); + + it("never groups external MCP edits (aiChatId null) — each is a single", () => { + const units = groupAgentRuns([ + editComment("m1", { aiChatId: null }), + editComment("m2", { aiChatId: null }), + ]); + expect(units).toHaveLength(2); + expect(units.every((u) => u.kind === "single")).toBe(true); + }); + + it("does not collapse two different roles sharing one chat", () => { + const units = groupAgentRuns([ + editComment("a", { agent: { name: "Corrector" } as any }), + editComment("b", { agent: { name: "FactChecker" } as any }), + ]); + // Each key has count 1 -> both remain singles. + expect(units).toHaveLength(2); + expect(units.every((u) => u.kind === "single")).toBe(true); + }); + + it("preserves order and keeps human threads as singles interleaved with a run", () => { + const units = groupAgentRuns([ + human("h1"), + editComment("e1"), + editComment("e2"), + human("h2"), + ]); + // h1 single, then the run (emitted at e1's position, e2 absorbed), then h2. + expect(units.map((u) => u.kind)).toEqual(["single", "run", "single"]); + if (units[1].kind === "run") { + expect(units[1].comments.map((c) => c.id)).toEqual(["e1", "e2"]); + } + }); +}); diff --git a/apps/client/src/features/comment/utils/group-agent-runs.ts b/apps/client/src/features/comment/utils/group-agent-runs.ts new file mode 100644 index 00000000..2e4b6f73 --- /dev/null +++ b/apps/client/src/features/comment/utils/group-agent-runs.ts @@ -0,0 +1,76 @@ +import { IComment } from "@/features/comment/types/comment.types"; + +// A visual-only series threshold: this many agent suggested-edits sharing one +// run key collapse under a single RunHeader. A group of one renders as a plain +// standalone card (no header). Policy constant — no env override. +export const GROUP_MIN = 2; + +// One rendered unit of the top-level comment list: either a collapsed agent-run +// group (>= GROUP_MIN suggested-edits from the same chat+role) or a single +// comment (a human thread, an agent thread without an edit, or a lone edit). +export interface AgentRunGroup { + kind: "run"; + key: string; + comments: IComment[]; +} +export interface SingleUnit { + kind: "single"; + comment: IComment; +} +export type CommentRenderUnit = AgentRunGroup | SingleUnit; + +// The grouping key of a top-level agent suggested-edit, or null when the comment +// is not a groupable edit. The key pins BOTH the AI chat and the acting role +// (`aiChatId + ":" + agent.name`) so two roles running in the same chat do not +// collapse under one header. A comment with `aiChatId == null` (an external MCP +// agent) has no chat to group by — it is deliberately never groupable and always +// renders as a single card (a time-bucketed synthetic run would split/merge runs +// arbitrarily and choke on ISO `createdAt`). PURE. +export function runKey(c: IComment): string | null { + if ( + c.createdSource === "agent" && + c.suggestedText != null && + !c.parentCommentId && + c.aiChatId != null && + c.agent?.name + ) { + return `${c.aiChatId}:${c.agent.name}`; + } + return null; +} + +// Collapse an ORDERED list of top-level comments into render units, preserving +// list order: a run is emitted in place of its FIRST member (later members are +// absorbed), everything else stays a single unit. Grouping is computed over the +// snapshot handed in; a streamed page / WS update may later promote a single +// edit into a run as more members arrive — that is acceptable (purely visual). +// PURE — no React/DOM, no mutation of the input. +export function groupAgentRuns(comments: IComment[]): CommentRenderUnit[] { + // First pass: tally groupable edits per key so we know which keys clear + // GROUP_MIN before we start emitting. + const counts = new Map(); + for (const c of comments) { + const key = runKey(c); + if (key) counts.set(key, (counts.get(key) ?? 0) + 1); + } + + const emitted = new Set(); + const units: CommentRenderUnit[] = []; + for (const c of comments) { + const key = runKey(c); + if (key && (counts.get(key) ?? 0) >= GROUP_MIN) { + // Emit the whole run once, at the position of its first member; skip the + // absorbed later members. + if (emitted.has(key)) continue; + emitted.add(key); + units.push({ + kind: "run", + key, + comments: comments.filter((x) => runKey(x) === key), + }); + } else { + units.push({ kind: "single", comment: c }); + } + } + return units; +} diff --git a/apps/client/src/features/comment/utils/severity.test.ts b/apps/client/src/features/comment/utils/severity.test.ts new file mode 100644 index 00000000..b61087e8 --- /dev/null +++ b/apps/client/src/features/comment/utils/severity.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { parseSeverity, isSignificant } from "./severity"; + +describe("parseSeverity — exact importance dictionary", () => { + it("maps the Russian importance tags", () => { + expect(parseSeverity("[Критично] fix now")).toBe("critical"); + expect(parseSeverity("[Существенно] tighten")).toBe("major"); + expect(parseSeverity("[Незначительно] nit")).toBe("minor"); + }); + + it("maps the English equivalents and is case-insensitive", () => { + expect(parseSeverity("[Critical] x")).toBe("critical"); + expect(parseSeverity("[MAJOR] x")).toBe("major"); + expect(parseSeverity("[minor] x")).toBe("minor"); + expect(parseSeverity("[критично] x")).toBe("critical"); + }); + + it("treats fact-checker verdicts as unknown, NOT a severity", () => { + expect(parseSeverity("[Неверно] this is wrong")).toBe("unknown"); + expect(parseSeverity("[Не проверено] can't verify")).toBe("unknown"); + expect(parseSeverity("[Непроверяемо] x")).toBe("unknown"); + expect(parseSeverity("[Это мнение] x")).toBe("unknown"); + }); + + it("treats a typo'd / unrecognised tag as unknown (never falls back to minor)", () => { + expect(parseSeverity("[Существенное] typo ending")).toBe("unknown"); + expect(parseSeverity("[whatever] x")).toBe("unknown"); + }); + + it("returns unknown for no tag or empty input", () => { + expect(parseSeverity("plain body, no tag")).toBe("unknown"); + expect(parseSeverity("")).toBe("unknown"); + }); + + it("finds the recognised importance tag even after a leading verdict tag", () => { + expect(parseSeverity("[Неверно] [Существенно] body")).toBe("major"); + }); +}); + +describe("isSignificant", () => { + it("counts only major and critical", () => { + expect(isSignificant("critical")).toBe(true); + expect(isSignificant("major")).toBe(true); + expect(isSignificant("minor")).toBe(false); + expect(isSignificant("unknown")).toBe(false); + }); +}); diff --git a/apps/client/src/features/comment/utils/severity.ts b/apps/client/src/features/comment/utils/severity.ts new file mode 100644 index 00000000..069ff6f5 --- /dev/null +++ b/apps/client/src/features/comment/utils/severity.ts @@ -0,0 +1,44 @@ +// Severity of an agent suggested-edit, parsed CLIENT-SIDE from the importance +// tag the editorial role prompts emit at the head of the comment body +// (`[Критично]` / `[Существенно]` / `[Незначительно]`, or their English +// equivalents). It is a DISPLAY-ONLY signal: it drives the coloured dot on the +// edit card and the "M major" counter in a run header. It never gates an action. +// +// `unknown` is a first-class value, NOT a synonym for `minor`: any bracketed +// text that is not in the exact dictionary — a Fact-checker verdict +// (`[Неверно]`, `[Не проверено]`, `[Непроверяемо]`, `[Это мнение]`), a typo +// (`[Существенное]`), or the absence of a tag — resolves to `unknown` and draws +// a neutral dot. Passing an unrecognised tag off as `minor` would mis-label it. +export type Severity = "critical" | "major" | "minor" | "unknown"; + +// EXACT (case-insensitive) dictionary. Only these tokens map to a real severity; +// everything else falls through to `unknown` on purpose. +const SEVERITY_TAGS: Record = { + критично: "critical", + существенно: "major", + незначительно: "minor", + critical: "critical", + major: "major", + minor: "minor", +}; + +// Parse the first RECOGNISED importance tag out of the flattened comment text +// (use `commentContentToText` to obtain it). Scans every `[...]` token so a tag +// that trails a verdict still counts; the first exact-dictionary hit wins. When +// no token matches the dictionary the result is `unknown`. PURE — no React/DOM. +export function parseSeverity(text: string): Severity { + if (!text) return "unknown"; + const matches = text.matchAll(/\[([^\]]+)\]/g); + for (const m of matches) { + const tag = m[1].trim().toLowerCase(); + const sev = SEVERITY_TAGS[tag]; + if (sev) return sev; + } + return "unknown"; +} + +// Whether a severity counts toward the "M major" tally in a run header: only the +// genuinely significant ones (major + critical). `minor` and `unknown` do not. +export function isSignificant(severity: Severity): boolean { + return severity === "major" || severity === "critical"; +}