Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d44fd1943 | |||
| 642f50e9df |
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* 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<void>;
|
||||
/** Жёсткое удаление на сервере (для треда без ответов). Вызывается ПОСЛЕ окна undo. */
|
||||
onDismiss: (id: string) => Promise<void>;
|
||||
/** Резолв обычного треда. */
|
||||
onResolve?: (id: string) => Promise<void>;
|
||||
/** Клик по карточке/цитате — скролл документа к якорю и подсветка. */
|
||||
onNavigateToAnchor: (id: string) => void;
|
||||
onClose?: () => void;
|
||||
/** Мс до фактического удаления после Dismiss (окно undo). По умолчанию 5000. */
|
||||
dismissUndoMs?: number;
|
||||
}
|
||||
|
||||
/* ───────────────────── Клиентский парсинг меток ───────────────────── */
|
||||
|
||||
const SEVERITY_WORDS: Record<string, Severity> = {
|
||||
'существенно': '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<Severity, string> = {
|
||||
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 (
|
||||
<Group gap={7} wrap="nowrap" align="flex-start">
|
||||
<Text ff="monospace" fw={600} fz={12} c={signColor} w={11} ta="center" style={{ flex: 'none', lineHeight: 1.5 }}>{sign}</Text>
|
||||
<Text fz={13.5} c={baseColor} style={{ lineHeight: 1.45 }}>
|
||||
{segments.map((seg, i) =>
|
||||
seg.changed ? (
|
||||
<Box key={i} component="mark" px={3} fw={600}
|
||||
style={{ background: markBg, color: markFg, borderRadius: 3,
|
||||
textDecoration: isDel ? 'line-through' : 'none' }}>
|
||||
{visibleWhitespace(seg.text)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box key={i} component="span" style={{ textDecoration: isDel ? 'line-through' : 'none' }}>{seg.text}</Box>
|
||||
)
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffBlock({ edit }: { edit: SuggestedEdit }) {
|
||||
const pureInsert = edit.before.length === 0;
|
||||
const pureDelete = edit.after.length === 0;
|
||||
return (
|
||||
<Stack gap={1}>
|
||||
{!pureInsert && <DiffLine segments={edit.before} kind="del" />}
|
||||
{!pureDelete && <DiffLine segments={edit.after} kind="ins" />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────── Карточка правки ──────────────────────── */
|
||||
|
||||
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<number>();
|
||||
|
||||
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 (
|
||||
<Box
|
||||
p="10px 12px"
|
||||
onClick={() => onNavigateToAnchor(c.id)}
|
||||
style={(t) => ({
|
||||
cursor: 'pointer',
|
||||
borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`,
|
||||
})}
|
||||
>
|
||||
<Stack gap={8}>
|
||||
{c.edit && <DiffBlock edit={c.edit} />}
|
||||
|
||||
{conflict && (
|
||||
<Group gap={7} wrap="nowrap" p="6px 8px"
|
||||
style={(t) => ({ background: t.colorScheme === 'dark' ? 'rgba(230,180,20,.12)' : '#fff9db', borderRadius: 7 })}>
|
||||
<Text fz={12}>⚠</Text>
|
||||
<Text fz={12} c="yellow.8" style={{ lineHeight: 1.4 }}>Text changed — this edit can’t be applied</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{parsed.text && !conflict && (
|
||||
<Text fz={12.5} c="dimmed" style={{ lineHeight: 1.45 }}>{parsed.text}</Text>
|
||||
)}
|
||||
|
||||
<Group gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<Box w={8} h={8} style={(t) => ({ flex: 'none', borderRadius: '50%', background: t.colors[SEV_COLOR[parsed.severity]][6] })} />
|
||||
<Text fz={10} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.06em', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{[parsed.category, parsed.severity === 'major' ? 'Major' : parsed.severity === 'critical' ? 'Critical' : null].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
|
||||
{c.status === 'applied' ? (
|
||||
<Badge color="green" variant="light" radius="xl" size="sm">✓ Applied</Badge>
|
||||
) : c.status === 'dismissed' ? (
|
||||
<Badge color="gray" variant="light" radius="xl" size="sm">Dismissed</Badge>
|
||||
) : conflict ? (
|
||||
<Button size="compact-sm" variant="default" onClick={() => onNavigateToAnchor(c.id)}>Go to text</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="compact-sm" variant="default" color="gray" onClick={dismiss}>Dismiss</Button>
|
||||
<Button size="compact-sm" color="green" loading={busy} onClick={apply}>Apply</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────── Человеческий тред ──────────────────────── */
|
||||
|
||||
function HumanThread({ c, onResolve, onNavigateToAnchor }: {
|
||||
c: Comment; onResolve?: CommentsPanelProps['onResolve']; onNavigateToAnchor: CommentsPanelProps['onNavigateToAnchor'];
|
||||
}) {
|
||||
const parsed = useMemo(() => parseBody(c.bodyRaw), [c.bodyRaw]);
|
||||
return (
|
||||
<Box p="12px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Stack gap={9}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Avatar size={26} radius="xl" color="orange">{c.authorName[0]}</Avatar>
|
||||
<Text fz={12.5} fw={600}>{c.authorName}
|
||||
<Text span c="dimmed" fw={400}> · {c.triggeredBy ? `${c.triggeredBy} · ` : ''}{c.createdAtLabel}</Text>
|
||||
</Text>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Tooltip label="Resolve"><ActionIcon variant="default" size="md" onClick={() => onResolve?.(c.id)}>✓</ActionIcon></Tooltip>
|
||||
<ActionIcon variant="default" size="md">⋯</ActionIcon>
|
||||
</Group>
|
||||
<Text fz={13.5} style={{ lineHeight: 1.5 }}>{parsed.text}</Text>
|
||||
<Group gap={8}>
|
||||
{c.replyCount ? <Text fz={12} c="dimmed">{c.replyCount} replies</Text> : null}
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Button variant="subtle" size="compact-sm">Reply</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────── Шапка серии + прогресс ──────────────────── */
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
<Group gap={10} wrap="nowrap" p="11px 13px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Box style={{ position: 'relative', width: 30, height: 30, flex: 'none' }}>
|
||||
<Avatar size={30} radius={8} color="teal">{head.authorName[0]}</Avatar>
|
||||
{head.triggeredBy && (
|
||||
<Avatar size={14} radius="xl" color="gray"
|
||||
style={{ position: 'absolute', right: -4, bottom: -4, border: '2px solid var(--mantine-color-body)' }} />
|
||||
)}
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text fz={13} fw={600}>{head.authorName}
|
||||
<Text span c="dimmed" fw={400}> · {head.triggeredBy} · {head.createdAtLabel}</Text>
|
||||
</Text>
|
||||
<Text fz={11.5} c="dimmed">
|
||||
{runComments.length} edits · <Text span c="orange.7" fw={600}>{majors} major</Text> · {applied} applied
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box style={{ flex: 1 }} />
|
||||
{!progress && <Button size="compact-sm" color="green" onClick={onAcceptAll}>Accept all</Button>}
|
||||
</Group>
|
||||
{progress && (
|
||||
<Box p="9px 13px 11px" style={(t) => ({ background: t.colorScheme === 'dark' ? 'rgba(47,158,68,.08)' : '#f8fbf9', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}>
|
||||
<Group gap={8} mb={6}>
|
||||
<Text fz={12} fw={600} c="green.7">Applying {progress.done} of {progress.total}…</Text>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Button variant="subtle" color="gray" size="compact-xs">Stop</Button>
|
||||
</Group>
|
||||
<Progress value={(progress.done / progress.total) * 100} color="green" size="sm" radius="xl" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────── Панель ──────────────────────────── */
|
||||
|
||||
/** Роль-автор для фильтра: у агентов — имя роли, у людей — «Пользователь». */
|
||||
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<string | null>(null);
|
||||
const [progress, setProgress] = useState<Record<string, { done: number; total: number }>>({});
|
||||
|
||||
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<string, number> = {};
|
||||
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<string, Comment[]>();
|
||||
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 (
|
||||
<Stack gap={0} h="100%" style={{ width: 380, maxWidth: '100%', borderLeft: '1px solid var(--mantine-color-default-border)' }}>
|
||||
{/* Вкладки — статусная ось. Open/Resolved сохранены. */}
|
||||
<Group gap={4} p="10px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}>
|
||||
<Tabs value={tab} onChange={(v) => setTab(v as 'open' | 'resolved')} variant="pills">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="open" rightSection={<Badge size="sm" variant="light" color="blue">{open.length}</Badge>}>Open</Tabs.Tab>
|
||||
<Tabs.Tab value="resolved" rightSection={<Badge size="sm" variant="light" color="gray">{resolved.length}</Badge>}>Resolved</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Box style={{ flex: 1 }} />
|
||||
{onClose && <ActionIcon variant="subtle" color="gray" onClick={onClose}>✕</ActionIcon>}
|
||||
</Group>
|
||||
|
||||
{/* Фильтры-чипы: только по ролям (Корректор / Фактчекер / Пользователь). */}
|
||||
{tab === 'open' && roles.length > 1 && (
|
||||
<ScrollArea type="never" p="8px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FilterChip active={!roleFilter} onClick={() => setRoleFilter(null)} label={`All ${list.length}`} solid />
|
||||
{roles.map(({ role, count }) => (
|
||||
<FilterChip key={role} active={roleFilter === role} onClick={() => setRoleFilter(roleFilter === role ? null : role)} label={`${role} ${count}`} />
|
||||
))}
|
||||
</Group>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* Лента */}
|
||||
<ScrollArea style={{ flex: 1 }} bg="var(--mantine-color-default-hover)">
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState tab={tab} />
|
||||
) : (
|
||||
<>
|
||||
{groups.runs.map(([runId, items]) => (
|
||||
<Box key={runId} m="6px 10px" bg="var(--mantine-color-body)" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<RunHeader runComments={items} progress={progress[runId] ?? null} onAcceptAll={() => acceptAll(runId, items)} />
|
||||
{items.map((c) => (
|
||||
<EditCard key={c.id} c={c} onApply={onApply} onDismiss={onDismiss} onNavigateToAnchor={onNavigateToAnchor} dismissUndoMs={dismissUndoMs} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{groups.singles.map((c) => (
|
||||
<Box key={c.id} m="6px 10px" bg="var(--mantine-color-body)" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{c.edit
|
||||
? <EditCard c={c} onApply={onApply} onDismiss={onDismiss} onNavigateToAnchor={onNavigateToAnchor} dismissUndoMs={dismissUndoMs} />
|
||||
: <HumanThread c={c} onResolve={onResolve} onNavigateToAnchor={onNavigateToAnchor} />}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Мелкие компоненты ─────────────────────── */
|
||||
|
||||
function FilterChip({ label, active, onClick, dot, solid }: {
|
||||
label: string; active: boolean; onClick: () => void; dot?: string; solid?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
size="compact-sm"
|
||||
radius="xl"
|
||||
variant={active ? 'filled' : 'default'}
|
||||
color={active ? (solid ? 'dark' : 'blue') : 'gray'}
|
||||
leftSection={dot ? <Box w={7} h={7} style={(t) => ({ borderRadius: '50%', background: t.colors[dot][6] })} /> : undefined}
|
||||
styles={{ root: { flex: 'none' }, label: { fontWeight: 600, fontSize: 12 } }}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ tab }: { tab: 'open' | 'resolved' }) {
|
||||
const isOpen = tab === 'open';
|
||||
return (
|
||||
<Stack align="center" gap={6} p="40px 20px">
|
||||
<Avatar size={40} radius="xl" color={isOpen ? 'green' : 'gray'}>{isOpen ? '✓' : '◌'}</Avatar>
|
||||
<Text fw={600} fz={13}>{isOpen ? 'All caught up' : 'Nothing here yet'}</Text>
|
||||
<Text fz={12} c="dimmed" ta="center" style={{ lineHeight: 1.45 }}>
|
||||
{isOpen ? 'No edits waiting on you.' : 'Applied and closed items will appear here.'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentsPanel;
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* PageHistoryModal — редизайн окна «Page history».
|
||||
* Mantine v7. Light/dark. Полноразмерное модальное окно.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* ЧТО ЭТО
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* Окно истории версий страницы. Слева — панель навигации: мини-календарь
|
||||
* (heatmap: яркость дня = число ревизий, обводка = выбранный день) + плотный
|
||||
* список ревизий (одна ревизия = одна строка). Справа — реально отрендеренная
|
||||
* версия страницы с подсветкой изменений. Все контролы — в одну строку шапки.
|
||||
*
|
||||
* Ключевые решения:
|
||||
* - Плотный список: одна ревизия на строку (аватар · время · автор · [SAVED]).
|
||||
* Бейдж показывается ТОЛЬКО у сохранённых версий; всё остальное — autosave.
|
||||
* - Агентские ревизии визуально отличаются: квадратный глиф роли (К/Ф) вместо
|
||||
* круглого аватара пользователя + «via <кто запустил>».
|
||||
* - Календарь объединён со списком в одной панели (мини-календарь = date jumper).
|
||||
* - Highlight changes + навигация по изменениям (N of M, ↑/↓) вынесены в шапку.
|
||||
* - Текущая версия помечена; Restore для неё недоступен.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* import { PageHistoryModal, Revision } from './PageHistoryModal';
|
||||
*
|
||||
* <PageHistoryModal
|
||||
* opened={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* revisions={revisions} // Revision[]
|
||||
* selectedId={selId}
|
||||
* onSelect={setSelId} // клик по ревизии → рендер справа
|
||||
* renderVersion={(rev) => <ArticleView versionId={rev.id} />} // ваш рендер страницы
|
||||
* highlightChanges={hl}
|
||||
* onToggleHighlight={setHl}
|
||||
* changeNav={{ index: 1, total: 3, onPrev, onNext }} // навигация по диффу
|
||||
* onlySaved={onlySaved}
|
||||
* onToggleOnlySaved={setOnlySaved}
|
||||
* onRestore={(rev) => api.restore(rev.id)} // async; текущая версия → disabled
|
||||
* />
|
||||
*
|
||||
* Требует MantineProvider на корне приложения (defaultColorScheme="auto" для тем).
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* ФОРМАТ ДАННЫХ
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* interface Revision {
|
||||
* id: string;
|
||||
* at: Date | string; // время ревизии; форматируется вызывающим/util-ом
|
||||
* dayGroup: string; // 'Today' | 'Yesterday' | 'Mon 12 Jul' — заголовок группы
|
||||
* saved: boolean; // true → бейдж SAVED; false → autosave (без бейджа)
|
||||
* author: { name: string } & (
|
||||
* | { kind: 'human' }
|
||||
* | { kind: 'agent'; role: string; triggeredBy: string } // роль + кто запустил
|
||||
* );
|
||||
* isCurrent?: boolean; // текущая (последняя) версия — Restore недоступен
|
||||
* }
|
||||
*
|
||||
* Ревизии приходят плоским массивом; группировка по dayGroup — на клиенте.
|
||||
* Никаких изменений API не требуется: агентское авторство берётся из author.kind.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* СОСТОЯНИЯ (нарисованы/поддержаны)
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* - Ревизия: обычная / hover / выбранная / текущая / агентская / человеческая.
|
||||
* - Restore: default / disabled (выбрана текущая) / loading (спиннер после клика).
|
||||
* - Highlight changes: on/off; при off навигация по изменениям неактивна.
|
||||
* - Пустая история: одна версия / нет ревизий → EmptyState.
|
||||
* - Тёмная тема: через токены Mantine (useMantineColorScheme, var(--mantine-*)).
|
||||
*/
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Modal, Box, Group, Stack, Text, Switch, Avatar, Badge, Button, ActionIcon,
|
||||
ScrollArea, useMantineTheme, useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
|
||||
/* ─────────────────────────── Типы ─────────────────────────── */
|
||||
|
||||
export type Author =
|
||||
| { name: string; kind: 'human' }
|
||||
| { name: string; kind: 'agent'; role: string; triggeredBy: string };
|
||||
|
||||
export interface Revision {
|
||||
id: string;
|
||||
at: Date | string;
|
||||
atLabel: string; // предформатированное «5:35AM»
|
||||
dayGroup: string; // «Today» | «Yesterday» | «Mon 12 Jul»
|
||||
saved: boolean;
|
||||
author: Author;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
label: string; // «12»
|
||||
inMonth: boolean;
|
||||
count: number; // число ревизий за день (для heatmap)
|
||||
selected?: boolean;
|
||||
date: Date | string;
|
||||
}
|
||||
|
||||
export interface PageHistoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
revisions: Revision[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
renderVersion: (rev: Revision | null) => React.ReactNode;
|
||||
highlightChanges: boolean;
|
||||
onToggleHighlight: (v: boolean) => void;
|
||||
changeNav?: { index: number; total: number; onPrev: () => void; onNext: () => void };
|
||||
onlySaved: boolean;
|
||||
onToggleOnlySaved: (v: boolean) => void;
|
||||
onRestore: (rev: Revision) => Promise<void>;
|
||||
/** Ячейки текущего месяца календаря + управление. Необязательно — без него панель = только список. */
|
||||
calendar?: {
|
||||
monthLabel: string; // «July 2025»
|
||||
weekdays: string[]; // ['Mon',…,'Sun']
|
||||
days: CalendarDay[]; // обычно 35/42 ячейки
|
||||
onPrevMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
onToday: () => void;
|
||||
onPickDay: (d: CalendarDay) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Утилиты представления ─────────────────────────── */
|
||||
|
||||
const USER_PALETTE = ['#495057', '#e8590c', '#1c7ed6', '#7048e8', '#0ca678'];
|
||||
function userColor(name: string) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return USER_PALETTE[h % USER_PALETTE.length];
|
||||
}
|
||||
const AGENT_GRAD: Record<string, string> = {
|
||||
'Корректор': 'linear-gradient(135deg,#20c997,#12b886)',
|
||||
'Фактчекер': 'linear-gradient(135deg,#4c6ef5,#7048e8)',
|
||||
};
|
||||
function agentGrad(role: string) { return AGENT_GRAD[role] ?? 'linear-gradient(135deg,#868e96,#495057)'; }
|
||||
|
||||
/** heatmap: число ревизий → фон/текст ячейки календаря. */
|
||||
function heat(n: number, dark: boolean) {
|
||||
if (n === 0) return { bg: 'transparent', fg: dark ? '#5c5f66' : '#adb5bd' };
|
||||
if (n <= 2) return { bg: dark ? 'rgba(34,139,230,.20)' : '#e7f0ff', fg: dark ? '#74c0fc' : '#1971c2' };
|
||||
if (n <= 4) return { bg: dark ? 'rgba(34,139,230,.45)' : '#a5c8ff', fg: dark ? '#dbeafe' : '#0b3d91' };
|
||||
return { bg: '#4c8dff', fg: '#ffffff' };
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Строка ревизии ─────────────────────────── */
|
||||
|
||||
function RevisionRow({ rev, selected, onSelect }: { rev: Revision; selected: boolean; onSelect: () => void }) {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === 'dark';
|
||||
const a = rev.author;
|
||||
const isAgent = a.kind === 'agent';
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={8} wrap="nowrap" h={28} px="12px 14px" m="1px 6px"
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
cursor: 'pointer', borderRadius: 7,
|
||||
background: selected ? (dark ? 'rgba(34,139,230,.14)' : '#eef4ff')
|
||||
: isAgent ? (dark ? 'rgba(112,72,232,.06)' : '#fbfaff') : undefined,
|
||||
}}
|
||||
>
|
||||
{/* аватар/глиф */}
|
||||
{isAgent ? (
|
||||
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, background: agentGrad(a.role), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
|
||||
{a.role[0]}
|
||||
</Box>
|
||||
) : (
|
||||
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: '50%', background: userColor(a.name), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
|
||||
{a.name[0].toUpperCase()}
|
||||
</Box>
|
||||
)}
|
||||
{/* время */}
|
||||
<Text fz={12.5} fw={rev.isCurrent ? 600 : 500} c={rev.isCurrent ? undefined : 'dimmed'} style={{ flex: 'none', minWidth: 58 }}>
|
||||
{rev.atLabel}
|
||||
</Text>
|
||||
{/* автор + via */}
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0, alignItems: 'baseline', overflow: 'hidden' }}>
|
||||
<Text fz={12} fw={isAgent ? 600 : 400} c={isAgent ? undefined : 'dimmed'} truncate style={{ flex: 'none', maxWidth: 100 }}>
|
||||
{isAgent ? a.role : a.name}
|
||||
</Text>
|
||||
{isAgent && <Text fz={11} c="dimmed" truncate>· via {a.triggeredBy}</Text>}
|
||||
</Group>
|
||||
{/* бейдж — только SAVED */}
|
||||
{rev.saved && <Badge size="sm" radius="sm" variant="light" color="blue">SAVED</Badge>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Мини-календарь ─────────────────────────── */
|
||||
|
||||
function MiniCalendar({ cal }: { cal: NonNullable<PageHistoryModalProps['calendar']> }) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === 'dark';
|
||||
return (
|
||||
<Box p="10px 12px 8px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Group gap={6} mb={6}>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onPrevMonth}>‹</ActionIcon>
|
||||
<Text fz={12} fw={600}>{cal.monthLabel}</Text>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onNextMonth}>›</ActionIcon>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Button variant="subtle" size="compact-xs" onClick={cal.onToday}>Today</Button>
|
||||
</Group>
|
||||
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2, marginBottom: 2 }}>
|
||||
{cal.weekdays.map((w) => (
|
||||
<Text key={w} ta="center" fz={8.5} fw={600} c="dimmed">{w}</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2 }}>
|
||||
{cal.days.map((d, i) => {
|
||||
const h = heat(d.count, dark);
|
||||
return (
|
||||
<Box
|
||||
key={i} onClick={() => cal.onPickDay(d)}
|
||||
style={{
|
||||
position: 'relative', height: 26, borderRadius: 6, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: d.inMonth ? h.bg : 'transparent',
|
||||
boxShadow: d.selected ? `inset 0 0 0 2px ${dark ? '#f1f3f5' : '#1a1b1e'}` : undefined,
|
||||
}}
|
||||
>
|
||||
<Text fz={10.5} fw={d.selected ? 700 : 500} style={{ color: d.inMonth ? h.fg : (dark ? '#3a3d42' : '#dee2e6') }}>
|
||||
{d.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{/* легенда heatmap */}
|
||||
<Group gap={6} mt={8} align="center">
|
||||
<Text fz={9.5} c="dimmed">fewer</Text>
|
||||
{['#e7f0ff', '#a5c8ff', '#4c8dff'].map((c) => (
|
||||
<Box key={c} style={{ width: 14, height: 10, borderRadius: 2, background: c }} />
|
||||
))}
|
||||
<Text fz={9.5} c="dimmed">more revisions</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Окно ─────────────────────────── */
|
||||
|
||||
export function PageHistoryModal(props: PageHistoryModalProps) {
|
||||
const {
|
||||
opened, onClose, revisions, selectedId, onSelect, renderVersion,
|
||||
highlightChanges, onToggleHighlight, changeNav, onlySaved, onToggleOnlySaved,
|
||||
onRestore, calendar,
|
||||
} = props;
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
const selected = revisions.find((r) => r.id === selectedId) ?? null;
|
||||
const isEmpty = revisions.length <= 1;
|
||||
|
||||
// группировка по dayGroup, с фильтром Only saved
|
||||
const groups = useMemo(() => {
|
||||
const list = onlySaved ? revisions.filter((r) => r.saved) : revisions;
|
||||
const map: { head: string; items: Revision[] }[] = [];
|
||||
for (const r of list) {
|
||||
let g = map.find((x) => x.head === r.dayGroup);
|
||||
if (!g) { g = { head: r.dayGroup, items: [] }; map.push(g); }
|
||||
g.items.push(r);
|
||||
}
|
||||
return map;
|
||||
}, [revisions, onlySaved]);
|
||||
|
||||
const restore = useCallback(async () => {
|
||||
if (!selected || selected.isCurrent) return;
|
||||
setRestoring(true);
|
||||
try { await onRestore(selected); } finally { setRestoring(false); }
|
||||
}, [selected, onRestore]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened} onClose={onClose} withCloseButton={false}
|
||||
size="80rem" radius="lg" padding={0}
|
||||
styles={{ body: { height: '80vh', maxHeight: 760, display: 'flex', flexDirection: 'column' } }}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}
|
||||
>
|
||||
{/* ── single-row toolbar ── */}
|
||||
<Group gap={14} h={60} px={16} wrap="nowrap" style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Text fz={16} fw={600}>Page history</Text>
|
||||
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text fz={12} fw={600} truncate>{selected ? `${selected.dayGroup} · ${selected.atLabel}` : '—'}</Text>
|
||||
<Text fz={10.5} c="dimmed">selected version</Text>
|
||||
</Stack>
|
||||
|
||||
<Box style={{ flex: 1 }} />
|
||||
|
||||
{/* diff cluster */}
|
||||
<Group gap={2} p={3} wrap="nowrap" style={(t) => ({ background: t.colorScheme === 'dark' ? t.colors.dark[6] : '#f4f6f8', borderRadius: 10 })}>
|
||||
<Button variant="white" size="compact-sm" onClick={() => onToggleHighlight(!highlightChanges)}
|
||||
leftSection={<Switch checked={highlightChanges} onChange={() => {}} size="xs" tabIndex={-1} styles={{ track: { cursor: 'pointer' } }} />}
|
||||
styles={{ root: { boxShadow: '0 1px 2px rgba(0,0,0,.08)' } }}>
|
||||
Highlight changes
|
||||
</Button>
|
||||
{changeNav && (
|
||||
<>
|
||||
<Text fz={12} fw={600} c="dimmed" px={6}>{changeNav.index} / {changeNav.total}</Text>
|
||||
<Box style={{ display: 'flex' }}>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onPrev} style={{ width: 26 }}>↑</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onNext} style={{ width: 26 }}>↓</ActionIcon>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
|
||||
|
||||
<Button color="blue" onClick={restore} loading={restoring} disabled={!selected || selected.isCurrent}>
|
||||
Restore this version
|
||||
</Button>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}>✕</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* ── body ── */}
|
||||
<Box style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||
{/* left: nav panel */}
|
||||
<Stack gap={0} style={(t) => ({ flex: 'none', width: 280, minHeight: 0, borderRight: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`, background: t.colorScheme === 'dark' ? t.colors.dark[7] : '#fcfcfd' })}>
|
||||
<Group h={44} px={16} style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Switch checked={onlySaved} onChange={(e) => onToggleOnlySaved(e.currentTarget.checked)} size="sm" label="Only saved" labelPosition="right" styles={{ label: { fontSize: 13, fontWeight: 500 } }} />
|
||||
</Group>
|
||||
|
||||
{calendar && <MiniCalendar cal={calendar} />}
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
{groups.map((g) => (
|
||||
<Box key={g.head}>
|
||||
<Text px={16} pt={9} pb={5} fz={10.5} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.05em', position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
|
||||
{g.head}
|
||||
</Text>
|
||||
{g.items.map((r) => (
|
||||
<RevisionRow key={r.id} rev={r} selected={r.id === selectedId} onSelect={() => onSelect(r.id)} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
|
||||
{/* right: rendered version */}
|
||||
<ScrollArea style={{ flex: 1, minWidth: 0 }} bg="var(--mantine-color-default-hover)">
|
||||
{isEmpty ? (
|
||||
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
|
||||
<Text fw={600} fz={14}>No earlier versions</Text>
|
||||
<Text fz={12.5} c="dimmed" ta="center">У страницы пока одна версия — сравнивать не с чем.</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box p="26px 0" maw={660} mx="auto">
|
||||
{renderVersion(selected)}
|
||||
</Box>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHistoryModal;
|
||||
@@ -48,7 +48,6 @@
|
||||
"Create page": "Create page",
|
||||
"Create space": "Create space",
|
||||
"Create workspace": "Create workspace",
|
||||
"Critical": "Critical",
|
||||
"Current password": "Current password",
|
||||
"Dark": "Dark",
|
||||
"Date": "Date",
|
||||
@@ -126,7 +125,6 @@
|
||||
"Link copied": "Link copied",
|
||||
"Login": "Login",
|
||||
"Logout": "Logout",
|
||||
"Major": "Major",
|
||||
"Manage Group": "Manage Group",
|
||||
"Manage members": "Manage members",
|
||||
"member": "member",
|
||||
@@ -455,8 +453,6 @@
|
||||
"{{count}} Columns": "{{count}} Columns",
|
||||
"{{count}} command available_one": "1 command available",
|
||||
"{{count}} command available_other": "{{count}} commands available",
|
||||
"{{count}} edits": "{{count}} edits",
|
||||
"{{count}} major": "{{count}} major",
|
||||
"{{count}} result available_one": "1 result available",
|
||||
"{{count}} result available_other": "{{count}} results available",
|
||||
"{{count}} result found_one": "{{count}} result found",
|
||||
@@ -1474,5 +1470,14 @@
|
||||
"≈ {{minutes}}m": "≈ {{minutes}}m",
|
||||
"{{hours}}h {{minutes}}m": "{{hours}}h {{minutes}}m",
|
||||
"{{hours}}h": "{{hours}}h",
|
||||
"{{minutes}}m": "{{minutes}}m"
|
||||
"{{minutes}}m": "{{minutes}}m",
|
||||
"Selected version": "Selected version",
|
||||
"via": "via",
|
||||
"of": "of",
|
||||
"Previous change": "Previous change",
|
||||
"Next change": "Next change",
|
||||
"Previous month": "Previous month",
|
||||
"Next month": "Next month",
|
||||
"No revisions found for that day": "No revisions found for that day",
|
||||
"{{date}}: {{count}} versions": "{{date}}: {{count}} versions"
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"Create page": "Создать страницу",
|
||||
"Create space": "Создать пространство",
|
||||
"Create workspace": "Создать рабочую область",
|
||||
"Critical": "Критично",
|
||||
"Current password": "Текущий пароль",
|
||||
"Dark": "Темная",
|
||||
"Date": "Дата",
|
||||
@@ -128,7 +127,6 @@
|
||||
"Link copied": "Ссылка скопирована",
|
||||
"Login": "Войти",
|
||||
"Logout": "Выйти",
|
||||
"Major": "Существенно",
|
||||
"Manage Group": "Управление группой",
|
||||
"Manage members": "Управление участниками",
|
||||
"member": "участник",
|
||||
@@ -463,8 +461,6 @@
|
||||
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
|
||||
"{{count}} command available_one": "Доступна 1 команда",
|
||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||
"{{count}} edits": "{count, plural, one{# правка} few{# правки} many{# правок} other{# правки}}",
|
||||
"{{count}} major": "{count, plural, one{# существенная} few{# существенных} many{# существенных} other{# существенных}}",
|
||||
"{{count}} result available_one": "Доступен 1 результат",
|
||||
"{{count}} result available_other": "Доступно {{count}} результатов",
|
||||
"{{count}} result found_one": "Найден {{count}} результат",
|
||||
@@ -1491,5 +1487,14 @@
|
||||
"≈ {{minutes}}m": "≈ {{minutes}} мин",
|
||||
"{{hours}}h {{minutes}}m": "{{hours}} ч {{minutes}} м",
|
||||
"{{hours}}h": "{{hours}} ч",
|
||||
"{{minutes}}m": "{{minutes}} м"
|
||||
"{{minutes}}m": "{{minutes}} м",
|
||||
"Selected version": "Выбранная версия",
|
||||
"via": "через",
|
||||
"of": "из",
|
||||
"Previous change": "Предыдущее изменение",
|
||||
"Next change": "Следующее изменение",
|
||||
"Previous month": "Предыдущий месяц",
|
||||
"Next month": "Следующий месяц",
|
||||
"No revisions found for that day": "Нет ревизий за этот день",
|
||||
"{{date}}: {{count}} versions": "{{date}}: версий — {{count}}"
|
||||
}
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { createInstance } from "i18next";
|
||||
import { initReactI18next, I18nextProvider } from "react-i18next";
|
||||
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, { RunHeader } from "./agent-edit-card";
|
||||
|
||||
const body = (text: string) =>
|
||||
JSON.stringify({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||
});
|
||||
|
||||
const edit = (over?: Partial<IComment>): 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(
|
||||
<MantineProvider>
|
||||
<AgentEditCard
|
||||
comment={comment}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("shows the Applied badge for an applied suggestion, not a pending one (F1)", () => {
|
||||
// Pending: no Applied badge.
|
||||
const { unmount } = renderCard(edit(), true);
|
||||
expect(screen.queryByText("Applied")).toBeNull();
|
||||
unmount();
|
||||
// Applied (kept alive by replies -> resolved, #329): the badge is restored.
|
||||
renderCard(edit({ suggestionAppliedAt: new Date() }), true);
|
||||
expect(screen.getByText("Applied")).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).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());
|
||||
});
|
||||
|
||||
// The RunHeader's "N edits · M major" is built with interpolated t() keys; the
|
||||
// default (uninitialized) react-i18next t returns the key verbatim WITHOUT
|
||||
// interpolating, so a numeric assertion needs a real, initialised i18n instance.
|
||||
// This isolated instance carries just the two count keys with escapeValue off so
|
||||
// "{{count}}" is substituted and the rendered numbers are assertable.
|
||||
const headerI18n = createInstance();
|
||||
headerI18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
fallbackLng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
"{{count}} edits": "{{count}} edits",
|
||||
"{{count}} major": "{{count}} major",
|
||||
},
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
const runComment = (id: string, tag: string): IComment =>
|
||||
edit({
|
||||
id,
|
||||
content: body(`${tag} some rationale`),
|
||||
});
|
||||
|
||||
function renderRunHeader(comments: IComment[]) {
|
||||
return render(
|
||||
<I18nextProvider i18n={headerI18n}>
|
||||
<MantineProvider>
|
||||
<RunHeader comments={comments} />
|
||||
</MantineProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RunHeader — agent-run series header (F3)", () => {
|
||||
// 5 edits: 2 critical + 1 major + 1 minor + 1 unknown(verdict) => 3 "major".
|
||||
const series = () => [
|
||||
runComment("e1", "[Критично]"),
|
||||
runComment("e2", "[Критично]"),
|
||||
runComment("e3", "[Существенно]"),
|
||||
runComment("e4", "[Незначительно]"),
|
||||
runComment("e5", "[Неверно]"),
|
||||
];
|
||||
|
||||
it("shows the total edit count", () => {
|
||||
renderRunHeader(series());
|
||||
expect(screen.getByText(/5 edits/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("counts ONLY major+critical as major (not minor/unknown)", () => {
|
||||
renderRunHeader(series());
|
||||
// 2 critical + 1 major = 3; minor and the [Неверно] verdict are excluded.
|
||||
expect(screen.getByText(/3 major/)).toBeDefined();
|
||||
// Non-vacuous: the wrong tally (counting all 5, or 4) must NOT appear.
|
||||
expect(screen.queryByText(/5 major/)).toBeNull();
|
||||
expect(screen.queryByText(/4 major/)).toBeNull();
|
||||
});
|
||||
|
||||
it("omits the major segment entirely when there are no significant edits", () => {
|
||||
renderRunHeader([
|
||||
runComment("e1", "[Незначительно]"),
|
||||
runComment("e2", "[Неверно]"),
|
||||
]);
|
||||
expect(screen.getByText(/2 edits/)).toBeDefined();
|
||||
expect(screen.queryByText(/major/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the provenance line (agent role name)", () => {
|
||||
renderRunHeader(series());
|
||||
expect(screen.getAllByText("Corrector").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders NO 'Accept all' control (rejected by product)", () => {
|
||||
renderRunHeader(series());
|
||||
expect(screen.queryByText(/accept all/i)).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /accept all/i }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
Badge,
|
||||
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 (
|
||||
<Group gap={7} wrap="nowrap" align="flex-start">
|
||||
<Text
|
||||
ff="monospace"
|
||||
fw={600}
|
||||
fz={12}
|
||||
c={signColor}
|
||||
w={11}
|
||||
ta="center"
|
||||
aria-hidden
|
||||
style={{ flex: "none", lineHeight: 1.5 }}
|
||||
>
|
||||
{sign}
|
||||
</Text>
|
||||
<Text fz={13.5} c={baseColor} style={{ lineHeight: 1.45 }}>
|
||||
{segments.map((seg, i) =>
|
||||
seg.changed ? (
|
||||
<Box
|
||||
key={i}
|
||||
component="mark"
|
||||
px={3}
|
||||
fw={600}
|
||||
style={{
|
||||
background: markBg,
|
||||
color: markFg,
|
||||
borderRadius: 3,
|
||||
textDecoration: isDel ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{visibleWhitespace(seg.text)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
key={i}
|
||||
component="span"
|
||||
style={{ textDecoration: isDel ? "line-through" : "none" }}
|
||||
>
|
||||
{seg.text}
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Stack gap={1}>
|
||||
{!pureInsert && <DiffLine segments={before} kind="del" />}
|
||||
{!pureDelete && <DiffLine segments={after} kind="ins" />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const SEV_DOT: Record<Severity, { color: string; shade: number }> = {
|
||||
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 (
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
aria-hidden
|
||||
style={(t) => ({
|
||||
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 isApplied = comment.suggestionAppliedAt != null;
|
||||
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 (
|
||||
<Box
|
||||
p="10px 12px"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("Jump to comment selection")}
|
||||
onClick={() => scrollToCommentMark(comment.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
scrollToCommentMark(comment.id);
|
||||
}
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<Stack gap={8}>
|
||||
{showProvenance && comment.agent && (
|
||||
<Group
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
justify="space-between"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AgentAvatarStack
|
||||
agent={comment.agent}
|
||||
launcher={comment.launcher}
|
||||
aiChatId={comment.aiChatId}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" style={{ flex: "none" }}>
|
||||
{createdAtAgo}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<DiffBlock before={diff.old} after={diff.new} />
|
||||
|
||||
{/* Agent rationale — rendered through the static ProseMirror view, not
|
||||
restructured into a flat string. */}
|
||||
<Box fz="xs" c="dimmed">
|
||||
<CommentContentView content={comment.content} />
|
||||
</Box>
|
||||
|
||||
<Group gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<SeverityDot severity={severity} />
|
||||
{sevLabel && (
|
||||
<Text
|
||||
fz={10}
|
||||
fw={600}
|
||||
tt="uppercase"
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: ".06em", flex: 1 }}
|
||||
>
|
||||
{sevLabel}
|
||||
</Text>
|
||||
)}
|
||||
<Box style={{ flex: 1 }} />
|
||||
{/* Applied state (#315): a suggestion that was applied but kept alive by
|
||||
its replies (so #329 resolved instead of hard-deleting it) still
|
||||
shows it was applied — the badge the pre-redesign card carried. A
|
||||
childless applied suggestion is gone from the list entirely, so this
|
||||
only renders in the Resolved tab. */}
|
||||
{isApplied && (
|
||||
<Badge
|
||||
size="sm"
|
||||
color="green"
|
||||
variant="light"
|
||||
aria-label={t("Applied")}
|
||||
>
|
||||
{t("Applied")}
|
||||
</Badge>
|
||||
)}
|
||||
{showDismiss && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
color="gray"
|
||||
loading={dismissSuggestionMutation.isPending}
|
||||
disabled={pending}
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
{t("Dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
{showApply && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
loading={applySuggestionMutation.isPending}
|
||||
disabled={pending}
|
||||
onClick={handleApply}
|
||||
>
|
||||
{t("Apply")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box
|
||||
p="11px 13px"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
|
||||
>
|
||||
<Group gap={10} wrap="nowrap" justify="space-between">
|
||||
{head.agent ? (
|
||||
<AgentAvatarStack
|
||||
agent={head.agent}
|
||||
launcher={head.launcher}
|
||||
aiChatId={head.aiChatId}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" fw={600}>
|
||||
{head.creator?.name}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" style={{ flex: "none" }}>
|
||||
{createdAtAgo}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={11.5} c="dimmed" mt={4}>
|
||||
{t("{{count}} edits", { count: comments.length })}
|
||||
{majors > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<Text span c="orange.7" fw={600} inherit>
|
||||
{t("{{count}} major", { count: majors })}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(AgentEditCard);
|
||||
@@ -156,6 +156,125 @@ 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>): 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>): 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>): IComment =>
|
||||
({ suggestedText: "x", ...over }) as IComment;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Group, Text, Box } from "@mantine/core";
|
||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import React, { useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
@@ -12,11 +12,18 @@ 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";
|
||||
@@ -25,21 +32,13 @@ interface CommentListItemProps {
|
||||
comment: IComment;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
// 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).
|
||||
// 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).
|
||||
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,
|
||||
@@ -56,20 +55,29 @@ 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);
|
||||
|
||||
// `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;
|
||||
// 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],
|
||||
);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const isOwnerOrAdmin =
|
||||
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||
|
||||
const isAgent = comment.createdSource === "agent" && !!comment.agent;
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
@@ -113,9 +121,34 @@ function CommentListItem({
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentClick(target: IComment) {
|
||||
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) {
|
||||
const el = document.querySelector(
|
||||
`.comment-mark[data-comment-id="${target.id}"]`,
|
||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -136,10 +169,10 @@ function CommentListItem({
|
||||
|
||||
return (
|
||||
<Box ref={ref} pb={6}>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
{isAgent ? (
|
||||
<Group gap="xs">
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<AgentAvatarStack
|
||||
agent={comment.agent!}
|
||||
agent={comment.agent}
|
||||
launcher={comment.launcher}
|
||||
aiChatId={comment.aiChatId}
|
||||
showName={false}
|
||||
@@ -152,13 +185,13 @@ function CommentListItem({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{isAgent ? (
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<>
|
||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
||||
{comment.agent!.name}
|
||||
{comment.agent.name}
|
||||
</Text>
|
||||
{comment.launcher && (
|
||||
<>
|
||||
@@ -229,6 +262,87 @@ function CommentListItem({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
|
||||
carrying a suggestion. Old text struck-through/red, new text green. */}
|
||||
{!comment.parentCommentId && comment.suggestedText && (
|
||||
<Box className={classes.suggestionBlock}>
|
||||
{comment.selection && (
|
||||
// Old line: read as removed as a whole (line-through/red); only the
|
||||
// changed fragments carry the extra intraline emphasis.
|
||||
<Text size="xs" className={classes.suggestionOld}>
|
||||
{suggestionDiff?.old.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" className={classes.suggestionNew}>
|
||||
{suggestionDiff?.new.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
|
||||
{comment.suggestionAppliedAt ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
color="green"
|
||||
variant="light"
|
||||
mt={6}
|
||||
aria-label={t("Applied")}
|
||||
>
|
||||
{t("Applied")}
|
||||
</Badge>
|
||||
) : (
|
||||
(canShowApply(comment, canEdit) ||
|
||||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||
<Group gap="xs" mt={6}>
|
||||
{canShowApply(comment, canEdit) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={handleApplySuggestion}
|
||||
loading={applySuggestionMutation.isPending}
|
||||
disabled={
|
||||
applySuggestionMutation.isPending ||
|
||||
dismissSuggestionMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("Apply")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Dismiss ("Не применять", #329): removes the suggestion
|
||||
without changing the page text. Gated on canComment. */}
|
||||
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleDismissSuggestion}
|
||||
loading={dismissSuggestionMutation.isPending}
|
||||
disabled={
|
||||
applySuggestionMutation.isPending ||
|
||||
dismissSuggestionMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("Dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isEditing ? (
|
||||
<CommentContentView content={comment.content} />
|
||||
) : (
|
||||
@@ -236,9 +350,7 @@ function CommentListItem({
|
||||
<CommentEditor
|
||||
defaultContent={comment.content}
|
||||
editable={true}
|
||||
onUpdate={(newContent: any) => {
|
||||
editContentRef.current = newContent;
|
||||
}}
|
||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||
onSave={handleUpdateComment}
|
||||
autofocus={true}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Badge,
|
||||
@@ -14,9 +14,6 @@ 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,
|
||||
@@ -25,10 +22,6 @@ 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";
|
||||
@@ -76,32 +69,6 @@ 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 (
|
||||
<Box
|
||||
m="8px 10px"
|
||||
p={0}
|
||||
style={{
|
||||
background: "var(--mantine-color-body)",
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
@@ -123,8 +90,6 @@ 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) {
|
||||
@@ -148,17 +113,6 @@ 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.
|
||||
@@ -214,104 +168,56 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
[createCommentAsync, page?.id],
|
||||
);
|
||||
|
||||
// The full subtree for ONE top-level comment: its head card (thread row or
|
||||
// agent edit card), its nested replies, and a lazily-mounted reply editor.
|
||||
// Shared by a standalone card and a card inside an agent-run group so the
|
||||
// reply threading / lazy editor (#340) is wired identically in both.
|
||||
const renderCommentSubtree = useCallback(
|
||||
(comment: IComment, isEdit: boolean, showProvenance: boolean) => (
|
||||
<>
|
||||
{isEdit ? (
|
||||
<AgentEditCard
|
||||
const renderComments = useCallback(
|
||||
(comment: IComment) => (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
p="xs"
|
||||
mb="xs"
|
||||
withBorder
|
||||
key={comment.id}
|
||||
data-comment-id={comment.id}
|
||||
>
|
||||
<div>
|
||||
<CommentListItem
|
||||
comment={comment}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
showProvenance={showProvenance}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
) : (
|
||||
<Box p="xs">
|
||||
<CommentListItem
|
||||
comment={comment}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box px="xs">
|
||||
<MemoizedChildComments
|
||||
childrenByParent={childrenByParent}
|
||||
parentId={comment.id}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{!comment.resolvedAt && canComment && (
|
||||
<Box px="xs" pb="xs">
|
||||
<>
|
||||
<Divider my={2} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</Paper>
|
||||
),
|
||||
[
|
||||
childrenByParent,
|
||||
handleAddReply,
|
||||
page?.id,
|
||||
userSpaceRole,
|
||||
space?.membership?.role,
|
||||
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 (
|
||||
<PanelCard key={c.id} data-comment-id={c.id}>
|
||||
{renderCommentSubtree(c, isEdit, true)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
|
||||
// A collapsed agent run: one header, then each edit card (provenance
|
||||
// suppressed on the cards — the header carries the single provenance line).
|
||||
return (
|
||||
<PanelCard key={unit.key}>
|
||||
<RunHeader comments={unit.comments} />
|
||||
{unit.comments.map((c) => (
|
||||
<Box
|
||||
key={c.id}
|
||||
data-comment-id={c.id}
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
{renderCommentSubtree(c, true, false)}
|
||||
</Box>
|
||||
))}
|
||||
</PanelCard>
|
||||
);
|
||||
},
|
||||
[renderCommentSubtree],
|
||||
);
|
||||
|
||||
if (isCommentsLoading) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -320,6 +226,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
return <div>{t("Error loading comments.")}</div>;
|
||||
}
|
||||
|
||||
const totalComments = activeComments.length + resolvedComments.length;
|
||||
|
||||
const pageCommentInput = canComment ? (
|
||||
<PageCommentInput
|
||||
onSave={handleAddPageComment}
|
||||
@@ -420,7 +328,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
activeUnits.map(renderUnit)
|
||||
activeComments.map(renderComments)
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -439,7 +347,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
resolvedUnits.map(renderUnit)
|
||||
resolvedComments.map(renderComments)
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,53 @@
|
||||
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) {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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>): 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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
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<string, number>();
|
||||
for (const c of comments) {
|
||||
const key = runKey(c);
|
||||
if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const emitted = new Set<string>();
|
||||
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;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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<string, Severity> = {
|
||||
критично: "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";
|
||||
}
|
||||
@@ -77,3 +77,130 @@
|
||||
flex: 1;
|
||||
padding: rem(16px) rem(40px);
|
||||
}
|
||||
|
||||
/* #568 — redesigned desktop history window */
|
||||
|
||||
.desktopRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 78vh;
|
||||
max-height: rem(760px);
|
||||
}
|
||||
|
||||
.desktopToolbar {
|
||||
flex: none;
|
||||
height: rem(56px);
|
||||
padding: 0 rem(16px);
|
||||
border-bottom: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.desktopBody {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.navPanel {
|
||||
flex: none;
|
||||
width: rem(280px);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.navFilterRow {
|
||||
flex: none;
|
||||
height: rem(44px);
|
||||
padding: 0 rem(16px);
|
||||
border-bottom: rem(1px) solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.rightView {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.dayHeading {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: rem(9px) rem(16px) rem(5px);
|
||||
font-size: rem(10.5px);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-7));
|
||||
}
|
||||
|
||||
.revisionRow {
|
||||
margin: rem(1px) rem(6px);
|
||||
border-radius: rem(7px);
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.revisionRowActive {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-0),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* mini-calendar heatmap */
|
||||
.calDay {
|
||||
position: relative;
|
||||
height: rem(26px);
|
||||
border-radius: rem(6px);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: rem(10.5px);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.calHeat0 {
|
||||
background: transparent;
|
||||
}
|
||||
.calHeat1 {
|
||||
background: light-dark(#e7f0ff, rgba(34, 139, 230, 0.2));
|
||||
color: light-dark(#1971c2, #74c0fc);
|
||||
}
|
||||
.calHeat2 {
|
||||
background: light-dark(#a5c8ff, rgba(34, 139, 230, 0.45));
|
||||
color: light-dark(#0b3d91, #dbeafe);
|
||||
}
|
||||
.calHeat3 {
|
||||
background: #4c8dff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.calDaySelected {
|
||||
box-shadow: inset 0 0 0 rem(2px)
|
||||
light-dark(var(--mantine-color-dark-9), var(--mantine-color-gray-0));
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calDayOutside {
|
||||
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -16,28 +16,14 @@ import { memo, useCallback } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
// #568 — historyKindMeta moved to a pure module; re-exported here so existing
|
||||
// importers (history-list, this file) keep their import path unchanged.
|
||||
import { historyKindMeta } from "@/features/page-history/utils/history-kind-meta";
|
||||
|
||||
export { historyKindMeta };
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
/**
|
||||
* #370 — map a snapshot's intentionality tier to its badge. `version: true`
|
||||
* marks the intentional points (manual / agent); autosaves (boundary / idle /
|
||||
* legacy null) are non-versions and get dimmed in the list.
|
||||
*/
|
||||
type HistoryKindMeta = { labelKey: string; color: string; version: boolean };
|
||||
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
|
||||
switch (kind) {
|
||||
case "manual":
|
||||
return { labelKey: "Saved", color: "blue", version: true };
|
||||
case "agent":
|
||||
return { labelKey: "Agent version", color: "violet", version: true };
|
||||
case "boundary":
|
||||
return { labelKey: "Boundary", color: "gray", version: false };
|
||||
default: // "idle" | null | undefined (legacy autosave)
|
||||
return { labelKey: "Autosave", color: "gray", version: false };
|
||||
}
|
||||
}
|
||||
|
||||
interface HistoryItemProps {
|
||||
historyItem: IPageHistory;
|
||||
// The previous snapshot for diff/restore is resolved by id from the FULL list
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import HistoryList from "@/features/page-history/components/history-list";
|
||||
import classes from "./css/history.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
diffCountsAtom,
|
||||
highlightChangesAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import HistoryView from "@/features/page-history/components/history-view";
|
||||
import { useRef } from "react";
|
||||
import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useDiffNavigation,
|
||||
useHistoryReset,
|
||||
} from "@/features/page-history/hooks";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export default function HistoryModalBody({ pageId }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activeHistoryId = useAtomValue(activeHistoryIdAtom);
|
||||
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
|
||||
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||
const diffCounts = useAtomValue(diffCountsAtom);
|
||||
|
||||
useHistoryReset(pageId);
|
||||
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||
useDiffNavigation(scrollViewportRef);
|
||||
|
||||
return (
|
||||
<div className={classes.sidebarFlex}>
|
||||
<nav className={classes.sidebar}>
|
||||
<div className={classes.sidebarMain}>
|
||||
<HistoryList pageId={pageId} />
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div style={{ position: "relative", flex: 1 }}>
|
||||
<ScrollArea
|
||||
h={650}
|
||||
w="100%"
|
||||
scrollbarSize={5}
|
||||
viewportRef={scrollViewportRef}
|
||||
>
|
||||
<div className={classes.sidebarRightSection}>
|
||||
{activeHistoryId && <HistoryView />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{activeHistoryId && activeHistoryPrevId && (
|
||||
<Paper
|
||||
shadow="md"
|
||||
radius="xl"
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Switch
|
||||
label={t("Highlight changes")}
|
||||
checked={highlightChanges}
|
||||
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
|
||||
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
|
||||
/>
|
||||
{highlightChanges && diffCounts && diffCounts.total > 0 && (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{currentChangeIndex} of {diffCounts.total}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handlePrevChange}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleNextChange}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
activeHistoryIdAtom,
|
||||
activeHistoryPrevIdAtom,
|
||||
diffCountsAtom,
|
||||
highlightChangesAtom,
|
||||
} from "@/features/page-history/atoms/history-atoms";
|
||||
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query";
|
||||
import { usePageHistoryDayCounts } from "@/features/page-history/queries/day-counts-query";
|
||||
import HistoryView from "@/features/page-history/components/history-view";
|
||||
import HistoryNavPanel from "@/features/page-history/components/history-nav-panel";
|
||||
import {
|
||||
useDiffNavigation,
|
||||
useHistoryReset,
|
||||
useHistoryRestore,
|
||||
} from "@/features/page-history/hooks";
|
||||
import { resolvePrevSnapshotId } from "@/features/page-history/utils/resolve-prev-snapshot";
|
||||
import {
|
||||
dayGroupLabel,
|
||||
toRevisionRow,
|
||||
} from "@/features/page-history/utils/revision-row";
|
||||
import { viewerTimezone } from "@/features/page-history/work-time/work-time-service";
|
||||
import classes from "./css/history.module.css";
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* #568 — redesigned DESKTOP page-history window: a single-row header (title,
|
||||
* selected-version label, Highlight-changes toggle + N/M diff navigation, Restore,
|
||||
* close) over a two-pane body — LEFT navigation panel (calendar heatmap + dense
|
||||
* revision list), RIGHT the rendered version with change highlighting.
|
||||
*
|
||||
* Mechanics are REUSED verbatim: the same list query + atoms + diff engine +
|
||||
* restore hook as the original body. Only the layout and row density change.
|
||||
* The mobile branch (history-modal-mobile) is untouched.
|
||||
*/
|
||||
export default function HistoryModalDesktop({ pageId, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const tz = useMemo(() => viewerTimezone(), []);
|
||||
const now = useMemo(() => new Date(), []);
|
||||
const scrollViewportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
|
||||
const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom);
|
||||
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
|
||||
const diffCounts = useAtomValue(diffCountsAtom);
|
||||
const [onlyVersions, setOnlyVersions] = useState(false);
|
||||
|
||||
useHistoryReset(pageId);
|
||||
const { canRestore, confirmRestore } = useHistoryRestore();
|
||||
const { currentChangeIndex, handlePrevChange, handleNextChange } =
|
||||
useDiffNavigation(scrollViewportRef);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isLoading,
|
||||
} = usePageHistoryListQuery(pageId);
|
||||
const historyItems = useMemo(
|
||||
() => data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
// fail-open: heatmap counts degrade to an empty grid on error; the list and
|
||||
// restore keep working.
|
||||
const { data: dayCounts } = usePageHistoryDayCounts(pageId);
|
||||
const counts = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const d of dayCounts ?? []) map.set(d.dayISO, d.count);
|
||||
return map;
|
||||
}, [dayCounts]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
setActiveHistoryId(id);
|
||||
// Baseline = true previous snapshot in the FULL flat list (never the
|
||||
// filtered/grouped neighbour), so diff/restore stay correct under "Only
|
||||
// versions".
|
||||
setActiveHistoryPrevId(resolvePrevSnapshotId(historyItems, id));
|
||||
},
|
||||
[historyItems, setActiveHistoryId, setActiveHistoryPrevId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyItems.length > 0 && !activeHistoryId) {
|
||||
setActiveHistoryId(historyItems[0].id);
|
||||
setActiveHistoryPrevId(historyItems[1]?.id ?? "");
|
||||
}
|
||||
}, [historyItems, activeHistoryId, setActiveHistoryId, setActiveHistoryPrevId]);
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
const item = historyItems.find((i) => i.id === activeHistoryId);
|
||||
return item ? toRevisionRow(item, tz) : null;
|
||||
}, [historyItems, activeHistoryId, tz]);
|
||||
|
||||
const selectedLabel = selectedRow
|
||||
? `${dayGroupLabel(selectedRow, tz, now, t)} · ${selectedRow.atLabel}`
|
||||
: "—";
|
||||
|
||||
// #568 §C — Restore disabled for the newest snapshot (index 0, globally newest
|
||||
// even mid-pagination) and when the viewer lacks Manage-Page.
|
||||
const isNewestSelected =
|
||||
!!activeHistoryId && historyItems[0]?.id === activeHistoryId;
|
||||
const restoreDisabled = !canRestore || isNewestSelected || !selectedRow;
|
||||
|
||||
const showDiffNav =
|
||||
highlightChanges && !!diffCounts && diffCounts.total > 0;
|
||||
|
||||
return (
|
||||
<div className={classes.desktopRoot}>
|
||||
<Group className={classes.desktopToolbar} gap={14} wrap="nowrap">
|
||||
<Text fz={16} fw={600} style={{ flex: "none" }}>
|
||||
{t("Page history")}
|
||||
</Text>
|
||||
<Divider orientation="vertical" my={14} />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fz={12} fw={600} truncate>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<Text fz={10.5} c="dimmed">
|
||||
{t("Selected version")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Box style={{ flex: 1 }} />
|
||||
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: "none" }}>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={highlightChanges}
|
||||
onChange={(e) => setHighlightChanges(e.currentTarget.checked)}
|
||||
label={t("Highlight changes")}
|
||||
styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
|
||||
/>
|
||||
{showDiffNav && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{currentChangeIndex} {t("of")} {diffCounts.total}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("Previous change")}
|
||||
onClick={handlePrevChange}
|
||||
>
|
||||
<IconChevronUp size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("Next change")}
|
||||
onClick={handleNextChange}
|
||||
>
|
||||
<IconChevronDown size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Divider orientation="vertical" my={14} />
|
||||
|
||||
<Button
|
||||
size="compact-md"
|
||||
onClick={confirmRestore}
|
||||
disabled={restoreDisabled}
|
||||
style={{ flex: "none" }}
|
||||
>
|
||||
{t("Restore")}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
aria-label={t("Close")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<div className={classes.desktopBody}>
|
||||
<HistoryNavPanel
|
||||
fullItems={historyItems}
|
||||
activeId={activeHistoryId}
|
||||
onSelect={handleSelect}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={!!hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isError={isError}
|
||||
counts={counts}
|
||||
selectedDayISO={selectedRow?.dayISO ?? null}
|
||||
tz={tz}
|
||||
onlyVersions={onlyVersions}
|
||||
setOnlyVersions={setOnlyVersions}
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
className={classes.rightView}
|
||||
viewportRef={scrollViewportRef}
|
||||
scrollbarSize={5}
|
||||
>
|
||||
{/* F1 — the right pane mirrors the list state instead of going blank:
|
||||
error on a failed history query, an empty state for a page with no
|
||||
revisions, otherwise the rendered version. */}
|
||||
{isError ? (
|
||||
<Center h="100%" p={40}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Error fetching page data.")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : !isLoading && historyItems.length === 0 ? (
|
||||
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
|
||||
<Text fw={600} fz="sm">
|
||||
{t("No page history saved yet.")}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box p="26px 0" maw={720} mx="auto">
|
||||
{activeHistoryId && <HistoryView />}
|
||||
</Box>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Modal, Text } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms";
|
||||
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
|
||||
import HistoryModalDesktop from "@/features/page-history/components/history-modal-desktop";
|
||||
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
@@ -45,6 +45,9 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// #568 — the redesigned desktop window carries its OWN single-row header
|
||||
// (title + selected label + diff nav + Restore + close), so the Modal chrome is
|
||||
// dropped for desktop and the body renders edge-to-edge.
|
||||
return (
|
||||
<Modal.Root
|
||||
size={1400}
|
||||
@@ -54,16 +57,11 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header>
|
||||
<Modal.Title>
|
||||
<Text size="md" fw={500}>
|
||||
{t("Page history")}
|
||||
</Text>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton aria-label={t("Close")} />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<HistoryModalBody pageId={pageId} />
|
||||
<Modal.Body p={0}>
|
||||
<HistoryModalDesktop
|
||||
pageId={pageId}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import HistoryNavPanel from "./history-nav-panel";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { isoDayInTz } from "@/features/page-history/utils/revision-row";
|
||||
|
||||
// matchMedia / ResizeObserver are stubbed globally in vitest.setup.ts.
|
||||
// react-i18next with no initialized instance returns the key as the label.
|
||||
|
||||
const TZ = "UTC";
|
||||
const todayISO = isoDayInTz(new Date(), TZ);
|
||||
|
||||
function item(partial: Partial<IPageHistory>): IPageHistory {
|
||||
return {
|
||||
id: "x",
|
||||
// Default to "today" so the current-month calendar has a matching cell.
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||
...partial,
|
||||
} as IPageHistory;
|
||||
}
|
||||
|
||||
const manual = item({ id: "m1", kind: "manual", lastUpdatedSource: "user" });
|
||||
const autosave = item({ id: "a1", kind: null, lastUpdatedSource: "user" });
|
||||
|
||||
// Controlled harness so the "Only versions" switch actually toggles the filter,
|
||||
// exercising the switch → filter wiring (not just a static prop).
|
||||
function Harness({
|
||||
items = [manual, autosave],
|
||||
counts = new Map<string, number>(),
|
||||
onPick = vi.fn(),
|
||||
fetchNextPage = vi.fn(),
|
||||
}: {
|
||||
items?: IPageHistory[];
|
||||
counts?: Map<string, number>;
|
||||
onPick?: () => void;
|
||||
fetchNextPage?: () => Promise<any>;
|
||||
}) {
|
||||
const [onlyVersions, setOnlyVersions] = useState(false);
|
||||
return (
|
||||
<MantineProvider>
|
||||
<HistoryNavPanel
|
||||
fullItems={items}
|
||||
activeId=""
|
||||
onSelect={onPick}
|
||||
fetchNextPage={fetchNextPage as any}
|
||||
hasNextPage={false}
|
||||
isFetchingNextPage={false}
|
||||
isError={false}
|
||||
counts={counts}
|
||||
selectedDayISO={todayISO}
|
||||
tz={TZ}
|
||||
onlyVersions={onlyVersions}
|
||||
setOnlyVersions={setOnlyVersions}
|
||||
/>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("HistoryNavPanel (#568 left nav)", () => {
|
||||
beforeEach(() => {
|
||||
// jsdom has no scrollTo on elements — provide a spyable stub.
|
||||
// @ts-ignore
|
||||
Element.prototype.scrollTo = vi.fn();
|
||||
});
|
||||
|
||||
it("renders one dense row per revision and the mini-calendar", () => {
|
||||
render(<Harness />);
|
||||
expect(screen.getAllByTestId("revision-row")).toHaveLength(2);
|
||||
// 42-cell month grid is present.
|
||||
expect(screen.getAllByTestId("calendar-day")).toHaveLength(42);
|
||||
});
|
||||
|
||||
it("the 'Only versions' switch filters out autosaves (non-vacuous)", () => {
|
||||
render(<Harness />);
|
||||
// Both the manual version and the autosave are shown initially.
|
||||
expect(screen.getAllByTestId("revision-row")).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Only versions"));
|
||||
|
||||
// After filtering, only the manual VERSION row remains — if the filter
|
||||
// predicate were dropped this assertion would fail.
|
||||
const rows = screen.getAllByTestId("revision-row");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(screen.getByText("Alice")).toBeDefined();
|
||||
});
|
||||
|
||||
it("picking a loaded day scrolls the list to that day's anchor", () => {
|
||||
render(<Harness counts={new Map([[todayISO, 1]])} />);
|
||||
const todayCell = screen
|
||||
.getAllByTestId("calendar-day")
|
||||
.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||
fireEvent.click(todayCell);
|
||||
// The day IS loaded (the row is on `today`), so scrollTo is invoked directly
|
||||
// without any pagination fetch.
|
||||
expect(Element.prototype.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an explicit error state instead of a blank list", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<HistoryNavPanel
|
||||
fullItems={[]}
|
||||
activeId=""
|
||||
onSelect={vi.fn()}
|
||||
fetchNextPage={vi.fn() as any}
|
||||
hasNextPage={false}
|
||||
isFetchingNextPage={false}
|
||||
isError
|
||||
counts={new Map()}
|
||||
selectedDayISO={null}
|
||||
tz={TZ}
|
||||
onlyVersions={false}
|
||||
setOnlyVersions={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("Error loading page history.")).toBeDefined();
|
||||
expect(screen.queryAllByTestId("revision-row")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows an empty state (not blank) when there is no history", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<HistoryNavPanel
|
||||
fullItems={[]}
|
||||
activeId=""
|
||||
onSelect={vi.fn()}
|
||||
fetchNextPage={vi.fn() as any}
|
||||
hasNextPage={false}
|
||||
isFetchingNextPage={false}
|
||||
isError={false}
|
||||
counts={new Map()}
|
||||
selectedDayISO={null}
|
||||
tz={TZ}
|
||||
onlyVersions={false}
|
||||
setOnlyVersions={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("No page history saved yet.")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Box, Center, Group, Loader, ScrollArea, Switch, Text } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import {
|
||||
dayGroupLabel,
|
||||
groupRevisionsByDay,
|
||||
isoDayInTz,
|
||||
toRevisionRow,
|
||||
} from "@/features/page-history/utils/revision-row";
|
||||
import RevisionRow from "@/features/page-history/components/revision-row";
|
||||
import MiniCalendar from "@/features/page-history/components/mini-calendar";
|
||||
import classes from "./css/history.module.css";
|
||||
|
||||
// react-query's fetchNextPage returns the accumulated infinite result; typed
|
||||
// loosely here to avoid re-importing its generics.
|
||||
type FetchNextPage = () => Promise<{
|
||||
data?: { pages: Array<{ items: IPageHistory[] }> };
|
||||
hasNextPage?: boolean;
|
||||
}>;
|
||||
|
||||
interface Props {
|
||||
fullItems: IPageHistory[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onHover?: (id: string) => void;
|
||||
onHoverEnd?: () => void;
|
||||
fetchNextPage: FetchNextPage;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isError: boolean;
|
||||
counts: Map<string, number>;
|
||||
selectedDayISO: string | null;
|
||||
tz: string;
|
||||
onlyVersions: boolean;
|
||||
setOnlyVersions: (v: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* #568 — left navigation panel: "Only versions" filter + mini-calendar heatmap +
|
||||
* the DENSE, day-grouped revision list. The grouping is presentational only —
|
||||
* selection still resolves the previous snapshot from the FULL flat list (done
|
||||
* in the parent), so diff/restore never target a filtered neighbour.
|
||||
*/
|
||||
export default function HistoryNavPanel({
|
||||
fullItems,
|
||||
activeId,
|
||||
onSelect,
|
||||
onHover,
|
||||
onHoverEnd,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
counts,
|
||||
selectedDayISO,
|
||||
tz,
|
||||
onlyVersions,
|
||||
setOnlyVersions,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const rows = fullItems
|
||||
.map((item) => toRevisionRow(item, tz))
|
||||
// "Only versions": same predicate as the badge (manual/agent), so the
|
||||
// filter and the heatmap describe the same set.
|
||||
.filter((row) => (onlyVersions ? row.version : true));
|
||||
return groupRevisionsByDay(rows);
|
||||
}, [fullItems, tz, onlyVersions]);
|
||||
|
||||
// Bottom-sentinel auto-load (mirrors history-list.tsx).
|
||||
useEffect(() => {
|
||||
const sentinel = loadMoreRef.current;
|
||||
if (!sentinel || !hasNextPage) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const scrollToDay = useCallback((dayISO: string): boolean => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return false;
|
||||
// ISO 'YYYY-MM-DD' is attribute-selector safe (digits + hyphens).
|
||||
const el = viewport.querySelector(`[data-day="${dayISO}"]`);
|
||||
if (el instanceof HTMLElement) {
|
||||
viewport.scrollTo({ top: Math.max(el.offsetTop - 8, 0), behavior: "smooth" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const handlePickDay = useCallback(
|
||||
async (targetDayISO: string) => {
|
||||
if (scrollToDay(targetDayISO)) return;
|
||||
|
||||
// Not in the loaded pages yet — the list is newest-first, so pull pages
|
||||
// until the OLDEST loaded day is on/older than the target (a correct
|
||||
// dayISO boundary, NOT a fixed page count), or pagination is exhausted.
|
||||
let items = fullItems;
|
||||
let more = hasNextPage;
|
||||
let guard = 0;
|
||||
while (more && guard < 200) {
|
||||
const last = items[items.length - 1];
|
||||
const oldestDay = last
|
||||
? isoDayInTz(new Date(last.createdAt), tz)
|
||||
: null;
|
||||
if (oldestDay && oldestDay <= targetDayISO) break;
|
||||
const res = await fetchNextPage();
|
||||
items = res.data?.pages.flatMap((p) => p.items) ?? items;
|
||||
more = res.hasNextPage ?? false;
|
||||
guard++;
|
||||
}
|
||||
|
||||
// Let React paint the newly-loaded rows before scrolling to the anchor.
|
||||
requestAnimationFrame(() => {
|
||||
if (!scrollToDay(targetDayISO)) {
|
||||
// Unreachable day (exhausted pagination): explicit no-op + toast, never
|
||||
// a silent hang.
|
||||
notifications.show({
|
||||
message: t("No revisions found for that day"),
|
||||
color: "gray",
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
[scrollToDay, fullItems, hasNextPage, fetchNextPage, tz, t],
|
||||
);
|
||||
|
||||
const now = useMemo(() => new Date(), []);
|
||||
|
||||
return (
|
||||
<Box className={classes.navPanel}>
|
||||
<Group className={classes.navFilterRow} justify="space-between" wrap="nowrap">
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={onlyVersions}
|
||||
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
|
||||
label={t("Only versions")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<MiniCalendar
|
||||
counts={counts}
|
||||
selectedDayISO={selectedDayISO}
|
||||
onPickDay={handlePickDay}
|
||||
tz={tz}
|
||||
/>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewportRef} scrollbarSize={5}>
|
||||
{/* F1 — explicit error/empty states instead of a blank panel. The
|
||||
heatmap fails open independently; the list keeps its own states. */}
|
||||
{isError ? (
|
||||
<Center py="md" px="sm">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("Error loading page history.")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : groups.length === 0 ? (
|
||||
<Center py="md" px="sm">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{onlyVersions
|
||||
? t("No saved versions yet.")
|
||||
: t("No page history saved yet.")}
|
||||
</Text>
|
||||
</Center>
|
||||
) : null}
|
||||
{!isError &&
|
||||
groups.map((group) => (
|
||||
<Box key={group.dayISO}>
|
||||
<Text
|
||||
className={classes.dayHeading}
|
||||
data-day={group.dayISO}
|
||||
data-testid="day-heading"
|
||||
>
|
||||
{dayGroupLabel(group, tz, now, t)}
|
||||
</Text>
|
||||
{group.rows.map((row) => (
|
||||
<RevisionRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
selected={row.id === activeId}
|
||||
onSelect={onSelect}
|
||||
onHover={onHover}
|
||||
onHoverEnd={onHoverEnd}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{hasNextPage && <div ref={loadMoreRef} style={{ height: 1 }} />}
|
||||
{isFetchingNextPage && (
|
||||
<Center py="sm">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import MiniCalendar from "./mini-calendar";
|
||||
import { isoDayInTz } from "@/features/page-history/utils/revision-row";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const TZ = "UTC";
|
||||
|
||||
function renderCal(counts: Map<string, number>, onPickDay = vi.fn()) {
|
||||
const todayISO = isoDayInTz(new Date(), TZ);
|
||||
return {
|
||||
todayISO,
|
||||
onPickDay,
|
||||
...render(
|
||||
<MantineProvider>
|
||||
<MiniCalendar
|
||||
counts={counts}
|
||||
selectedDayISO={todayISO}
|
||||
onPickDay={onPickDay}
|
||||
tz={TZ}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("MiniCalendar (#568 heatmap)", () => {
|
||||
it("renders a 6x7 grid and highlights the selected day", () => {
|
||||
const { todayISO } = renderCal(new Map());
|
||||
const cells = screen.getAllByTestId("calendar-day");
|
||||
expect(cells).toHaveLength(42);
|
||||
const today = cells.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||
expect(today.className).toContain("calDaySelected");
|
||||
});
|
||||
|
||||
it("applies a heat class scaled to the day's revision count", () => {
|
||||
const day = isoDayInTz(new Date(), TZ);
|
||||
const { todayISO } = renderCal(new Map([[day, 7]]));
|
||||
const today = screen
|
||||
.getAllByTestId("calendar-day")
|
||||
.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||
// 7 revisions → top tier (calHeat3).
|
||||
expect(today.className).toContain("calHeat3");
|
||||
});
|
||||
|
||||
it("picking an in-month day delegates the dayISO to onPickDay", () => {
|
||||
const { todayISO, onPickDay } = renderCal(new Map());
|
||||
const today = screen
|
||||
.getAllByTestId("calendar-day")
|
||||
.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||
fireEvent.click(today);
|
||||
expect(onPickDay).toHaveBeenCalledWith(todayISO);
|
||||
});
|
||||
|
||||
it("Enter/Space activates a focusable day cell (F3 keyboard)", () => {
|
||||
const { todayISO, onPickDay } = renderCal(new Map());
|
||||
const today = screen
|
||||
.getAllByTestId("calendar-day")
|
||||
.find((c) => c.getAttribute("data-day") === todayISO)!;
|
||||
// In-month cells are focusable buttons.
|
||||
expect(today.getAttribute("role")).toBe("button");
|
||||
expect(today.getAttribute("tabindex")).toBe("0");
|
||||
fireEvent.keyDown(today, { key: "Enter" });
|
||||
fireEvent.keyDown(today, { key: " " });
|
||||
expect(onPickDay).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("day cells carry a non-color aria/title cue for the count (F3)", () => {
|
||||
const day = isoDayInTz(new Date(), TZ);
|
||||
renderCal(new Map([[day, 3]]));
|
||||
const today = screen
|
||||
.getAllByTestId("calendar-day")
|
||||
.find((c) => c.getAttribute("data-day") === day)!;
|
||||
// A non-color cue is wired (the interpolated count is filled by the real
|
||||
// i18next instance in-app; the test's key-fallback t returns the template).
|
||||
const label = today.getAttribute("aria-label") ?? "";
|
||||
expect(label).toContain("versions");
|
||||
// The same text is mirrored to the native tooltip.
|
||||
expect(today.getAttribute("title")).toBe(label);
|
||||
// Outside-month cells are inert (no aria-label / not focusable).
|
||||
const outside = screen
|
||||
.getAllByTestId("calendar-day")
|
||||
.find((c) => c.getAttribute("role") !== "button");
|
||||
expect(outside?.getAttribute("aria-label")).toBeNull();
|
||||
});
|
||||
|
||||
it("maps count tiers to heat classes at the prototype boundaries", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[0, "calHeat0"],
|
||||
[2, "calHeat1"],
|
||||
[3, "calHeat2"],
|
||||
[4, "calHeat2"],
|
||||
[5, "calHeat3"],
|
||||
];
|
||||
for (const [count, cls] of cases) {
|
||||
const day = isoDayInTz(new Date(), TZ);
|
||||
const { unmount } = renderCal(
|
||||
count > 0 ? new Map([[day, count]]) : new Map(),
|
||||
);
|
||||
const today = screen
|
||||
.getAllByTestId("calendar-day")
|
||||
.find((c) => c.getAttribute("data-day") === day)!;
|
||||
expect(today.className).toContain(cls);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { ActionIcon, Box, Button, Group, Text } from "@mantine/core";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
heatLevel,
|
||||
isoDayInTz,
|
||||
} from "@/features/page-history/utils/revision-row";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface CalendarCell {
|
||||
date: Date;
|
||||
dayISO: string;
|
||||
label: string;
|
||||
inMonth: boolean;
|
||||
}
|
||||
|
||||
/** Build a Monday-first 6×7 grid for `viewYear`/`viewMonth` (0-based month).
|
||||
* Cells are dated at local noon so `isoDayInTz` never straddles a DST midnight. */
|
||||
function buildGrid(
|
||||
viewYear: number,
|
||||
viewMonth: number,
|
||||
tz: string,
|
||||
): CalendarCell[] {
|
||||
const first = new Date(viewYear, viewMonth, 1, 12);
|
||||
// JS getDay(): 0=Sun..6=Sat → shift to Monday-first offset.
|
||||
const offset = (first.getDay() + 6) % 7;
|
||||
const cells: CalendarCell[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const date = new Date(viewYear, viewMonth, 1 - offset + i, 12);
|
||||
cells.push({
|
||||
date,
|
||||
dayISO: isoDayInTz(date, tz),
|
||||
label: String(date.getDate()),
|
||||
inMonth: date.getMonth() === viewMonth,
|
||||
});
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** dayISO → version-revision count (heatmap intensity). */
|
||||
counts: Map<string, number>;
|
||||
selectedDayISO: string | null;
|
||||
onPickDay: (dayISO: string) => void;
|
||||
tz: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* #568 — mini-calendar heatmap + date-jumper. Cell intensity = revisions that
|
||||
* day (`counts`); month navigation is purely client-side (whole history already
|
||||
* loaded). Picking a day delegates to `onPickDay(dayISO)` which scrolls/loads the
|
||||
* dense list to that day. Empty `counts` (fail-open) simply renders a blank grid.
|
||||
*/
|
||||
export default function MiniCalendar({
|
||||
counts,
|
||||
selectedDayISO,
|
||||
onPickDay,
|
||||
tz,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const now = new Date();
|
||||
const [view, setView] = useState({
|
||||
year: now.getFullYear(),
|
||||
month: now.getMonth(),
|
||||
});
|
||||
|
||||
const cells = useMemo(
|
||||
() => buildGrid(view.year, view.month, tz),
|
||||
[view.year, view.month, tz],
|
||||
);
|
||||
|
||||
const monthLabel = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(new Date(view.year, view.month, 1)),
|
||||
[view.year, view.month],
|
||||
);
|
||||
|
||||
const weekdays = useMemo(() => {
|
||||
const fmt = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||
// 2024-01-01 is a Monday — build a Monday-first header.
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
fmt.format(new Date(2024, 0, 1 + i)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const step = (delta: number) =>
|
||||
setView((v) => {
|
||||
const m = v.month + delta;
|
||||
return {
|
||||
year: v.year + Math.floor(m / 12),
|
||||
month: ((m % 12) + 12) % 12,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Box p="10px 12px 8px" className={classes.navFilterRow} style={{ height: "auto" }}>
|
||||
<Group gap={6} mb={6} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("Previous month")}
|
||||
onClick={() => step(-1)}
|
||||
>
|
||||
<IconChevronLeft size={16} />
|
||||
</ActionIcon>
|
||||
<Text fz={12} fw={600} style={{ flex: 1, textAlign: "center" }}>
|
||||
{monthLabel}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("Next month")}
|
||||
onClick={() => step(1)}
|
||||
>
|
||||
<IconChevronRight size={16} />
|
||||
</ActionIcon>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() =>
|
||||
setView({ year: now.getFullYear(), month: now.getMonth() })
|
||||
}
|
||||
>
|
||||
{t("Today")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Box style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2 }}>
|
||||
{weekdays.map((w, i) => (
|
||||
<Text key={i} ta="center" fz={9} fw={600} c="dimmed">
|
||||
{w}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(7,1fr)",
|
||||
gap: 2,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{cells.map((cell) => {
|
||||
const count = counts.get(cell.dayISO) ?? 0;
|
||||
const level = cell.inMonth ? heatLevel(count) : 0;
|
||||
const selected = cell.inMonth && cell.dayISO === selectedDayISO;
|
||||
// Non-color cue (F3 a11y): the count is announced, not conveyed by the
|
||||
// heat color alone — e.g. "12 Jul: 3 versions".
|
||||
const dayLabel = new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: tz,
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
}).format(cell.date);
|
||||
const ariaLabel = t("{{date}}: {{count}} versions", {
|
||||
date: dayLabel,
|
||||
count,
|
||||
});
|
||||
return (
|
||||
<Box
|
||||
key={cell.dayISO}
|
||||
data-testid="calendar-day"
|
||||
data-day={cell.dayISO}
|
||||
// Only in-month cells are interactive → focusable buttons with a
|
||||
// title/aria-label and Enter/Space activation; outside cells inert.
|
||||
role={cell.inMonth ? "button" : undefined}
|
||||
tabIndex={cell.inMonth ? 0 : undefined}
|
||||
aria-label={cell.inMonth ? ariaLabel : undefined}
|
||||
aria-pressed={cell.inMonth ? selected : undefined}
|
||||
title={cell.inMonth ? ariaLabel : undefined}
|
||||
onClick={() => cell.inMonth && onPickDay(cell.dayISO)}
|
||||
onKeyDown={(e) => {
|
||||
if (cell.inMonth && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
onPickDay(cell.dayISO);
|
||||
}
|
||||
}}
|
||||
className={clsx(
|
||||
classes.calDay,
|
||||
classes[`calHeat${level}` as keyof typeof classes],
|
||||
{
|
||||
[classes.calDaySelected]: selected,
|
||||
[classes.calDayOutside]: !cell.inMonth,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{cell.label}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import RevisionRow from "./revision-row";
|
||||
import { RevisionRowData } from "@/features/page-history/utils/revision-row";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
// react-i18next with no initialized instance returns the key as the label, so
|
||||
// t("Saved") renders "Saved" and t("via") renders "via".
|
||||
|
||||
function renderRow(row: RevisionRowData, onSelect = vi.fn()) {
|
||||
return {
|
||||
onSelect,
|
||||
...render(
|
||||
<MantineProvider>
|
||||
<RevisionRow row={row} selected={false} onSelect={onSelect} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const base: RevisionRowData = {
|
||||
id: "1",
|
||||
ts: 0,
|
||||
atLabel: "5:35 AM",
|
||||
dayISO: "2026-07-12",
|
||||
isAgent: false,
|
||||
authorName: "Alice",
|
||||
authorAvatarUrl: "",
|
||||
saved: false,
|
||||
version: true,
|
||||
};
|
||||
|
||||
describe("RevisionRow (#568 dense row)", () => {
|
||||
it("human manual version: avatar + name + time + SAVED badge", () => {
|
||||
renderRow({ ...base, saved: true });
|
||||
expect(screen.getByText("5:35 AM")).toBeDefined();
|
||||
expect(screen.getByText("Alice")).toBeDefined();
|
||||
// SAVED badge only when saved (kind === manual).
|
||||
expect(screen.getByText("Saved")).toBeDefined();
|
||||
expect(screen.queryByTestId("revision-agent-glyph")).toBeNull();
|
||||
});
|
||||
|
||||
it("autosave (not a version): dimmed, NO SAVED badge", () => {
|
||||
const { container } = renderRow({ ...base, saved: false, version: false });
|
||||
expect(screen.queryByText("Saved")).toBeNull();
|
||||
const rowEl = container.querySelector(
|
||||
'[data-testid="revision-row"]',
|
||||
) as HTMLElement;
|
||||
expect(rowEl.style.opacity).toBe("0.55");
|
||||
});
|
||||
|
||||
it("agent version: square role glyph + 'via <launcher>', NO SAVED badge", () => {
|
||||
renderRow({
|
||||
...base,
|
||||
isAgent: true,
|
||||
agentName: "Corrector",
|
||||
agentEmoji: "🛠",
|
||||
launcherName: "Bob",
|
||||
saved: false,
|
||||
});
|
||||
// Square role glyph carries the emoji.
|
||||
const glyph = screen.getByTestId("revision-agent-glyph");
|
||||
expect(glyph.textContent).toBe("🛠");
|
||||
expect(screen.getByText("Corrector")).toBeDefined();
|
||||
// "· via Bob" provenance.
|
||||
expect(screen.getByText(/via/)).toBeDefined();
|
||||
expect(screen.getByText(/Bob/)).toBeDefined();
|
||||
// Agent versions are marked by the glyph, not a duplicate SAVED badge.
|
||||
expect(screen.queryByText("Saved")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking the row selects it by id", () => {
|
||||
const onSelect = vi.fn();
|
||||
const { container } = renderRow(base, onSelect);
|
||||
fireEvent.click(
|
||||
container.querySelector('[data-testid="revision-row"]') as HTMLElement,
|
||||
);
|
||||
expect(onSelect).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("carries a data-day anchor for jump-to-day scrolling", () => {
|
||||
const { container } = renderRow(base);
|
||||
const rowEl = container.querySelector('[data-testid="revision-row"]');
|
||||
expect(rowEl?.getAttribute("data-day")).toBe("2026-07-12");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Badge, Box, Group, Text } from "@mantine/core";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo } from "react";
|
||||
import { RevisionRowData } from "@/features/page-history/utils/revision-row";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface Props {
|
||||
row: RevisionRowData;
|
||||
selected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onHover?: (id: string) => void;
|
||||
onHoverEnd?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* #568 — one revision = one dense row: `glyph · time · author · [SAVED]`.
|
||||
* - Agent identity (`row.isAgent`) → a SQUARE role glyph (emoji or initial) +
|
||||
* "via <launcher>"; a human → the round CustomAvatar. (Multi-contributor
|
||||
* stacks are deliberately dropped in the dense list — one glyph per row.)
|
||||
* - SAVED badge only when `row.saved` (kind === 'manual'); agent versions rely
|
||||
* on the glyph, autosaves are dimmed (`!row.version`), neither gets a badge.
|
||||
*/
|
||||
const RevisionRow = memo(function RevisionRow({
|
||||
row,
|
||||
selected,
|
||||
onSelect,
|
||||
onHover,
|
||||
onHoverEnd,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
h={30}
|
||||
px={12}
|
||||
data-testid="revision-row"
|
||||
data-day={row.dayISO}
|
||||
// Keyboard-accessible (F3 a11y): a focusable button-role row activated by
|
||||
// Enter/Space, not just a mouse onClick.
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(row.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(row.id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => onHover?.(row.id)}
|
||||
onMouseLeave={onHoverEnd}
|
||||
className={clsx(classes.revisionRow, {
|
||||
[classes.revisionRowActive]: selected,
|
||||
})}
|
||||
// #370 — dim autosnapshots so intentional versions stand out.
|
||||
style={{ opacity: row.version ? 1 : 0.55, cursor: "pointer" }}
|
||||
>
|
||||
{row.isAgent ? (
|
||||
<Box
|
||||
data-testid="revision-agent-glyph"
|
||||
style={{
|
||||
flex: "none",
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1,
|
||||
background: "var(--mantine-color-violet-light)",
|
||||
color: "var(--mantine-color-violet-filled)",
|
||||
}}
|
||||
aria-label={row.agentName}
|
||||
>
|
||||
{row.agentEmoji ?? row.agentName?.[0]?.toUpperCase() ?? "A"}
|
||||
</Box>
|
||||
) : (
|
||||
<CustomAvatar
|
||||
size={18}
|
||||
avatarUrl={row.authorAvatarUrl}
|
||||
name={row.authorName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text
|
||||
fz={12.5}
|
||||
c="dimmed"
|
||||
style={{ flex: "none", minWidth: 62 }}
|
||||
data-testid="revision-time"
|
||||
>
|
||||
{row.atLabel}
|
||||
</Text>
|
||||
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz={12} fw={row.isAgent ? 600 : 400} truncate>
|
||||
{row.isAgent ? row.agentName : row.authorName}
|
||||
</Text>
|
||||
{row.isAgent && row.launcherName && (
|
||||
<Text fz={11} c="dimmed" truncate>
|
||||
· {t("via")} {row.launcherName}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{row.saved && (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
tt="uppercase"
|
||||
>
|
||||
{t("Saved")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
export default RevisionRow;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
getPageHistoryDayCounts,
|
||||
IPageHistoryDayCount,
|
||||
} from "@/features/page-history/services/day-counts-service";
|
||||
import { viewerTimezone } from "@/features/page-history/work-time/work-time-service";
|
||||
|
||||
const DAY_COUNTS_STALE_TIME = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* #568 — revisions-per-day aggregate for the mini-calendar heatmap. The whole
|
||||
* history is fetched in one request (no `month` param) so month navigation is
|
||||
* purely client-side. `tz` is the SAME viewer zone the dense list groups days
|
||||
* in, so a heatmap cell and its list rows can never drift onto different days.
|
||||
*
|
||||
* fail-open: the caller renders an empty grid on error and the list/restore keep
|
||||
* working (the heatmap is navigational sugar, never a gate).
|
||||
*/
|
||||
export function usePageHistoryDayCounts(
|
||||
pageId: string,
|
||||
): UseQueryResult<IPageHistoryDayCount[], Error> {
|
||||
const tz = viewerTimezone();
|
||||
return useQuery({
|
||||
queryKey: ["page-history-day-counts", pageId, tz],
|
||||
queryFn: () => getPageHistoryDayCounts(pageId, tz),
|
||||
enabled: !!pageId,
|
||||
staleTime: DAY_COUNTS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import api from "@/lib/api-client";
|
||||
|
||||
/** #568 — one calendar day of the page-history heatmap: the number of *version*
|
||||
* revisions (manual/agent) on that day, keyed by 'YYYY-MM-DD' in the viewer tz.
|
||||
* Same set as the "Only versions" filter, so a lit day always has a list row. */
|
||||
export interface IPageHistoryDayCount {
|
||||
dayISO: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export async function getPageHistoryDayCounts(
|
||||
pageId: string,
|
||||
tz: string,
|
||||
): Promise<IPageHistoryDayCount[]> {
|
||||
const req = await api.post<IPageHistoryDayCount[]>(
|
||||
"/pages/history/day-counts",
|
||||
{ pageId, tz },
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* #370 — map a snapshot's intentionality tier to its badge metadata. `version:
|
||||
* true` marks the intentional points (manual / agent); autosaves (boundary /
|
||||
* idle / legacy null) are non-versions and get dimmed in the list.
|
||||
*
|
||||
* Extracted from history-item.tsx (#568) into a pure, UI-free module so the dense
|
||||
* revision-row adapter and the "Only versions" filter reuse the EXACT same
|
||||
* predicate as the badge — they can never drift when a new kind is added.
|
||||
*/
|
||||
export type HistoryKindMeta = {
|
||||
labelKey: string;
|
||||
color: string;
|
||||
version: boolean;
|
||||
};
|
||||
|
||||
export function historyKindMeta(kind?: string | null): HistoryKindMeta {
|
||||
switch (kind) {
|
||||
case "manual":
|
||||
return { labelKey: "Saved", color: "blue", version: true };
|
||||
case "agent":
|
||||
return { labelKey: "Agent version", color: "violet", version: true };
|
||||
case "boundary":
|
||||
return { labelKey: "Boundary", color: "gray", version: false };
|
||||
default: // "idle" | null | undefined (legacy autosave)
|
||||
return { labelKey: "Autosave", color: "gray", version: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
dayGroupLabel,
|
||||
groupRevisionsByDay,
|
||||
heatLevel,
|
||||
isoDayInTz,
|
||||
toRevisionRow,
|
||||
RevisionRowData,
|
||||
} from "./revision-row";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
|
||||
function hist(partial: Partial<IPageHistory>): IPageHistory {
|
||||
return {
|
||||
id: "id",
|
||||
createdAt: "2026-07-12T05:35:00Z",
|
||||
lastUpdatedBy: { id: "u1", name: "Alice", avatarUrl: "" },
|
||||
...partial,
|
||||
} as IPageHistory;
|
||||
}
|
||||
|
||||
describe("toRevisionRow — kind → badge/glyph mapping (#568/#370)", () => {
|
||||
it("manual → SAVED badge (saved) and a version, human glyph", () => {
|
||||
const row = toRevisionRow(
|
||||
hist({ kind: "manual", lastUpdatedSource: "user" }),
|
||||
"UTC",
|
||||
);
|
||||
expect(row.saved).toBe(true);
|
||||
expect(row.version).toBe(true);
|
||||
expect(row.isAgent).toBe(false);
|
||||
expect(row.authorName).toBe("Alice");
|
||||
});
|
||||
|
||||
it("agent → NO saved badge but IS a version, agent glyph + launcher (via)", () => {
|
||||
const row = toRevisionRow(
|
||||
hist({
|
||||
kind: "agent",
|
||||
lastUpdatedSource: "agent",
|
||||
agent: { name: "Corrector", emoji: "🛠" },
|
||||
launcher: { name: "Bob" },
|
||||
}),
|
||||
"UTC",
|
||||
);
|
||||
// SAVED is only for manual — the agent version relies on its glyph.
|
||||
expect(row.saved).toBe(false);
|
||||
expect(row.version).toBe(true);
|
||||
expect(row.isAgent).toBe(true);
|
||||
expect(row.agentName).toBe("Corrector");
|
||||
expect(row.agentEmoji).toBe("🛠");
|
||||
expect(row.launcherName).toBe("Bob");
|
||||
});
|
||||
|
||||
it("agent AUTOSAVE keeps agent identity (glyph) but is not a version, no badge", () => {
|
||||
// Two orthogonal signals: identity=agent, intentionality=idle (autosave).
|
||||
const row = toRevisionRow(
|
||||
hist({
|
||||
kind: "idle",
|
||||
lastUpdatedSource: "agent",
|
||||
agent: { name: "Factchecker", emoji: null },
|
||||
launcher: { name: "Bob" },
|
||||
}),
|
||||
"UTC",
|
||||
);
|
||||
expect(row.isAgent).toBe(true); // identity preserved
|
||||
expect(row.saved).toBe(false);
|
||||
expect(row.version).toBe(false); // dimmed autosave
|
||||
});
|
||||
|
||||
it("autosave (null kind, human) → not a version, dimmed, no badge, human glyph", () => {
|
||||
const row = toRevisionRow(
|
||||
hist({ kind: null, lastUpdatedSource: "user" }),
|
||||
"UTC",
|
||||
);
|
||||
expect(row.saved).toBe(false);
|
||||
expect(row.version).toBe(false);
|
||||
expect(row.isAgent).toBe(false);
|
||||
});
|
||||
|
||||
it("source=agent WITHOUT resolved agent → human glyph (no square glyph)", () => {
|
||||
const row = toRevisionRow(
|
||||
hist({ kind: "manual", lastUpdatedSource: "agent", agent: null }),
|
||||
"UTC",
|
||||
);
|
||||
expect(row.isAgent).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isoDayInTz — stable per-row day key in a tz", () => {
|
||||
it("formats YYYY-MM-DD and shifts with the tz", () => {
|
||||
// 02:00 UTC is the previous calendar day in America/New_York (22:00).
|
||||
const d = new Date("2026-07-04T02:00:00Z");
|
||||
expect(isoDayInTz(d, "UTC")).toBe("2026-07-04");
|
||||
expect(isoDayInTz(d, "America/New_York")).toBe("2026-07-03");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupRevisionsByDay — contiguous day buckets, order preserved", () => {
|
||||
it("buckets newest-first rows into day groups", () => {
|
||||
const rows: RevisionRowData[] = [
|
||||
{ dayISO: "2026-07-12", ts: 3 } as RevisionRowData,
|
||||
{ dayISO: "2026-07-12", ts: 2 } as RevisionRowData,
|
||||
{ dayISO: "2026-07-11", ts: 1 } as RevisionRowData,
|
||||
];
|
||||
const groups = groupRevisionsByDay(rows);
|
||||
expect(groups.map((g) => g.dayISO)).toEqual(["2026-07-12", "2026-07-11"]);
|
||||
expect(groups[0].rows).toHaveLength(2);
|
||||
expect(groups[1].rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dayGroupLabel — relative Today/Yesterday else absolute", () => {
|
||||
const now = new Date("2026-07-12T12:00:00Z");
|
||||
it("Today / Yesterday are relative and i18n-able", () => {
|
||||
expect(dayGroupLabel({ dayISO: "2026-07-12", ts: 0 }, "UTC", now)).toBe(
|
||||
"Today",
|
||||
);
|
||||
expect(dayGroupLabel({ dayISO: "2026-07-11", ts: 0 }, "UTC", now)).toBe(
|
||||
"Yesterday",
|
||||
);
|
||||
});
|
||||
it("older days fall back to an absolute label (not Today/Yesterday)", () => {
|
||||
const label = dayGroupLabel(
|
||||
{ dayISO: "2026-07-04", ts: Date.UTC(2026, 6, 4, 12) },
|
||||
"UTC",
|
||||
now,
|
||||
);
|
||||
expect(label).not.toBe("Today");
|
||||
expect(label).not.toBe("Yesterday");
|
||||
expect(label).toMatch(/4/); // day number present
|
||||
});
|
||||
});
|
||||
|
||||
describe("heatLevel — prototype heat() thresholds", () => {
|
||||
it("maps counts to 0/1/2/3 tiers", () => {
|
||||
expect(heatLevel(0)).toBe(0);
|
||||
expect(heatLevel(1)).toBe(1);
|
||||
expect(heatLevel(2)).toBe(1);
|
||||
expect(heatLevel(3)).toBe(2);
|
||||
expect(heatLevel(4)).toBe(2);
|
||||
expect(heatLevel(5)).toBe(3);
|
||||
expect(heatLevel(99)).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { historyKindMeta } from "@/features/page-history/utils/history-kind-meta";
|
||||
|
||||
/**
|
||||
* #568 — pure `IPageHistory → dense-row` adapter for the redesigned page-history
|
||||
* panel. No React / Mantine here so it is unit-tested without mounting.
|
||||
*
|
||||
* TWO orthogonal signals are honored (per #370/#300):
|
||||
* - GLYPH is by AUTHOR IDENTITY: `lastUpdatedSource === 'agent'` with an
|
||||
* `agent` present → a square role glyph + "via <launcher>"; otherwise the
|
||||
* human's round avatar. (An agent AUTOSAVE keeps its agent identity.)
|
||||
* - BADGE is by INTENTIONALITY, simplified to a single SAVED badge: only
|
||||
* `kind === 'manual'`. Agent versions are distinguished by the glyph (no
|
||||
* duplicate badge); autosaves are dimmed (`version === false`).
|
||||
*/
|
||||
|
||||
/** 'YYYY-MM-DD' for `date` in `tz` — the stable per-row key used for day
|
||||
* grouping, jump-to-day and matching heatmap cells. Matches the server's
|
||||
* `isoDay` format (en-CA yields ISO order), so both sides agree. */
|
||||
export function isoDayInTz(date: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** Localized clock label for `date` in `tz` (e.g. "5:35 AM"). */
|
||||
export function timeLabelInTz(date: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: tz,
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export interface RevisionRowData {
|
||||
id: string;
|
||||
/** epoch-ms, kept for jump-to-day boundary math. */
|
||||
ts: number;
|
||||
atLabel: string;
|
||||
dayISO: string;
|
||||
/** author-identity glyph: true → square agent role glyph + "via launcher". */
|
||||
isAgent: boolean;
|
||||
agentName?: string;
|
||||
agentEmoji?: string | null;
|
||||
launcherName?: string | null;
|
||||
authorName?: string;
|
||||
authorAvatarUrl?: string;
|
||||
/** intentionality badge: SAVED shown only when true (kind === 'manual'). */
|
||||
saved: boolean;
|
||||
/** historyKindMeta().version — non-versions (autosaves) are dimmed. */
|
||||
version: boolean;
|
||||
}
|
||||
|
||||
export function toRevisionRow(item: IPageHistory, tz: string): RevisionRowData {
|
||||
const date = new Date(item.createdAt);
|
||||
const isAgent = item.lastUpdatedSource === "agent" && !!item.agent;
|
||||
return {
|
||||
id: item.id,
|
||||
ts: date.getTime(),
|
||||
atLabel: timeLabelInTz(date, tz),
|
||||
dayISO: isoDayInTz(date, tz),
|
||||
isAgent,
|
||||
agentName: item.agent?.name,
|
||||
agentEmoji: item.agent?.emoji,
|
||||
launcherName: item.launcher?.name ?? null,
|
||||
authorName: item.lastUpdatedBy?.name,
|
||||
authorAvatarUrl: item.lastUpdatedBy?.avatarUrl,
|
||||
saved: item.kind === "manual",
|
||||
version: historyKindMeta(item.kind).version,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RevisionDayGroup {
|
||||
dayISO: string;
|
||||
/** Representative epoch-ms of the group (first row) for label formatting. */
|
||||
ts: number;
|
||||
rows: RevisionRowData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group already-ordered (newest-first) rows into contiguous day buckets. The
|
||||
* grouping is PURELY presentational — diff/restore still resolve the previous
|
||||
* snapshot from the FULL flat list, never from a group. Rows for one day always
|
||||
* arrive contiguous because the list is time-ordered, so a single pass suffices.
|
||||
*/
|
||||
export function groupRevisionsByDay(
|
||||
rows: RevisionRowData[],
|
||||
): RevisionDayGroup[] {
|
||||
const groups: RevisionDayGroup[] = [];
|
||||
for (const row of rows) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.dayISO === row.dayISO) {
|
||||
last.rows.push(row);
|
||||
} else {
|
||||
groups.push({ dayISO: row.dayISO, ts: row.ts, rows: [row] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative day-group heading: "Today" / "Yesterday" (i18n via `t`) else an
|
||||
* absolute "Mon 12 Jul" (locale-formatted, no year to stay compact). `now` and
|
||||
* `tz` are injected so the function stays pure/testable.
|
||||
*/
|
||||
export function dayGroupLabel(
|
||||
group: Pick<RevisionDayGroup, "dayISO" | "ts">,
|
||||
tz: string,
|
||||
now: Date,
|
||||
t: (key: string) => string = (k) => k,
|
||||
): string {
|
||||
const todayISO = isoDayInTz(now, tz);
|
||||
const yesterdayISO = isoDayInTz(
|
||||
new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||
tz,
|
||||
);
|
||||
if (group.dayISO === todayISO) return t("Today");
|
||||
if (group.dayISO === yesterdayISO) return t("Yesterday");
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: tz,
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
}).format(new Date(group.ts));
|
||||
}
|
||||
|
||||
/** heatmap intensity tier for a day's revision count (thresholds from the
|
||||
* prototype's `heat()`): 0 none, 1 few (≤2), 2 some (≤4), 3 many. */
|
||||
export function heatLevel(count: number): 0 | 1 | 2 | 3 {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 2) return 1;
|
||||
if (count <= 4) return 2;
|
||||
return 3;
|
||||
}
|
||||
@@ -59,6 +59,17 @@ export class PageWorkTimeDto extends PageIdDto {
|
||||
tz?: string;
|
||||
}
|
||||
|
||||
export class PageHistoryDayCountsDto extends PageIdDto {
|
||||
// #568 — viewer IANA timezone the revisions-per-day heatmap is bucketed in.
|
||||
// Optional (falls back to UTC server-side); length-capped so a bogus value
|
||||
// cannot bloat the request. The value only ever reaches Intl.DateTimeFormat,
|
||||
// which throws on an unknown zone (caught by the controller → 400).
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
tz?: string;
|
||||
}
|
||||
|
||||
export class DeletePageDto extends PageIdDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -111,4 +111,89 @@ describe('PageController', () => {
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
|
||||
// #568 — the revisions-per-day heatmap endpoint must be gated exactly like
|
||||
// /history and /history/time (view-gated, bad tz → 400).
|
||||
describe('getPageHistoryDayCounts', () => {
|
||||
const user = { id: 'u1' } as any;
|
||||
|
||||
function build(overrides: {
|
||||
page?: any;
|
||||
validate?: jest.Mock;
|
||||
compute?: jest.Mock;
|
||||
}) {
|
||||
const pageRepo = { findById: jest.fn().mockResolvedValue(overrides.page) };
|
||||
const pageAccessService = {
|
||||
validateCanView:
|
||||
overrides.validate ?? jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const pageHistoryService = {
|
||||
computeDayCounts:
|
||||
overrides.compute ?? jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const c = new PageController(
|
||||
{} as any,
|
||||
pageRepo as any,
|
||||
pageHistoryService as any,
|
||||
{} as any,
|
||||
pageAccessService as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
return { c, pageRepo, pageAccessService, pageHistoryService };
|
||||
}
|
||||
|
||||
it('404s when the page does not exist', async () => {
|
||||
const { c } = build({ page: null });
|
||||
await expect(
|
||||
c.getPageHistoryDayCounts({ pageId: 'p1' } as any, user),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('enforces validateCanView before computing, then delegates with tz', async () => {
|
||||
const validate = jest.fn().mockResolvedValue(undefined);
|
||||
const compute = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ dayISO: '2026-07-04', count: 3 }]);
|
||||
const { c } = build({ page: { id: 'pg' }, validate, compute });
|
||||
const out = await c.getPageHistoryDayCounts(
|
||||
{ pageId: 'pg', tz: 'Europe/Moscow' } as any,
|
||||
user,
|
||||
);
|
||||
expect(validate).toHaveBeenCalledWith({ id: 'pg' }, user);
|
||||
expect(compute).toHaveBeenCalledWith('pg', 'Europe/Moscow');
|
||||
expect(out).toEqual([{ dayISO: '2026-07-04', count: 3 }]);
|
||||
});
|
||||
|
||||
it('propagates a denied view gate and does NOT reach compute (security)', async () => {
|
||||
const validate = jest.fn().mockRejectedValue(new ForbiddenException());
|
||||
const compute = jest.fn().mockResolvedValue([]);
|
||||
const { c, pageHistoryService } = build({
|
||||
page: { id: 'pg' },
|
||||
validate,
|
||||
compute,
|
||||
});
|
||||
await expect(
|
||||
c.getPageHistoryDayCounts({ pageId: 'pg' } as any, user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(pageHistoryService.computeDayCounts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps an unknown-timezone RangeError to a 400', async () => {
|
||||
const compute = jest.fn().mockRejectedValue(new RangeError('bad tz'));
|
||||
const { c } = build({ page: { id: 'pg' }, compute });
|
||||
await expect(
|
||||
c.getPageHistoryDayCounts({ pageId: 'pg', tz: 'X/Y' } as any, user),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('does not swallow a non-RangeError from the service', async () => {
|
||||
const compute = jest.fn().mockRejectedValue(new Error('db down'));
|
||||
const { c } = build({ page: { id: 'pg' }, compute });
|
||||
await expect(
|
||||
c.getPageHistoryDayCounts({ pageId: 'pg' } as any, user),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UpdatePageDto } from './dto/update-page.dto';
|
||||
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
||||
import {
|
||||
DeletePageDto,
|
||||
PageHistoryDayCountsDto,
|
||||
PageHistoryIdDto,
|
||||
PageIdDto,
|
||||
PageInfoDto,
|
||||
@@ -558,6 +559,33 @@ export class PageController {
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history/day-counts')
|
||||
async getPageHistoryDayCounts(
|
||||
@Body() dto: PageHistoryDayCountsDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId);
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// #568 — same view gate as /history and /history/time: never expose a page's
|
||||
// revision counts (even without content) to a non-viewer.
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
try {
|
||||
return await this.pageHistoryService.computeDayCounts(page.id, dto.tz);
|
||||
} catch (e) {
|
||||
// Intl.DateTimeFormat throws RangeError on an unknown IANA zone; surface
|
||||
// it as a 400 rather than a 500 (same contract as /history/time).
|
||||
if (e instanceof RangeError) {
|
||||
throw new BadRequestException('Invalid timezone');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history/info')
|
||||
async getPageHistoryInfo(
|
||||
|
||||
@@ -6,9 +6,11 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
|
||||
import {
|
||||
computeWorkTime,
|
||||
bucketByDay,
|
||||
countRevisionsByDay,
|
||||
DEFAULT_WORK_TIME_CONFIG,
|
||||
WorkTimeConfig,
|
||||
PerDay,
|
||||
DayCount,
|
||||
} from '../work-time';
|
||||
|
||||
export interface PageWorkTime {
|
||||
@@ -69,4 +71,20 @@ export class PageHistoryService {
|
||||
tz,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #568 — "revisions per day" aggregate for the page-history mini-calendar
|
||||
* heatmap. Reads only the cheap timeline projection (no `content`) — the same
|
||||
* source as computeWorkTime — and tallies VERSION rows (manual/agent) into the
|
||||
* viewer's calendar days, reusing the shared tz core. Whole history in one
|
||||
* request (no `month` param) so month navigation is purely client-side and the
|
||||
* counts always match the "Only versions" list.
|
||||
*
|
||||
* `tz` is the viewer's IANA zone; an unknown zone makes the Intl-backed core
|
||||
* throw a RangeError, which the controller maps to a 400 (like /history/time).
|
||||
*/
|
||||
async computeDayCounts(pageId: string, tz = 'UTC'): Promise<DayCount[]> {
|
||||
const rows = await this.pageHistoryRepo.findTimelineByPageId(pageId);
|
||||
return countRevisionsByDay(rows, tz);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ function nextDayStart(dayStart: number, tz: string): number {
|
||||
return zonedDayStart(dayStart + 26 * 60 * 60 * 1000, tz);
|
||||
}
|
||||
|
||||
function isoDay(dayStart: number, tz: string): string {
|
||||
export function isoDay(dayStart: number, tz: string): string {
|
||||
const p = wallParts(dayStart, tz);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { countRevisionsByDay } from './day-counts';
|
||||
import { TimelineSample } from './work-time.types';
|
||||
|
||||
function row(createdAt: string, kind: string | null): TimelineSample {
|
||||
return {
|
||||
createdAt,
|
||||
kind,
|
||||
lastUpdatedById: 'h',
|
||||
lastUpdatedSource: 'user',
|
||||
lastUpdatedAiChatId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('countRevisionsByDay (#568 heatmap aggregate)', () => {
|
||||
it('empty input → no days', () => {
|
||||
expect(countRevisionsByDay([], 'UTC')).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts only VERSION rows (manual/agent); ignores autosnapshots', () => {
|
||||
const rows: TimelineSample[] = [
|
||||
row('2026-07-04T10:00:00Z', 'manual'),
|
||||
row('2026-07-04T11:00:00Z', 'agent'),
|
||||
row('2026-07-04T12:00:00Z', 'idle'), // ignored
|
||||
row('2026-07-04T13:00:00Z', 'boundary'), // ignored
|
||||
row('2026-07-04T14:00:00Z', null), // legacy autosave, ignored
|
||||
];
|
||||
expect(countRevisionsByDay(rows, 'UTC')).toEqual([
|
||||
{ dayISO: '2026-07-04', count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('groups by calendar day and returns days ascending', () => {
|
||||
const rows: TimelineSample[] = [
|
||||
row('2026-07-06T09:00:00Z', 'manual'),
|
||||
row('2026-07-04T09:00:00Z', 'manual'),
|
||||
row('2026-07-04T20:00:00Z', 'agent'),
|
||||
row('2026-07-05T01:00:00Z', 'manual'),
|
||||
];
|
||||
expect(countRevisionsByDay(rows, 'UTC')).toEqual([
|
||||
{ dayISO: '2026-07-04', count: 2 },
|
||||
{ dayISO: '2026-07-05', count: 1 },
|
||||
{ dayISO: '2026-07-06', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('buckets in the requested tz — the same instant can fall on a different day', () => {
|
||||
// 2026-07-04T02:00:00Z is still 2026-07-03 (22:00) in America/New_York.
|
||||
const rows: TimelineSample[] = [row('2026-07-04T02:00:00Z', 'manual')];
|
||||
expect(countRevisionsByDay(rows, 'UTC')).toEqual([
|
||||
{ dayISO: '2026-07-04', count: 1 },
|
||||
]);
|
||||
expect(countRevisionsByDay(rows, 'America/New_York')).toEqual([
|
||||
{ dayISO: '2026-07-03', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws a RangeError on an unknown tz (mapped to 400 by the controller)', () => {
|
||||
const rows: TimelineSample[] = [row('2026-07-04T10:00:00Z', 'manual')];
|
||||
expect(() => countRevisionsByDay(rows, 'Mars/Phobos')).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('tolerates a Date instance for createdAt (driver may hand back Date)', () => {
|
||||
const rows = [
|
||||
{ ...row('x', 'manual'), createdAt: new Date('2026-07-04T10:00:00Z') },
|
||||
];
|
||||
expect(countRevisionsByDay(rows, 'UTC')).toEqual([
|
||||
{ dayISO: '2026-07-04', count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { TimelineSample } from './work-time.types';
|
||||
import { zonedDayStart, isoDay } from './bucket-by-day';
|
||||
|
||||
/** One calendar day of the page-history heatmap (#568): the number of
|
||||
* *version* revisions (kind ∈ {manual, agent}) that landed on that day. */
|
||||
export interface DayCount {
|
||||
/** 'YYYY-MM-DD' in the requested tz — the same stable key the client uses to
|
||||
* group the dense revision list, so a heatmap cell always maps to a list row. */
|
||||
dayISO: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* #568 — pure, tz-aware "revisions per day" bucketer for the mini-calendar
|
||||
* heatmap. Reuses the already-tested tz core (`zonedDayStart` + `isoDay` from
|
||||
* bucket-by-day.ts) so DST/day boundaries stay identical to the work-time
|
||||
* punch-card — no copy-pasted date math.
|
||||
*
|
||||
* Only VERSION rows are counted (`kind === 'manual' | 'agent'`): the heatmap and
|
||||
* the client's "Only versions" filter then describe the same set, so every lit
|
||||
* day has a visible target in the list (issue criterion 6). Autosnapshots
|
||||
* (idle/boundary/legacy null) are ignored.
|
||||
*
|
||||
* Returns days ascending by `dayISO`; days with no version revision are omitted
|
||||
* (the client renders a full month grid and treats a missing day as count 0).
|
||||
*/
|
||||
export function countRevisionsByDay(
|
||||
rows: ReadonlyArray<Pick<TimelineSample, 'createdAt' | 'kind'>>,
|
||||
tz: string,
|
||||
): DayCount[] {
|
||||
const tally = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
if (row.kind !== 'manual' && row.kind !== 'agent') continue;
|
||||
const ms = new Date(row.createdAt).getTime();
|
||||
if (Number.isNaN(ms)) continue;
|
||||
const key = isoDay(zonedDayStart(ms, tz), tz);
|
||||
tally.set(key, (tally.get(key) ?? 0) + 1);
|
||||
}
|
||||
return [...tally.entries()]
|
||||
.map(([dayISO, count]) => ({ dayISO, count }))
|
||||
.sort((a, b) => (a.dayISO < b.dayISO ? -1 : a.dayISO > b.dayISO ? 1 : 0));
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export { computeWorkTime } from './compute-work-time';
|
||||
export { bucketByDay, zonedDayStart } from './bucket-by-day';
|
||||
export { bucketByDay, zonedDayStart, isoDay } from './bucket-by-day';
|
||||
export { countRevisionsByDay } from './day-counts';
|
||||
export type { DayCount } from './day-counts';
|
||||
export {
|
||||
DEFAULT_WORK_TIME_CONFIG,
|
||||
resolveWorkTimeConfig,
|
||||
|
||||
Reference in New Issue
Block a user