Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbac8e6976 |
@@ -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,362 +0,0 @@
|
||||
/**
|
||||
* PageHistoryModal — редизайн окна «Page history».
|
||||
* Mantine v7. Light/dark. Полноразмерное модальное окно.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* ЧТО ЭТО
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* Окно истории версий страницы. Слева — панель навигации: мини-календарь
|
||||
* (heatmap: яркость дня = число ревизий, обводка = выбранный день) + плотный
|
||||
* список ревизий (одна ревизия = одна строка). Справа — реально отрендеренная
|
||||
* версия страницы с подсветкой изменений. Все контролы — в одну строку шапки.
|
||||
*
|
||||
* Ключевые решения:
|
||||
* - Плотный список: одна ревизия на строку (аватар · время · автор · [SAVED]).
|
||||
* Бейдж показывается ТОЛЬКО у сохранённых версий; всё остальное — autosave.
|
||||
* - Агентские ревизии визуально отличаются: квадратный глиф роли (К/Ф) вместо
|
||||
* круглого аватара пользователя + «via <кто запустил>».
|
||||
* - Календарь объединён со списком в одной панели (мини-календарь = date jumper).
|
||||
* - Highlight changes + навигация по изменениям (N of M, ↑/↓) вынесены в шапку.
|
||||
* - Текущая версия помечена; Restore для неё недоступен.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* import { PageHistoryModal, Revision } from './PageHistoryModal';
|
||||
*
|
||||
* <PageHistoryModal
|
||||
* opened={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* revisions={revisions} // Revision[]
|
||||
* selectedId={selId}
|
||||
* onSelect={setSelId} // клик по ревизии → рендер справа
|
||||
* renderVersion={(rev) => <ArticleView versionId={rev.id} />} // ваш рендер страницы
|
||||
* highlightChanges={hl}
|
||||
* onToggleHighlight={setHl}
|
||||
* changeNav={{ index: 1, total: 3, onPrev, onNext }} // навигация по диффу
|
||||
* onlySaved={onlySaved}
|
||||
* onToggleOnlySaved={setOnlySaved}
|
||||
* onRestore={(rev) => api.restore(rev.id)} // async; текущая версия → disabled
|
||||
* />
|
||||
*
|
||||
* Требует MantineProvider на корне приложения (defaultColorScheme="auto" для тем).
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* ФОРМАТ ДАННЫХ
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* interface Revision {
|
||||
* id: string;
|
||||
* at: Date | string; // время ревизии; форматируется вызывающим/util-ом
|
||||
* dayGroup: string; // 'Today' | 'Yesterday' | 'Mon 12 Jul' — заголовок группы
|
||||
* saved: boolean; // true → бейдж SAVED; false → autosave (без бейджа)
|
||||
* author: { name: string } & (
|
||||
* | { kind: 'human' }
|
||||
* | { kind: 'agent'; role: string; triggeredBy: string } // роль + кто запустил
|
||||
* );
|
||||
* isCurrent?: boolean; // текущая (последняя) версия — Restore недоступен
|
||||
* }
|
||||
*
|
||||
* Ревизии приходят плоским массивом; группировка по dayGroup — на клиенте.
|
||||
* Никаких изменений API не требуется: агентское авторство берётся из author.kind.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* СОСТОЯНИЯ (нарисованы/поддержаны)
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* - Ревизия: обычная / hover / выбранная / текущая / агентская / человеческая.
|
||||
* - Restore: default / disabled (выбрана текущая) / loading (спиннер после клика).
|
||||
* - Highlight changes: on/off; при off навигация по изменениям неактивна.
|
||||
* - Пустая история: одна версия / нет ревизий → EmptyState.
|
||||
* - Тёмная тема: через токены Mantine (useMantineColorScheme, var(--mantine-*)).
|
||||
*/
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Modal, Box, Group, Stack, Text, Switch, Avatar, Badge, Button, ActionIcon,
|
||||
ScrollArea, useMantineTheme, useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
|
||||
/* ─────────────────────────── Типы ─────────────────────────── */
|
||||
|
||||
export type Author =
|
||||
| { name: string; kind: 'human' }
|
||||
| { name: string; kind: 'agent'; role: string; triggeredBy: string };
|
||||
|
||||
export interface Revision {
|
||||
id: string;
|
||||
at: Date | string;
|
||||
atLabel: string; // предформатированное «5:35AM»
|
||||
dayGroup: string; // «Today» | «Yesterday» | «Mon 12 Jul»
|
||||
saved: boolean;
|
||||
author: Author;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
label: string; // «12»
|
||||
inMonth: boolean;
|
||||
count: number; // число ревизий за день (для heatmap)
|
||||
selected?: boolean;
|
||||
date: Date | string;
|
||||
}
|
||||
|
||||
export interface PageHistoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
revisions: Revision[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
renderVersion: (rev: Revision | null) => React.ReactNode;
|
||||
highlightChanges: boolean;
|
||||
onToggleHighlight: (v: boolean) => void;
|
||||
changeNav?: { index: number; total: number; onPrev: () => void; onNext: () => void };
|
||||
onlySaved: boolean;
|
||||
onToggleOnlySaved: (v: boolean) => void;
|
||||
onRestore: (rev: Revision) => Promise<void>;
|
||||
/** Ячейки текущего месяца календаря + управление. Необязательно — без него панель = только список. */
|
||||
calendar?: {
|
||||
monthLabel: string; // «July 2025»
|
||||
weekdays: string[]; // ['Mon',…,'Sun']
|
||||
days: CalendarDay[]; // обычно 35/42 ячейки
|
||||
onPrevMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
onToday: () => void;
|
||||
onPickDay: (d: CalendarDay) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Утилиты представления ─────────────────────────── */
|
||||
|
||||
const USER_PALETTE = ['#495057', '#e8590c', '#1c7ed6', '#7048e8', '#0ca678'];
|
||||
function userColor(name: string) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return USER_PALETTE[h % USER_PALETTE.length];
|
||||
}
|
||||
const AGENT_GRAD: Record<string, string> = {
|
||||
'Корректор': 'linear-gradient(135deg,#20c997,#12b886)',
|
||||
'Фактчекер': 'linear-gradient(135deg,#4c6ef5,#7048e8)',
|
||||
};
|
||||
function agentGrad(role: string) { return AGENT_GRAD[role] ?? 'linear-gradient(135deg,#868e96,#495057)'; }
|
||||
|
||||
/** heatmap: число ревизий → фон/текст ячейки календаря. */
|
||||
function heat(n: number, dark: boolean) {
|
||||
if (n === 0) return { bg: 'transparent', fg: dark ? '#5c5f66' : '#adb5bd' };
|
||||
if (n <= 2) return { bg: dark ? 'rgba(34,139,230,.20)' : '#e7f0ff', fg: dark ? '#74c0fc' : '#1971c2' };
|
||||
if (n <= 4) return { bg: dark ? 'rgba(34,139,230,.45)' : '#a5c8ff', fg: dark ? '#dbeafe' : '#0b3d91' };
|
||||
return { bg: '#4c8dff', fg: '#ffffff' };
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Строка ревизии ─────────────────────────── */
|
||||
|
||||
function RevisionRow({ rev, selected, onSelect }: { rev: Revision; selected: boolean; onSelect: () => void }) {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === 'dark';
|
||||
const a = rev.author;
|
||||
const isAgent = a.kind === 'agent';
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={8} wrap="nowrap" h={28} px="12px 14px" m="1px 6px"
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
cursor: 'pointer', borderRadius: 7,
|
||||
background: selected ? (dark ? 'rgba(34,139,230,.14)' : '#eef4ff')
|
||||
: isAgent ? (dark ? 'rgba(112,72,232,.06)' : '#fbfaff') : undefined,
|
||||
}}
|
||||
>
|
||||
{/* аватар/глиф */}
|
||||
{isAgent ? (
|
||||
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, background: agentGrad(a.role), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
|
||||
{a.role[0]}
|
||||
</Box>
|
||||
) : (
|
||||
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: '50%', background: userColor(a.name), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
|
||||
{a.name[0].toUpperCase()}
|
||||
</Box>
|
||||
)}
|
||||
{/* время */}
|
||||
<Text fz={12.5} fw={rev.isCurrent ? 600 : 500} c={rev.isCurrent ? undefined : 'dimmed'} style={{ flex: 'none', minWidth: 58 }}>
|
||||
{rev.atLabel}
|
||||
</Text>
|
||||
{/* автор + via */}
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0, alignItems: 'baseline', overflow: 'hidden' }}>
|
||||
<Text fz={12} fw={isAgent ? 600 : 400} c={isAgent ? undefined : 'dimmed'} truncate style={{ flex: 'none', maxWidth: 100 }}>
|
||||
{isAgent ? a.role : a.name}
|
||||
</Text>
|
||||
{isAgent && <Text fz={11} c="dimmed" truncate>· via {a.triggeredBy}</Text>}
|
||||
</Group>
|
||||
{/* бейдж — только SAVED */}
|
||||
{rev.saved && <Badge size="sm" radius="sm" variant="light" color="blue">SAVED</Badge>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Мини-календарь ─────────────────────────── */
|
||||
|
||||
function MiniCalendar({ cal }: { cal: NonNullable<PageHistoryModalProps['calendar']> }) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === 'dark';
|
||||
return (
|
||||
<Box p="10px 12px 8px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Group gap={6} mb={6}>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onPrevMonth}>‹</ActionIcon>
|
||||
<Text fz={12} fw={600}>{cal.monthLabel}</Text>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onNextMonth}>›</ActionIcon>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Button variant="subtle" size="compact-xs" onClick={cal.onToday}>Today</Button>
|
||||
</Group>
|
||||
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2, marginBottom: 2 }}>
|
||||
{cal.weekdays.map((w) => (
|
||||
<Text key={w} ta="center" fz={8.5} fw={600} c="dimmed">{w}</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2 }}>
|
||||
{cal.days.map((d, i) => {
|
||||
const h = heat(d.count, dark);
|
||||
return (
|
||||
<Box
|
||||
key={i} onClick={() => cal.onPickDay(d)}
|
||||
style={{
|
||||
position: 'relative', height: 26, borderRadius: 6, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: d.inMonth ? h.bg : 'transparent',
|
||||
boxShadow: d.selected ? `inset 0 0 0 2px ${dark ? '#f1f3f5' : '#1a1b1e'}` : undefined,
|
||||
}}
|
||||
>
|
||||
<Text fz={10.5} fw={d.selected ? 700 : 500} style={{ color: d.inMonth ? h.fg : (dark ? '#3a3d42' : '#dee2e6') }}>
|
||||
{d.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{/* легенда heatmap */}
|
||||
<Group gap={6} mt={8} align="center">
|
||||
<Text fz={9.5} c="dimmed">fewer</Text>
|
||||
{['#e7f0ff', '#a5c8ff', '#4c8dff'].map((c) => (
|
||||
<Box key={c} style={{ width: 14, height: 10, borderRadius: 2, background: c }} />
|
||||
))}
|
||||
<Text fz={9.5} c="dimmed">more revisions</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Окно ─────────────────────────── */
|
||||
|
||||
export function PageHistoryModal(props: PageHistoryModalProps) {
|
||||
const {
|
||||
opened, onClose, revisions, selectedId, onSelect, renderVersion,
|
||||
highlightChanges, onToggleHighlight, changeNav, onlySaved, onToggleOnlySaved,
|
||||
onRestore, calendar,
|
||||
} = props;
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
const selected = revisions.find((r) => r.id === selectedId) ?? null;
|
||||
const isEmpty = revisions.length <= 1;
|
||||
|
||||
// группировка по dayGroup, с фильтром Only saved
|
||||
const groups = useMemo(() => {
|
||||
const list = onlySaved ? revisions.filter((r) => r.saved) : revisions;
|
||||
const map: { head: string; items: Revision[] }[] = [];
|
||||
for (const r of list) {
|
||||
let g = map.find((x) => x.head === r.dayGroup);
|
||||
if (!g) { g = { head: r.dayGroup, items: [] }; map.push(g); }
|
||||
g.items.push(r);
|
||||
}
|
||||
return map;
|
||||
}, [revisions, onlySaved]);
|
||||
|
||||
const restore = useCallback(async () => {
|
||||
if (!selected || selected.isCurrent) return;
|
||||
setRestoring(true);
|
||||
try { await onRestore(selected); } finally { setRestoring(false); }
|
||||
}, [selected, onRestore]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened} onClose={onClose} withCloseButton={false}
|
||||
size="80rem" radius="lg" padding={0}
|
||||
styles={{ body: { height: '80vh', maxHeight: 760, display: 'flex', flexDirection: 'column' } }}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}
|
||||
>
|
||||
{/* ── single-row toolbar ── */}
|
||||
<Group gap={14} h={60} px={16} wrap="nowrap" style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Text fz={16} fw={600}>Page history</Text>
|
||||
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text fz={12} fw={600} truncate>{selected ? `${selected.dayGroup} · ${selected.atLabel}` : '—'}</Text>
|
||||
<Text fz={10.5} c="dimmed">selected version</Text>
|
||||
</Stack>
|
||||
|
||||
<Box style={{ flex: 1 }} />
|
||||
|
||||
{/* diff cluster */}
|
||||
<Group gap={2} p={3} wrap="nowrap" style={(t) => ({ background: t.colorScheme === 'dark' ? t.colors.dark[6] : '#f4f6f8', borderRadius: 10 })}>
|
||||
<Button variant="white" size="compact-sm" onClick={() => onToggleHighlight(!highlightChanges)}
|
||||
leftSection={<Switch checked={highlightChanges} onChange={() => {}} size="xs" tabIndex={-1} styles={{ track: { cursor: 'pointer' } }} />}
|
||||
styles={{ root: { boxShadow: '0 1px 2px rgba(0,0,0,.08)' } }}>
|
||||
Highlight changes
|
||||
</Button>
|
||||
{changeNav && (
|
||||
<>
|
||||
<Text fz={12} fw={600} c="dimmed" px={6}>{changeNav.index} / {changeNav.total}</Text>
|
||||
<Box style={{ display: 'flex' }}>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onPrev} style={{ width: 26 }}>↑</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onNext} style={{ width: 26 }}>↓</ActionIcon>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
|
||||
|
||||
<Button color="blue" onClick={restore} loading={restoring} disabled={!selected || selected.isCurrent}>
|
||||
Restore this version
|
||||
</Button>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}>✕</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* ── body ── */}
|
||||
<Box style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||
{/* left: nav panel */}
|
||||
<Stack gap={0} style={(t) => ({ flex: 'none', width: 280, minHeight: 0, borderRight: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`, background: t.colorScheme === 'dark' ? t.colors.dark[7] : '#fcfcfd' })}>
|
||||
<Group h={44} px={16} style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Switch checked={onlySaved} onChange={(e) => onToggleOnlySaved(e.currentTarget.checked)} size="sm" label="Only saved" labelPosition="right" styles={{ label: { fontSize: 13, fontWeight: 500 } }} />
|
||||
</Group>
|
||||
|
||||
{calendar && <MiniCalendar cal={calendar} />}
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
{groups.map((g) => (
|
||||
<Box key={g.head}>
|
||||
<Text px={16} pt={9} pb={5} fz={10.5} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.05em', position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
|
||||
{g.head}
|
||||
</Text>
|
||||
{g.items.map((r) => (
|
||||
<RevisionRow key={r.id} rev={r} selected={r.id === selectedId} onSelect={() => onSelect(r.id)} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
|
||||
{/* right: rendered version */}
|
||||
<ScrollArea style={{ flex: 1, minWidth: 0 }} bg="var(--mantine-color-default-hover)">
|
||||
{isEmpty ? (
|
||||
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
|
||||
<Text fw={600} fz={14}>No earlier versions</Text>
|
||||
<Text fz={12.5} c="dimmed" ta="center">У страницы пока одна версия — сравнивать не с чем.</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box p="26px 0" maw={660} mx="auto">
|
||||
{renderVersion(selected)}
|
||||
</Box>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHistoryModal;
|
||||
@@ -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;
|
||||
@@ -346,9 +346,6 @@ roles:
|
||||
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
|
||||
appendix at the end of the document or clearly separated from the report
|
||||
body.
|
||||
□ Once the report is complete, call save_page_version to pin the finished
|
||||
document as a restorable named version (a checkpoint the reader can
|
||||
return to). A repeat call after no further edits is a harmless no-op.
|
||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
||||
a guess as a fact.
|
||||
autoStart: false
|
||||
@@ -445,7 +442,6 @@ roles:
|
||||
- **The language of the notes = the main language of the call.** Technical terms — in their canonical spelling (usually Latin).
|
||||
- **Don't evaluate the participants** and don't comment on the quality of the discussion.
|
||||
- The output is the notes only, with no preambles or meta-comments, apart from targeted uncertainty marks.
|
||||
- **Pin the result.** Once the notes are complete on the page, call save_page_version to save them as a restorable named version (a checkpoint). Calling it again after no further edits is a harmless no-op.
|
||||
|
||||
## Style example (excerpt)
|
||||
|
||||
|
||||
@@ -345,9 +345,6 @@ roles:
|
||||
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
|
||||
an appendix at the end of the document or clearly separated from the
|
||||
report body.
|
||||
□ Once the report is complete, call save_page_version to pin the finished
|
||||
document as a restorable named version (a checkpoint the reader can
|
||||
return to). A repeat call after no further edits is a harmless no-op.
|
||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
||||
a guess as a fact.
|
||||
autoStart: false
|
||||
@@ -444,7 +441,6 @@ roles:
|
||||
- **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей).
|
||||
- **Не оценивай участников** и не комментируй качество обсуждения.
|
||||
- На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности.
|
||||
- **Зафиксируй результат.** Когда конспект готов на странице, вызови save_page_version, чтобы сохранить его как именованную версию (точку восстановления). Повторный вызов без новых правок — безвредный no-op.
|
||||
|
||||
## Пример стиля (фрагмент)
|
||||
|
||||
|
||||
@@ -33,6 +33,6 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: researcher
|
||||
version: 10
|
||||
version: 9
|
||||
- slug: call-summarizer
|
||||
version: 2
|
||||
version: 1
|
||||
|
||||
@@ -29,6 +29,7 @@ import ShareAliasSection from "@/features/share/components/share-alias-section.t
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query.ts";
|
||||
|
||||
interface ShareModalProps {
|
||||
readOnly: boolean;
|
||||
@@ -54,6 +55,33 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
// if level is greater than zero, then it is a descendant page from a shared page
|
||||
const isDescendantShared = share && share.level > 0;
|
||||
|
||||
// #370 Stage B — "publish only the saved version". Mirrors the server XOR:
|
||||
// approved and includeSubPages are mutually exclusive.
|
||||
const isApproved = share?.publishedMode === "approved";
|
||||
const includeSubPages = share?.includeSubPages ?? false;
|
||||
|
||||
// Resolve the latest MANUAL version (the one an approved share publishes) so
|
||||
// the modal can caption it and warn when the live draft has drifted ahead.
|
||||
// Only fetched for a directly-shared page whose modal is actually open.
|
||||
const { data: historyData } = usePageHistoryListQuery(
|
||||
pageIsShared ? pageId : "",
|
||||
);
|
||||
const latestManual = useMemo(() => {
|
||||
const items = historyData?.pages?.flatMap((p) => p.items) ?? [];
|
||||
// The list is newest-first; the first manual row is the published version.
|
||||
return items.find((h) => h.kind === "manual") ?? null;
|
||||
}, [historyData]);
|
||||
|
||||
// The live draft has edits newer than the published saved version when the
|
||||
// page was updated after that manual snapshot was taken.
|
||||
const hasUnpublishedEdits = useMemo(() => {
|
||||
if (!isApproved || !latestManual || !page?.updatedAt) return false;
|
||||
return (
|
||||
new Date(page.updatedAt).getTime() >
|
||||
new Date(latestManual.createdAt).getTime()
|
||||
);
|
||||
}, [isApproved, latestManual, page?.updatedAt]);
|
||||
|
||||
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
|
||||
|
||||
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
|
||||
@@ -115,6 +143,23 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// #370 Stage B — toggle between publishing the live draft ('live') and the
|
||||
// last saved version ('approved'). The server rejects approved + sub-pages, so
|
||||
// the UI keeps the two toggles mutually exclusive (see the disabled props).
|
||||
const handlePublishedModeChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
await updateShareMutation.mutateAsync({
|
||||
shareId: share.id,
|
||||
publishedMode: value ? "approved" : "live",
|
||||
});
|
||||
} catch {
|
||||
// query invalidation will revert the UI
|
||||
}
|
||||
};
|
||||
|
||||
const shareLink = useMemo(
|
||||
() => (
|
||||
<Group my="sm" gap={4} wrap="nowrap">
|
||||
@@ -238,11 +283,42 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
|
||||
<Switch
|
||||
onChange={handleSubPagesChange}
|
||||
checked={share.includeSubPages}
|
||||
checked={includeSubPages}
|
||||
size="xs"
|
||||
disabled={readOnly}
|
||||
// XOR with approved mode: a version-frozen share cannot also
|
||||
// publish a whole sub-tree.
|
||||
disabled={readOnly || isApproved}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{t("Publish only the saved version")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{isApproved
|
||||
? t(
|
||||
"Visitors see the last saved version, not live edits",
|
||||
)
|
||||
: t("Visitors see live edits as you make them")}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={handlePublishedModeChange}
|
||||
checked={isApproved}
|
||||
size="xs"
|
||||
// XOR with sub-pages: hidden intent is enforced by disabling
|
||||
// this toggle whenever sub-pages are shared.
|
||||
disabled={readOnly || includeSubPages}
|
||||
/>
|
||||
</Group>
|
||||
{isApproved && hasUnpublishedEdits && (
|
||||
<Text size="xs" c="orange" mt={4}>
|
||||
{t(
|
||||
"You have unsaved changes newer than the published version",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
|
||||
<div>
|
||||
<Text size="sm">{t("Search engine indexing")}</Text>
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface IShare {
|
||||
pageId: string;
|
||||
includeSubPages: boolean;
|
||||
searchIndexing: boolean;
|
||||
// #370 Stage B — 'live' serves the current draft; 'approved' serves the last
|
||||
// manually-saved version. Mutually exclusive with includeSubPages.
|
||||
publishedMode: "live" | "approved";
|
||||
creatorId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
@@ -75,6 +78,7 @@ export interface ICreateShare {
|
||||
pageId?: string;
|
||||
includeSubPages?: boolean;
|
||||
searchIndexing?: boolean;
|
||||
publishedMode?: "live" | "approved";
|
||||
}
|
||||
|
||||
export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string };
|
||||
|
||||
@@ -14,11 +14,9 @@
|
||||
*/
|
||||
|
||||
/*
|
||||
* Anchor the top toast containers to the very top edge of the viewport (a small
|
||||
* 8px gap), above the header/search chrome, per product request. The toast
|
||||
* (z-index 10000) therefore renders over the header/toolbar (both z-index 99)
|
||||
* for the few seconds it is visible — intentional, since it is the top-most,
|
||||
* in-the-line-of-sight surface.
|
||||
* Push the top-anchored toast containers below the top chrome (fixed 45px
|
||||
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
|
||||
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
|
||||
*
|
||||
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
||||
* containers simultaneously (`position` only routes toasts into one via the
|
||||
@@ -36,7 +34,7 @@
|
||||
* (the :where() contributes 0), regardless of stylesheet order.
|
||||
*/
|
||||
.mantine-Notifications-root[data-position^='top'] {
|
||||
top: 8px;
|
||||
top: 96px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
||||
|
||||
@@ -736,53 +736,6 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
||||
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
|
||||
expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG);
|
||||
});
|
||||
|
||||
// #370 F2 — an effectively-empty page is a REACHABLE no-op (agent calls
|
||||
// save_page_version on a blank page): the version tx short-circuits with
|
||||
// nothing to pin. The handler MUST still broadcast a TERMINAL reply
|
||||
// (version.skipped, reason:'empty') so the client resolves at once instead of
|
||||
// waiting out its 20s ack timeout and misreporting a healthy server as
|
||||
// unreachable. MUTATION: drop the `else if (skipped)` broadcast → no terminal
|
||||
// reply is sent → this reddens.
|
||||
it('empty page → no version written, broadcasts a terminal version.skipped(empty)', async () => {
|
||||
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const document = ydocFor(emptyDoc);
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: emptyDoc,
|
||||
});
|
||||
|
||||
await emitSave(document, 'agent');
|
||||
|
||||
// Nothing pinned.
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
|
||||
// But a terminal reply WAS sent so the client never times out. The flush
|
||||
// (onStoreDocument) emits its own `page.updated`; the version.skipped is the
|
||||
// LAST broadcast (dropping the skip branch leaves page.updated last → reds).
|
||||
const calls = (document as any).broadcastStateless.mock.calls;
|
||||
const msg = JSON.parse(calls[calls.length - 1][0]);
|
||||
expect(msg).toEqual({ type: 'version.skipped', reason: 'empty' });
|
||||
});
|
||||
|
||||
// #370 F2 — the page row is gone (deleted / never persisted). Same rule: a
|
||||
// terminal reply MUST be sent (version.skipped, reason:'page-not-found') so the
|
||||
// client surfaces a truthful "not found" immediately rather than a health
|
||||
// timeout. onStoreDocument's own `!page` guard returns early without throwing,
|
||||
// so the handler reaches the version tx and its `!page` skip branch.
|
||||
it('page not found → broadcasts a terminal version.skipped(page-not-found)', async () => {
|
||||
const document = ydocFor(doc('GONE'));
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await emitSave(document, 'agent');
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
|
||||
const msg = JSON.parse(
|
||||
(document as any).broadcastStateless.mock.calls[0][0],
|
||||
);
|
||||
expect(msg).toEqual({ type: 'version.skipped', reason: 'page-not-found' });
|
||||
});
|
||||
});
|
||||
|
||||
// #370 — the in-memory idle-burst marker must be dropped on doc unload (like
|
||||
|
||||
@@ -68,19 +68,6 @@ export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
||||
*/
|
||||
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
|
||||
|
||||
/**
|
||||
* #370 F2 — wire format of the server→client REPLY to a save-version signal, sent
|
||||
* over the same stateless channel. `version.saved` means a version was created or
|
||||
* promoted; `version.skipped` is a TERMINAL "nothing was pinned" reply for the two
|
||||
* reachable no-op cases (an effectively-empty page, or the page row is gone) so the
|
||||
* client resolves at once instead of waiting out its ack timeout and misreporting a
|
||||
* healthy server as unreachable. EXACTLY ONE of these is broadcast per handled
|
||||
* save. The MCP client duplicates these literals (it cannot import server code) —
|
||||
* keep the two in sync (see packages/mcp/src/lib/collaboration.ts).
|
||||
*/
|
||||
export const VERSION_SAVED_MESSAGE_TYPE = 'version.saved';
|
||||
export const VERSION_SKIPPED_MESSAGE_TYPE = 'version.skipped';
|
||||
|
||||
/**
|
||||
* #251 — how long an intentional-clear signal stays "pending" before it is
|
||||
* ignored. The signal is set on the clearing keystroke but consumed by the
|
||||
@@ -656,10 +643,6 @@ export class PersistenceExtension implements Extension {
|
||||
let result:
|
||||
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
|
||||
| undefined;
|
||||
// #370 F2 — set when there is nothing to version (empty page / page gone), so
|
||||
// the tail broadcasts a terminal `version.skipped` instead of staying silent
|
||||
// and forcing the client to time out. Mutually exclusive with `result`.
|
||||
let skipped: 'empty' | 'page-not-found' | undefined;
|
||||
|
||||
// #370 F8-twin — the contributor set popped from Redis (destructive SPOP)
|
||||
// must be restored if the version row does not durably land. The inner
|
||||
@@ -685,20 +668,11 @@ export class PersistenceExtension implements Extension {
|
||||
includeContent: true,
|
||||
trx,
|
||||
});
|
||||
if (!page) {
|
||||
// The page row is gone (deleted/never persisted). Nothing to version —
|
||||
// record it so the tail sends a terminal skip reply (#370 F2).
|
||||
skipped = 'page-not-found';
|
||||
return;
|
||||
}
|
||||
if (!page) return;
|
||||
versionedPageId = page.id;
|
||||
// Never version an effectively-empty page (mirrors the processor's
|
||||
// first-history guard); there is nothing intentional to pin. Record the
|
||||
// skip so the client gets a terminal reply rather than a timeout (#370 F2).
|
||||
if (isEmptyParagraphDoc(page.content as any)) {
|
||||
skipped = 'empty';
|
||||
return;
|
||||
}
|
||||
// first-history guard); there is nothing intentional to pin.
|
||||
if (isEmptyParagraphDoc(page.content as any)) return;
|
||||
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
page.id,
|
||||
@@ -773,26 +747,15 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
this.idleBurstStart.delete(documentName);
|
||||
|
||||
// EXACTLY ONE terminal reply per handled save (#370 F2): a real save/promote
|
||||
// broadcasts `version.saved`; the two no-op early returns (empty page / page
|
||||
// gone) broadcast `version.skipped` so the client never waits out its ack
|
||||
// timeout. A genuine failure threw above and rejected before reaching here.
|
||||
if (result) {
|
||||
document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
type: VERSION_SAVED_MESSAGE_TYPE,
|
||||
type: 'version.saved',
|
||||
historyId: result.historyId,
|
||||
kind: result.kind,
|
||||
alreadySaved: result.alreadySaved,
|
||||
}),
|
||||
);
|
||||
} else if (skipped) {
|
||||
document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
type: VERSION_SKIPPED_MESSAGE_TYPE,
|
||||
reason: skipped,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,6 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
||||
void client.sharePage(s, true);
|
||||
void client.unsharePage(s);
|
||||
void client.restorePageVersion(s);
|
||||
void client.savePageVersion(s);
|
||||
void client.transformPage(s, s, { dryRun: true });
|
||||
void client.stashPage(s);
|
||||
// --- write (image / footnote), in-app since #410 ---
|
||||
|
||||
@@ -58,7 +58,6 @@ type DocmostClientMethod =
|
||||
| 'sharePage'
|
||||
| 'unsharePage'
|
||||
| 'restorePageVersion'
|
||||
| 'savePageVersion'
|
||||
| 'transformPage'
|
||||
| 'stashPage'
|
||||
// --- write (image / footnote), in-app since #410 ---
|
||||
|
||||
@@ -210,6 +210,55 @@ describe('PublicShareChatToolsService.forShare', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharePage under approved publication (owner decision: assistant sees the frozen saved version)', () => {
|
||||
it('reads the FROZEN saved content/title returned by the boundary, not a live re-fetch', async () => {
|
||||
// #370 Stage B owner decision: ALL public reads — including the share
|
||||
// assistant's page-read tool — see the published saved version, so a
|
||||
// visitor and the assistant see the same bytes. The content swap lives in
|
||||
// resolveReadableSharePage (the single canonical boundary), so the tool
|
||||
// simply consumes the frozen { page } it returns: the assistant must never
|
||||
// re-read the live draft behind the boundary's back.
|
||||
const frozenContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'FROZEN saved version body' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
// The boundary already swapped content+title to the manual version while
|
||||
// preserving the live id (its unit test pins that); the tool sees this.
|
||||
const frozenPage = {
|
||||
id: 'page-1',
|
||||
title: 'Saved Version Title',
|
||||
deletedAt: null,
|
||||
content: frozenContent,
|
||||
};
|
||||
|
||||
const { svc, shareService } = makeService({
|
||||
resolveReadableSharePage: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ share: { id: 'SHARE-A' }, page: frozenPage }),
|
||||
});
|
||||
// updatePublicAttachments sanitizes the FROZEN page it was handed.
|
||||
shareService.updatePublicAttachments.mockResolvedValue(frozenContent);
|
||||
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
const out = (await (tools.getSharePage as unknown as ToolExec).execute({
|
||||
pageId: 'page-1',
|
||||
})) as { title: string; markdown: string };
|
||||
|
||||
// Sanitizer is handed the frozen page (never a separate live fetch).
|
||||
expect(shareService.updatePublicAttachments).toHaveBeenCalledWith(
|
||||
frozenPage,
|
||||
);
|
||||
// Title + body come from the saved (frozen) version.
|
||||
expect(out.title).toBe('Saved Version Title');
|
||||
expect(out.markdown).toContain('FROZEN saved version body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharePage non-resolving page (deleted / restricted / out-of-share)', () => {
|
||||
it('resolveReadableSharePage returns null (e.g. soft-deleted page) => generic error, NO content sanitized/returned', async () => {
|
||||
// The canonical boundary 404s a soft-deleted / restricted / out-of-tree
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -18,6 +19,13 @@ export class CreateShareDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
searchIndexing: boolean;
|
||||
|
||||
// #370 Stage B — publication mode. 'live' (default) serves the current draft;
|
||||
// 'approved' serves the last manually-saved version (page_history.kind
|
||||
// ='manual'). Mutually exclusive with includeSubPages (enforced server-side).
|
||||
@IsOptional()
|
||||
@IsIn(['live', 'approved'])
|
||||
publishedMode?: 'live' | 'approved';
|
||||
}
|
||||
|
||||
export class UpdateShareDto extends CreateShareDto {
|
||||
|
||||
@@ -39,6 +39,7 @@ function buildService() {
|
||||
tokenService as any,
|
||||
{} as any, // transclusionService (unused)
|
||||
workspaceRepo as any,
|
||||
{} as any, // pageHistoryRepo (unused on this path)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ function buildService(over: {
|
||||
{} as any, // tokenService
|
||||
{} as any, // transclusionService
|
||||
{} as any, // workspaceRepo
|
||||
{} as any, // pageHistoryRepo
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -62,6 +62,7 @@ function buildService(opts: {
|
||||
tokenService as any,
|
||||
{} as any, // transclusionService (unused)
|
||||
workspaceRepo as any,
|
||||
{} as any, // pageHistoryRepo (unused on this path)
|
||||
);
|
||||
|
||||
// getSharedPage resolves the share via getShareForPage (a raw db query).
|
||||
@@ -181,6 +182,7 @@ describe('ShareService.lookupTransclusionForShare htmlEmbed kill-switch (real co
|
||||
tokenService as any,
|
||||
transclusionService as any,
|
||||
workspaceRepo as any,
|
||||
{} as any, // pageHistoryRepo (unused on this path)
|
||||
);
|
||||
|
||||
// isSharingAllowed and getShareForPage hit the raw db; stub them so the
|
||||
|
||||
@@ -23,6 +23,9 @@ function buildService(over: {
|
||||
resolvedShare?: unknown;
|
||||
page?: unknown;
|
||||
restricted?: boolean;
|
||||
// #370 Stage B — the latest manual page-history row returned for the approved
|
||||
// swap. `undefined` (the default) means the repo finds no manual version.
|
||||
manualHistory?: unknown;
|
||||
} = {}) {
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async () =>
|
||||
@@ -34,6 +37,9 @@ function buildService(over: {
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor: jest.fn(async () => over.restricted ?? false),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
findLatestByPageIdAndKind: jest.fn(async () => over.manualHistory ?? undefined),
|
||||
};
|
||||
|
||||
const service = new ShareService(
|
||||
{} as any, // shareRepo (unused on this path)
|
||||
@@ -43,6 +49,7 @@ function buildService(over: {
|
||||
{} as any, // tokenService (unused)
|
||||
{} as any, // transclusionService (unused)
|
||||
{} as any, // workspaceRepo (unused)
|
||||
pageHistoryRepo as any,
|
||||
);
|
||||
|
||||
jest
|
||||
@@ -53,7 +60,7 @@ function buildService(over: {
|
||||
: { id: SHARE, pageId: PAGE, spaceId: 'space-1' }) as any,
|
||||
);
|
||||
|
||||
return { service, pageRepo, pagePermissionRepo };
|
||||
return { service, pageRepo, pagePermissionRepo, pageHistoryRepo };
|
||||
}
|
||||
|
||||
describe('ShareService.resolveReadableSharePage (the share-access boundary)', () => {
|
||||
@@ -133,4 +140,90 @@ describe('ShareService.resolveReadableSharePage (the share-access boundary)', ()
|
||||
includeCreator: true,
|
||||
});
|
||||
});
|
||||
|
||||
// #370 Stage B — "approved" publication freezes the served content/title to
|
||||
// the latest manual page-history version, WITHOUT touching page.id or
|
||||
// page.workspaceId (downstream attachment tokens are minted off those, so
|
||||
// freezing them would mint tokens for the wrong owner).
|
||||
describe('approved publication mode (content freeze)', () => {
|
||||
const LIVE_PAGE = {
|
||||
id: PAGE,
|
||||
workspaceId: WS,
|
||||
deletedAt: null,
|
||||
title: 'Live draft title',
|
||||
content: { type: 'doc', live: true },
|
||||
};
|
||||
const MANUAL = {
|
||||
title: 'Saved version title',
|
||||
content: { type: 'doc', saved: true },
|
||||
};
|
||||
|
||||
it('swaps content + title to the latest manual version but PRESERVES id + workspaceId', async () => {
|
||||
const { service, pageHistoryRepo } = buildService({
|
||||
resolvedShare: {
|
||||
id: SHARE,
|
||||
pageId: PAGE,
|
||||
spaceId: 'space-1',
|
||||
publishedMode: 'approved',
|
||||
},
|
||||
page: { ...LIVE_PAGE },
|
||||
manualHistory: MANUAL,
|
||||
});
|
||||
|
||||
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
|
||||
|
||||
expect(out).not.toBeNull();
|
||||
// Content + title come from the saved version.
|
||||
expect(out!.page.content).toEqual(MANUAL.content);
|
||||
expect(out!.page.title).toBe(MANUAL.title);
|
||||
// id + workspaceId stay LIVE (attachment-token ownership must not shift).
|
||||
expect(out!.page.id).toBe(LIVE_PAGE.id);
|
||||
expect(out!.page.workspaceId).toBe(LIVE_PAGE.workspaceId);
|
||||
// Resolved against the LIVE page id and the manual tier, with content.
|
||||
expect(pageHistoryRepo.findLatestByPageIdAndKind).toHaveBeenCalledWith(
|
||||
LIVE_PAGE.id,
|
||||
'manual',
|
||||
{ includeContent: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to live content when no manual version exists yet', async () => {
|
||||
const { service, pageHistoryRepo } = buildService({
|
||||
resolvedShare: {
|
||||
id: SHARE,
|
||||
pageId: PAGE,
|
||||
spaceId: 'space-1',
|
||||
publishedMode: 'approved',
|
||||
},
|
||||
page: { ...LIVE_PAGE },
|
||||
// manualHistory omitted → repo returns undefined
|
||||
});
|
||||
|
||||
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
|
||||
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.page.content).toEqual(LIVE_PAGE.content);
|
||||
expect(out!.page.title).toBe(LIVE_PAGE.title);
|
||||
expect(pageHistoryRepo.findLatestByPageIdAndKind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT consult page-history for a live share', async () => {
|
||||
const { service, pageHistoryRepo } = buildService({
|
||||
resolvedShare: {
|
||||
id: SHARE,
|
||||
pageId: PAGE,
|
||||
spaceId: 'space-1',
|
||||
publishedMode: 'live',
|
||||
},
|
||||
page: { ...LIVE_PAGE },
|
||||
manualHistory: MANUAL,
|
||||
});
|
||||
|
||||
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
|
||||
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.page.content).toEqual(LIVE_PAGE.content);
|
||||
expect(pageHistoryRepo.findLatestByPageIdAndKind).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ function buildService() {
|
||||
tokenService as any,
|
||||
{} as any, // transclusionService (unused)
|
||||
workspaceRepo as any,
|
||||
{} as any, // pageHistoryRepo (unused on this path)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { updateAttachmentAttr } from './share.util';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
@@ -44,6 +45,7 @@ export class ShareService {
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -92,21 +94,39 @@ export class ShareService {
|
||||
}) {
|
||||
const { authUserId, workspaceId, page, createShareDto } = opts;
|
||||
|
||||
const includeSubPages = createShareDto.includeSubPages ?? false;
|
||||
const publishedMode = createShareDto.publishedMode ?? 'live';
|
||||
|
||||
// #370 Stage B — 'approved' freezes a SINGLE page to its saved version; a
|
||||
// sub-tree cannot be version-frozen in the MVP, so the two are mutually
|
||||
// exclusive. Thrown before the try so the specific 400 reaches the client
|
||||
// instead of being flattened to the generic "Failed to share page".
|
||||
this.assertPublishedModeCompatible(publishedMode, includeSubPages);
|
||||
|
||||
try {
|
||||
const shares = await this.shareRepo.findByPageId(page.id);
|
||||
if (shares) {
|
||||
return shares;
|
||||
}
|
||||
|
||||
return await this.shareRepo.insertShare({
|
||||
const share = await this.shareRepo.insertShare({
|
||||
key: nanoIdGen().toLowerCase(),
|
||||
pageId: page.id,
|
||||
includeSubPages: createShareDto.includeSubPages ?? false,
|
||||
includeSubPages,
|
||||
searchIndexing: createShareDto.searchIndexing ?? false,
|
||||
publishedMode,
|
||||
creatorId: authUserId,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
// Guarantee an approved share always has a saved version to serve: mint
|
||||
// the first manual baseline from the page's current content on enable.
|
||||
if (publishedMode === 'approved') {
|
||||
await this.ensureApprovedBaseline(page.id);
|
||||
}
|
||||
|
||||
return share;
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to share page');
|
||||
@@ -114,20 +134,84 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async updateShare(shareId: string, updateShareDto: UpdateShareDto) {
|
||||
// Resolve the current share so the XOR gate can be evaluated against the
|
||||
// EFFECTIVE post-update state (either field may be absent from the DTO).
|
||||
const current = await this.shareRepo.findById(shareId);
|
||||
if (!current) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const effectiveIncludeSubPages =
|
||||
updateShareDto.includeSubPages ?? current.includeSubPages ?? false;
|
||||
const effectivePublishedMode = (updateShareDto.publishedMode ??
|
||||
current.publishedMode ??
|
||||
'live') as 'live' | 'approved';
|
||||
|
||||
// #370 Stage B — enforce the approved/includeSubPages XOR on the merged
|
||||
// state. Thrown before the try so the specific 400 reaches the client.
|
||||
this.assertPublishedModeCompatible(
|
||||
effectivePublishedMode,
|
||||
effectiveIncludeSubPages,
|
||||
);
|
||||
|
||||
try {
|
||||
return this.shareRepo.updateShare(
|
||||
const updated = await this.shareRepo.updateShare(
|
||||
{
|
||||
includeSubPages: updateShareDto.includeSubPages,
|
||||
searchIndexing: updateShareDto.searchIndexing,
|
||||
publishedMode: updateShareDto.publishedMode,
|
||||
},
|
||||
shareId,
|
||||
);
|
||||
|
||||
// On (or while) enabling approved mode, ensure a manual baseline exists so
|
||||
// public readers always have a saved version to serve. Idempotent: a no-op
|
||||
// when the page already has a manual history row.
|
||||
if (effectivePublishedMode === 'approved') {
|
||||
await this.ensureApprovedBaseline(current.pageId);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to update share');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #370 Stage B — 'approved' publication is mutually exclusive with
|
||||
* includeSubPages: a whole sub-tree cannot be version-frozen in the MVP.
|
||||
*/
|
||||
private assertPublishedModeCompatible(
|
||||
publishedMode: 'live' | 'approved',
|
||||
includeSubPages: boolean,
|
||||
): void {
|
||||
if (publishedMode === 'approved' && includeSubPages) {
|
||||
throw new BadRequestException(
|
||||
'Approved publication cannot be combined with including sub-pages',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #370 Stage B — guarantee an approved share has a saved version to serve.
|
||||
* When the page has NO manual history row yet, snapshot its CURRENT content as
|
||||
* the first manual version. Idempotent: returns early when a manual version
|
||||
* already exists, so it is safe to call on every approved update.
|
||||
*/
|
||||
private async ensureApprovedBaseline(pageId: string): Promise<void> {
|
||||
const existing = await this.pageHistoryRepo.findLatestByPageIdAndKind(
|
||||
pageId,
|
||||
'manual',
|
||||
);
|
||||
if (existing) return;
|
||||
|
||||
const page = await this.pageRepo.findById(pageId, { includeContent: true });
|
||||
if (!page) return;
|
||||
|
||||
await this.pageHistoryRepo.saveHistory(page, { kind: 'manual' });
|
||||
}
|
||||
|
||||
/**
|
||||
* THE share access boundary in ONE place.
|
||||
*
|
||||
@@ -173,7 +257,7 @@ export class ShareService {
|
||||
// passes no shareId (it resolved the share from the page itself).
|
||||
if (shareId != null && share.id !== shareId) return null;
|
||||
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
let page = await this.pageRepo.findById(pageId, {
|
||||
includeContent: true,
|
||||
includeCreator: opts?.includeCreator ?? false,
|
||||
});
|
||||
@@ -185,6 +269,30 @@ export class ShareService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// #370 Stage B — "approved" publication freezes what public readers see to
|
||||
// the LAST manually-saved version (page_history.kind='manual'), not the live
|
||||
// draft. This is the SINGLE canonical resolver every public read funnels
|
||||
// through (the shared-page view, the SEO controller, AND the share
|
||||
// assistant's page-read tool), so the swap here freezes ALL of them
|
||||
// consistently — a public visitor and the assistant see the same bytes.
|
||||
//
|
||||
// Swap ONLY content + title. page.id and page.workspaceId stay LIVE on
|
||||
// purpose: downstream updatePublicAttachments mints per-attachment tokens
|
||||
// off page.id/workspaceId, so freezing those would mint tokens for the
|
||||
// wrong owner. The SEO controller reads resolved.page.title, so the frozen
|
||||
// title flows through automatically. If no manual version exists yet (rare —
|
||||
// enabling an approved share auto-creates a baseline), fall back to live.
|
||||
if (share.publishedMode === 'approved') {
|
||||
const hist = await this.pageHistoryRepo.findLatestByPageIdAndKind(
|
||||
page.id,
|
||||
'manual',
|
||||
{ includeContent: true },
|
||||
);
|
||||
if (hist) {
|
||||
page = { ...page, content: hist.content, title: hist.title };
|
||||
}
|
||||
}
|
||||
|
||||
return { share, page };
|
||||
}
|
||||
|
||||
@@ -302,6 +410,7 @@ export class ShareService {
|
||||
'shares.key as shareKey',
|
||||
'shares.includeSubPages',
|
||||
'shares.searchIndexing',
|
||||
'shares.publishedMode',
|
||||
'shares.creatorId',
|
||||
'shares.spaceId',
|
||||
'shares.workspaceId',
|
||||
@@ -326,6 +435,7 @@ export class ShareService {
|
||||
's.key as shareKey',
|
||||
's.includeSubPages',
|
||||
's.searchIndexing',
|
||||
's.publishedMode',
|
||||
's.creatorId',
|
||||
's.spaceId',
|
||||
's.workspaceId',
|
||||
@@ -355,6 +465,7 @@ export class ShareService {
|
||||
key: share.shareKey,
|
||||
includeSubPages: share.includeSubPages,
|
||||
searchIndexing: share.searchIndexing,
|
||||
publishedMode: share.publishedMode,
|
||||
pageId: share.id,
|
||||
creatorId: share.creatorId,
|
||||
spaceId: share.spaceId,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* #370 Stage B — share "approved" (publish-only-the-saved-version) mode.
|
||||
*
|
||||
* Adds `shares.published_mode`, the per-share switch between:
|
||||
* - 'live' — public readers see the current draft (legacy behavior)
|
||||
* - 'approved' — public readers see the LAST manually-saved version
|
||||
* (page_history.kind='manual'), not the live draft
|
||||
*
|
||||
* NOT NULL with a 'live' default so every existing share keeps its current
|
||||
* (live) semantics with no data backfill. Standalone + single-concern +
|
||||
* additive per the #363 crash-loop rule — no coupled baseline write here;
|
||||
* the first manual baseline is minted by the service on enable, not by this
|
||||
* migration. Stored as a short varchar to stay forward-compatible without an
|
||||
* enum migration.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('shares')
|
||||
.addColumn('published_mode', 'varchar(20)', (col) =>
|
||||
col.notNull().defaultTo(sql`'live'`),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('shares').dropColumn('published_mode').execute();
|
||||
}
|
||||
@@ -219,6 +219,36 @@ export class PageHistoryRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* #370 Stage B — resolve the latest snapshot of a page at a given
|
||||
* intentionality tier. Used by the "approved" share mode to serve the last
|
||||
* manually-saved version (`kind='manual'`) to public readers instead of the
|
||||
* live draft. Modeled on `findPageLastHistory` with an added `kind` filter and
|
||||
* the same deterministic `(createdAt desc, id desc)` tie-break, so two rows
|
||||
* sharing a createdAt still resolve to a single stable "latest".
|
||||
*/
|
||||
async findLatestByPageIdAndKind(
|
||||
pageId: string,
|
||||
kind: PageHistoryKind,
|
||||
opts?: {
|
||||
includeContent?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
) {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
return await db
|
||||
.selectFrom('pageHistory')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.where('pageId', '=', pageId)
|
||||
.where('kind', '=', kind)
|
||||
.limit(1)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.orderBy('id', 'desc')
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
withLastUpdatedBy(eb: ExpressionBuilder<DB, 'pageHistory'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
|
||||
@@ -28,6 +28,7 @@ export class ShareRepo {
|
||||
'pageId',
|
||||
'includeSubPages',
|
||||
'searchIndexing',
|
||||
'publishedMode',
|
||||
'creatorId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
|
||||
+1
@@ -340,6 +340,7 @@ export interface Shares {
|
||||
includeSubPages: Generated<boolean | null>;
|
||||
key: string;
|
||||
pageId: string | null;
|
||||
publishedMode: Generated<string>;
|
||||
searchIndexing: Generated<boolean | null>;
|
||||
spaceId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ShareService } from 'src/core/share/share.service';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import * as publishedModeMigration from 'src/database/migrations/20260712T130000-shares-published-mode';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createSpace,
|
||||
createPage,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* #370 Stage B — "approved" (publish-only-the-saved-version) share mode, against
|
||||
* real Postgres (so the migration column + the hand-written recursive-CTE
|
||||
* threading in getShareForPage are exercised for real, not mocked).
|
||||
*
|
||||
* Guards:
|
||||
* - getShareForPage carries `publishedMode` through ALL of the CTE points
|
||||
* (anchor select, recursive-union select, returned object) — a direct share
|
||||
* AND a descendant (union) share both read it back. If ANY of the 4 threading
|
||||
* points is dropped, one of these reddens.
|
||||
* - createShare / updateShare auto-mint the first manual baseline ON ENABLE.
|
||||
* - approved + includeSubPages is rejected (BadRequestException) on both paths.
|
||||
* - resolveReadableSharePage under approved swaps content+title to the manual
|
||||
* version while PRESERVING page.id + workspaceId.
|
||||
*/
|
||||
describe('share published_mode (approved) [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let service: ShareService;
|
||||
let shareRepo: ShareRepo;
|
||||
let pageHistoryRepo: PageHistoryRepo;
|
||||
let wsId: string;
|
||||
let spaceId: string;
|
||||
|
||||
// shares.creator_id / page_history.last_updated_by_id are uuid columns; use
|
||||
// null (nullable) rather than a synthetic non-uuid id.
|
||||
const USER = null as any;
|
||||
|
||||
// resolveReadableSharePage runs the restricted-ancestor gate; keep it open.
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor: async () => false,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
shareRepo = new ShareRepo(db as any, {} as any);
|
||||
const pageRepo = new PageRepo(db as any, {} as any, {} as any);
|
||||
pageHistoryRepo = new PageHistoryRepo(db as any);
|
||||
service = new ShareService(
|
||||
shareRepo as any,
|
||||
pageRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
db as any,
|
||||
{} as any, // tokenService — no attachments in these docs
|
||||
{} as any, // transclusionService
|
||||
{ findById: async () => ({ settings: {} }) } as any, // workspaceRepo
|
||||
pageHistoryRepo as any,
|
||||
);
|
||||
wsId = (await createWorkspace(db)).id;
|
||||
spaceId = (await createSpace(db, wsId)).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
const docWith = (text: string) => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text }] },
|
||||
],
|
||||
});
|
||||
|
||||
const newPage = async (
|
||||
text = 'live body',
|
||||
parentPageId?: string,
|
||||
): Promise<string> => {
|
||||
const { id } = await createPage(db, { workspaceId: wsId, spaceId });
|
||||
await db
|
||||
.updateTable('pages')
|
||||
.set({ content: docWith(text) as any, parentPageId: parentPageId ?? null })
|
||||
.where('id', '=', id)
|
||||
.execute();
|
||||
return id;
|
||||
};
|
||||
|
||||
const insertShare = async (args: {
|
||||
pageId: string;
|
||||
publishedMode?: string;
|
||||
includeSubPages?: boolean;
|
||||
}) => {
|
||||
const id = randomUUID();
|
||||
await db
|
||||
.insertInto('shares')
|
||||
.values({
|
||||
id,
|
||||
key: `k-${id.slice(0, 12)}`,
|
||||
pageId: args.pageId,
|
||||
includeSubPages: args.includeSubPages ?? false,
|
||||
publishedMode: args.publishedMode ?? 'live',
|
||||
creatorId: USER,
|
||||
spaceId,
|
||||
workspaceId: wsId,
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
};
|
||||
|
||||
const manualRows = (pageId: string) =>
|
||||
db
|
||||
.selectFrom('pageHistory')
|
||||
.select(['id', 'kind', 'title'])
|
||||
.where('pageId', '=', pageId)
|
||||
.where('kind', '=', 'manual')
|
||||
.execute();
|
||||
|
||||
const publishedModeColumn = async () =>
|
||||
db
|
||||
.selectFrom('information_schema.columns' as any)
|
||||
.select(['columnName', 'dataType', 'isNullable', 'columnDefault'] as any)
|
||||
.where('tableName' as any, '=', 'shares')
|
||||
.where('columnName' as any, '=', 'published_mode')
|
||||
.executeTakeFirst();
|
||||
|
||||
// --- migration up/down round-trip ----------------------------------------
|
||||
|
||||
it('migration down() drops published_mode; up() re-adds it NOT NULL default live', async () => {
|
||||
// global-setup already migrated up, so the column is present.
|
||||
const before: any = await publishedModeColumn();
|
||||
expect(before).toBeDefined();
|
||||
expect(before.isNullable).toBe('NO');
|
||||
expect(String(before.columnDefault)).toContain("'live'");
|
||||
|
||||
await publishedModeMigration.down(db as any);
|
||||
expect(await publishedModeColumn()).toBeUndefined();
|
||||
|
||||
// Restore so the rest of the suite (and afterAll) sees the real schema.
|
||||
await publishedModeMigration.up(db as any);
|
||||
const after: any = await publishedModeColumn();
|
||||
expect(after).toBeDefined();
|
||||
expect(after.isNullable).toBe('NO');
|
||||
});
|
||||
|
||||
// --- getShareForPage 4-point threading -----------------------------------
|
||||
|
||||
it('getShareForPage carries publishedMode for a DIRECT share (anchor select + returned object)', async () => {
|
||||
const pageId = await newPage();
|
||||
await insertShare({ pageId, publishedMode: 'approved' });
|
||||
|
||||
const share = await service.getShareForPage(pageId, wsId);
|
||||
expect(share).toBeDefined();
|
||||
expect(share!.publishedMode).toBe('approved');
|
||||
});
|
||||
|
||||
it('getShareForPage carries publishedMode for a DESCENDANT share (recursive-union select)', async () => {
|
||||
// Parent shared with includeSubPages + approved (inserted directly, bypassing
|
||||
// the service XOR gate, purely to exercise the CTE union threading on a
|
||||
// child that inherits the ancestor share).
|
||||
const parentId = await newPage('parent body');
|
||||
await insertShare({
|
||||
pageId: parentId,
|
||||
publishedMode: 'approved',
|
||||
includeSubPages: true,
|
||||
});
|
||||
const childId = await newPage('child body', parentId);
|
||||
|
||||
const share = await service.getShareForPage(childId, wsId);
|
||||
expect(share).toBeDefined();
|
||||
// Resolved via the recursive union up to the ancestor share.
|
||||
expect((share!.level as number) > 0).toBe(true);
|
||||
expect(share!.publishedMode).toBe('approved');
|
||||
});
|
||||
|
||||
// --- baseline auto-create on enable --------------------------------------
|
||||
|
||||
it('createShare(approved) mints the first manual baseline from current content', async () => {
|
||||
const pageId = await newPage('baseline body');
|
||||
const page = { id: pageId, spaceId } as any;
|
||||
|
||||
expect(await manualRows(pageId)).toHaveLength(0);
|
||||
|
||||
await service.createShare({
|
||||
authUserId: USER,
|
||||
workspaceId: wsId,
|
||||
page,
|
||||
createShareDto: { pageId, includeSubPages: false, publishedMode: 'approved' } as any,
|
||||
});
|
||||
|
||||
const rows = await manualRows(pageId);
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updateShare enabling approved on a live share mints the baseline; a second call is idempotent', async () => {
|
||||
const pageId = await newPage('later-saved body');
|
||||
const shareId = await insertShare({ pageId, publishedMode: 'live' });
|
||||
|
||||
expect(await manualRows(pageId)).toHaveLength(0);
|
||||
|
||||
await service.updateShare(shareId, {
|
||||
shareId,
|
||||
publishedMode: 'approved',
|
||||
} as any);
|
||||
expect(await manualRows(pageId)).toHaveLength(1);
|
||||
|
||||
// Idempotent: staying approved does not duplicate the baseline.
|
||||
await service.updateShare(shareId, {
|
||||
shareId,
|
||||
searchIndexing: true,
|
||||
} as any);
|
||||
expect(await manualRows(pageId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// --- XOR gate ------------------------------------------------------------
|
||||
|
||||
it('createShare rejects approved + includeSubPages', async () => {
|
||||
const pageId = await newPage();
|
||||
await expect(
|
||||
service.createShare({
|
||||
authUserId: USER,
|
||||
workspaceId: wsId,
|
||||
page: { id: pageId, spaceId } as any,
|
||||
createShareDto: {
|
||||
pageId,
|
||||
includeSubPages: true,
|
||||
publishedMode: 'approved',
|
||||
} as any,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('updateShare rejects setting approved while includeSubPages is already on', async () => {
|
||||
const pageId = await newPage();
|
||||
const shareId = await insertShare({ pageId, includeSubPages: true });
|
||||
await expect(
|
||||
service.updateShare(shareId, { shareId, publishedMode: 'approved' } as any),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
// --- resolveReadableSharePage content freeze -----------------------------
|
||||
|
||||
it('resolveReadableSharePage under approved swaps content+title but PRESERVES id + workspaceId', async () => {
|
||||
const pageId = await newPage('LIVE draft body');
|
||||
const shareId = await insertShare({ pageId, publishedMode: 'approved' });
|
||||
|
||||
// Seed a manual version distinct from the live draft.
|
||||
await pageHistoryRepo.insertPageHistory({
|
||||
pageId,
|
||||
slugId: `hist-${pageId.slice(0, 8)}`,
|
||||
title: 'SAVED title',
|
||||
content: docWith('SAVED version body') as any,
|
||||
kind: 'manual',
|
||||
spaceId,
|
||||
workspaceId: wsId,
|
||||
} as any);
|
||||
|
||||
const resolved = await service.resolveReadableSharePage(
|
||||
shareId,
|
||||
pageId,
|
||||
wsId,
|
||||
);
|
||||
expect(resolved).not.toBeNull();
|
||||
// Content + title are the SAVED version.
|
||||
expect(resolved!.page.title).toBe('SAVED title');
|
||||
expect(JSON.stringify(resolved!.page.content)).toContain(
|
||||
'SAVED version body',
|
||||
);
|
||||
// id + workspaceId stay LIVE (attachment-token ownership must not shift).
|
||||
expect(resolved!.page.id).toBe(pageId);
|
||||
expect(resolved!.page.workspaceId).toBe(wsId);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
savePageVersionRealtime,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
@@ -56,7 +55,6 @@ export interface IPagesMixin {
|
||||
listPageHistory(pageId: string, cursor?: string): any;
|
||||
getPageHistory(historyId: string): any;
|
||||
restorePageVersion(historyId: string): any;
|
||||
savePageVersion(pageId: string): any;
|
||||
diffPageVersions(pageId: string, from?: string, to?: string): any;
|
||||
}
|
||||
|
||||
@@ -589,24 +587,6 @@ export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(Bas
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an intentional NAMED version of a page's CURRENT live content (#370).
|
||||
* The write goes over the same agent-authenticated collab session content edits
|
||||
* use, so the server derives kind='agent' from the signed actor. Resolves a
|
||||
* SaveVersionResult: `{ saved:true, historyId, kind, alreadySaved }` on success,
|
||||
* or `{ saved:false, skipped:true, reason }` when the server had nothing to pin
|
||||
* (e.g. an empty page). A stale/missing pageId throws (not a benign skip).
|
||||
*/
|
||||
async savePageVersion(pageId: string) {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Self-heal a rejected WS handshake once (#486): re-mint the collab token and
|
||||
// retry, symmetric to the content-write path.
|
||||
return this.writeWithCollabAuthRetry(collabToken, (token) =>
|
||||
savePageVersionRealtime(pageUuid, token, this.apiUrl),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two versions of a page and return a Docmost-equivalent change set.
|
||||
* `from`/`to` each resolve to a ProseMirror doc:
|
||||
|
||||
@@ -139,13 +139,7 @@ export interface CollabProviderLike {
|
||||
unsyncedChanges: number;
|
||||
destroy(): void;
|
||||
on(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
|
||||
on(event: "stateless", handler: (data: { payload: string }) => void): void;
|
||||
off(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
|
||||
off(event: "stateless", handler: (data: { payload: string }) => void): void;
|
||||
// Send a stateless (out-of-band, non-doc) message to the server. Used by the
|
||||
// #370 explicit save-version path (payload `{type:'save-version'}`); the server
|
||||
// replies with a broadcast `{type:'version.saved', …}` on the same channel.
|
||||
sendStateless(payload: string): void;
|
||||
}
|
||||
|
||||
/** The configuration object passed to the provider factory. */
|
||||
@@ -570,125 +564,6 @@ export class CollabSession {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a stateless message over the live collaboration connection and resolve
|
||||
* with the FIRST reply whose parsed payload satisfies `predicate`, rejecting on
|
||||
* a bounded `timeoutMs`.
|
||||
*
|
||||
* Used by the #370 explicit save-version path: the client sends
|
||||
* `{type:'save-version'}` and awaits the server's broadcast
|
||||
* `{type:'version.saved', historyId, kind, alreadySaved}`. The stateless channel
|
||||
* (not a REST read) is deliberate — the server versions the LIVE in-memory ydoc,
|
||||
* which the up-to-10s-stale page row would not yet reflect.
|
||||
*
|
||||
* `predicate` receives each incoming stateless message (already JSON-parsed) and
|
||||
* returns the resolved value for a match or `undefined` to keep waiting; a
|
||||
* malformed / unrelated payload is ignored. The listener is registered BEFORE
|
||||
* the send so a fast reply is never missed.
|
||||
*
|
||||
* Lifecycle mirrors mutate(): it registers through `inflightReject` so a
|
||||
* disconnect/close/auth-failure/teardown rejects THIS wait with the same
|
||||
* connection-loss error, refuses to run on a non-ready session, fails fast on a
|
||||
* concurrent in-flight op (the caller MUST hold the per-page lock), and re-arms
|
||||
* the idle TTL (or self-destroys an ephemeral session) on completion.
|
||||
*
|
||||
* NOTE: the server broadcast carries no request-correlation id, so under a
|
||||
* genuinely concurrent save on the SAME page (e.g. a human Cmd+S racing the
|
||||
* agent) this resolves on whichever `version.saved` arrives first. The per-page
|
||||
* lock serializes THIS process's saves; a cross-client race is inherent to the
|
||||
* broadcast design and is benign (the result still describes a real save of this
|
||||
* page's current content, and the server's save is promote-not-duplicate).
|
||||
*/
|
||||
sendStatelessAndAwait<T>(
|
||||
payload: string,
|
||||
predicate: (message: any) => T | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
// Belt-and-suspenders (acquire already validated): refuse to send on a
|
||||
// session that is not in a live, synced, ready state.
|
||||
if (
|
||||
this.dead ||
|
||||
this.state !== "ready" ||
|
||||
this.connectionLost ||
|
||||
!this.provider ||
|
||||
this.provider.synced !== true
|
||||
) {
|
||||
return Promise.reject(
|
||||
new Error("Collaboration session is not in a ready state"),
|
||||
);
|
||||
}
|
||||
|
||||
// Fail-fast on concurrent use: a second overlapping op would overwrite the
|
||||
// first's inflightReject and hang it on disconnect (same guard as mutate).
|
||||
if (this.inflightReject) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"stateless op already in-flight; caller must serialize (hold the page lock)",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let statelessHandler: ((data: { payload: string }) => void) | undefined;
|
||||
|
||||
const localFinish = (err: Error | null, value?: T) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (statelessHandler && this.provider) {
|
||||
try {
|
||||
this.provider.off("stateless", statelessHandler);
|
||||
} catch (e) {}
|
||||
}
|
||||
this.inflightReject = undefined;
|
||||
if (err) reject(err);
|
||||
else resolve(value as T);
|
||||
// Post-settle lifecycle, identical to mutate's localFinish.
|
||||
if (this.ephemeral) {
|
||||
this.destroy("ephemeral op complete");
|
||||
} else if (!this.dead) {
|
||||
this.armIdle();
|
||||
}
|
||||
};
|
||||
|
||||
// Register so a disconnect/close/auth-failure/teardown rejects THIS op with
|
||||
// the connection-loss error text (the `settled` guard makes a racing
|
||||
// teardown + normal resolve safe — first one wins).
|
||||
this.inflightReject = (e: Error) => localFinish(e);
|
||||
|
||||
// Register the reply listener BEFORE sending so a fast server broadcast is
|
||||
// never missed.
|
||||
statelessHandler = (data: { payload: string }) => {
|
||||
if (settled) return;
|
||||
let message: any;
|
||||
try {
|
||||
message = JSON.parse(data.payload);
|
||||
} catch {
|
||||
return; // unrelated / malformed stateless message — keep waiting
|
||||
}
|
||||
const matched = predicate(message);
|
||||
if (matched !== undefined) localFinish(null, matched);
|
||||
};
|
||||
this.provider!.on("stateless", statelessHandler);
|
||||
|
||||
timer = setTimeout(() => {
|
||||
localFinish(
|
||||
new Error(
|
||||
`Timeout waiting for a stateless reply from the collaboration server ${this.hint()}`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
this.provider!.sendStateless(payload);
|
||||
} catch (e) {
|
||||
localFinish(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** (Re)arm the idle TTL so the clock starts from the most recent activity. */
|
||||
armIdle(): void {
|
||||
if (this.dead || this.ephemeral) return;
|
||||
|
||||
@@ -324,146 +324,6 @@ export async function mutatePageContent(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateless-channel message types for the #370 explicit save-version handshake.
|
||||
* These MUST match the server constants in
|
||||
* apps/server/src/collaboration/extensions/persistence.extension.ts
|
||||
* (SAVE_VERSION_MESSAGE_TYPE and the VERSION_SAVED / VERSION_SKIPPED replies) —
|
||||
* the mcp package cannot import server code, so the literals are duplicated here.
|
||||
* KEEP THE TWO SIDES IN SYNC: the server broadcasts exactly ONE terminal reply per
|
||||
* handled save (`version.saved` for a real save/promote, `version.skipped` for a
|
||||
* reachable no-op — an empty page or a missing page row); the client must match
|
||||
* BOTH or it waits out the ack timeout on the skip case and misreports a healthy
|
||||
* server as unreachable (#370 F2).
|
||||
*/
|
||||
const SAVE_VERSION_MESSAGE_TYPE = "save-version";
|
||||
const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
|
||||
const VERSION_SKIPPED_MESSAGE_TYPE = "version.skipped";
|
||||
|
||||
/**
|
||||
* Bounded wait for the server's terminal reply after we ask it to save a version.
|
||||
* The server flushes the live ydoc through its store path and writes a history row
|
||||
* inside a DB transaction before broadcasting, so allow the same headroom as a
|
||||
* persistence ack; reject (do NOT hang) past it. A timeout here means NO reply at
|
||||
* all arrived — the genuine "collab server unreachable/overloaded" case — as
|
||||
* distinct from a `version.skipped` reply, which resolves immediately.
|
||||
*/
|
||||
const SAVE_VERSION_ACK_TIMEOUT_MS = 20000;
|
||||
|
||||
/**
|
||||
* The resolved outcome of an explicit save-version, surfaced to the tool caller.
|
||||
* `saved:true` → a version was created or promoted (historyId/kind/alreadySaved
|
||||
* are present). `saved:false` → the server had nothing to pin (a reachable no-op,
|
||||
* e.g. an empty page); `skipped` + `reason` explain why. A page-not-found reply is
|
||||
* NOT represented here — it is surfaced as a thrown error (a bad/stale pageId).
|
||||
*/
|
||||
export interface SaveVersionResult {
|
||||
saved: boolean;
|
||||
historyId?: string;
|
||||
kind?: string;
|
||||
alreadySaved?: boolean;
|
||||
skipped?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed terminal reply the predicate hands back to savePageVersionRealtime. Kept
|
||||
* internal: it carries the page-not-found case (mapped to a thrown error, never a
|
||||
* returned result) that SaveVersionResult deliberately does not model.
|
||||
*/
|
||||
type SaveVersionReply =
|
||||
| { outcome: "saved"; historyId: string; kind: string; alreadySaved: boolean }
|
||||
| { outcome: "skipped"; reason: string };
|
||||
|
||||
/** Match the server's terminal reply (version.saved / version.skipped), ignoring
|
||||
* any unrelated / cross-page stateless message so the wait is not resolved by
|
||||
* noise. Returns `undefined` to keep waiting (#370 F1 predicate coverage). */
|
||||
function matchSaveVersionReply(message: any): SaveVersionReply | undefined {
|
||||
if (!message || typeof message !== "object") return undefined;
|
||||
if (message.type === VERSION_SAVED_MESSAGE_TYPE) {
|
||||
return {
|
||||
outcome: "saved",
|
||||
historyId: String(message.historyId),
|
||||
kind: String(message.kind),
|
||||
alreadySaved: !!message.alreadySaved,
|
||||
};
|
||||
}
|
||||
if (message.type === VERSION_SKIPPED_MESSAGE_TYPE) {
|
||||
return { outcome: "skipped", reason: String(message.reason ?? "unknown") };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an intentional version of a page's CURRENT live collaboration content
|
||||
* (#370). Runs under the per-page lock, acquires the SAME cached CollabSession the
|
||||
* content writes use (#400) — so it authenticates with the caller's agent collab
|
||||
* token, and the server derives kind='agent' from that signed actor — then sends a
|
||||
* `{type:'save-version'}` stateless message and awaits the server's terminal reply
|
||||
* (`version.saved` or `version.skipped`) on the same channel.
|
||||
*
|
||||
* Deliberately does NOT read `pages.content` over REST: the versioned content is
|
||||
* the live in-memory ydoc, which the debounced (up-to-10s-stale) page row would
|
||||
* not yet reflect. The stateless round-trip is what makes the save exact.
|
||||
*
|
||||
* Outcomes:
|
||||
* - a real save/promote → `{ saved:true, historyId, kind, alreadySaved }`;
|
||||
* - the server had nothing to pin (empty page) → `{ saved:false, skipped:true,
|
||||
* reason:'empty' }` — a clean, immediate no-op, NOT a stall;
|
||||
* - the page row is gone (a stale/bad pageId) → a thrown error, immediate and
|
||||
* truthful (not the health-timeout path);
|
||||
* - no reply within the timeout → a thrown timeout error (the genuine "server
|
||||
* unreachable/overloaded" case).
|
||||
*
|
||||
* On a TRANSPORT failure (timeout / disconnect) the session is destroyed so the
|
||||
* next call reconnects fresh; a page-not-found is a healthy-connection terminal
|
||||
* reply, so the session is left cached. A save is safe to retry — the server
|
||||
* promotes-not-duplicates an identical latest version — so the caller (and its
|
||||
* agent) may re-issue it without risking a duplicate heavy history row.
|
||||
*/
|
||||
export async function savePageVersionRealtime(
|
||||
pageId: PageId,
|
||||
collabToken: string,
|
||||
baseUrl: string,
|
||||
): Promise<SaveVersionResult> {
|
||||
return withPageLock(pageId, async () => {
|
||||
const session = await acquireCollabSession(pageId, collabToken, baseUrl);
|
||||
let reply: SaveVersionReply;
|
||||
try {
|
||||
reply = await session.sendStatelessAndAwait<SaveVersionReply>(
|
||||
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
|
||||
matchSaveVersionReply,
|
||||
SAVE_VERSION_ACK_TIMEOUT_MS,
|
||||
);
|
||||
} catch (e) {
|
||||
// TRANSPORT failure (no reply within the timeout, or a disconnect): drop the
|
||||
// session so the next call reconnects fresh.
|
||||
session.destroy("save-version failed");
|
||||
throw e;
|
||||
}
|
||||
// A terminal reply arrived over a healthy connection — do NOT destroy the
|
||||
// session; interpret the outcome.
|
||||
if (reply.outcome === "skipped") {
|
||||
if (reply.reason === "page-not-found") {
|
||||
// A resolved pageId that the collab server no longer holds (deleted, or a
|
||||
// stale id). Surface it immediately and truthfully, not as a health error.
|
||||
throw new Error(
|
||||
`savePageVersion: page ${pageId} was not found on the collaboration ` +
|
||||
`server (it may have been deleted) — nothing was saved.`,
|
||||
);
|
||||
}
|
||||
// Reachable benign no-op (e.g. an empty page): a clean result, not a throw.
|
||||
return { saved: false, skipped: true, reason: reply.reason };
|
||||
}
|
||||
return {
|
||||
saved: true,
|
||||
historyId: reply.historyId,
|
||||
kind: reply.kind,
|
||||
alreadySaved: reply.alreadySaved,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the live content of a page over the collaboration websocket.
|
||||
* Accepts a ready ProseMirror JSON document; the caller controls whether
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ROUTING_PROSE =
|
||||
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
|
||||
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Pin the page's CURRENT content as a restorable named checkpoint when you finish a coherent editing pass -> savePageVersion (kind derived server-side as an agent version; an identical-to-last save is promoted/no-op'd, so a redundant call is harmless). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
|
||||
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
|
||||
|
||||
/**
|
||||
* Non-tool camelCase identifiers that legitimately appear in ROUTING_PROSE:
|
||||
@@ -218,7 +218,6 @@ const TOOL_FAMILY: Record<string, Family> = {
|
||||
diffPageVersions: "HISTORY",
|
||||
listPageHistory: "HISTORY",
|
||||
restorePageVersion: "HISTORY",
|
||||
savePageVersion: "HISTORY",
|
||||
exportPageMarkdown: "HISTORY",
|
||||
// importPageMarkdown is now inAppOnly (#411) — it is not registered on the
|
||||
// external MCP host, so it no longer appears in the generated inventory.
|
||||
|
||||
@@ -93,7 +93,6 @@ export type DocmostClientLike = Pick<
|
||||
| 'sharePage'
|
||||
| 'unsharePage'
|
||||
| 'restorePageVersion'
|
||||
| 'savePageVersion'
|
||||
| 'stashPage'
|
||||
| 'insertFootnote'
|
||||
| 'insertImage'
|
||||
@@ -744,30 +743,6 @@ export const SHARED_TOOL_SPECS = {
|
||||
client.restorePageVersion(historyId as string),
|
||||
},
|
||||
|
||||
savePageVersion: {
|
||||
mcpName: 'savePageVersion',
|
||||
inAppKey: 'savePageVersion',
|
||||
writeClass: 'write',
|
||||
description:
|
||||
'Save an intentional, NAMED version (kind=agent) of the page\'s CURRENT ' +
|
||||
'live content — a restorable checkpoint pinned into its history. Call it ' +
|
||||
'when you have FINISHED a coherent editing pass (not after every small ' +
|
||||
'edit), so the reader can see and roll back to the state you left. The ' +
|
||||
'version type is derived SERVER-SIDE from your signed agent identity (you ' +
|
||||
'cannot mislabel it); a save whose content is IDENTICAL to the last saved ' +
|
||||
'version is promoted/no-op\'d server-side, so a redundant call is harmless ' +
|
||||
'and never duplicates a version. Returns { saved:true, historyId, kind, ' +
|
||||
'alreadySaved } on success, or { saved:false, skipped:true, reason:\'empty\' } ' +
|
||||
'when the page had nothing to pin (e.g. it was empty).',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'savePageVersion — pin the page\'s current content as a named agent version (restorable checkpoint).',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
}),
|
||||
execute: (client, { pageId }) => client.savePageVersion(pageId as string),
|
||||
},
|
||||
|
||||
// --- markdown round-trip ---
|
||||
|
||||
importPageMarkdown: {
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import { test, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import {
|
||||
acquireCollabSession,
|
||||
destroyAllSessions,
|
||||
__setCollabProviderFactory,
|
||||
} from "../../build/lib/collab-session.js";
|
||||
import { savePageVersionRealtime } from "../../build/lib/collaboration.js";
|
||||
|
||||
// #370 — unit coverage for the MCP client transport of the explicit save-version.
|
||||
// There is no collaboration server in the test env, so a fake HocuspocusProvider
|
||||
// drives the stateless round-trip: it auto-completes the connect/sync handshake on
|
||||
// a microtask (like the real provider), records sends, and either auto-broadcasts a
|
||||
// configured reply (`opts.reply`) or lets the test emit stateless messages /
|
||||
// disconnects by hand (`_emitStateless` / `_disconnect`) to exercise the predicate
|
||||
// filter, the teardown-mid-wait path, and the timeout.
|
||||
class FakeProvider extends EventEmitter {
|
||||
static instances = [];
|
||||
|
||||
static reset() {
|
||||
FakeProvider.instances = [];
|
||||
}
|
||||
|
||||
static last() {
|
||||
return FakeProvider.instances[FakeProvider.instances.length - 1];
|
||||
}
|
||||
|
||||
constructor(config, opts = {}) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.synced = false;
|
||||
this.unsyncedChanges = 0;
|
||||
this.destroyed = false;
|
||||
this.sent = [];
|
||||
this.opts = opts;
|
||||
FakeProvider.instances.push(this);
|
||||
// Real HocuspocusProvider fires onSynced asynchronously after the handshake;
|
||||
// a microtask reproduces that without depending on timers (so mock.timers on
|
||||
// setTimeout does not stall the connect).
|
||||
queueMicrotask(() => {
|
||||
if (this.destroyed) return;
|
||||
this.config.onConnect?.();
|
||||
this.synced = true;
|
||||
this.config.onSynced?.();
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
sendStateless(payload) {
|
||||
this.sent.push(payload);
|
||||
const reply = this.opts.reply;
|
||||
if (!reply) return; // silent → the caller times out, or the test drives it
|
||||
// Broadcast the configured server reply on a microtask, mirroring
|
||||
// document.broadcastStateless landing back on this provider's `stateless` event.
|
||||
queueMicrotask(() => {
|
||||
if (this.destroyed) return;
|
||||
this.emit("stateless", { payload: JSON.stringify(reply) });
|
||||
});
|
||||
}
|
||||
|
||||
// --- test drivers ---
|
||||
_emitStateless(obj) {
|
||||
this.emit("stateless", { payload: JSON.stringify(obj) });
|
||||
}
|
||||
_disconnect() {
|
||||
this.config.onDisconnect?.();
|
||||
}
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
"MCP_COLLAB_SESSION_IDLE_MS",
|
||||
"MCP_COLLAB_SESSION_MAX_AGE_MS",
|
||||
"MCP_COLLAB_SESSION_MAX_ENTRIES",
|
||||
"MCP_COLLAB_TOKEN_TTL_MS",
|
||||
];
|
||||
let savedEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = {};
|
||||
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
|
||||
FakeProvider.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyAllSessions();
|
||||
__setCollabProviderFactory(null);
|
||||
mock.timers.reset();
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = savedEnv[k];
|
||||
}
|
||||
});
|
||||
|
||||
const PAGE_A = "11111111-1111-4111-8111-111111111111";
|
||||
const PAGE_B = "22222222-2222-4222-8222-222222222222";
|
||||
const PAGE_C = "33333333-3333-4333-8333-333333333333";
|
||||
const PAGE_D = "44444444-4444-4444-8444-444444444444";
|
||||
const PAGE_E = "55555555-5555-4555-8555-555555555555";
|
||||
const PAGE_F = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
// --- happy paths (resolve with the server's terminal reply) -----------------
|
||||
|
||||
test("saved: resolves { saved:true, historyId, kind, alreadySaved }", async () => {
|
||||
__setCollabProviderFactory(
|
||||
(config) =>
|
||||
new FakeProvider(config, {
|
||||
reply: {
|
||||
type: "version.saved",
|
||||
historyId: "hist-42",
|
||||
kind: "agent",
|
||||
alreadySaved: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await savePageVersionRealtime(PAGE_A, "collab-tok", "http://host/api");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
saved: true,
|
||||
historyId: "hist-42",
|
||||
kind: "agent",
|
||||
alreadySaved: false,
|
||||
});
|
||||
// The client actually asked the server to save a version (the stateless send).
|
||||
assert.deepEqual(
|
||||
FakeProvider.last().sent.map((p) => JSON.parse(p)),
|
||||
[{ type: "save-version" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("saved: surfaces alreadySaved=true (promote-not-dup / no-op save)", async () => {
|
||||
__setCollabProviderFactory(
|
||||
(config) =>
|
||||
new FakeProvider(config, {
|
||||
reply: {
|
||||
type: "version.saved",
|
||||
historyId: "hist-7",
|
||||
kind: "manual",
|
||||
alreadySaved: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
saved: true,
|
||||
historyId: "hist-7",
|
||||
kind: "manual",
|
||||
alreadySaved: true,
|
||||
});
|
||||
});
|
||||
|
||||
// --- F2: terminal SKIP replies (no timeout, no false "unreachable") ---------
|
||||
|
||||
test("F2 skip: an empty-page reply resolves to a clean { saved:false, skipped:true, reason:'empty' } — no timeout", async () => {
|
||||
__setCollabProviderFactory(
|
||||
(config) =>
|
||||
new FakeProvider(config, {
|
||||
reply: { type: "version.skipped", reason: "empty" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await savePageVersionRealtime(PAGE_C, "collab-tok", "http://host/api");
|
||||
|
||||
assert.deepEqual(result, { saved: false, skipped: true, reason: "empty" });
|
||||
});
|
||||
|
||||
test("F2 skip: a page-not-found reply throws an immediate, truthful error (not the health-timeout path)", async () => {
|
||||
__setCollabProviderFactory(
|
||||
(config) =>
|
||||
new FakeProvider(config, {
|
||||
reply: { type: "version.skipped", reason: "page-not-found" },
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
savePageVersionRealtime(PAGE_D, "collab-tok", "http://host/api"),
|
||||
/was not found on the collaboration server/,
|
||||
);
|
||||
});
|
||||
|
||||
// --- F1 (a): predicate filters unrelated messages, resolves on the real reply ---
|
||||
|
||||
test("F1(a) unrelated-then-real: an unrelated stateless message does NOT resolve; the later version.saved does", async () => {
|
||||
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // manual drive
|
||||
|
||||
const p = savePageVersionRealtime(PAGE_E, "collab-tok", "http://host/api");
|
||||
// Let acquire's handshake + the stateless send complete so the listener is armed.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const provider = FakeProvider.last();
|
||||
|
||||
// Noise first: an unrelated stateless type (e.g. another feature's broadcast).
|
||||
provider._emitStateless({ type: "something-else", historyId: "NOPE" });
|
||||
// The wait must still be pending — a microtask turn would have resolved it if the
|
||||
// predicate wrongly matched.
|
||||
let settledEarly = false;
|
||||
p.then(() => (settledEarly = true)).catch(() => (settledEarly = true));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(settledEarly, false, "resolved on an unrelated stateless message");
|
||||
|
||||
// Now the real reply arrives → it resolves with that payload.
|
||||
provider._emitStateless({
|
||||
type: "version.saved",
|
||||
historyId: "h-real",
|
||||
kind: "agent",
|
||||
alreadySaved: false,
|
||||
});
|
||||
const result = await p;
|
||||
assert.deepEqual(result, {
|
||||
saved: true,
|
||||
historyId: "h-real",
|
||||
kind: "agent",
|
||||
alreadySaved: false,
|
||||
});
|
||||
});
|
||||
|
||||
// --- F1 (b): teardown mid-wait rejects AND removes the stateless listener -----
|
||||
|
||||
test("F1(b) teardown mid-wait: a disconnect rejects the wait with the connection-loss error AND removes the stateless listener (no leak)", async () => {
|
||||
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
|
||||
|
||||
const session = await acquireCollabSession(PAGE_F, "collab-tok", "http://host/api");
|
||||
const provider = FakeProvider.last();
|
||||
|
||||
const p = session.sendStatelessAndAwait(
|
||||
JSON.stringify({ type: "save-version" }),
|
||||
(m) => (m && m.type === "version.saved" ? m : undefined),
|
||||
20000,
|
||||
);
|
||||
// Swallow the eventual rejection so it does not race the assertions below.
|
||||
p.catch(() => {});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
// The wait armed exactly one stateless listener.
|
||||
assert.equal(
|
||||
provider.listenerCount("stateless"),
|
||||
1,
|
||||
"the wait must register one stateless listener",
|
||||
);
|
||||
|
||||
// A disconnect tears the session down mid-wait.
|
||||
provider._disconnect();
|
||||
|
||||
await assert.rejects(p, /connection closed before the update was persisted/);
|
||||
// The listener was removed on teardown — dropping provider.off() reds this.
|
||||
assert.equal(
|
||||
provider.listenerCount("stateless"),
|
||||
0,
|
||||
"the stateless listener leaked after teardown",
|
||||
);
|
||||
});
|
||||
|
||||
// --- F1 (c): concurrent-in-flight guard fails fast, no cross-wiring -----------
|
||||
|
||||
test("F1(c) concurrent guard: a second sendStatelessAndAwait while one is in flight fails fast", async () => {
|
||||
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
|
||||
|
||||
const session = await acquireCollabSession(PAGE_A, "collab-tok", "http://host/api");
|
||||
|
||||
const first = session.sendStatelessAndAwait(
|
||||
JSON.stringify({ type: "save-version" }),
|
||||
(m) => (m && m.type === "version.saved" ? m : undefined),
|
||||
20000,
|
||||
);
|
||||
first.catch(() => {}); // it stays pending until afterEach destroys the session
|
||||
|
||||
await assert.rejects(
|
||||
session.sendStatelessAndAwait(
|
||||
JSON.stringify({ type: "save-version" }),
|
||||
(m) => (m && m.type === "version.saved" ? m : undefined),
|
||||
20000,
|
||||
),
|
||||
/already in-flight/,
|
||||
);
|
||||
});
|
||||
|
||||
// --- timeout: NO reply at all is the genuine "server unreachable" path --------
|
||||
|
||||
test("timeout: rejects on a bounded timeout when the server never replies", async () => {
|
||||
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
|
||||
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const p = savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api");
|
||||
// Swallow the eventual rejection so an unhandled-rejection does not race the tick.
|
||||
p.catch(() => {});
|
||||
|
||||
// Flush the (microtask-driven) connect handshake + the stateless send so the
|
||||
// ack timer is armed before we advance the clock.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
// Advance well past the 20s ack window; the connect timer (25s) does not fire.
|
||||
mock.timers.tick(20000 + 100);
|
||||
|
||||
await assert.rejects(p, /Timeout waiting for a stateless reply/);
|
||||
});
|
||||
Reference in New Issue
Block a user