Compare commits

..

3 Commits

Author SHA1 Message Date
vvzvlad 624e03a9e3 Merge pull request 'feat(comments): редизайн панели комментариев — обе карточки на новом визуале (#561)' (#577) from feat/561-comments-redesign into develop
Reviewed-on: #577
2026-07-12 23:22:43 +03:00
agent_coder be8ed579a9 fix(comments): review fixes for the edit-card redesign (#561)
- F1: restore the "Applied" badge on an applied suggested-edit card (green light
  Badge + aria-label, reusing the pre-redesign t("Applied") key). Renders only
  when suggestionAppliedAt is set (a reply-kept, resolved suggestion — a
  childless applied one is hard-deleted per #329), never for a pending edit.
- F2: add the four new t() keys introduced by the redesign to both locales —
  "Critical", "Major", "{{count}} edits", "{{count}} major" (en value==key; ru
  translated, ICU plural for the count keys, placeholders intact).
- F3: test RunHeader — edit count, major = major+critical only (not
  minor/unknown, non-vacuous), provenance line, no "Accept all" control. Uses an
  isolated initialised i18n instance so the interpolated count keys render.
- F4: restore the card aria-label (t("Jump to comment selection")) on the
  clickable edit card for screen readers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:14:01 +03:00
agent_coder d542fc58ae feat(comments): интеграция редизайна панели комментариев (обе карточки) (#561)
Port the NewDesign/CommentsPanel prototype onto the entire live comment panel,
both card types (agent suggested-edit + human/agent thread) on the new visual,
reusing existing mutations/gates/editor/menu/resolve/anchor-nav (not forked),
zero backend changes. New: agent-edit-card, severity.ts, group-agent-runs.ts.
Delete only the CommentsPanel prototype (TimeWorkedModal/PageHistoryModal belong
to #566/#568 and are deleted by their own PRs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:14:01 +03:00
38 changed files with 1628 additions and 2699 deletions
-460
View File
@@ -1,460 +0,0 @@
/**
* CommentsPanel — редизайн панели комментариев с фокусом на агентские правки.
* Mantine v7. Узкая колонка ~340–400px, светлая/тёмная темы.
*
* Реализованные решения (см. README.md):
* - Дифф-первая карточка: цитата = старая строка диффа (без тройного дублирования)
* - Группировка серии прогона под одной шапкой ("Корректор · 28 правок")
* - Пакетное "Accept all" на фронте (по одной под капотом) с полоской прогресса
* - Метки важности/категории парсятся клиентом из тегов "[Корректура][Существенно]"
* - Фильтры по важности/категории
* - Безопасный Dismiss через undo-тост (удаление откладывается на клиенте)
* - Состояния: pending / applied / dismissed / conflict (потерян якорь)
* - Крайние диффы: вставка (␣), удаление, одна буква, дефис→тире, длинный абзац
* - Человеческий тред не деградирует (та же система, другое наполнение)
* - Вкладки Open/Resolved сохранены
*/
import { useMemo, useState, useCallback, useRef } from 'react';
import {
Box, Group, Stack, Text, Badge, Button, ActionIcon, Tabs, Progress,
Avatar, Tooltip, ScrollArea, useMantineColorScheme, useMantineTheme,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
/* ─────────────────────────── Типы данных ─────────────────────────── */
export type Severity = 'critical' | 'major' | 'minor';
export type Category = string; // "Корректура" | "Факт" | "Стиль" | …
/** Один сегмент интра-диффа: изменённый фрагмент подсвечивается. */
export interface DiffSegment {
text: string;
changed: boolean;
}
/** Правка: сервер уже отдаёт посегментную разметку обеих строк. */
export interface SuggestedEdit {
before: DiffSegment[]; // "было" (пустой массив ⇒ чистая вставка)
after: DiffSegment[]; // "стало" (пустой массив ⇒ чистое удаление)
}
export type CommentStatus = 'pending' | 'applied' | 'dismissed' | 'conflict';
export interface Comment {
id: string;
runId?: string; // id прогона агента — для группировки серии
authorName: string; // "Корректор" | "Нарратор" | имя человека
authorKind: 'agent' | 'human';
triggeredBy?: string; // кто запустил агента ("vvzvlad")
createdAtLabel: string; // "15 ч", "2 дн" — форматируется вызывающим кодом
/** Сырой текст комментария от агента, метки в квадратных скобках. */
bodyRaw: string;
edit?: SuggestedEdit; // есть ⇒ агентская правка; нет ⇒ обычный тред
status: CommentStatus;
replyCount?: number;
anchorLost?: boolean; // сервер ответил конфликтом якоря
}
export interface CommentsPanelProps {
comments: Comment[];
/** Применить одну правку. Резолвит тред на сервере. */
onApply: (id: string) => Promise<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 cant 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;
+362
View File
@@ -0,0 +1,362 @@
/**
* 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,6 +48,7 @@
"Create page": "Create page",
"Create space": "Create space",
"Create workspace": "Create workspace",
"Critical": "Critical",
"Current password": "Current password",
"Dark": "Dark",
"Date": "Date",
@@ -125,6 +126,7 @@
"Link copied": "Link copied",
"Login": "Login",
"Logout": "Logout",
"Major": "Major",
"Manage Group": "Manage Group",
"Manage members": "Manage members",
"member": "member",
@@ -453,6 +455,8 @@
"{{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",
@@ -1470,14 +1474,5 @@
"≈ {{minutes}}m": "≈ {{minutes}}m",
"{{hours}}h {{minutes}}m": "{{hours}}h {{minutes}}m",
"{{hours}}h": "{{hours}}h",
"{{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"
"{{minutes}}m": "{{minutes}}m"
}
@@ -48,6 +48,7 @@
"Create page": "Создать страницу",
"Create space": "Создать пространство",
"Create workspace": "Создать рабочую область",
"Critical": "Критично",
"Current password": "Текущий пароль",
"Dark": "Темная",
"Date": "Дата",
@@ -127,6 +128,7 @@
"Link copied": "Ссылка скопирована",
"Login": "Войти",
"Logout": "Выйти",
"Major": "Существенно",
"Manage Group": "Управление группой",
"Manage members": "Управление участниками",
"member": "участник",
@@ -461,6 +463,8 @@
"{{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}} результат",
@@ -1487,14 +1491,5 @@
"≈ {{minutes}}m": "≈ {{minutes}} мин",
"{{hours}}h {{minutes}}m": "{{hours}} ч {{minutes}} м",
"{{hours}}h": "{{hours}} ч",
"{{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}}"
"{{minutes}}m": "{{minutes}} м"
}
@@ -0,0 +1,284 @@
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();
});
});
@@ -0,0 +1,418 @@
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,125 +156,6 @@ describe("CommentListItem — agent avatar stack", () => {
// only guards the insertion gate (agent → stack, user → no stack).
});
describe("CommentListItem — suggested edit (#315)", () => {
const suggestion = (over?: Partial<IComment>): 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, Badge, Button } from "@mantine/core";
import { Group, Text, Box } from "@mantine/core";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import React, { useMemo, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { useTimeAgo } from "@/hooks/use-time-ago";
@@ -12,18 +12,11 @@ import CommentMenu from "@/features/comment/components/comment-menu";
import ResolveComment from "@/features/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks";
import {
useApplySuggestionMutation,
useDeleteCommentMutation,
useDismissSuggestionMutation,
useResolveCommentMutation,
useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import {
canShowApply,
canShowDismiss,
computeSuggestionDiff,
} from "@/features/comment/utils/suggestion";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
@@ -32,13 +25,21 @@ interface CommentListItemProps {
comment: IComment;
pageId: string;
canComment: boolean;
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
// allowed to comment cannot apply edits).
// Real page-edit permission (page.permissions.canEdit). Kept on the props for
// parity with the container's wiring even though the thread row itself no
// longer renders the suggestion Apply button (that moved to AgentEditCard).
canEdit?: boolean;
userSpaceRole?: string;
}
// Type B — the thread ROW. Renders a single human OR agent-without-edit comment
// in the redesigned visual: provenance avatar, author + timeago, hover-revealed
// resolve + edit/delete menu, the anchored selection quote, and the body through
// the static CommentContentView (or the inline TipTap editor while editing). It
// is used for both a top-level thread comment and, recursively, each reply row.
// ALL wiring (update/delete/resolve mutations, owner/admin gate, anchor nav) is
// the same logic the old row carried — only the presentation changed, and the
// agent suggested-edit block was lifted out into AgentEditCard.
function CommentListItem({
comment,
pageId,
@@ -55,29 +56,20 @@ function CommentListItem({
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const applySuggestionMutation = useApplySuggestionMutation();
const dismissSuggestionMutation = useDismissSuggestionMutation();
const [currentUser] = useAtom(currentUserAtom);
const createdAtAgo = useTimeAgo(comment.createdAt);
// Intraline "before -> after" diff (#331) for a suggested edit: only the
// fragments that actually changed get emphasised inside the red/green block,
// instead of striking through / greening the whole line. Memoised on the
// (selection, suggestedText) pair so it recomputes only when they change.
const suggestionDiff = useMemo(
() =>
comment.suggestedText != null
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
: null,
[comment.selection, comment.suggestedText],
);
// `canEdit`/`pageId` are threaded through for wiring parity with the container;
// the thread row does not itself gate on them (Apply lives on AgentEditCard).
void canEdit;
void pageId;
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
// render an action the server will 403.
// Owner-or-space-admin gate (#338): mirrors the server authz for the comment
// menu (edit/delete), so we never render an action the server will 403.
const isOwnerOrAdmin =
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
const isAgent = comment.createdSource === "agent" && !!comment.agent;
async function handleUpdateComment() {
try {
@@ -121,34 +113,9 @@ function CommentListItem({
}
}
async function handleApplySuggestion() {
try {
await applySuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Errors surface via the mutation's onError notification (incl. 409).
console.error("Failed to apply suggestion:", error);
}
}
async function handleDismissSuggestion() {
try {
await dismissSuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Idempotent races are reconciled to success in the mutation's onError;
// anything else surfaces there as a notification.
console.error("Failed to dismiss suggestion:", error);
}
}
function handleCommentClick(comment: IComment) {
function handleCommentClick(target: IComment) {
const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`,
`.comment-mark[data-comment-id="${target.id}"]`,
);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -169,10 +136,10 @@ function CommentListItem({
return (
<Box ref={ref} pb={6}>
<Group gap="xs">
{comment.createdSource === "agent" && comment.agent ? (
<Group gap="xs" wrap="nowrap" align="flex-start">
{isAgent ? (
<AgentAvatarStack
agent={comment.agent}
agent={comment.agent!}
launcher={comment.launcher}
aiChatId={comment.aiChatId}
showName={false}
@@ -185,13 +152,13 @@ function CommentListItem({
/>
)}
<div style={{ flex: 1 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
{comment.createdSource === "agent" && comment.agent ? (
{isAgent ? (
<>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{comment.agent.name}
{comment.agent!.name}
</Text>
{comment.launcher && (
<>
@@ -262,87 +229,6 @@ 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} />
) : (
@@ -350,7 +236,9 @@ 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,
Paper,
Box,
Stack,
Tabs,
Badge,
@@ -14,6 +14,9 @@ import {
Tooltip,
} from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import AgentEditCard, {
RunHeader,
} from "@/features/comment/components/agent-edit-card";
import {
useCommentsQuery,
useCreateCommentMutation,
@@ -22,6 +25,10 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import {
groupAgentRuns,
CommentRenderUnit,
} from "@/features/comment/utils/group-agent-runs";
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
@@ -69,6 +76,32 @@ export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
);
}
// The redesigned card shell: a rounded, bordered surface on the panel body. Both
// a thread card and an agent-run group live inside one of these.
function PanelCard({
children,
...rest
}: {
children: React.ReactNode;
[key: string]: unknown;
}) {
return (
<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();
@@ -90,6 +123,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
const userSpaceRole = space?.membership?.role;
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
if (!comments?.items) {
@@ -113,6 +148,17 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
};
}, [comments]);
// Collapse each tab's top-level list into render units (a lone comment or a
// collapsed agent run). Purely visual — the underlying data is untouched.
const activeUnits = useMemo(
() => groupAgentRuns(activeComments),
[activeComments],
);
const resolvedUnits = useMemo(
() => groupAgentRuns(resolvedComments),
[resolvedComments],
);
// Index replies by their parent once, instead of an O(n^2) filter per thread.
// The map ref changes on any comments update, so MemoizedChildComments re-runs
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
@@ -168,56 +214,104 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
[createCommentAsync, page?.id],
);
const renderComments = useCallback(
(comment: IComment) => (
<Paper
shadow="sm"
radius="md"
p="xs"
mb="xs"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
// 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
comment={comment}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
userSpaceRole={userSpaceRole}
showProvenance={showProvenance}
/>
) : (
<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={space?.membership?.role}
userSpaceRole={userSpaceRole}
/>
</div>
</Box>
{!comment.resolvedAt && canComment && (
<>
<Box px="xs" pb="xs">
<Divider my={2} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
/>
</>
</Box>
)}
</Paper>
</>
),
[
childrenByParent,
handleAddReply,
page?.id,
space?.membership?.role,
userSpaceRole,
canComment,
canEdit,
],
);
// Render one collapsed unit: a standalone card (thread or lone edit) or an
// agent-run group (one RunHeader over N stacked edit cards).
const renderUnit = useCallback(
(unit: CommentRenderUnit) => {
if (unit.kind === "single") {
const c = unit.comment;
const isEdit =
c.createdSource === "agent" &&
c.suggestedText != null &&
!c.parentCommentId;
return (
<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 <></>;
}
@@ -226,8 +320,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
return <div>{t("Error loading comments.")}</div>;
}
const totalComments = activeComments.length + resolvedComments.length;
const pageCommentInput = canComment ? (
<PageCommentInput
onSave={handleAddPageComment}
@@ -328,7 +420,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
</Stack>
</Center>
) : (
activeComments.map(renderComments)
activeUnits.map(renderUnit)
)}
</Tabs.Panel>
@@ -347,7 +439,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
</Stack>
</Center>
) : (
resolvedComments.map(renderComments)
resolvedUnits.map(renderUnit)
)}
</Tabs.Panel>
</div>
@@ -21,53 +21,6 @@
box-sizing: border-box;
}
/* Suggested-edit (#315) "было → стало" diff block. */
.suggestionBlock {
margin-top: 8px;
margin-left: 6px;
padding: 6px;
border-radius: var(--mantine-radius-sm);
border: 1px solid var(--mantine-color-default-border);
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.suggestionOld {
text-decoration: line-through;
color: var(--mantine-color-red-7);
background: var(--mantine-color-red-light);
border-radius: 2px;
padding: 1px 3px;
}
.suggestionNew {
color: var(--mantine-color-green-9);
background: var(--mantine-color-green-light);
border-radius: 2px;
padding: 1px 3px;
margin-top: 4px;
}
/* Intraline diff (#331): the fragment that actually changed within the
red "before" / green "after" block. It inherits the surrounding red/green
framing and adds a stronger tint plus bold weight so the eye lands on the
changed letters/words (git/GitHub-style) rather than the whole line. The
container's line-through (old) / green (new) still marks the full line. */
.suggestionChanged {
/* Stronger tint of the surrounding red/green so the changed fragment pops
within the block. `currentColor` follows the parent's red (old) or green
(new) text colour. No `text-decoration` here on purpose: the old block's
inherited line-through must survive on the changed letters too. */
background: color-mix(in srgb, currentColor 22%, transparent);
border-radius: 2px;
font-weight: 700;
}
.commentEditor {
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import { groupAgentRuns, runKey, GROUP_MIN } from "./group-agent-runs";
import { IComment } from "@/features/comment/types/comment.types";
const editComment = (id: string, over?: Partial<IComment>): 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"]);
}
});
});
@@ -0,0 +1,76 @@
import { IComment } from "@/features/comment/types/comment.types";
// A visual-only series threshold: this many agent suggested-edits sharing one
// run key collapse under a single RunHeader. A group of one renders as a plain
// standalone card (no header). Policy constant — no env override.
export const GROUP_MIN = 2;
// One rendered unit of the top-level comment list: either a collapsed agent-run
// group (>= GROUP_MIN suggested-edits from the same chat+role) or a single
// comment (a human thread, an agent thread without an edit, or a lone edit).
export interface AgentRunGroup {
kind: "run";
key: string;
comments: IComment[];
}
export interface SingleUnit {
kind: "single";
comment: IComment;
}
export type CommentRenderUnit = AgentRunGroup | SingleUnit;
// The grouping key of a top-level agent suggested-edit, or null when the comment
// is not a groupable edit. The key pins BOTH the AI chat and the acting role
// (`aiChatId + ":" + agent.name`) so two roles running in the same chat do not
// collapse under one header. A comment with `aiChatId == null` (an external MCP
// agent) has no chat to group by — it is deliberately never groupable and always
// renders as a single card (a time-bucketed synthetic run would split/merge runs
// arbitrarily and choke on ISO `createdAt`). PURE.
export function runKey(c: IComment): string | null {
if (
c.createdSource === "agent" &&
c.suggestedText != null &&
!c.parentCommentId &&
c.aiChatId != null &&
c.agent?.name
) {
return `${c.aiChatId}:${c.agent.name}`;
}
return null;
}
// Collapse an ORDERED list of top-level comments into render units, preserving
// list order: a run is emitted in place of its FIRST member (later members are
// absorbed), everything else stays a single unit. Grouping is computed over the
// snapshot handed in; a streamed page / WS update may later promote a single
// edit into a run as more members arrive — that is acceptable (purely visual).
// PURE — no React/DOM, no mutation of the input.
export function groupAgentRuns(comments: IComment[]): CommentRenderUnit[] {
// First pass: tally groupable edits per key so we know which keys clear
// GROUP_MIN before we start emitting.
const counts = new Map<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;
}
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { parseSeverity, isSignificant } from "./severity";
describe("parseSeverity — exact importance dictionary", () => {
it("maps the Russian importance tags", () => {
expect(parseSeverity("[Критично] fix now")).toBe("critical");
expect(parseSeverity("[Существенно] tighten")).toBe("major");
expect(parseSeverity("[Незначительно] nit")).toBe("minor");
});
it("maps the English equivalents and is case-insensitive", () => {
expect(parseSeverity("[Critical] x")).toBe("critical");
expect(parseSeverity("[MAJOR] x")).toBe("major");
expect(parseSeverity("[minor] x")).toBe("minor");
expect(parseSeverity("[критично] x")).toBe("critical");
});
it("treats fact-checker verdicts as unknown, NOT a severity", () => {
expect(parseSeverity("[Неверно] this is wrong")).toBe("unknown");
expect(parseSeverity("[Не проверено] can't verify")).toBe("unknown");
expect(parseSeverity("[Непроверяемо] x")).toBe("unknown");
expect(parseSeverity("[Это мнение] x")).toBe("unknown");
});
it("treats a typo'd / unrecognised tag as unknown (never falls back to minor)", () => {
expect(parseSeverity("[Существенное] typo ending")).toBe("unknown");
expect(parseSeverity("[whatever] x")).toBe("unknown");
});
it("returns unknown for no tag or empty input", () => {
expect(parseSeverity("plain body, no tag")).toBe("unknown");
expect(parseSeverity("")).toBe("unknown");
});
it("finds the recognised importance tag even after a leading verdict tag", () => {
expect(parseSeverity("[Неверно] [Существенно] body")).toBe("major");
});
});
describe("isSignificant", () => {
it("counts only major and critical", () => {
expect(isSignificant("critical")).toBe(true);
expect(isSignificant("major")).toBe(true);
expect(isSignificant("minor")).toBe(false);
expect(isSignificant("unknown")).toBe(false);
});
});
@@ -0,0 +1,44 @@
// Severity of an agent suggested-edit, parsed CLIENT-SIDE from the importance
// tag the editorial role prompts emit at the head of the comment body
// (`[Критично]` / `[Существенно]` / `[Незначительно]`, or their English
// equivalents). It is a DISPLAY-ONLY signal: it drives the coloured dot on the
// edit card and the "M major" counter in a run header. It never gates an action.
//
// `unknown` is a first-class value, NOT a synonym for `minor`: any bracketed
// text that is not in the exact dictionary — a Fact-checker verdict
// (`[Неверно]`, `[Не проверено]`, `[Непроверяемо]`, `[Это мнение]`), a typo
// (`[Существенное]`), or the absence of a tag — resolves to `unknown` and draws
// a neutral dot. Passing an unrecognised tag off as `minor` would mis-label it.
export type Severity = "critical" | "major" | "minor" | "unknown";
// EXACT (case-insensitive) dictionary. Only these tokens map to a real severity;
// everything else falls through to `unknown` on purpose.
const SEVERITY_TAGS: Record<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,130 +77,3 @@
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,14 +16,28 @@ 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
@@ -0,0 +1,111 @@
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>
);
}
@@ -1,253 +0,0 @@
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 HistoryModalDesktop from "@/features/page-history/components/history-modal-desktop";
import HistoryModalBody from "@/features/page-history/components/history-modal-body";
import HistoryModalMobile from "@/features/page-history/components/history-modal-mobile";
import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mantine/hooks";
@@ -45,9 +45,6 @@ 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}
@@ -57,11 +54,16 @@ export default function HistoryModal({ pageId, pageTitle }: Props) {
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body p={0}>
<HistoryModalDesktop
pageId={pageId}
onClose={() => setModalOpen(false)}
/>
<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>
</Modal.Content>
</Modal.Root>
@@ -1,145 +0,0 @@
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();
});
});
@@ -1,209 +0,0 @@
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>
);
}
@@ -1,109 +0,0 @@
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();
}
});
});
@@ -1,201 +0,0 @@
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>
);
}
@@ -1,87 +0,0 @@
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");
});
});
@@ -1,126 +0,0 @@
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;
@@ -1,29 +0,0 @@
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,
});
}
@@ -1,20 +0,0 @@
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;
}
@@ -1,27 +0,0 @@
/**
* #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 };
}
}
@@ -1,142 +0,0 @@
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);
});
});
@@ -1,137 +0,0 @@
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;
}
-11
View File
@@ -59,17 +59,6 @@ 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,89 +111,4 @@ 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,7 +18,6 @@ import { UpdatePageDto } from './dto/update-page.dto';
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
import {
DeletePageDto,
PageHistoryDayCountsDto,
PageHistoryIdDto,
PageIdDto,
PageInfoDto,
@@ -559,33 +558,6 @@ 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,11 +6,9 @@ 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 {
@@ -71,20 +69,4 @@ 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);
}
export function isoDay(dayStart: number, tz: string): string {
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)}`;
@@ -1,70 +0,0 @@
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 },
]);
});
});
@@ -1,42 +0,0 @@
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 -3
View File
@@ -1,7 +1,5 @@
export { computeWorkTime } from './compute-work-time';
export { bucketByDay, zonedDayStart, isoDay } from './bucket-by-day';
export { countRevisionsByDay } from './day-counts';
export type { DayCount } from './day-counts';
export { bucketByDay, zonedDayStart } from './bucket-by-day';
export {
DEFAULT_WORK_TIME_CONFIG,
resolveWorkTimeConfig,