/** * 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;