Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1644de87f7 | |||
| 498a87f3c4 | |||
| 67c94de6ef |
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 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,6 +346,9 @@ 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
|
||||
@@ -442,6 +445,7 @@ 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,6 +345,9 @@ 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
|
||||
@@ -441,6 +444,7 @@ roles:
|
||||
- **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей).
|
||||
- **Не оценивай участников** и не комментируй качество обсуждения.
|
||||
- На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности.
|
||||
- **Зафиксируй результат.** Когда конспект готов на странице, вызови save_page_version, чтобы сохранить его как именованную версию (точку восстановления). Повторный вызов без новых правок — безвредный no-op.
|
||||
|
||||
## Пример стиля (фрагмент)
|
||||
|
||||
|
||||
@@ -33,6 +33,6 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: researcher
|
||||
version: 9
|
||||
version: 10
|
||||
- slug: call-summarizer
|
||||
version: 1
|
||||
version: 2
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"1 year": "1 year",
|
||||
"30 days": "30 days",
|
||||
"90 days": "90 days",
|
||||
"A new version is available": "A new version is available",
|
||||
"Account": "Account",
|
||||
"Active": "Active",
|
||||
@@ -13,16 +10,11 @@
|
||||
"Add space members": "Add space members",
|
||||
"Add to favorites": "Add to favorites",
|
||||
"Admin": "Admin",
|
||||
"API key created": "API key created",
|
||||
"API key revoked": "API key revoked",
|
||||
"API keys across the workspace.": "API keys across the workspace.",
|
||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
|
||||
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.",
|
||||
"Author": "Author",
|
||||
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
|
||||
"Can create and edit pages in space.": "Can create and edit pages in space.",
|
||||
"Can edit": "Can edit",
|
||||
@@ -41,9 +33,7 @@
|
||||
"Confirm": "Confirm",
|
||||
"Copy as Markdown": "Copy as Markdown",
|
||||
"Copy link": "Copy link",
|
||||
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
|
||||
"Create": "Create",
|
||||
"Create API key": "Create API key",
|
||||
"Create group": "Create group",
|
||||
"Create page": "Create page",
|
||||
"Create space": "Create space",
|
||||
@@ -65,14 +55,7 @@
|
||||
"e.g Sales": "e.g Sales",
|
||||
"e.g Space for product team": "e.g Space for product team",
|
||||
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
||||
"e.g. CI deploy token": "e.g. CI deploy token",
|
||||
"Edit": "Edit",
|
||||
"Expiring soon": "Expiring soon",
|
||||
"Failed to create API key": "Failed to create API key",
|
||||
"Failed to load API keys.": "Failed to load API keys.",
|
||||
"Failed to revoke API key": "Failed to revoke API key",
|
||||
"Never used": "Never used",
|
||||
"No API keys yet": "No API keys yet",
|
||||
"Read": "Read",
|
||||
"Edit group": "Edit group",
|
||||
"Email": "Email",
|
||||
@@ -151,8 +134,6 @@
|
||||
"page": "page",
|
||||
"Page deleted successfully": "Page deleted successfully",
|
||||
"Page history": "Page history",
|
||||
"Revoke API key": "Revoke API key",
|
||||
"Revoke {{name}}": "Revoke {{name}}",
|
||||
"Select version": "Select version",
|
||||
"Highlight changes": "Highlight changes",
|
||||
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||
@@ -208,9 +189,6 @@
|
||||
"Template": "Template",
|
||||
"Templates": "Templates",
|
||||
"Theme": "Theme",
|
||||
"This key expires within 30 days": "This key expires within 30 days",
|
||||
"This key has expired": "This key has expired",
|
||||
"This key never expires": "This key never expires",
|
||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||
"Toggle full page width": "Toggle full page width",
|
||||
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
||||
@@ -218,7 +196,6 @@
|
||||
"Untitled": "Untitled",
|
||||
"Updated successfully": "Updated successfully",
|
||||
"User": "User",
|
||||
"Within the last hour": "Within the last hour",
|
||||
"Workspace": "Workspace",
|
||||
"Workspace Name": "Workspace Name",
|
||||
"Workspace settings": "Workspace settings",
|
||||
@@ -449,7 +426,6 @@
|
||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||
"Write...": "Write...",
|
||||
"Column count": "Column count",
|
||||
"Your personal API keys.": "Your personal API keys.",
|
||||
"{{count}} Columns": "{{count}} Columns",
|
||||
"{{count}} command available_one": "1 command available",
|
||||
"{{count}} command available_other": "{{count}} commands available",
|
||||
@@ -1464,7 +1440,6 @@
|
||||
"agent: {{value}}": "agent: {{value}}",
|
||||
"Work": "Work",
|
||||
"Agent": "Agent",
|
||||
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
|
||||
"≈ {{hours}}h": "≈ {{hours}}h",
|
||||
"≈ {{minutes}}m": "≈ {{minutes}}m",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"1 year": "1 год",
|
||||
"30 days": "30 дней",
|
||||
"90 days": "90 дней",
|
||||
"A new version is available": "Доступна новая версия",
|
||||
"Account": "Аккаунт",
|
||||
"Active": "Активный",
|
||||
@@ -13,16 +10,11 @@
|
||||
"Add space members": "Добавить участников пространства",
|
||||
"Add to favorites": "Добавить в избранное",
|
||||
"Admin": "Администратор",
|
||||
"API key created": "API ключ создан",
|
||||
"API key revoked": "API ключ отозван",
|
||||
"API keys across the workspace.": "API ключи всего рабочего пространства.",
|
||||
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
|
||||
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
||||
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Вы уверены, что хотите отозвать «{{name}}»? Любой клиент, использующий этот ключ, немедленно потеряет доступ. Это действие нельзя отменить.",
|
||||
"Author": "Автор",
|
||||
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
||||
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
||||
"Can edit": "Может изменять",
|
||||
@@ -41,9 +33,7 @@
|
||||
"Confirm": "Подтвердить",
|
||||
"Copy as Markdown": "Копировать как Markdown",
|
||||
"Copy link": "Копировать ссылку",
|
||||
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Скопируйте API ключ сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.",
|
||||
"Create": "Создать",
|
||||
"Create API key": "Создать API ключ",
|
||||
"Create group": "Создать группу",
|
||||
"Create page": "Создать страницу",
|
||||
"Create space": "Создать пространство",
|
||||
@@ -56,7 +46,6 @@
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
|
||||
"Description": "Описание",
|
||||
"Details": "Подробности",
|
||||
"Done": "Готово",
|
||||
"e.g ACME": "например, ACME",
|
||||
"e.g ACME Inc": "например, ACME Inc",
|
||||
"e.g Developers": "например, Developers",
|
||||
@@ -66,15 +55,7 @@
|
||||
"e.g Sales": "например, Sales",
|
||||
"e.g Space for product team": "например, Пространство для команды продукта",
|
||||
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
|
||||
"e.g. CI deploy token": "например, токен деплоя CI",
|
||||
"Edit": "Редактировать",
|
||||
"Expiring soon": "Скоро истекает",
|
||||
"Failed to create API key": "Не удалось создать API ключ",
|
||||
"Failed to load API keys.": "Не удалось загрузить API ключи.",
|
||||
"Failed to revoke API key": "Не удалось отозвать API ключ",
|
||||
"Name is required": "Имя обязательно",
|
||||
"Never used": "Не использовался",
|
||||
"No API keys yet": "Пока нет API ключей",
|
||||
"Read": "Чтение",
|
||||
"Edit group": "Редактировать группу",
|
||||
"Email": "Электронная почта",
|
||||
@@ -153,8 +134,6 @@
|
||||
"page": "страница",
|
||||
"Page deleted successfully": "Страница успешно удалена",
|
||||
"Page history": "История страницы",
|
||||
"Revoke API key": "Отозвать API ключ",
|
||||
"Revoke {{name}}": "Отозвать {{name}}",
|
||||
"Select version": "Выбрать версию",
|
||||
"Highlight changes": "Выделить изменения",
|
||||
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
|
||||
@@ -210,9 +189,6 @@
|
||||
"Template": "Шаблон",
|
||||
"Templates": "Шаблоны",
|
||||
"Theme": "Тема",
|
||||
"This key expires within 30 days": "Этот ключ истекает в течение 30 дней",
|
||||
"This key has expired": "Этот ключ истек",
|
||||
"This key never expires": "Этот ключ никогда не истекает",
|
||||
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
||||
"Toggle full page width": "Переключить полную ширину страницы",
|
||||
"Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.",
|
||||
@@ -220,7 +196,6 @@
|
||||
"Untitled": "Без названия",
|
||||
"Updated successfully": "Успешно обновлено",
|
||||
"User": "Пользователь",
|
||||
"Within the last hour": "За последний час",
|
||||
"Workspace": "Рабочее пространство",
|
||||
"Workspace Name": "Название рабочего пространства",
|
||||
"Workspace settings": "Настройки рабочего пространства",
|
||||
@@ -457,7 +432,6 @@
|
||||
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для команд",
|
||||
"Write...": "Напишите...",
|
||||
"Column count": "Количество столбцов",
|
||||
"Your personal API keys.": "Ваши личные API ключи.",
|
||||
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
|
||||
"{{count}} command available_one": "Доступна 1 команда",
|
||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||
@@ -1481,7 +1455,6 @@
|
||||
"agent: {{value}}": "агент: {{value}}",
|
||||
"Work": "Работа",
|
||||
"Agent": "Агент",
|
||||
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
|
||||
"≈ {{hours}}h": "≈ {{hours}} ч",
|
||||
"≈ {{minutes}}m": "≈ {{minutes}} мин",
|
||||
|
||||
@@ -44,12 +44,6 @@ const AccountSettings = lazy(
|
||||
const AccountPreferences = lazy(
|
||||
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||
);
|
||||
// #506 — lazy leaf (own chunk): the API-keys management page is route-split so
|
||||
// its code (Mantine table/modals + the create/revoke flow) stays out of the
|
||||
// entry bundle (post-#342 bundle discipline).
|
||||
const AccountApiKeys = lazy(
|
||||
() => import("@/pages/settings/account/account-api-keys.tsx"),
|
||||
);
|
||||
const WorkspaceSettings = lazy(
|
||||
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||
);
|
||||
@@ -111,7 +105,6 @@ export default function App() {
|
||||
path={"account/preferences"}
|
||||
element={<AccountPreferences />}
|
||||
/>
|
||||
<Route path={"account/api-keys"} element={<AccountApiKeys />} />
|
||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
IconBrush,
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
IconKey,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
@@ -47,11 +46,6 @@ const groupedData: DataGroup[] = [
|
||||
icon: IconBrush,
|
||||
path: "/settings/account/preferences",
|
||||
},
|
||||
{
|
||||
label: "API keys",
|
||||
icon: IconKey,
|
||||
path: "/settings/account/api-keys",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { UserRole } from "@/lib/types";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { IApiKey } from "@/features/api-key/types/api-key.types";
|
||||
|
||||
// Mock the service layer so no real HTTP is attempted; every test drives the
|
||||
// component through these three functions.
|
||||
vi.mock("@/features/api-key/services/api-key-service", () => ({
|
||||
getApiKeys: vi.fn(),
|
||||
createApiKey: vi.fn(),
|
||||
revokeApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
revokeApiKey,
|
||||
} from "@/features/api-key/services/api-key-service";
|
||||
import ApiKeysManager from "./api-keys-manager";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const ISO_SOON = new Date(Date.now() + 10 * 864e5).toISOString();
|
||||
const ISO_FAR = new Date(Date.now() + 200 * 864e5).toISOString();
|
||||
const ISO_EXPIRED = new Date(Date.now() - 3 * 864e5).toISOString();
|
||||
|
||||
// Dump the storage stub via the Web Storage API — its data lives in a closure
|
||||
// (see vitest.setup.ts), so JSON.stringify(localStorage) would be vacuous.
|
||||
function storageDump(): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i) as string;
|
||||
out += `${k}=${localStorage.getItem(k)};`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeKey(overrides: Partial<IApiKey> = {}): IApiKey {
|
||||
return {
|
||||
id: "key-1",
|
||||
name: "CI token",
|
||||
expiresAt: ISO_FAR,
|
||||
lastUsedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderManager(role: UserRole) {
|
||||
const store = createStore();
|
||||
store.set(currentUserAtom, {
|
||||
user: { id: "me", role } as never,
|
||||
workspace: {} as never,
|
||||
});
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const utils = render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<ModalsProvider>
|
||||
<ApiKeysManager />
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>,
|
||||
);
|
||||
return { store, queryClient, ...utils };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("ApiKeysManager — list rendering", () => {
|
||||
it("renders an explicit expiry date and highlights a <30-day key (acceptance #3)", async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ id: "k-soon", name: "Soon key", expiresAt: ISO_SOON }),
|
||||
makeKey({ id: "k-far", name: "Far key", expiresAt: ISO_FAR }),
|
||||
]);
|
||||
|
||||
renderManager(UserRole.MEMBER);
|
||||
|
||||
await screen.findByText("Soon key");
|
||||
// Explicit dates, not "in N days": the year is rendered verbatim.
|
||||
const soonYear = new Date(ISO_SOON).getFullYear().toString();
|
||||
expect(screen.getAllByText(new RegExp(soonYear)).length).toBeGreaterThan(0);
|
||||
// Exactly one key is inside the 30-day warning window.
|
||||
expect(screen.getAllByText("Expiring soon")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows "Expired" (not "Expiring soon") for an already-expired key', async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ id: "k-dead", name: "Dead key", expiresAt: ISO_EXPIRED }),
|
||||
]);
|
||||
renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("Dead key");
|
||||
// A past expiry is labelled "Expired", never the forward-looking badge.
|
||||
expect(screen.getByText("Expired")).toBeDefined();
|
||||
expect(screen.queryByText("Expiring soon")).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Never" for an unlimited key and no highlight', async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ id: "k-forever", name: "Forever", expiresAt: null }),
|
||||
]);
|
||||
renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("Forever");
|
||||
expect(screen.getByText("Never")).toBeDefined();
|
||||
expect(screen.queryByText("Expiring soon")).toBeNull();
|
||||
});
|
||||
|
||||
it("empty list shows the empty state", async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([]);
|
||||
renderManager(UserRole.MEMBER);
|
||||
expect(await screen.findByText("No API keys yet")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiKeysManager — admin vs member view (acceptance #6)", () => {
|
||||
it("a member does NOT see the author column", async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ creator: { id: "me", name: "Me", email: "m@x.io", avatarUrl: null } }),
|
||||
]);
|
||||
renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("CI token");
|
||||
// Author header absent + creator name not rendered (no author column).
|
||||
expect(screen.queryByText("Author")).toBeNull();
|
||||
expect(screen.queryByText("Me")).toBeNull();
|
||||
});
|
||||
|
||||
it("an admin sees the author column with the creator's name", async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null } }),
|
||||
]);
|
||||
renderManager(UserRole.ADMIN);
|
||||
await screen.findByText("CI token");
|
||||
expect(screen.getByText("Author")).toBeDefined();
|
||||
expect(screen.getByText("Alice")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiKeysManager — revoke (acceptance #4)", () => {
|
||||
it("revoke removes the row from the list", async () => {
|
||||
vi.mocked(getApiKeys)
|
||||
.mockResolvedValueOnce([
|
||||
makeKey({ id: "k1", name: "Doomed" }),
|
||||
makeKey({ id: "k2", name: "Survivor" }),
|
||||
])
|
||||
// After revoke, invalidation refetches the reduced list.
|
||||
.mockResolvedValue([makeKey({ id: "k2", name: "Survivor" })]);
|
||||
vi.mocked(revokeApiKey).mockResolvedValue();
|
||||
|
||||
renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("Doomed");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Revoke Doomed"));
|
||||
// Confirm modal → click the destructive confirm button.
|
||||
const confirm = await screen.findByRole("button", { name: "Revoke" });
|
||||
fireEvent.click(confirm);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Doomed")).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText("Survivor")).toBeDefined();
|
||||
expect(revokeApiKey).toHaveBeenCalledWith("k1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => {
|
||||
it("shows the token once, then discards it from the UI, localStorage and query cache", async () => {
|
||||
const SECRET = "gm_secret-token-value-xyz";
|
||||
vi.mocked(getApiKeys).mockResolvedValue([]);
|
||||
vi.mocked(createApiKey).mockResolvedValue({
|
||||
token: SECRET,
|
||||
apiKey: {
|
||||
id: "new-1",
|
||||
name: "My key",
|
||||
expiresAt: ISO_FAR,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const { queryClient } = renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("No API keys yet");
|
||||
|
||||
// Open create modal (use the header CTA), fill the name, submit.
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Create API key" })[0],
|
||||
);
|
||||
const nameInput = await screen.findByLabelText(/Name/);
|
||||
fireEvent.change(nameInput, { target: { value: "My key" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
// Token is shown exactly once in the show-once modal.
|
||||
const tokenEl = await screen.findByTestId("api-key-token");
|
||||
expect(tokenEl.textContent).toBe(SECRET);
|
||||
|
||||
// Close the modal → token discarded from the DOM.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Done" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("api-key-token")).toBeNull(),
|
||||
);
|
||||
|
||||
// Acceptance #2: the secret is nowhere in localStorage or the react-query
|
||||
// caches (query cache never carried it; the mutation copy was reset()).
|
||||
// Non-vacuous: currentUser IS in storage, so the dump is exercised.
|
||||
const dump = storageDump();
|
||||
expect(dump).toContain("currentUser");
|
||||
expect(dump).not.toContain(SECRET);
|
||||
const cacheDump = JSON.stringify(
|
||||
queryClient.getQueryCache().getAll().map((q) => q.state.data),
|
||||
);
|
||||
expect(cacheDump).not.toContain(SECRET);
|
||||
const mutationDump = JSON.stringify(
|
||||
queryClient.getMutationCache().getAll().map((m) => m.state.data),
|
||||
);
|
||||
expect(mutationDump).not.toContain(SECRET);
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconAlertTriangle, IconKey, IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
import {
|
||||
useApiKeysQuery,
|
||||
useCreateApiKeyMutation,
|
||||
useRevokeApiKeyMutation,
|
||||
} from "@/features/api-key/queries/api-key-query";
|
||||
import {
|
||||
IApiKey,
|
||||
ICreateApiKeyResponse,
|
||||
} from "@/features/api-key/types/api-key.types";
|
||||
import {
|
||||
isExpired,
|
||||
isExpiringSoon,
|
||||
lastUsedBucket,
|
||||
} from "@/features/api-key/utils";
|
||||
import { CreateApiKeyModal } from "./create-api-key-modal";
|
||||
import { ShowTokenModal } from "./show-token-modal";
|
||||
|
||||
export default function ApiKeysManager() {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateFnsLocale();
|
||||
// Manage-on-API === Owner/Admin server-side, so the admin (workspace-wide)
|
||||
// list + "author" column mirror exactly the roles the server serves the
|
||||
// whole-workspace response to.
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const { data: keys, isLoading, isError } = useApiKeysQuery();
|
||||
const createMutation = useCreateApiKeyMutation();
|
||||
const revokeMutation = useRevokeApiKeyMutation();
|
||||
|
||||
const [createOpened, setCreateOpened] = useState(false);
|
||||
// SECURITY: the show-once token lives ONLY here, in this component's local
|
||||
// state. It is never written to localStorage, the query cache or a log. Closing
|
||||
// the modal sets it back to null (handleCloseToken) — discarded forever.
|
||||
const [createdKey, setCreatedKey] = useState<ICreateApiKeyResponse | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleCreate = async (values: {
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
const res = await createMutation.mutateAsync(values);
|
||||
setCreateOpened(false);
|
||||
// Move the token into local state, then immediately purge react-query's
|
||||
// own copy of the mutation result so the secret does not linger in the
|
||||
// mutation cache.
|
||||
setCreatedKey(res);
|
||||
createMutation.reset();
|
||||
return true;
|
||||
} catch {
|
||||
notifications.show({
|
||||
message: t("Failed to create API key"),
|
||||
color: "red",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseToken = () => {
|
||||
// Token discarded — the only copy the UI ever held is dropped here.
|
||||
setCreatedKey(null);
|
||||
};
|
||||
|
||||
const openRevokeModal = (key: IApiKey) =>
|
||||
modals.openConfirmModal({
|
||||
title: t("Revoke API key"),
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'Are you sure you want to revoke "{{name}}"? Any client using this key will immediately lose access. This cannot be undone.',
|
||||
{ name: key.name },
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Revoke"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => revokeMutation.mutate(key.id),
|
||||
});
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
formatLocalized(new Date(iso), "MMM dd, yyyy", "PP", locale);
|
||||
|
||||
const renderLastUsed = (lastUsedAt: string | null) => {
|
||||
switch (lastUsedBucket(lastUsedAt)) {
|
||||
case "never":
|
||||
return t("Never used");
|
||||
case "recent":
|
||||
return t("Within the last hour");
|
||||
case "stale":
|
||||
// Coarse relative time — last_used_at is throttled to ~1h server-side,
|
||||
// so we don't promise finer precision.
|
||||
return timeAgo(new Date(lastUsedAt as string));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = (keys ?? []).map((key) => {
|
||||
// Mutually exclusive: an already-expired key is labelled "Expired" (a past
|
||||
// expiry) rather than the forward-looking "Expiring soon". isExpiringSoon
|
||||
// also matches past expiries, so gate "soon" on !expired.
|
||||
const expired = isExpired(key.expiresAt);
|
||||
const soon = !expired && isExpiringSoon(key.expiresAt);
|
||||
return (
|
||||
<Table.Tr key={key.id}>
|
||||
<Table.Td>
|
||||
<Text fw={500}>{key.name}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatDate(key.createdAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
{key.expiresAt ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">{formatDate(key.expiresAt)}</Text>
|
||||
{expired && (
|
||||
<Tooltip label={t("This key has expired")} withArrow>
|
||||
<Badge
|
||||
color="red"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
>
|
||||
{t("Expired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{soon && (
|
||||
<Tooltip
|
||||
label={t("This key expires within 30 days")}
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
color="orange"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
>
|
||||
{t("Expiring soon")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Never")}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{renderLastUsed(key.lastUsedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
{isAdmin && (
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{key.creator?.name ?? key.creator?.email ?? t("Unknown")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td style={{ textAlign: "right" }}>
|
||||
<Tooltip label={t("Revoke")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("Revoke {{name}}", { name: key.name })}
|
||||
onClick={() => openRevokeModal(key)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{isAdmin
|
||||
? t("API keys across the workspace.")
|
||||
: t("Your personal API keys.")}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<IconKey size={16} />}
|
||||
onClick={() => setCreateOpened(true)}
|
||||
>
|
||||
{t("Create API key")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isError ? (
|
||||
<Text c="red" size="sm">
|
||||
{t("Failed to load API keys.")}
|
||||
</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<IconKey size={32} opacity={0.4} />
|
||||
<Text c="dimmed">{t("No API keys yet")}</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconKey size={16} />}
|
||||
onClick={() => setCreateOpened(true)}
|
||||
>
|
||||
{t("Create API key")}
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Name")}</Table.Th>
|
||||
<Table.Th>{t("Created")}</Table.Th>
|
||||
<Table.Th>{t("Expires")}</Table.Th>
|
||||
<Table.Th>{t("Last used")}</Table.Th>
|
||||
{isAdmin && <Table.Th>{t("Author")}</Table.Th>}
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{rows}</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<CreateApiKeyModal
|
||||
opened={createOpened}
|
||||
onClose={() => setCreateOpened(false)}
|
||||
onSubmit={handleCreate}
|
||||
loading={createMutation.isPending}
|
||||
/>
|
||||
|
||||
<ShowTokenModal created={createdKey} onClose={handleCloseToken} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Button, Group, Modal, Select, Stack, TextInput } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiKeyLifetime,
|
||||
DEFAULT_LIFETIME,
|
||||
lifetimeToExpiresAt,
|
||||
} from "@/features/api-key/utils";
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
// Resolves the create request, returning true on success. The parent owns the
|
||||
// mutation (and the token it returns); this modal only collects the name +
|
||||
// lifetime. On failure (false) the form state is kept so the user can retry.
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
}) => Promise<boolean>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
lifetime: ApiKeyLifetime;
|
||||
}
|
||||
|
||||
export function CreateApiKeyModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
lifetime: DEFAULT_LIFETIME,
|
||||
},
|
||||
validate: {
|
||||
name: (value) =>
|
||||
value.trim().length === 0 ? t("Name is required") : null,
|
||||
},
|
||||
});
|
||||
|
||||
const lifetimeOptions: { value: ApiKeyLifetime; label: string }[] = [
|
||||
{ value: "30d", label: t("30 days") },
|
||||
{ value: "90d", label: t("90 days") },
|
||||
{ value: "1y", label: t("1 year") },
|
||||
{ value: "never", label: t("No expiration") },
|
||||
];
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const ok = await onSubmit({
|
||||
name: values.name.trim(),
|
||||
expiresAt: lifetimeToExpiresAt(values.lifetime),
|
||||
});
|
||||
// Reset only after a successful submit so a failed create keeps the form
|
||||
// state (the parent surfaces the error via a notification).
|
||||
if (ok) form.reset();
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Create API key")}
|
||||
centered
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t("Name")}
|
||||
placeholder={t("e.g. CI deploy token")}
|
||||
data-autofocus
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Select
|
||||
label={t("Expiration")}
|
||||
data={lifetimeOptions}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
{...form.getInputProps("lifetime")}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={handleClose} type="button">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{t("Create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Code,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
|
||||
import { ICreateApiKeyResponse } from "@/features/api-key/types/api-key.types";
|
||||
|
||||
interface Props {
|
||||
// The freshly-created key incl. its token. Owned by the parent; this modal
|
||||
// only renders it and never copies it into its own persistent state.
|
||||
created: ICreateApiKeyResponse | null;
|
||||
// Closing MUST discard the token in the parent (set the `created` prop back to
|
||||
// null) — the token is shown exactly once.
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ShowTokenModal({ created, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateFnsLocale();
|
||||
|
||||
const expiresAt = created?.apiKey.expiresAt ?? null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={created !== null}
|
||||
onClose={onClose}
|
||||
title={t("API key created")}
|
||||
centered
|
||||
// No dismiss-on-outside-click: the token is irretrievable, so closing is a
|
||||
// deliberate act (the user confirms they have saved it).
|
||||
closeOnClickOutside={false}
|
||||
>
|
||||
{created && (
|
||||
<Stack gap="sm">
|
||||
<Alert
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
variant="light"
|
||||
>
|
||||
{t(
|
||||
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb={4}>
|
||||
{t("Token")}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Code
|
||||
block
|
||||
data-testid="api-key-token"
|
||||
style={{ flex: 1, wordBreak: "break-all" }}
|
||||
>
|
||||
{created.token}
|
||||
</Code>
|
||||
<CopyButton value={created.token}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
color={copied ? "teal" : "blue"}
|
||||
leftSection={
|
||||
copied ? (
|
||||
<IconCheck size={16} />
|
||||
) : (
|
||||
<IconCopy size={16} />
|
||||
)
|
||||
}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t("Copied") : t("Copy")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
{expiresAt
|
||||
? t("Expires {{date}}", {
|
||||
date: formatLocalized(
|
||||
new Date(expiresAt),
|
||||
"MMM dd, yyyy",
|
||||
"PP",
|
||||
locale,
|
||||
),
|
||||
})
|
||||
: t("This key never expires")}
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button onClick={onClose}>{t("Done")}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createApiKey,
|
||||
getApiKeys,
|
||||
revokeApiKey,
|
||||
} from "@/features/api-key/services/api-key-service";
|
||||
import {
|
||||
IApiKey,
|
||||
ICreateApiKey,
|
||||
ICreateApiKeyResponse,
|
||||
} from "@/features/api-key/types/api-key.types";
|
||||
|
||||
export const API_KEYS_QUERY_KEY = ["api-keys"];
|
||||
|
||||
export function useApiKeysQuery(): UseQueryResult<IApiKey[], Error> {
|
||||
return useQuery({
|
||||
queryKey: API_KEYS_QUERY_KEY,
|
||||
queryFn: () => getApiKeys(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mutation.
|
||||
*
|
||||
* SECURITY: the response contains the token exactly once. This hook deliberately
|
||||
* does NOT stash it anywhere — the caller reads it from `mutateAsync`'s resolved
|
||||
* value, moves it into the show-once modal's local state, then calls
|
||||
* `mutation.reset()` to purge react-query's own copy immediately. `gcTime: 0`
|
||||
* is a second belt so nothing lingers in the mutation cache after the observer
|
||||
* unmounts. The list is invalidated here (the list carries no token).
|
||||
*/
|
||||
export function useCreateApiKeyMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<ICreateApiKeyResponse, Error, ICreateApiKey>({
|
||||
mutationFn: (data) => createApiKey(data),
|
||||
gcTime: 0,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeApiKeyMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (id) => revokeApiKey(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||
notifications.show({ message: t("API key revoked") });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to revoke API key"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the api-client (axios instance). vi.mock replaces the whole module, so
|
||||
// the real response interceptor — which returns `response.data`, i.e. the
|
||||
// server envelope { data, success, status } — is bypassed. Our mocked post/get
|
||||
// therefore resolve to that post-interceptor envelope shape directly, and the
|
||||
// service under test reads `.data` off it to unwrap the inner payload. This is
|
||||
// the one bug-prone line the component tests (which fully mock the service)
|
||||
// never exercise.
|
||||
const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
|
||||
vi.mock("@/lib/api-client", () => ({
|
||||
default: { post, get },
|
||||
}));
|
||||
|
||||
import {
|
||||
createApiKey,
|
||||
getApiKeys,
|
||||
} from "@/features/api-key/services/api-key-service";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("api-key-service response-contract unwrap", () => {
|
||||
it("createApiKey unwraps the envelope to { token, apiKey }", async () => {
|
||||
const payload = {
|
||||
token: "gm_secret-abc",
|
||||
apiKey: {
|
||||
id: "key-1",
|
||||
name: "CI token",
|
||||
expiresAt: "2027-07-11T12:00:00.000Z",
|
||||
createdAt: "2026-07-11T12:00:00.000Z",
|
||||
},
|
||||
};
|
||||
// The server envelope as the interceptor hands it to the service.
|
||||
post.mockResolvedValue({ data: payload, success: true, status: 200 });
|
||||
|
||||
const result = await createApiKey({
|
||||
name: "CI token",
|
||||
expiresAt: "2027-07-11T12:00:00.000Z",
|
||||
});
|
||||
|
||||
// The inner payload only — not the { data, success, status } wrapper.
|
||||
expect(result).toEqual(payload);
|
||||
expect(result.token).toBe("gm_secret-abc");
|
||||
expect(result.apiKey).toEqual(payload.apiKey);
|
||||
expect(post).toHaveBeenCalledWith("/api-keys/create", {
|
||||
name: "CI token",
|
||||
expiresAt: "2027-07-11T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("getApiKeys unwraps the envelope to the array of rows", async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: "key-1",
|
||||
name: "CI token",
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
name: "Deploy token",
|
||||
expiresAt: "2027-01-01T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
createdAt: "2026-02-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
post.mockResolvedValue({ data: rows, success: true, status: 200 });
|
||||
|
||||
const result = await getApiKeys();
|
||||
|
||||
expect(result).toEqual(rows);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(post).toHaveBeenCalledWith("/api-keys/list");
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IApiKey,
|
||||
ICreateApiKey,
|
||||
ICreateApiKeyResponse,
|
||||
} from "@/features/api-key/types/api-key.types";
|
||||
|
||||
// Mint a new key. The response carries the token ONCE — the caller must move it
|
||||
// straight into the show-once modal's local state and never cache it. See
|
||||
// queries/api-key-query.ts (gcTime: 0 + query invalidation) and
|
||||
// components/api-keys-manager.tsx `handleCreate` (createMutation.reset() right
|
||||
// after reading the token) for the reset()-after-read pattern.
|
||||
export async function createApiKey(
|
||||
data: ICreateApiKey,
|
||||
): Promise<ICreateApiKeyResponse> {
|
||||
const res = await api.post<ICreateApiKeyResponse>("/api-keys/create", data);
|
||||
return res.data as ICreateApiKeyResponse;
|
||||
}
|
||||
|
||||
// List the caller's keys (or, for an admin, every key in the workspace with
|
||||
// creator attribution). Never returns token material.
|
||||
export async function getApiKeys(): Promise<IApiKey[]> {
|
||||
const res = await api.post<IApiKey[]>("/api-keys/list");
|
||||
return res.data as IApiKey[];
|
||||
}
|
||||
|
||||
// Revocation is server-side immediate; the caller drops the row on success.
|
||||
export async function revokeApiKey(id: string): Promise<void> {
|
||||
await api.post("/api-keys/revoke", { id });
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Compact creator attribution embedded in the admin (workspace-wide) list. A
|
||||
// normal member's list only ever contains their own keys, so the field is
|
||||
// present but redundant; the author column is only rendered for admins.
|
||||
export interface IApiKeyCreator {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
// A single api-key row as returned by `POST /api/api-keys/list`. Note: the list
|
||||
// NEVER carries token material — only metadata.
|
||||
export interface IApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
// ISO string, or null for an unlimited ("never expires") key.
|
||||
expiresAt: string | null;
|
||||
// ISO string, or null if the key was never used. Throttled to ~1h server-side
|
||||
// (#501), so the UI must not promise sub-hour precision.
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
creator?: IApiKeyCreator | null;
|
||||
}
|
||||
|
||||
// Payload for `POST /api/api-keys/create`. `expiresAt`: an ISO date string for a
|
||||
// bounded lifetime, or null for an unlimited key. (undefined would let the
|
||||
// server apply its 1-year default, but the form always sends an explicit value.)
|
||||
export interface ICreateApiKey {
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
// The metadata half of the create response. The token itself is carried
|
||||
// separately (see ICreateApiKeyResponse) and is shown exactly once.
|
||||
export interface ICreatedApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Response of `POST /api/api-keys/create`. `token` is the ONLY time the secret
|
||||
// is ever returned — it must live only in the show-once modal's local state and
|
||||
// must never be cached, persisted or logged.
|
||||
export interface ICreateApiKeyResponse {
|
||||
token: string;
|
||||
apiKey: ICreatedApiKey;
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DEFAULT_LIFETIME,
|
||||
EXPIRY_WARNING_DAYS,
|
||||
isExpired,
|
||||
isExpiringSoon,
|
||||
lastUsedBucket,
|
||||
lifetimeToExpiresAt,
|
||||
} from "./utils";
|
||||
|
||||
const NOW = new Date("2026-07-11T12:00:00.000Z");
|
||||
const daysFromNow = (n: number) =>
|
||||
new Date(NOW.getTime() + n * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
describe("lifetimeToExpiresAt", () => {
|
||||
it("default lifetime is 1 year (acceptance #7)", () => {
|
||||
expect(DEFAULT_LIFETIME).toBe("1y");
|
||||
const iso = lifetimeToExpiresAt("1y", NOW);
|
||||
expect(iso).toBe("2027-07-11T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it('"never" sends null (acceptance #7)', () => {
|
||||
expect(lifetimeToExpiresAt("never", NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("30d / 90d map to the exact future instant", () => {
|
||||
expect(lifetimeToExpiresAt("30d", NOW)).toBe(daysFromNow(30));
|
||||
expect(lifetimeToExpiresAt("90d", NOW)).toBe(daysFromNow(90));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExpiringSoon (acceptance #3 highlight)", () => {
|
||||
it("an unlimited key is never 'soon'", () => {
|
||||
expect(isExpiringSoon(null, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("highlights a key expiring within the 30-day window", () => {
|
||||
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(true);
|
||||
expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not highlight a key well outside the window", () => {
|
||||
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS + 1), NOW)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isExpiringSoon(daysFromNow(200), NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("an already-expired key is highlighted", () => {
|
||||
expect(isExpiringSoon(daysFromNow(-3), NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExpired (past vs future expiry)", () => {
|
||||
it("an unlimited key is never expired", () => {
|
||||
expect(isExpired(null, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("a past expiry is expired", () => {
|
||||
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
|
||||
expect(isExpired(daysFromNow(-1), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("a future expiry is not expired (even within the warning window)", () => {
|
||||
expect(isExpired(daysFromNow(1), NOW)).toBe(false);
|
||||
expect(isExpired(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(false);
|
||||
expect(isExpired(daysFromNow(200), NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("an expiry exactly at 'now' counts as expired (boundary is inclusive)", () => {
|
||||
expect(isExpired(NOW.toISOString(), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("is mutually distinguishable from isExpiringSoon: expired vs soon-but-future", () => {
|
||||
// A key 3 days in the past: expired, and (by design) also matches
|
||||
// isExpiringSoon — the UI resolves this by checking isExpired first.
|
||||
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
|
||||
// A key 10 days in the future: NOT expired, but expiring soon.
|
||||
expect(isExpired(daysFromNow(10), NOW)).toBe(false);
|
||||
expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastUsedBucket (within-the-last-hour semantics)", () => {
|
||||
const minutesAgo = (n: number) =>
|
||||
new Date(NOW.getTime() - n * 60 * 1000).toISOString();
|
||||
|
||||
it("null last-used is 'never'", () => {
|
||||
expect(lastUsedBucket(null, NOW)).toBe("never");
|
||||
});
|
||||
|
||||
it("under an hour is 'recent' (no sub-hour precision promised)", () => {
|
||||
expect(lastUsedBucket(minutesAgo(5), NOW)).toBe("recent");
|
||||
expect(lastUsedBucket(minutesAgo(59), NOW)).toBe("recent");
|
||||
});
|
||||
|
||||
it("over an hour is 'stale'", () => {
|
||||
expect(lastUsedBucket(minutesAgo(90), NOW)).toBe("stale");
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { addDays, addYears, differenceInMinutes } from "date-fns";
|
||||
|
||||
// The single early-warning window (#501/#506): keys whose expiry is closer than
|
||||
// this are visually highlighted in the list. Also used to classify a key as
|
||||
// already-expired (a negative "days until" is < 30 too).
|
||||
export const EXPIRY_WARNING_DAYS = 30;
|
||||
|
||||
// last_used_at is throttled to ~1h server-side, so anything under an hour is
|
||||
// shown as the coarse "within the last hour" rather than a false-precise
|
||||
// "5 minutes ago".
|
||||
export const LAST_USED_THROTTLE_MINUTES = 60;
|
||||
|
||||
export type ApiKeyLifetime = "30d" | "90d" | "1y" | "never";
|
||||
|
||||
export const DEFAULT_LIFETIME: ApiKeyLifetime = "1y";
|
||||
|
||||
// Maps a lifetime choice to the `expiresAt` payload sent to the server: an ISO
|
||||
// string for a bounded lifetime, or null for an explicit unlimited key.
|
||||
export function lifetimeToExpiresAt(
|
||||
lifetime: ApiKeyLifetime,
|
||||
now: Date = new Date(),
|
||||
): string | null {
|
||||
switch (lifetime) {
|
||||
case "30d":
|
||||
return addDays(now, 30).toISOString();
|
||||
case "90d":
|
||||
return addDays(now, 90).toISOString();
|
||||
case "1y":
|
||||
return addYears(now, 1).toISOString();
|
||||
case "never":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// True when a bounded key's expiry is already in the past (or exactly now). An
|
||||
// unlimited key (null) is never expired. This is distinct from "expiring soon":
|
||||
// the two states are mutually exclusive at the call site (see api-keys-manager),
|
||||
// so an already-expired key is labelled "Expired", not "Expiring soon".
|
||||
export function isExpired(
|
||||
expiresAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!expiresAt) return false;
|
||||
const expiry = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(expiry)) return false;
|
||||
return expiry <= now.getTime();
|
||||
}
|
||||
|
||||
// True when a bounded key expires within the warning window (or is already
|
||||
// expired). An unlimited key (null) is never "expiring soon". Callers that need
|
||||
// to distinguish an already-past expiry should check isExpired() first, as this
|
||||
// predicate deliberately also covers the already-expired case.
|
||||
export function isExpiringSoon(
|
||||
expiresAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!expiresAt) return false;
|
||||
const expiry = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(expiry)) return false;
|
||||
const msLeft = expiry - now.getTime();
|
||||
return msLeft < EXPIRY_WARNING_DAYS * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
// Classifies last-used recency so the caller can pick the honest label without
|
||||
// promising sub-hour precision. Returns "never", "recent" (< 1h) or "stale".
|
||||
export function lastUsedBucket(
|
||||
lastUsedAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): "never" | "recent" | "stale" {
|
||||
if (!lastUsedAt) return "never";
|
||||
const used = new Date(lastUsedAt);
|
||||
if (Number.isNaN(used.getTime())) return "never";
|
||||
return differenceInMinutes(now, used) < LAST_USED_THROTTLE_MINUTES
|
||||
? "recent"
|
||||
: "stale";
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildRows,
|
||||
formatBlockTooltip,
|
||||
summaryLabels,
|
||||
toBlocks,
|
||||
toTimelineDay,
|
||||
EMPTY_RUN_COLLAPSE,
|
||||
} from "./work-time-adapter";
|
||||
import { IPageWorkTime, IPerDay } from "./work-time.types";
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY_MS = 24 * HOUR;
|
||||
|
||||
// Fake translator: renders the key with {{tokens}} substituted, so the tests
|
||||
// assert the mapping/branching without depending on the i18n catalogue.
|
||||
const t = (key: string, opts?: Record<string, unknown>) =>
|
||||
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
|
||||
|
||||
// A day midnight anchored at a fixed UTC instant (tz handled server-side; we
|
||||
// only lay out fractions of the day the server already bucketed).
|
||||
const DAY0 = Date.UTC(2026, 5, 29, 0, 0, 0); // Mon 29 Jun 2026 00:00 UTC
|
||||
|
||||
function day(over: Partial<IPerDay> & { day: number }): IPerDay {
|
||||
return {
|
||||
dayISO: new Date(over.day).toISOString().slice(0, 10),
|
||||
activeMs: 0,
|
||||
agentMs: 0,
|
||||
windows: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const config: IPageWorkTime["config"] = {
|
||||
tGap: 15 * MIN,
|
||||
agentTGap: 15 * MIN,
|
||||
pIn: 0,
|
||||
pOut: 0,
|
||||
pSingle: 30 * 1000,
|
||||
excludeGit: false,
|
||||
dedupRoundMs: 1000,
|
||||
};
|
||||
|
||||
function payload(over: Partial<IPageWorkTime>): IPageWorkTime {
|
||||
return {
|
||||
workMs: 0,
|
||||
agentOnlyMs: 0,
|
||||
perDay: [],
|
||||
config,
|
||||
tz: "UTC",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toBlocks", () => {
|
||||
it("maps work+agent windows to time-of-day segments (hour fractions)", () => {
|
||||
const d = day({
|
||||
day: DAY0,
|
||||
activeMs: 90 * MIN,
|
||||
windows: [
|
||||
{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" },
|
||||
{ start: DAY0 + 22 * HOUR, end: DAY0 + 23 * HOUR, class: "agent_only" },
|
||||
],
|
||||
});
|
||||
const blocks = toBlocks(d);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0]).toMatchObject({ start: 9, end: 10.5, kind: "work" });
|
||||
expect(blocks[1]).toMatchObject({ start: 22, end: 23, kind: "agent" });
|
||||
// real epoch preserved for the DST-safe tooltip
|
||||
expect(blocks[0].startEpoch).toBe(DAY0 + 9 * HOUR);
|
||||
});
|
||||
|
||||
it("clamps out-of-day fractions to [0,24]", () => {
|
||||
const d = day({
|
||||
day: DAY0,
|
||||
windows: [{ start: DAY0 - HOUR, end: DAY0 + 25 * HOUR, class: "work" }],
|
||||
});
|
||||
const [b] = toBlocks(d);
|
||||
expect(b.start).toBe(0);
|
||||
expect(b.end).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toTimelineDay", () => {
|
||||
it("draws the now-line only on today's row", () => {
|
||||
const today = day({ day: DAY0, activeMs: HOUR });
|
||||
const noon = DAY0 + 12 * HOUR;
|
||||
const asToday = toTimelineDay(today, t, noon);
|
||||
expect(asToday.isToday).toBe(true);
|
||||
expect(asToday.nowFraction).toBeCloseTo(0.5, 5);
|
||||
|
||||
const past = day({ day: DAY0 - DAY_MS, activeMs: HOUR });
|
||||
const asPast = toTimelineDay(past, t, noon);
|
||||
expect(asPast.isToday).toBe(false);
|
||||
expect(asPast.nowFraction).toBeUndefined();
|
||||
});
|
||||
|
||||
it("labels an empty day and totals it as —", () => {
|
||||
const empty = day({ day: DAY0 });
|
||||
const d = toTimelineDay(empty, t, DAY0 + 12 * HOUR);
|
||||
expect(d.isEmpty).toBe(true);
|
||||
expect(d.totalLabel).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRows (empty-run collapsing)", () => {
|
||||
it("collapses a run of >= EMPTY_RUN_COLLAPSE empty days in place", () => {
|
||||
const perDay: IPerDay[] = [
|
||||
day({ day: DAY0, activeMs: HOUR }),
|
||||
...Array.from({ length: EMPTY_RUN_COLLAPSE }, (_, i) =>
|
||||
day({ day: DAY0 + (i + 1) * DAY_MS }),
|
||||
),
|
||||
day({ day: DAY0 + (EMPTY_RUN_COLLAPSE + 1) * DAY_MS, activeMs: HOUR }),
|
||||
];
|
||||
const rows = buildRows(perDay, t, 0);
|
||||
expect(rows.map((r) => r.type)).toEqual(["day", "gap", "day"]);
|
||||
const gap = rows[1];
|
||||
expect(gap.type === "gap" && gap.count).toBe(EMPTY_RUN_COLLAPSE);
|
||||
});
|
||||
|
||||
it("keeps a short empty run as individual dimmed day rows", () => {
|
||||
const perDay: IPerDay[] = [
|
||||
day({ day: DAY0, activeMs: HOUR }),
|
||||
day({ day: DAY0 + DAY_MS }),
|
||||
day({ day: DAY0 + 2 * DAY_MS, activeMs: HOUR }),
|
||||
];
|
||||
const rows = buildRows(perDay, t, 0);
|
||||
expect(rows.map((r) => r.type)).toEqual(["day", "day", "day"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summaryLabels", () => {
|
||||
it("derives totalLabel/agentLabel from ms via the shared formatter", () => {
|
||||
const { total, agent } = summaryLabels(
|
||||
payload({ workMs: 4 * HOUR + 27 * MIN, agentOnlyMs: 80 * MIN }),
|
||||
t,
|
||||
);
|
||||
expect(total).toBe("≈ 4h 25m");
|
||||
expect(agent).toBe("≈ 1h 20m");
|
||||
});
|
||||
|
||||
it("agent-only page: agent estimate fills the main slot, no secondary line", () => {
|
||||
const { total, agent } = summaryLabels(
|
||||
payload({ workMs: 0, agentOnlyMs: 80 * MIN }),
|
||||
t,
|
||||
);
|
||||
expect(total).toBe("agent: ≈ 1h 20m");
|
||||
expect(agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("human-only page: no agent line", () => {
|
||||
const { agent } = summaryLabels(payload({ workMs: HOUR, agentOnlyMs: 0 }), t);
|
||||
expect(agent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBlockTooltip", () => {
|
||||
it("formats start–end from the real epoch in the data tz + duration", () => {
|
||||
const d = day({
|
||||
day: DAY0,
|
||||
windows: [{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" }],
|
||||
});
|
||||
const [b] = toBlocks(d);
|
||||
const label = formatBlockTooltip(b, "UTC", "en-US", t);
|
||||
expect(label).toBe("09:00 – 10:30 · 1h 30m");
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
// #566 — adapter: real IPageWorkTime → the render-ready shape the redesigned
|
||||
// time-of-day timeline consumes. The visual prototype (NewDesign/TimeWorkedModal)
|
||||
// was written against invented props (`DaySummary[]`, pre-made `totalLabel`); the
|
||||
// real payload is IPageWorkTime. Everything below is pure so the mapping (windows
|
||||
// → time-of-day blocks, ms → labels, the "now" boundary, empty-run collapsing) is
|
||||
// unit-testable without React. No re-bucketing: the server already grouped the
|
||||
// windows by the request timezone — we only lay out the windows it returned.
|
||||
|
||||
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||
import { formatDayTotal, formatHeadline } from "./format-work-time";
|
||||
|
||||
type Translate = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||
// "× N days" separator (§6.2 long-range) — preserved from the original punch-card.
|
||||
export const EMPTY_RUN_COLLAPSE = 8;
|
||||
|
||||
export interface TimelineBlock {
|
||||
/** Hour fraction 0..24 within the day — drives left/width positioning. */
|
||||
start: number;
|
||||
end: number;
|
||||
kind: "work" | "agent";
|
||||
/** Real epoch ms, kept for a DST-safe tooltip (formatted in the data tz). */
|
||||
startEpoch: number;
|
||||
endEpoch: number;
|
||||
}
|
||||
|
||||
export interface TimelineDay {
|
||||
key: string;
|
||||
label: string;
|
||||
totalLabel: string;
|
||||
blocks: TimelineBlock[];
|
||||
isEmpty: boolean;
|
||||
/** Today lives only in the last active bucket; drives the "now" boundary. */
|
||||
isToday: boolean;
|
||||
/** 0..1 position of "now" within today's track (undefined when not today). */
|
||||
nowFraction?: number;
|
||||
}
|
||||
|
||||
export type TimelineRow =
|
||||
| { type: "day"; day: TimelineDay }
|
||||
| { type: "gap"; count: number };
|
||||
|
||||
/** Weekday-day-month heading in the browser locale (matches the server tz
|
||||
* bucketing, since usePageWorkTime requests buckets in the viewer tz). */
|
||||
export function dayHeading(day: number): string {
|
||||
return new Date(day).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/** Map one day's absolute windows into time-of-day blocks. `class` work|agent_only
|
||||
* → kind work|agent; positions are the in-day hour fraction (epoch kept for the
|
||||
* tooltip). Fractions are clamped to [0,24] to match the original punch-card. */
|
||||
export function toBlocks(day: IPerDay): TimelineBlock[] {
|
||||
return day.windows.map((w: IDayWindow) => {
|
||||
const start = clampHour((w.start - day.day) / (DAY_MS / 24));
|
||||
const end = clampHour((w.end - day.day) / (DAY_MS / 24));
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
kind: w.class === "work" ? "work" : "agent",
|
||||
startEpoch: w.start,
|
||||
endEpoch: w.end,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function clampHour(h: number): number {
|
||||
return Math.max(0, Math.min(24, h));
|
||||
}
|
||||
|
||||
/** Build a single render-ready day. `now` is injected for testability; the
|
||||
* "now" boundary is drawn only when `now` falls inside this bucket's calendar
|
||||
* day (so it appears on today's row and only when today has edits). */
|
||||
export function toTimelineDay(
|
||||
day: IPerDay,
|
||||
t: Translate,
|
||||
now: number,
|
||||
): TimelineDay {
|
||||
const isToday = now >= day.day && now < day.day + DAY_MS;
|
||||
return {
|
||||
key: day.dayISO,
|
||||
label: dayHeading(day.day),
|
||||
totalLabel: formatDayTotal(day.activeMs, t),
|
||||
blocks: toBlocks(day),
|
||||
isEmpty: day.activeMs === 0 && day.agentMs === 0,
|
||||
isToday,
|
||||
nowFraction: isToday ? (now - day.day) / DAY_MS : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Collapse long edit-free runs (≥ EMPTY_RUN_COLLAPSE) into an in-place "gap"
|
||||
* row; short runs stay as (dimmed, "—") day rows. Preserved from the original
|
||||
* punch-card so a page edited over months does not render hundreds of rows. */
|
||||
export function buildRows(
|
||||
perDay: IPerDay[],
|
||||
t: Translate,
|
||||
now: number,
|
||||
): TimelineRow[] {
|
||||
const rows: TimelineRow[] = [];
|
||||
let emptyRun: IPerDay[] = [];
|
||||
const flush = () => {
|
||||
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||
rows.push({ type: "gap", count: emptyRun.length });
|
||||
} else {
|
||||
for (const d of emptyRun) {
|
||||
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
|
||||
}
|
||||
}
|
||||
emptyRun = [];
|
||||
};
|
||||
for (const d of perDay) {
|
||||
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||
emptyRun.push(d);
|
||||
} else {
|
||||
flush();
|
||||
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The big summary slot. Fail-safe for an agent-only page (#395/#551): since
|
||||
* formatHeadline(0) === "", never leave the 22px slot empty — put the agent
|
||||
* estimate in the main slot, and only show the secondary `agent:` line when
|
||||
* BOTH a human and an agent estimate exist. */
|
||||
export function summaryLabels(
|
||||
data: IPageWorkTime,
|
||||
t: Translate,
|
||||
): { total: string; agent?: string } {
|
||||
const total =
|
||||
data.workMs > 0
|
||||
? formatHeadline(data.workMs, t)
|
||||
: t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) });
|
||||
const agent =
|
||||
data.workMs > 0 && data.agentOnlyMs > 0
|
||||
? formatHeadline(data.agentOnlyMs, t)
|
||||
: undefined;
|
||||
return { total, agent };
|
||||
}
|
||||
|
||||
/** Block hover label "start – end · duration". Times come from the REAL epoch
|
||||
* rendered in the data tz (NOT the 24h fraction) so a DST-transition day does
|
||||
* not skew the shown clock time. Duration reuses formatDayTotal (always > 0
|
||||
* here, so never "—"). */
|
||||
export function formatBlockTooltip(
|
||||
block: TimelineBlock,
|
||||
tz: string,
|
||||
locale: string,
|
||||
t: Translate,
|
||||
): string {
|
||||
const fmt = new Intl.DateTimeFormat(locale || undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: tz,
|
||||
});
|
||||
return t("{{start}} – {{end}} · {{duration}}", {
|
||||
start: fmt.format(block.startEpoch),
|
||||
end: fmt.format(block.endEpoch),
|
||||
duration: formatDayTotal(block.endEpoch - block.startEpoch, t),
|
||||
});
|
||||
}
|
||||
@@ -1,86 +1,97 @@
|
||||
// #566 — redesigned "Time worked" body: daily time-of-day timelines. Each day is
|
||||
// a 24h track showing WHEN the work happened — a sticky 00/06/12/18/24 hour axis,
|
||||
// shaded night hours, per-block hover tooltip ("start – end · duration"), and a
|
||||
// "now" boundary on today's row. Presentation ported from NewDesign/TimeWorkedModal;
|
||||
// all data comes through the pure adapter over the real IPageWorkTime (zero backend
|
||||
// change). Positioning math, empty-run collapsing and the formatters are reused.
|
||||
import { Box, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { IPageWorkTime } from "./work-time.types";
|
||||
import { formatGapMinutes } from "./format-work-time";
|
||||
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||
import {
|
||||
buildRows,
|
||||
formatBlockTooltip,
|
||||
summaryLabels,
|
||||
TimelineBlock,
|
||||
TimelineDay,
|
||||
} from "./work-time-adapter";
|
||||
formatDayTotal,
|
||||
formatGapMinutes,
|
||||
formatHeadline,
|
||||
} from "./format-work-time";
|
||||
import classes from "./work-time.module.css";
|
||||
|
||||
// Minimum visible width so a very short session neither vanishes nor fakes dense
|
||||
// work (§6.2); kept from the original punch-card.
|
||||
const MIN_BLOCK_WIDTH_PCT = 0.6;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||
// "× N days" separator (§6.2 long-range) — the row is still always one day.
|
||||
const EMPTY_RUN_COLLAPSE = 8;
|
||||
|
||||
function ActivityBlock({
|
||||
block,
|
||||
tz,
|
||||
locale,
|
||||
}: {
|
||||
block: TimelineBlock;
|
||||
tz: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const left = (block.start / 24) * 100;
|
||||
const width = Math.max(((block.end - block.start) / 24) * 100, MIN_BLOCK_WIDTH_PCT);
|
||||
const cls = [
|
||||
classes.window,
|
||||
block.kind === "work" ? classes.windowWork : classes.windowAgent,
|
||||
].join(" ");
|
||||
return (
|
||||
<Tooltip
|
||||
label={formatBlockTooltip(block, tz, locale, t)}
|
||||
withArrow
|
||||
openDelay={120}
|
||||
fz={11}
|
||||
>
|
||||
<div className={cls} style={{ left: `${left}%`, width: `${width}%` }} />
|
||||
</Tooltip>
|
||||
);
|
||||
type Row =
|
||||
| { type: "day"; day: IPerDay }
|
||||
| { type: "gap"; count: number };
|
||||
|
||||
function collapseEmptyRuns(perDay: IPerDay[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
let emptyRun: IPerDay[] = [];
|
||||
const flush = () => {
|
||||
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||
rows.push({ type: "gap", count: emptyRun.length });
|
||||
} else {
|
||||
for (const d of emptyRun) rows.push({ type: "day", day: d });
|
||||
}
|
||||
emptyRun = [];
|
||||
};
|
||||
for (const d of perDay) {
|
||||
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||
emptyRun.push(d);
|
||||
} else {
|
||||
flush();
|
||||
rows.push({ type: "day", day: d });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
function dayHeading(day: number): string {
|
||||
return new Date(day).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function DayTrack({
|
||||
day,
|
||||
tz,
|
||||
locale,
|
||||
pSingle,
|
||||
}: {
|
||||
day: TimelineDay;
|
||||
tz: string;
|
||||
locale: string;
|
||||
day: IPerDay;
|
||||
pSingle: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ticks = [6, 12, 18];
|
||||
return (
|
||||
<div className={classes.row}>
|
||||
<span className={classes.dayLabel}>{day.label}</span>
|
||||
<div className={`${classes.track} ${day.isEmpty ? classes.trackEmpty : ""}`}>
|
||||
{[25, 50, 75].map((p) => (
|
||||
<div key={p} className={classes.hourTick} style={{ left: `${p}%` }} />
|
||||
))}
|
||||
{day.blocks.map((b, i) => (
|
||||
<ActivityBlock key={i} block={b} tz={tz} locale={locale} />
|
||||
))}
|
||||
{day.isToday && day.nowFraction != null && (
|
||||
<span className={classes.dayLabel}>{dayHeading(day.day)}</span>
|
||||
<div className={classes.track}>
|
||||
{ticks.map((h) => (
|
||||
<div
|
||||
className={classes.nowLine}
|
||||
style={{ left: `${day.nowFraction * 100}%` }}
|
||||
key={h}
|
||||
className={classes.hourTick}
|
||||
style={{ left: `${(h / 24) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
{day.windows.map((w: IDayWindow, i) => {
|
||||
const leftPct = ((w.start - day.day) / DAY_MS) * 100;
|
||||
const widthPct = ((w.end - w.start) / DAY_MS) * 100;
|
||||
const isSingle = w.end - w.start <= pSingle;
|
||||
const cls = [
|
||||
classes.window,
|
||||
w.class === "work" ? classes.windowWork : classes.windowAgent,
|
||||
isSingle ? classes.windowSingle : "",
|
||||
].join(" ");
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cls}
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, leftPct))}%`,
|
||||
width: `${Math.max(0, Math.min(100, widthPct))}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span
|
||||
className={classes.daySum}
|
||||
data-empty={day.totalLabel === "—" ? true : undefined}
|
||||
>
|
||||
{day.totalLabel}
|
||||
<span className={classes.daySum}>
|
||||
{formatDayTotal(day.activeMs, t)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -91,16 +102,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function WorkTimePunchCard({ data }: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language;
|
||||
const now = Date.now();
|
||||
const rows = useMemo(
|
||||
() => buildRows(data.perDay, t, now),
|
||||
// `now` intentionally re-read on each open; excluded so the memo tracks data.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[data.perDay, t],
|
||||
);
|
||||
const { total, agent } = summaryLabels(data, t);
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]);
|
||||
const gapMin = formatGapMinutes(data.config.tGap);
|
||||
|
||||
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
|
||||
@@ -113,19 +116,17 @@ export default function WorkTimePunchCard({ data }: Props) {
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{/* summary */}
|
||||
<Group align="baseline" gap="md">
|
||||
<Text fz={22} fw={700}>
|
||||
{total}
|
||||
<Group gap="lg">
|
||||
<Text size="sm" fw={500}>
|
||||
{formatHeadline(data.workMs, t)}
|
||||
</Text>
|
||||
{agent && (
|
||||
{data.agentOnlyMs > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("agent: {{value}}", { value: agent })}
|
||||
{t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* legend */}
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
<span
|
||||
@@ -143,47 +144,21 @@ export default function WorkTimePunchCard({ data }: Props) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* sticky hour axis */}
|
||||
<div className={`${classes.row} ${classes.axisRow}`}>
|
||||
<span />
|
||||
<div className={classes.axis}>
|
||||
{[
|
||||
["0%", "00", "start"],
|
||||
["25%", "06", "center"],
|
||||
["50%", "12", "center"],
|
||||
["75%", "18", "center"],
|
||||
["100%", "24", "end"],
|
||||
].map(([l, label, align]) => (
|
||||
<span
|
||||
key={label}
|
||||
className={classes.axisTick}
|
||||
data-align={align}
|
||||
style={{ left: l }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* day rows */}
|
||||
<ScrollArea.Autosize mah="60vh" type="hover">
|
||||
<div>
|
||||
{rows.map((row, i) =>
|
||||
row.type === "day" ? (
|
||||
<DayTrack
|
||||
key={row.day.key}
|
||||
key={row.day.dayISO}
|
||||
day={row.day}
|
||||
tz={data.tz}
|
||||
locale={locale}
|
||||
pSingle={data.config.pSingle}
|
||||
/>
|
||||
) : (
|
||||
<Box key={`gap-${i}`} className={classes.gapRow}>
|
||||
<div key={`gap-${i}`} className={classes.gapRow}>
|
||||
{t("× {{count}} days without edits", { count: row.count })}
|
||||
</Box>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</div>
|
||||
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function WorkTimeStat({ pageId }: Props) {
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t("Time worked on this article")}
|
||||
size="46rem"
|
||||
size="lg"
|
||||
>
|
||||
<WorkTimePunchCard data={data} />
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/* #566 — time-of-day timeline. Custom CSS segments on a fixed 24-hour track
|
||||
(position = offset-in-day / 24h, width = duration / 24h), no chart library.
|
||||
Night hours (0–6, 21–24) are shaded so the eye reads morning-vs-evening; a
|
||||
sticky hour axis labels 00/06/12/18/24. Theme-aware via Mantine tokens. */
|
||||
/* #395 — 24h × days punch-card. Custom CSS segments on a fixed 24-hour track
|
||||
(position = offset-in-day / 24h, width = duration / 24h), no chart library. */
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
@@ -18,29 +16,17 @@
|
||||
}
|
||||
|
||||
.track {
|
||||
--wt-night: rgba(90, 100, 130, 0.16);
|
||||
--wt-day: rgba(90, 100, 130, 0.03);
|
||||
position: relative;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
overflow: hidden;
|
||||
/* base fill + night shading: 0–6h and 21–24h (87.5%) darker than daytime */
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--wt-night) 0 25%,
|
||||
var(--wt-day) 25% 87.5%,
|
||||
var(--wt-night) 87.5% 100%
|
||||
),
|
||||
light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
/* Short (< EMPTY_RUN_COLLAPSE) edit-free days: dimmed, empty track. */
|
||||
.trackEmpty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Quarter-day grid divisions (06/12/18) inside the track. */
|
||||
/* Faint hour grid so the eye can read "morning vs evening". */
|
||||
.hourTick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -54,11 +40,10 @@
|
||||
|
||||
.window {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
height: 14px;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 3px;
|
||||
min-width: 3px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.windowWork {
|
||||
@@ -69,57 +54,18 @@
|
||||
background-color: var(--mantine-color-grape-5);
|
||||
}
|
||||
|
||||
/* The "now" boundary on today's row. */
|
||||
.nowLine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: var(--mantine-color-red-6);
|
||||
/* A lone single-sample (P_single) window: minimal + dimmed, so it neither
|
||||
vanishes nor fakes dense work (§6.2). */
|
||||
.windowSingle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.daySum {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daySum[data-empty] {
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Sticky hour axis header aligned to the track column. */
|
||||
.axisRow {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--mantine-color-body);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.axis {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.axisTick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.axisTick[data-align="center"] {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.axisTick[data-align="end"] {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.gapRow {
|
||||
padding: 6px 0 6px 108px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
|
||||
@@ -36,7 +36,6 @@ const STATIC_ROUTES = new Set<string>([
|
||||
'/setup/register',
|
||||
'/settings/account/profile',
|
||||
'/settings/account/preferences',
|
||||
'/settings/account/api-keys',
|
||||
'/settings/workspace',
|
||||
'/settings/ai',
|
||||
'/settings/members',
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import ApiKeysManager from "@/features/api-key/components/api-keys-manager";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AccountApiKeys() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("API keys")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<SettingsTitle title={t("API keys")} />
|
||||
|
||||
<ApiKeysManager />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -63,15 +63,3 @@ vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mantine's ScrollArea (used by Table.ScrollContainer, ScrollArea, etc.) reads
|
||||
// `ResizeObserver` in a layout effect on mount, which jsdom does not implement.
|
||||
// A no-op stub lets any test rendering those components mount cleanly.
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -736,6 +736,53 @@ 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,6 +68,19 @@ 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
|
||||
@@ -643,6 +656,10 @@ 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
|
||||
@@ -668,11 +685,20 @@ export class PersistenceExtension implements Extension {
|
||||
includeContent: true,
|
||||
trx,
|
||||
});
|
||||
if (!page) return;
|
||||
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;
|
||||
}
|
||||
versionedPageId = page.id;
|
||||
// Never version an effectively-empty page (mirrors the processor's
|
||||
// first-history guard); there is nothing intentional to pin.
|
||||
if (isEmptyParagraphDoc(page.content as any)) return;
|
||||
// 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;
|
||||
}
|
||||
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
page.id,
|
||||
@@ -747,15 +773,26 @@ 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',
|
||||
type: VERSION_SAVED_MESSAGE_TYPE,
|
||||
historyId: result.historyId,
|
||||
kind: result.kind,
|
||||
alreadySaved: result.alreadySaved,
|
||||
}),
|
||||
);
|
||||
} else if (skipped) {
|
||||
document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
type: VERSION_SKIPPED_MESSAGE_TYPE,
|
||||
reason: skipped,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ 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,6 +58,7 @@ type DocmostClientMethod =
|
||||
| 'sharePage'
|
||||
| 'unsharePage'
|
||||
| 'restorePageVersion'
|
||||
| 'savePageVersion'
|
||||
| 'transformPage'
|
||||
| 'stashPage'
|
||||
// --- write (image / footnote), in-app since #410 ---
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
savePageVersionRealtime,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
@@ -55,6 +56,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -587,6 +589,24 @@ 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,7 +139,13 @@ 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. */
|
||||
@@ -564,6 +570,125 @@ 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,6 +324,146 @@ 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). 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). 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.";
|
||||
|
||||
/**
|
||||
* Non-tool camelCase identifiers that legitimately appear in ROUTING_PROSE:
|
||||
@@ -218,6 +218,7 @@ 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,6 +93,7 @@ export type DocmostClientLike = Pick<
|
||||
| 'sharePage'
|
||||
| 'unsharePage'
|
||||
| 'restorePageVersion'
|
||||
| 'savePageVersion'
|
||||
| 'stashPage'
|
||||
| 'insertFootnote'
|
||||
| 'insertImage'
|
||||
@@ -743,6 +744,30 @@ 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: {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
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