Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfda79ef70 | |||
| 624e03a9e3 | |||
| be8ed579a9 | |||
| d542fc58ae | |||
| 85ffdea06b | |||
| 9aa7620461 | |||
| 710a690c78 | |||
| 50f8ed717a | |||
| 3cda008012 |
@@ -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 can’t be applied</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{parsed.text && !conflict && (
|
||||
<Text fz={12.5} c="dimmed" style={{ lineHeight: 1.45 }}>{parsed.text}</Text>
|
||||
)}
|
||||
|
||||
<Group gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<Box w={8} h={8} style={(t) => ({ flex: 'none', borderRadius: '50%', background: t.colors[SEV_COLOR[parsed.severity]][6] })} />
|
||||
<Text fz={10} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.06em', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{[parsed.category, parsed.severity === 'major' ? 'Major' : parsed.severity === 'critical' ? 'Critical' : null].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
|
||||
{c.status === 'applied' ? (
|
||||
<Badge color="green" variant="light" radius="xl" size="sm">✓ Applied</Badge>
|
||||
) : c.status === 'dismissed' ? (
|
||||
<Badge color="gray" variant="light" radius="xl" size="sm">Dismissed</Badge>
|
||||
) : conflict ? (
|
||||
<Button size="compact-sm" variant="default" onClick={() => onNavigateToAnchor(c.id)}>Go to text</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="compact-sm" variant="default" color="gray" onClick={dismiss}>Dismiss</Button>
|
||||
<Button size="compact-sm" color="green" loading={busy} onClick={apply}>Apply</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────── Человеческий тред ──────────────────────── */
|
||||
|
||||
function HumanThread({ c, onResolve, onNavigateToAnchor }: {
|
||||
c: Comment; onResolve?: CommentsPanelProps['onResolve']; onNavigateToAnchor: CommentsPanelProps['onNavigateToAnchor'];
|
||||
}) {
|
||||
const parsed = useMemo(() => parseBody(c.bodyRaw), [c.bodyRaw]);
|
||||
return (
|
||||
<Box p="12px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Stack gap={9}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Avatar size={26} radius="xl" color="orange">{c.authorName[0]}</Avatar>
|
||||
<Text fz={12.5} fw={600}>{c.authorName}
|
||||
<Text span c="dimmed" fw={400}> · {c.triggeredBy ? `${c.triggeredBy} · ` : ''}{c.createdAtLabel}</Text>
|
||||
</Text>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Tooltip label="Resolve"><ActionIcon variant="default" size="md" onClick={() => onResolve?.(c.id)}>✓</ActionIcon></Tooltip>
|
||||
<ActionIcon variant="default" size="md">⋯</ActionIcon>
|
||||
</Group>
|
||||
<Text fz={13.5} style={{ lineHeight: 1.5 }}>{parsed.text}</Text>
|
||||
<Group gap={8}>
|
||||
{c.replyCount ? <Text fz={12} c="dimmed">{c.replyCount} replies</Text> : null}
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Button variant="subtle" size="compact-sm">Reply</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────── Шапка серии + прогресс ──────────────────── */
|
||||
|
||||
function RunHeader({ runComments, onAcceptAll, progress }: {
|
||||
runComments: Comment[];
|
||||
onAcceptAll: () => void;
|
||||
progress?: { done: number; total: number } | null;
|
||||
}) {
|
||||
const head = runComments[0];
|
||||
const majors = runComments.filter((c) => parseBody(c.bodyRaw).severity !== 'minor').length;
|
||||
const applied = runComments.filter((c) => c.status === 'applied').length;
|
||||
return (
|
||||
<Box>
|
||||
<Group gap={10} wrap="nowrap" p="11px 13px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Box style={{ position: 'relative', width: 30, height: 30, flex: 'none' }}>
|
||||
<Avatar size={30} radius={8} color="teal">{head.authorName[0]}</Avatar>
|
||||
{head.triggeredBy && (
|
||||
<Avatar size={14} radius="xl" color="gray"
|
||||
style={{ position: 'absolute', right: -4, bottom: -4, border: '2px solid var(--mantine-color-body)' }} />
|
||||
)}
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text fz={13} fw={600}>{head.authorName}
|
||||
<Text span c="dimmed" fw={400}> · {head.triggeredBy} · {head.createdAtLabel}</Text>
|
||||
</Text>
|
||||
<Text fz={11.5} c="dimmed">
|
||||
{runComments.length} edits · <Text span c="orange.7" fw={600}>{majors} major</Text> · {applied} applied
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box style={{ flex: 1 }} />
|
||||
{!progress && <Button size="compact-sm" color="green" onClick={onAcceptAll}>Accept all</Button>}
|
||||
</Group>
|
||||
{progress && (
|
||||
<Box p="9px 13px 11px" style={(t) => ({ background: t.colorScheme === 'dark' ? 'rgba(47,158,68,.08)' : '#f8fbf9', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}>
|
||||
<Group gap={8} mb={6}>
|
||||
<Text fz={12} fw={600} c="green.7">Applying {progress.done} of {progress.total}…</Text>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Button variant="subtle" color="gray" size="compact-xs">Stop</Button>
|
||||
</Group>
|
||||
<Progress value={(progress.done / progress.total) * 100} color="green" size="sm" radius="xl" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────── Панель ──────────────────────────── */
|
||||
|
||||
/** Роль-автор для фильтра: у агентов — имя роли, у людей — «Пользователь». */
|
||||
function roleOf(c: Comment): string {
|
||||
return c.authorKind === 'human' ? 'Пользователь' : c.authorName;
|
||||
}
|
||||
|
||||
export function CommentsPanel(props: CommentsPanelProps) {
|
||||
const { comments, onApply, onDismiss, onResolve, onNavigateToAnchor, onClose, dismissUndoMs } = props;
|
||||
const [tab, setTab] = useState<'open' | 'resolved'>('open');
|
||||
const [roleFilter, setRoleFilter] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<Record<string, { done: number; total: number }>>({});
|
||||
|
||||
const open = comments.filter((c) => c.status === 'pending' || c.status === 'conflict');
|
||||
const resolved = comments.filter((c) => c.status === 'applied' || c.status === 'dismissed');
|
||||
const list = tab === 'open' ? open : resolved;
|
||||
|
||||
const filtered = list.filter((c) => !roleFilter || roleOf(c) === roleFilter);
|
||||
|
||||
// Роли и счётчики для чипов-фильтров (только роли: Корректор / Фактчекер / Пользователь …).
|
||||
const roles = useMemo(() => {
|
||||
const order: string[] = [];
|
||||
const count: Record<string, number> = {};
|
||||
for (const c of list) {
|
||||
const r = roleOf(c);
|
||||
if (!(r in count)) { count[r] = 0; order.push(r); }
|
||||
count[r]++;
|
||||
}
|
||||
return order.map((r) => ({ role: r, count: count[r] }));
|
||||
}, [list]);
|
||||
|
||||
// Группировка агентских правок по runId; человеческие треды — по одному.
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, Comment[]>();
|
||||
const singles: Comment[] = [];
|
||||
for (const c of filtered) {
|
||||
if (c.authorKind === 'agent' && c.runId && c.edit) {
|
||||
if (!map.has(c.runId)) map.set(c.runId, []);
|
||||
map.get(c.runId)!.push(c);
|
||||
} else singles.push(c);
|
||||
}
|
||||
return { runs: [...map.entries()], singles };
|
||||
}, [filtered]);
|
||||
|
||||
// Пакетное "Accept all" — по одной на фронте, обновляем прогресс.
|
||||
const acceptAll = useCallback(async (runId: string, items: Comment[]) => {
|
||||
const minor = items.filter((c) => parseBody(c.bodyRaw).severity === 'minor' && c.status === 'pending');
|
||||
for (let i = 0; i < minor.length; i++) {
|
||||
setProgress((p) => ({ ...p, [runId]: { done: i, total: minor.length } }));
|
||||
try { await onApply(minor[i].id); } catch { /* конфликт — пропускаем, копим для сводки */ }
|
||||
}
|
||||
setProgress((p) => { const n = { ...p }; delete n[runId]; return n; });
|
||||
notifications.show({ color: 'green', message: `${minor.length} applied` });
|
||||
}, [onApply]);
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%" style={{ width: 380, maxWidth: '100%', borderLeft: '1px solid var(--mantine-color-default-border)' }}>
|
||||
{/* Вкладки — статусная ось. Open/Resolved сохранены. */}
|
||||
<Group gap={4} p="10px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}>
|
||||
<Tabs value={tab} onChange={(v) => setTab(v as 'open' | 'resolved')} variant="pills">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="open" rightSection={<Badge size="sm" variant="light" color="blue">{open.length}</Badge>}>Open</Tabs.Tab>
|
||||
<Tabs.Tab value="resolved" rightSection={<Badge size="sm" variant="light" color="gray">{resolved.length}</Badge>}>Resolved</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Box style={{ flex: 1 }} />
|
||||
{onClose && <ActionIcon variant="subtle" color="gray" onClick={onClose}>✕</ActionIcon>}
|
||||
</Group>
|
||||
|
||||
{/* Фильтры-чипы: только по ролям (Корректор / Фактчекер / Пользователь). */}
|
||||
{tab === 'open' && roles.length > 1 && (
|
||||
<ScrollArea type="never" p="8px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FilterChip active={!roleFilter} onClick={() => setRoleFilter(null)} label={`All ${list.length}`} solid />
|
||||
{roles.map(({ role, count }) => (
|
||||
<FilterChip key={role} active={roleFilter === role} onClick={() => setRoleFilter(roleFilter === role ? null : role)} label={`${role} ${count}`} />
|
||||
))}
|
||||
</Group>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* Лента */}
|
||||
<ScrollArea style={{ flex: 1 }} bg="var(--mantine-color-default-hover)">
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState tab={tab} />
|
||||
) : (
|
||||
<>
|
||||
{groups.runs.map(([runId, items]) => (
|
||||
<Box key={runId} m="6px 10px" bg="var(--mantine-color-body)" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<RunHeader runComments={items} progress={progress[runId] ?? null} onAcceptAll={() => acceptAll(runId, items)} />
|
||||
{items.map((c) => (
|
||||
<EditCard key={c.id} c={c} onApply={onApply} onDismiss={onDismiss} onNavigateToAnchor={onNavigateToAnchor} dismissUndoMs={dismissUndoMs} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{groups.singles.map((c) => (
|
||||
<Box key={c.id} m="6px 10px" bg="var(--mantine-color-body)" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{c.edit
|
||||
? <EditCard c={c} onApply={onApply} onDismiss={onDismiss} onNavigateToAnchor={onNavigateToAnchor} dismissUndoMs={dismissUndoMs} />
|
||||
: <HumanThread c={c} onResolve={onResolve} onNavigateToAnchor={onNavigateToAnchor} />}
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Мелкие компоненты ─────────────────────── */
|
||||
|
||||
function FilterChip({ label, active, onClick, dot, solid }: {
|
||||
label: string; active: boolean; onClick: () => void; dot?: string; solid?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
size="compact-sm"
|
||||
radius="xl"
|
||||
variant={active ? 'filled' : 'default'}
|
||||
color={active ? (solid ? 'dark' : 'blue') : 'gray'}
|
||||
leftSection={dot ? <Box w={7} h={7} style={(t) => ({ borderRadius: '50%', background: t.colors[dot][6] })} /> : undefined}
|
||||
styles={{ root: { flex: 'none' }, label: { fontWeight: 600, fontSize: 12 } }}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ tab }: { tab: 'open' | 'resolved' }) {
|
||||
const isOpen = tab === 'open';
|
||||
return (
|
||||
<Stack align="center" gap={6} p="40px 20px">
|
||||
<Avatar size={40} radius="xl" color={isOpen ? 'green' : 'gray'}>{isOpen ? '✓' : '◌'}</Avatar>
|
||||
<Text fw={600} fz={13}>{isOpen ? 'All caught up' : 'Nothing here yet'}</Text>
|
||||
<Text fz={12} c="dimmed" ta="center" style={{ lineHeight: 1.45 }}>
|
||||
{isOpen ? 'No edits waiting on you.' : 'Applied and closed items will appear here.'}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentsPanel;
|
||||
@@ -1,234 +0,0 @@
|
||||
/**
|
||||
* TimeWorkedModal — редизайн окна «Time worked on this article».
|
||||
* Mantine v7. Light/dark.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* ЧТО ЭТО
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* Сводка трудозатрат по дням в виде суточных таймлайнов. Главное отличие от
|
||||
* старой версии: на барах ВИДНО время суток. Реализовано двумя способами
|
||||
* (проп `axis`):
|
||||
* - 'grid' — ось часов 00–06–12–18–24 сверху + вертикальные деления,
|
||||
* ночные часы (0–6, 21–24) слегка затемнены. По умолчанию.
|
||||
* - 'phases' — цветные полосы фаз дня за блоками (Ночь/Утро/День/Вечер),
|
||||
* период суток читается сразу, без счёта делений.
|
||||
*
|
||||
* Блоки позиционируются по времени: left = start/24, width = dur/24.
|
||||
* Интенсивность (opacity) блока = длительность/плотность работы, чтобы
|
||||
* очень короткие сессии не терялись (минимальная видимая ширина задана).
|
||||
* Hover-тултип на блоке: начало–конец · длительность.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* import { TimeWorkedModal, DaySummary } from './TimeWorkedModal';
|
||||
*
|
||||
* <TimeWorkedModal
|
||||
* opened={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* totalLabel="≈ 34h"
|
||||
* agentLabel="≈ 1h 20m" // undefined → строку agent не показываем
|
||||
* days={days} // DaySummary[]
|
||||
* axis="grid" // 'grid' | 'phases'
|
||||
* tz="Europe/Moscow"
|
||||
* inactivityGapMin={15}
|
||||
* />
|
||||
*
|
||||
* Требует MantineProvider на корне.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* ФОРМАТ ДАННЫХ
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* interface Block {
|
||||
* start: number; // час начала в сутках, 0..24 (напр. 13.7 = 13:42)
|
||||
* end: number; // час конца
|
||||
* kind: 'work' | 'agent';
|
||||
* }
|
||||
* interface DaySummary {
|
||||
* label: string; // «Mon 29 Jun»
|
||||
* totalLabel: string; // «1h 24m» | «—» для пустого дня
|
||||
* blocks: Block[]; // [] → пустой день (полупрозрачная дорожка, итог «—»)
|
||||
* isToday?: boolean; // сегодня — неполные сутки (рисуем границу «сейчас»)
|
||||
* nowFraction?: number;// 0..1 позиция «сейчас» для isToday
|
||||
* }
|
||||
*
|
||||
* Данные уже есть в бэкенде трудозатрат: сессии с началом/концом + тип
|
||||
* (work/agent). Часы = локальные к tz. Изменений API не требуется.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* СОСТОЯНИЯ (нарисованы/поддержаны)
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* - День: с активностью (work+agent) / только work / только agent / пустой (—).
|
||||
* - Сегодня: граница «сейчас» на дорожке (nowFraction).
|
||||
* - Короткий блок: минимальная ширина, не исчезает.
|
||||
* - Пустая панель целиком: нет трудозатрат → EmptyState.
|
||||
* - Длинный период: вертикальный скролл списка дней, шапка/легенда липкие.
|
||||
* - Тёмная тема: токены Mantine.
|
||||
*/
|
||||
import { Modal, Box, Group, Stack, Text, ActionIcon, ScrollArea, Tooltip, useMantineColorScheme } from '@mantine/core';
|
||||
|
||||
/* ─────────────────────────── Типы ─────────────────────────── */
|
||||
|
||||
export interface Block { start: number; end: number; kind: 'work' | 'agent'; }
|
||||
export interface DaySummary {
|
||||
label: string;
|
||||
totalLabel: string;
|
||||
blocks: Block[];
|
||||
isToday?: boolean;
|
||||
nowFraction?: number;
|
||||
}
|
||||
export interface TimeWorkedModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
totalLabel: string;
|
||||
agentLabel?: string;
|
||||
days: DaySummary[];
|
||||
axis?: 'grid' | 'phases';
|
||||
tz?: string;
|
||||
inactivityGapMin?: number;
|
||||
}
|
||||
|
||||
const WORK = '#3b82f6';
|
||||
const AGENT = '#c026d3';
|
||||
|
||||
const PHASES = [
|
||||
{ name: 'Ночь', s: 0, e: 6, bg: 'rgba(99,102,241,.10)', lg: '#e8eaff', fg: '#5b60c9' },
|
||||
{ name: 'Утро', s: 6, e: 12, bg: 'rgba(245,159,0,.10)', lg: '#fff2dc', fg: '#b5820e' },
|
||||
{ name: 'День', s: 12, e: 18, bg: 'rgba(56,178,172,.10)', lg: '#dcf5f2', fg: '#1a857d' },
|
||||
{ name: 'Вечер', s: 18, e: 24, bg: 'rgba(139,92,246,.11)', lg: '#efe7ff', fg: '#6d43c0' },
|
||||
];
|
||||
|
||||
/* ─────────────────────────── Блок активности ─────────────────────────── */
|
||||
|
||||
function fmtHour(h: number) {
|
||||
const hh = Math.floor(h);
|
||||
const mm = Math.round((h - hh) * 60);
|
||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
|
||||
}
|
||||
function fmtDur(h: number) {
|
||||
const total = Math.round(h * 60);
|
||||
const hh = Math.floor(total / 60), mm = total % 60;
|
||||
return hh ? `${hh}h ${mm}m` : `${mm}m`;
|
||||
}
|
||||
|
||||
function ActivityBlock({ b }: { b: Block }) {
|
||||
const left = (b.start / 24) * 100;
|
||||
const width = Math.max(((b.end - b.start) / 24) * 100, 0.6);
|
||||
const dur = b.end - b.start;
|
||||
const opacity = dur < 0.16 ? 0.5 : dur < 0.35 ? 0.8 : 1;
|
||||
return (
|
||||
<Tooltip label={`${fmtHour(b.start)} – ${fmtHour(b.end)} · ${fmtDur(dur)}`} withArrow openDelay={120} fz={11}>
|
||||
<Box style={{
|
||||
position: 'absolute', top: 5, height: 14, left: `${left}%`, width: `${width}%`,
|
||||
borderRadius: 2, background: b.kind === 'agent' ? AGENT : WORK, opacity, cursor: 'default',
|
||||
}} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Дорожка дня ─────────────────────────── */
|
||||
|
||||
function DayTrack({ d, axis, dark }: { d: DaySummary; axis: 'grid' | 'phases'; dark: boolean }) {
|
||||
const trackBg = axis === 'grid'
|
||||
? 'linear-gradient(90deg, rgba(90,100,130,.10) 0 25%, rgba(90,100,130,.02) 25% 87.5%, rgba(90,100,130,.10) 87.5% 100%)'
|
||||
: (dark ? 'rgba(255,255,255,.04)' : '#f6f8fa');
|
||||
|
||||
return (
|
||||
<Group gap={0} h={30} wrap="nowrap">
|
||||
<Text style={{ flex: 'none', width: 92 }} fz={13} c="dimmed">{d.label}</Text>
|
||||
<Box style={{ position: 'relative', flex: 1, height: 24, margin: '0 4px', borderRadius: 5, overflow: 'hidden', background: trackBg }}>
|
||||
{/* фон: полосы фаз или деления сетки */}
|
||||
{axis === 'phases'
|
||||
? PHASES.map((ph) => (
|
||||
<Box key={ph.name} style={{ position: 'absolute', top: 0, bottom: 0, left: `${(ph.s / 24) * 100}%`, width: `${((ph.e - ph.s) / 24) * 100}%`, background: ph.bg }} />
|
||||
))
|
||||
: [25, 50, 75].map((p) => (
|
||||
<Box key={p} style={{ position: 'absolute', top: 0, bottom: 0, left: `${p}%`, width: 1, background: 'rgba(120,130,150,.16)' }} />
|
||||
))}
|
||||
{/* блоки */}
|
||||
{d.blocks.map((b, i) => <ActivityBlock key={i} b={b} />)}
|
||||
{/* граница «сейчас» для сегодняшнего дня */}
|
||||
{d.isToday && d.nowFraction != null && (
|
||||
<Box style={{ position: 'absolute', top: 0, bottom: 0, left: `${d.nowFraction * 100}%`, width: 2, background: '#fa5252' }} />
|
||||
)}
|
||||
</Box>
|
||||
<Text style={{ flex: 'none', width: 64, textAlign: 'right' }} fz={12.5} fw={500} c={d.totalLabel === '—' ? 'dimmed' : undefined}>
|
||||
{d.totalLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Окно ─────────────────────────── */
|
||||
|
||||
export function TimeWorkedModal(props: TimeWorkedModalProps) {
|
||||
const { opened, onClose, totalLabel, agentLabel, days, axis = 'grid', tz = 'Europe/Moscow', inactivityGapMin = 15 } = props;
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === 'dark';
|
||||
const isEmpty = days.length === 0 || days.every((d) => d.blocks.length === 0);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} withCloseButton={false} size="46rem" radius="lg" padding={0}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}>
|
||||
<Box p="22px 24px 20px">
|
||||
{/* header */}
|
||||
<Group mb={14}>
|
||||
<Text fz={17} fw={600}>Time worked on this article</Text>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}>✕</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* summary */}
|
||||
<Group align="baseline" gap={16} mb={axis === 'grid' ? 8 : 16}>
|
||||
<Text fz={22} fw={700}>{totalLabel}</Text>
|
||||
{agentLabel && <Text fz={13} c="dimmed">agent: {agentLabel}</Text>}
|
||||
</Group>
|
||||
|
||||
{/* legend (grid only) */}
|
||||
{axis === 'grid' && (
|
||||
<Group gap={16} mb={16}>
|
||||
<Group gap={6}><Box w={12} h={12} style={{ borderRadius: 3, background: WORK }} /><Text fz={12} fw={500} c="dimmed">Work</Text></Group>
|
||||
<Group gap={6}><Box w={12} h={12} style={{ borderRadius: 3, background: AGENT }} /><Text fz={12} fw={500} c="dimmed">Agent</Text></Group>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
<Stack align="center" gap={6} p="48px 20px">
|
||||
<Text fw={600} fz={14}>No time tracked yet</Text>
|
||||
<Text fz={12.5} c="dimmed" ta="center">По этой статье ещё нет трудозатрат.</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{/* axis header (sticky) */}
|
||||
<Group gap={0} mb={5} wrap="nowrap" style={{ position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
|
||||
<Box style={{ flex: 'none', width: 92 }} />
|
||||
{axis === 'grid' ? (
|
||||
<Box style={{ flex: 1, position: 'relative', height: 14, margin: '0 4px' }}>
|
||||
{[['0%', '00', 'flex-start'], ['25%', '06', 'center'], ['50%', '12', 'center'], ['75%', '18', 'center'], ['100%', '24', 'flex-end']].map(([l, t, al]) => (
|
||||
<Text key={t as string} fz={10} fw={500} c="dimmed" style={{ position: 'absolute', left: l as string, transform: al === 'center' ? 'translateX(-50%)' : al === 'flex-end' ? 'translateX(-100%)' : undefined }}>{t}</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box style={{ flex: 1, display: 'flex', height: 16, margin: '0 4px', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{PHASES.map((ph) => (
|
||||
<Box key={ph.name} style={{ flex: ph.e - ph.s, display: 'flex', alignItems: 'center', justifyContent: 'center', background: ph.lg, color: ph.fg, font: '600 9.5px system-ui' }}>{ph.name}</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ flex: 'none', width: 64 }} />
|
||||
</Group>
|
||||
|
||||
{/* day rows */}
|
||||
<ScrollArea.Autosize mah="60vh" type="hover">
|
||||
{days.map((d, i) => <DayTrack key={i} d={d} axis={axis} dark={dark} />)}
|
||||
</ScrollArea.Autosize>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text mt={16} fz={11.5} c="dimmed">Estimate · timezone {tz} · inactivity gap {inactivityGapMin} min</Text>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeWorkedModal;
|
||||
@@ -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",
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"agent: {{value}}": "agent: {{value}}",
|
||||
"Work": "Work",
|
||||
"Agent": "Agent",
|
||||
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
|
||||
"≈ {{hours}}h": "≈ {{hours}}h",
|
||||
"≈ {{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}} результат",
|
||||
@@ -1481,6 +1485,7 @@
|
||||
"agent: {{value}}": "агент: {{value}}",
|
||||
"Work": "Работа",
|
||||
"Agent": "Агент",
|
||||
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
|
||||
"≈ {{hours}}h": "≈ {{hours}} ч",
|
||||
"≈ {{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,424 @@
|
||||
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"
|
||||
fw={600}
|
||||
style={{
|
||||
background: markBg,
|
||||
color: markFg,
|
||||
borderRadius: 2,
|
||||
// Snug intra-word highlight: a hair of horizontal padding keeps
|
||||
// the mark background slightly wider than the glyph, canceled by
|
||||
// an equal negative margin so neighbouring letters are NOT pushed
|
||||
// apart. The old px={3} padding made single-letter edits (е→ё,
|
||||
// х→е) look like they had spaces around the changed character.
|
||||
padding: "0 1px",
|
||||
margin: "0 -1px",
|
||||
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";
|
||||
}
|
||||
@@ -92,13 +92,23 @@
|
||||
wrapper, never in the document model) and is rendered inline at the start of
|
||||
the first content line via ::before. This keeps text and wrapped lines flush
|
||||
to the left margin — no hanging indent — while the editable contentDOM stays
|
||||
the FIRST DOM child (#146). */
|
||||
the FIRST DOM child (#146).
|
||||
|
||||
The rules below target `p:first-child`, NOT `> :first-child`: tiptap-react
|
||||
wraps the NodeViewContent children in an extra block-level div
|
||||
(`[data-node-view-content-react]`, style="white-space: inherit"), so
|
||||
`.definitionContent`'s first child is that wrapper, not the paragraph. An
|
||||
inline ::before on the block wrapper (whose child is a block <p>) drops onto
|
||||
its own line above the text — the "+1 line" regression. `p:first-child`
|
||||
reaches the real first paragraph so the number stays inline, and it works
|
||||
whether or not the wrapper is present (definition content is `paragraph+`,
|
||||
so there are no nested paragraphs to mis-match). */
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.definitionContent > :first-child::before {
|
||||
.definitionContent p:first-child::before {
|
||||
content: var(--footnote-number, "?") ". ";
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -108,12 +118,13 @@
|
||||
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`.
|
||||
Drop the outer margins so the definition sits tight to the heading above and
|
||||
the ::before number aligns with the top of the row — same approach used for
|
||||
callouts in core.css. */
|
||||
.definitionContent > :first-child {
|
||||
callouts in core.css. Target the paragraphs directly (through the
|
||||
tiptap-react content wrapper), same reasoning as the ::before rule above. */
|
||||
.definitionContent p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.definitionContent > :last-child {
|
||||
.definitionContent p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildRows,
|
||||
formatBlockTooltip,
|
||||
summaryLabels,
|
||||
toBlocks,
|
||||
toTimelineDay,
|
||||
EMPTY_RUN_COLLAPSE,
|
||||
} from "./work-time-adapter";
|
||||
import { IPageWorkTime, IPerDay } from "./work-time.types";
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY_MS = 24 * HOUR;
|
||||
|
||||
// Fake translator: renders the key with {{tokens}} substituted, so the tests
|
||||
// assert the mapping/branching without depending on the i18n catalogue.
|
||||
const t = (key: string, opts?: Record<string, unknown>) =>
|
||||
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
|
||||
|
||||
// A day midnight anchored at a fixed UTC instant (tz handled server-side; we
|
||||
// only lay out fractions of the day the server already bucketed).
|
||||
const DAY0 = Date.UTC(2026, 5, 29, 0, 0, 0); // Mon 29 Jun 2026 00:00 UTC
|
||||
|
||||
function day(over: Partial<IPerDay> & { day: number }): IPerDay {
|
||||
return {
|
||||
dayISO: new Date(over.day).toISOString().slice(0, 10),
|
||||
activeMs: 0,
|
||||
agentMs: 0,
|
||||
windows: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const config: IPageWorkTime["config"] = {
|
||||
tGap: 15 * MIN,
|
||||
agentTGap: 15 * MIN,
|
||||
pIn: 0,
|
||||
pOut: 0,
|
||||
pSingle: 30 * 1000,
|
||||
excludeGit: false,
|
||||
dedupRoundMs: 1000,
|
||||
};
|
||||
|
||||
function payload(over: Partial<IPageWorkTime>): IPageWorkTime {
|
||||
return {
|
||||
workMs: 0,
|
||||
agentOnlyMs: 0,
|
||||
perDay: [],
|
||||
config,
|
||||
tz: "UTC",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toBlocks", () => {
|
||||
it("maps work+agent windows to time-of-day segments (hour fractions)", () => {
|
||||
const d = day({
|
||||
day: DAY0,
|
||||
activeMs: 90 * MIN,
|
||||
windows: [
|
||||
{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" },
|
||||
{ start: DAY0 + 22 * HOUR, end: DAY0 + 23 * HOUR, class: "agent_only" },
|
||||
],
|
||||
});
|
||||
const blocks = toBlocks(d);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0]).toMatchObject({ start: 9, end: 10.5, kind: "work" });
|
||||
expect(blocks[1]).toMatchObject({ start: 22, end: 23, kind: "agent" });
|
||||
// real epoch preserved for the DST-safe tooltip
|
||||
expect(blocks[0].startEpoch).toBe(DAY0 + 9 * HOUR);
|
||||
});
|
||||
|
||||
it("clamps out-of-day fractions to [0,24]", () => {
|
||||
const d = day({
|
||||
day: DAY0,
|
||||
windows: [{ start: DAY0 - HOUR, end: DAY0 + 25 * HOUR, class: "work" }],
|
||||
});
|
||||
const [b] = toBlocks(d);
|
||||
expect(b.start).toBe(0);
|
||||
expect(b.end).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toTimelineDay", () => {
|
||||
it("draws the now-line only on today's row", () => {
|
||||
const today = day({ day: DAY0, activeMs: HOUR });
|
||||
const noon = DAY0 + 12 * HOUR;
|
||||
const asToday = toTimelineDay(today, t, noon);
|
||||
expect(asToday.isToday).toBe(true);
|
||||
expect(asToday.nowFraction).toBeCloseTo(0.5, 5);
|
||||
|
||||
const past = day({ day: DAY0 - DAY_MS, activeMs: HOUR });
|
||||
const asPast = toTimelineDay(past, t, noon);
|
||||
expect(asPast.isToday).toBe(false);
|
||||
expect(asPast.nowFraction).toBeUndefined();
|
||||
});
|
||||
|
||||
it("labels an empty day and totals it as —", () => {
|
||||
const empty = day({ day: DAY0 });
|
||||
const d = toTimelineDay(empty, t, DAY0 + 12 * HOUR);
|
||||
expect(d.isEmpty).toBe(true);
|
||||
expect(d.totalLabel).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRows (empty-run collapsing)", () => {
|
||||
it("collapses a run of >= EMPTY_RUN_COLLAPSE empty days in place", () => {
|
||||
const perDay: IPerDay[] = [
|
||||
day({ day: DAY0, activeMs: HOUR }),
|
||||
...Array.from({ length: EMPTY_RUN_COLLAPSE }, (_, i) =>
|
||||
day({ day: DAY0 + (i + 1) * DAY_MS }),
|
||||
),
|
||||
day({ day: DAY0 + (EMPTY_RUN_COLLAPSE + 1) * DAY_MS, activeMs: HOUR }),
|
||||
];
|
||||
const rows = buildRows(perDay, t, 0);
|
||||
expect(rows.map((r) => r.type)).toEqual(["day", "gap", "day"]);
|
||||
const gap = rows[1];
|
||||
expect(gap.type === "gap" && gap.count).toBe(EMPTY_RUN_COLLAPSE);
|
||||
});
|
||||
|
||||
it("keeps a short empty run as individual dimmed day rows", () => {
|
||||
const perDay: IPerDay[] = [
|
||||
day({ day: DAY0, activeMs: HOUR }),
|
||||
day({ day: DAY0 + DAY_MS }),
|
||||
day({ day: DAY0 + 2 * DAY_MS, activeMs: HOUR }),
|
||||
];
|
||||
const rows = buildRows(perDay, t, 0);
|
||||
expect(rows.map((r) => r.type)).toEqual(["day", "day", "day"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summaryLabels", () => {
|
||||
it("derives totalLabel/agentLabel from ms via the shared formatter", () => {
|
||||
const { total, agent } = summaryLabels(
|
||||
payload({ workMs: 4 * HOUR + 27 * MIN, agentOnlyMs: 80 * MIN }),
|
||||
t,
|
||||
);
|
||||
expect(total).toBe("≈ 4h 25m");
|
||||
expect(agent).toBe("≈ 1h 20m");
|
||||
});
|
||||
|
||||
it("agent-only page: agent estimate fills the main slot, no secondary line", () => {
|
||||
const { total, agent } = summaryLabels(
|
||||
payload({ workMs: 0, agentOnlyMs: 80 * MIN }),
|
||||
t,
|
||||
);
|
||||
expect(total).toBe("agent: ≈ 1h 20m");
|
||||
expect(agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("human-only page: no agent line", () => {
|
||||
const { agent } = summaryLabels(payload({ workMs: HOUR, agentOnlyMs: 0 }), t);
|
||||
expect(agent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBlockTooltip", () => {
|
||||
it("formats start–end from the real epoch in the data tz + duration", () => {
|
||||
const d = day({
|
||||
day: DAY0,
|
||||
windows: [{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" }],
|
||||
});
|
||||
const [b] = toBlocks(d);
|
||||
const label = formatBlockTooltip(b, "UTC", "en-US", t);
|
||||
expect(label).toBe("09:00 – 10:30 · 1h 30m");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// #566 — adapter: real IPageWorkTime → the render-ready shape the redesigned
|
||||
// time-of-day timeline consumes. The visual prototype (NewDesign/TimeWorkedModal)
|
||||
// was written against invented props (`DaySummary[]`, pre-made `totalLabel`); the
|
||||
// real payload is IPageWorkTime. Everything below is pure so the mapping (windows
|
||||
// → time-of-day blocks, ms → labels, the "now" boundary, empty-run collapsing) is
|
||||
// unit-testable without React. No re-bucketing: the server already grouped the
|
||||
// windows by the request timezone — we only lay out the windows it returned.
|
||||
|
||||
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||
import { formatDayTotal, formatHeadline } from "./format-work-time";
|
||||
|
||||
type Translate = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||
// "× N days" separator (§6.2 long-range) — preserved from the original punch-card.
|
||||
export const EMPTY_RUN_COLLAPSE = 8;
|
||||
|
||||
export interface TimelineBlock {
|
||||
/** Hour fraction 0..24 within the day — drives left/width positioning. */
|
||||
start: number;
|
||||
end: number;
|
||||
kind: "work" | "agent";
|
||||
/** Real epoch ms, kept for a DST-safe tooltip (formatted in the data tz). */
|
||||
startEpoch: number;
|
||||
endEpoch: number;
|
||||
}
|
||||
|
||||
export interface TimelineDay {
|
||||
key: string;
|
||||
label: string;
|
||||
totalLabel: string;
|
||||
blocks: TimelineBlock[];
|
||||
isEmpty: boolean;
|
||||
/** Today lives only in the last active bucket; drives the "now" boundary. */
|
||||
isToday: boolean;
|
||||
/** 0..1 position of "now" within today's track (undefined when not today). */
|
||||
nowFraction?: number;
|
||||
}
|
||||
|
||||
export type TimelineRow =
|
||||
| { type: "day"; day: TimelineDay }
|
||||
| { type: "gap"; count: number };
|
||||
|
||||
/** Weekday-day-month heading in the browser locale (matches the server tz
|
||||
* bucketing, since usePageWorkTime requests buckets in the viewer tz). */
|
||||
export function dayHeading(day: number): string {
|
||||
return new Date(day).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/** Map one day's absolute windows into time-of-day blocks. `class` work|agent_only
|
||||
* → kind work|agent; positions are the in-day hour fraction (epoch kept for the
|
||||
* tooltip). Fractions are clamped to [0,24] to match the original punch-card. */
|
||||
export function toBlocks(day: IPerDay): TimelineBlock[] {
|
||||
return day.windows.map((w: IDayWindow) => {
|
||||
const start = clampHour((w.start - day.day) / (DAY_MS / 24));
|
||||
const end = clampHour((w.end - day.day) / (DAY_MS / 24));
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
kind: w.class === "work" ? "work" : "agent",
|
||||
startEpoch: w.start,
|
||||
endEpoch: w.end,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function clampHour(h: number): number {
|
||||
return Math.max(0, Math.min(24, h));
|
||||
}
|
||||
|
||||
/** Build a single render-ready day. `now` is injected for testability; the
|
||||
* "now" boundary is drawn only when `now` falls inside this bucket's calendar
|
||||
* day (so it appears on today's row and only when today has edits). */
|
||||
export function toTimelineDay(
|
||||
day: IPerDay,
|
||||
t: Translate,
|
||||
now: number,
|
||||
): TimelineDay {
|
||||
const isToday = now >= day.day && now < day.day + DAY_MS;
|
||||
return {
|
||||
key: day.dayISO,
|
||||
label: dayHeading(day.day),
|
||||
totalLabel: formatDayTotal(day.activeMs, t),
|
||||
blocks: toBlocks(day),
|
||||
isEmpty: day.activeMs === 0 && day.agentMs === 0,
|
||||
isToday,
|
||||
nowFraction: isToday ? (now - day.day) / DAY_MS : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Collapse long edit-free runs (≥ EMPTY_RUN_COLLAPSE) into an in-place "gap"
|
||||
* row; short runs stay as (dimmed, "—") day rows. Preserved from the original
|
||||
* punch-card so a page edited over months does not render hundreds of rows. */
|
||||
export function buildRows(
|
||||
perDay: IPerDay[],
|
||||
t: Translate,
|
||||
now: number,
|
||||
): TimelineRow[] {
|
||||
const rows: TimelineRow[] = [];
|
||||
let emptyRun: IPerDay[] = [];
|
||||
const flush = () => {
|
||||
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||
rows.push({ type: "gap", count: emptyRun.length });
|
||||
} else {
|
||||
for (const d of emptyRun) {
|
||||
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
|
||||
}
|
||||
}
|
||||
emptyRun = [];
|
||||
};
|
||||
for (const d of perDay) {
|
||||
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||
emptyRun.push(d);
|
||||
} else {
|
||||
flush();
|
||||
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The big summary slot. Fail-safe for an agent-only page (#395/#551): since
|
||||
* formatHeadline(0) === "", never leave the 22px slot empty — put the agent
|
||||
* estimate in the main slot, and only show the secondary `agent:` line when
|
||||
* BOTH a human and an agent estimate exist. */
|
||||
export function summaryLabels(
|
||||
data: IPageWorkTime,
|
||||
t: Translate,
|
||||
): { total: string; agent?: string } {
|
||||
const total =
|
||||
data.workMs > 0
|
||||
? formatHeadline(data.workMs, t)
|
||||
: t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) });
|
||||
const agent =
|
||||
data.workMs > 0 && data.agentOnlyMs > 0
|
||||
? formatHeadline(data.agentOnlyMs, t)
|
||||
: undefined;
|
||||
return { total, agent };
|
||||
}
|
||||
|
||||
/** Block hover label "start – end · duration". Times come from the REAL epoch
|
||||
* rendered in the data tz (NOT the 24h fraction) so a DST-transition day does
|
||||
* not skew the shown clock time. Duration reuses formatDayTotal (always > 0
|
||||
* here, so never "—"). */
|
||||
export function formatBlockTooltip(
|
||||
block: TimelineBlock,
|
||||
tz: string,
|
||||
locale: string,
|
||||
t: Translate,
|
||||
): string {
|
||||
const fmt = new Intl.DateTimeFormat(locale || undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: tz,
|
||||
});
|
||||
return t("{{start}} – {{end}} · {{duration}}", {
|
||||
start: fmt.format(block.startEpoch),
|
||||
end: fmt.format(block.endEpoch),
|
||||
duration: formatDayTotal(block.endEpoch - block.startEpoch, t),
|
||||
});
|
||||
}
|
||||
@@ -1,97 +1,86 @@
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
// #566 — redesigned "Time worked" body: daily time-of-day timelines. Each day is
|
||||
// a 24h track showing WHEN the work happened — a sticky 00/06/12/18/24 hour axis,
|
||||
// shaded night hours, per-block hover tooltip ("start – end · duration"), and a
|
||||
// "now" boundary on today's row. Presentation ported from NewDesign/TimeWorkedModal;
|
||||
// all data comes through the pure adapter over the real IPageWorkTime (zero backend
|
||||
// change). Positioning math, empty-run collapsing and the formatters are reused.
|
||||
import { Box, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||
import { IPageWorkTime } from "./work-time.types";
|
||||
import { formatGapMinutes } from "./format-work-time";
|
||||
import {
|
||||
formatDayTotal,
|
||||
formatGapMinutes,
|
||||
formatHeadline,
|
||||
} from "./format-work-time";
|
||||
buildRows,
|
||||
formatBlockTooltip,
|
||||
summaryLabels,
|
||||
TimelineBlock,
|
||||
TimelineDay,
|
||||
} from "./work-time-adapter";
|
||||
import classes from "./work-time.module.css";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||
// "× N days" separator (§6.2 long-range) — the row is still always one day.
|
||||
const EMPTY_RUN_COLLAPSE = 8;
|
||||
// Minimum visible width so a very short session neither vanishes nor fakes dense
|
||||
// work (§6.2); kept from the original punch-card.
|
||||
const MIN_BLOCK_WIDTH_PCT = 0.6;
|
||||
|
||||
type Row =
|
||||
| { type: "day"; day: IPerDay }
|
||||
| { type: "gap"; count: number };
|
||||
|
||||
function collapseEmptyRuns(perDay: IPerDay[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
let emptyRun: IPerDay[] = [];
|
||||
const flush = () => {
|
||||
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||
rows.push({ type: "gap", count: emptyRun.length });
|
||||
} else {
|
||||
for (const d of emptyRun) rows.push({ type: "day", day: d });
|
||||
}
|
||||
emptyRun = [];
|
||||
};
|
||||
for (const d of perDay) {
|
||||
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||
emptyRun.push(d);
|
||||
} else {
|
||||
flush();
|
||||
rows.push({ type: "day", day: d });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
function dayHeading(day: number): string {
|
||||
return new Date(day).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
function ActivityBlock({
|
||||
block,
|
||||
tz,
|
||||
locale,
|
||||
}: {
|
||||
block: TimelineBlock;
|
||||
tz: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const left = (block.start / 24) * 100;
|
||||
const width = Math.max(((block.end - block.start) / 24) * 100, MIN_BLOCK_WIDTH_PCT);
|
||||
const cls = [
|
||||
classes.window,
|
||||
block.kind === "work" ? classes.windowWork : classes.windowAgent,
|
||||
].join(" ");
|
||||
return (
|
||||
<Tooltip
|
||||
label={formatBlockTooltip(block, tz, locale, t)}
|
||||
withArrow
|
||||
openDelay={120}
|
||||
fz={11}
|
||||
>
|
||||
<div className={cls} style={{ left: `${left}%`, width: `${width}%` }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function DayTrack({
|
||||
day,
|
||||
pSingle,
|
||||
tz,
|
||||
locale,
|
||||
}: {
|
||||
day: IPerDay;
|
||||
pSingle: number;
|
||||
day: TimelineDay;
|
||||
tz: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ticks = [6, 12, 18];
|
||||
return (
|
||||
<div className={classes.row}>
|
||||
<span className={classes.dayLabel}>{dayHeading(day.day)}</span>
|
||||
<div className={classes.track}>
|
||||
{ticks.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className={classes.hourTick}
|
||||
style={{ left: `${(h / 24) * 100}%` }}
|
||||
/>
|
||||
<span className={classes.dayLabel}>{day.label}</span>
|
||||
<div className={`${classes.track} ${day.isEmpty ? classes.trackEmpty : ""}`}>
|
||||
{[25, 50, 75].map((p) => (
|
||||
<div key={p} className={classes.hourTick} style={{ left: `${p}%` }} />
|
||||
))}
|
||||
{day.windows.map((w: IDayWindow, i) => {
|
||||
const leftPct = ((w.start - day.day) / DAY_MS) * 100;
|
||||
const widthPct = ((w.end - w.start) / DAY_MS) * 100;
|
||||
const isSingle = w.end - w.start <= pSingle;
|
||||
const cls = [
|
||||
classes.window,
|
||||
w.class === "work" ? classes.windowWork : classes.windowAgent,
|
||||
isSingle ? classes.windowSingle : "",
|
||||
].join(" ");
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cls}
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, leftPct))}%`,
|
||||
width: `${Math.max(0, Math.min(100, widthPct))}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{day.blocks.map((b, i) => (
|
||||
<ActivityBlock key={i} block={b} tz={tz} locale={locale} />
|
||||
))}
|
||||
{day.isToday && day.nowFraction != null && (
|
||||
<div
|
||||
className={classes.nowLine}
|
||||
style={{ left: `${day.nowFraction * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={classes.daySum}>
|
||||
{formatDayTotal(day.activeMs, t)}
|
||||
<span
|
||||
className={classes.daySum}
|
||||
data-empty={day.totalLabel === "—" ? true : undefined}
|
||||
>
|
||||
{day.totalLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -102,8 +91,16 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function WorkTimePunchCard({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]);
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language;
|
||||
const now = Date.now();
|
||||
const rows = useMemo(
|
||||
() => buildRows(data.perDay, t, now),
|
||||
// `now` intentionally re-read on each open; excluded so the memo tracks data.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[data.perDay, t],
|
||||
);
|
||||
const { total, agent } = summaryLabels(data, t);
|
||||
const gapMin = formatGapMinutes(data.config.tGap);
|
||||
|
||||
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
|
||||
@@ -116,17 +113,19 @@ export default function WorkTimePunchCard({ data }: Props) {
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Group gap="lg">
|
||||
<Text size="sm" fw={500}>
|
||||
{formatHeadline(data.workMs, t)}
|
||||
{/* summary */}
|
||||
<Group align="baseline" gap="md">
|
||||
<Text fz={22} fw={700}>
|
||||
{total}
|
||||
</Text>
|
||||
{data.agentOnlyMs > 0 && (
|
||||
{agent && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })}
|
||||
{t("agent: {{value}}", { value: agent })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* legend */}
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
<span
|
||||
@@ -144,21 +143,47 @@ export default function WorkTimePunchCard({ data }: Props) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
{/* sticky hour axis */}
|
||||
<div className={`${classes.row} ${classes.axisRow}`}>
|
||||
<span />
|
||||
<div className={classes.axis}>
|
||||
{[
|
||||
["0%", "00", "start"],
|
||||
["25%", "06", "center"],
|
||||
["50%", "12", "center"],
|
||||
["75%", "18", "center"],
|
||||
["100%", "24", "end"],
|
||||
].map(([l, label, align]) => (
|
||||
<span
|
||||
key={label}
|
||||
className={classes.axisTick}
|
||||
data-align={align}
|
||||
style={{ left: l }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* day rows */}
|
||||
<ScrollArea.Autosize mah="60vh" type="hover">
|
||||
{rows.map((row, i) =>
|
||||
row.type === "day" ? (
|
||||
<DayTrack
|
||||
key={row.day.dayISO}
|
||||
key={row.day.key}
|
||||
day={row.day}
|
||||
pSingle={data.config.pSingle}
|
||||
tz={data.tz}
|
||||
locale={locale}
|
||||
/>
|
||||
) : (
|
||||
<div key={`gap-${i}`} className={classes.gapRow}>
|
||||
<Box key={`gap-${i}`} className={classes.gapRow}>
|
||||
{t("× {{count}} days without edits", { count: row.count })}
|
||||
</div>
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function WorkTimeStat({ pageId }: Props) {
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t("Time worked on this article")}
|
||||
size="lg"
|
||||
size="46rem"
|
||||
>
|
||||
<WorkTimePunchCard data={data} />
|
||||
</Modal>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* #395 — 24h × days punch-card. Custom CSS segments on a fixed 24-hour track
|
||||
(position = offset-in-day / 24h, width = duration / 24h), no chart library. */
|
||||
/* #566 — time-of-day timeline. Custom CSS segments on a fixed 24-hour track
|
||||
(position = offset-in-day / 24h, width = duration / 24h), no chart library.
|
||||
Night hours (0–6, 21–24) are shaded so the eye reads morning-vs-evening; a
|
||||
sticky hour axis labels 00/06/12/18/24. Theme-aware via Mantine tokens. */
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
@@ -16,17 +18,29 @@
|
||||
}
|
||||
|
||||
.track {
|
||||
--wt-night: rgba(90, 100, 130, 0.16);
|
||||
--wt-day: rgba(90, 100, 130, 0.03);
|
||||
position: relative;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
/* base fill + night shading: 0–6h and 21–24h (87.5%) darker than daytime */
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--wt-night) 0 25%,
|
||||
var(--wt-day) 25% 87.5%,
|
||||
var(--wt-night) 87.5% 100%
|
||||
),
|
||||
light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
/* Faint hour grid so the eye can read "morning vs evening". */
|
||||
/* Short (< EMPTY_RUN_COLLAPSE) edit-free days: dimmed, empty track. */
|
||||
.trackEmpty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Quarter-day grid divisions (06/12/18) inside the track. */
|
||||
.hourTick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -40,10 +54,11 @@
|
||||
|
||||
.window {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
top: 3px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
min-width: 3px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.windowWork {
|
||||
@@ -54,18 +69,57 @@
|
||||
background-color: var(--mantine-color-grape-5);
|
||||
}
|
||||
|
||||
/* A lone single-sample (P_single) window: minimal + dimmed, so it neither
|
||||
vanishes nor fakes dense work (§6.2). */
|
||||
.windowSingle {
|
||||
opacity: 0.5;
|
||||
/* The "now" boundary on today's row. */
|
||||
.nowLine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: var(--mantine-color-red-6);
|
||||
}
|
||||
|
||||
.daySum {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daySum[data-empty] {
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Sticky hour axis header aligned to the track column. */
|
||||
.axisRow {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--mantine-color-body);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.axis {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.axisTick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.axisTick[data-align="center"] {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.axisTick[data-align="end"] {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.gapRow {
|
||||
padding: 6px 0 6px 108px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
|
||||
Reference in New Issue
Block a user