From 1542c99979435974daa3818b383bda6eff913e29 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sun, 5 Jul 2026 05:00:12 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(#370):=20page-history=20intentionality?= =?UTF-8?q?=20tiers=20=E2=80=94=20kind=20column=20+=20intentional/idle/bou?= =?UTF-8?q?ndary=20triggers=20(PR-1=20core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-1 'core' of #370: introduces page_history.kind ('manual'|'agent'|'idle'| 'boundary'; legacy null = autosave) and rebuilds the snapshot triggers around a three-tier intentionality model. Draft durability (pages/ydoc hocuspocus autosave) is unchanged; only the frequency and labelling of history points change. - Migration 20260705T120000: page_history.kind nullable varchar(20), no default. - Manual Save: one stateless 'save-version' path for human AND agent; kind is derived SERVER-SIDE from the signed context.actor (never the payload), readOnly connections rejected, the fresh ydoc runs through the existing store path (no REST race), then broadcasts version.saved. - Idle-flush: trailing debounce (one BullMQ job per page, remove-then-readd) with IDLE_INTERVAL_USER=60m / AGENT=15m AND a max-wait ceiling (IDLE_MAX_WAIT_USER=10m / AGENT=5m) so a continuous editing session can't starve the autosnapshot (review round-1 WARNING). - Boundary: generalized from the user→agent special-case to ANY lastUpdatedSource transition (user↔agent↔git), same isDeepStrictEqual gate — covers git-sync free. - Removed the agent delay=0 fast path and the old HISTORY_FAST_* constants; the agent joins the common idle pipeline. - Promote-not-dup: a manual save on unchanged content promotes the latest autosave's kind in place (or no-ops if already manual) instead of duplicating a heavy content row. - Client: mod+S hotkey + menu button (hidden when readOnly), history-panel kind badges, dimmed autosaves, a 'versions only' filter (indices map to the full list so diff/restore still target the true previous snapshot), live refresh on version.saved. Internal review: APPROVE-with-suggestions; the round-1 WARNING (idle starvation) is fixed here via the max-wait ceiling, and the generalized-boundary + ceiling behaviours are pinned with new tests (115 collab/repo specs green, server tsc 0). Deferred to later PRs: shares.published_mode (PR-2), the save_page_version MCP tool + role prompts (PR-3), actor='git' wiring into #359 (PR-4). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/en-US/translation.json | 11 +- .../public/locales/ru-RU/translation.json | 11 +- .../src/features/editor/atoms/editor-atoms.ts | 9 + .../src/features/editor/page-editor.tsx | 30 +++ .../page-history/components/history-item.tsx | 45 +++- .../page-history/components/history-list.tsx | 48 +++- .../features/page-history/types/page.types.ts | 4 + .../features/page-history/version-messages.ts | 28 ++ .../components/header/page-header-menu.tsx | 52 +++- apps/server/src/collaboration/constants.ts | 32 ++- .../extensions/compute-history-job.spec.ts | 139 +++++----- .../extensions/persistence-store.spec.ts | 183 ++++++++++++- .../extensions/persistence.extension.ts | 255 ++++++++++++++---- .../processors/history.processor.spec.ts | 2 +- .../processors/history.processor.ts | 6 +- .../20260705T120000-page-history-kind.ts | 27 ++ .../database/repos/page/page-history.repo.ts | 36 ++- apps/server/src/database/types/db.d.ts | 1 + .../queue/constants/queue.interface.ts | 4 + 19 files changed, 788 insertions(+), 135 deletions(-) create mode 100644 apps/client/src/features/page-history/version-messages.ts create mode 100644 apps/server/src/database/migrations/20260705T120000-page-history-kind.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 89988d9b..00b4b237 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1385,5 +1385,14 @@ "The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.", "Dismiss": "Dismiss", "Suggestion dismissed": "Suggestion dismissed", - "Failed to dismiss suggestion": "Failed to dismiss suggestion" + "Failed to dismiss suggestion": "Failed to dismiss suggestion", + "Save version": "Save version", + "Ctrl+S": "Ctrl+S", + "Version saved": "Version saved", + "Already saved as the latest version": "Already saved as the latest version", + "Agent version": "Agent version", + "Boundary": "Boundary", + "Autosave": "Autosave", + "Only versions": "Only versions", + "No saved versions yet.": "No saved versions yet." } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 0c792632..51b8e3bc 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1248,5 +1248,14 @@ "The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.", "Dismiss": "Не применять", "Suggestion dismissed": "Предложение отклонено", - "Failed to dismiss suggestion": "Не удалось отклонить предложение" + "Failed to dismiss suggestion": "Не удалось отклонить предложение", + "Save version": "Сохранить версию", + "Ctrl+S": "Ctrl+S", + "Version saved": "Версия сохранена", + "Already saved as the latest version": "Уже сохранено как последняя версия", + "Agent version": "Версия агента", + "Boundary": "Граница", + "Autosave": "Автосейв", + "Only versions": "Только версии", + "No saved versions yet.": "Пока нет сохранённых версий." } diff --git a/apps/client/src/features/editor/atoms/editor-atoms.ts b/apps/client/src/features/editor/atoms/editor-atoms.ts index 605a71db..7b513cc1 100644 --- a/apps/client/src/features/editor/atoms/editor-atoms.ts +++ b/apps/client/src/features/editor/atoms/editor-atoms.ts @@ -1,10 +1,19 @@ import { atom } from "jotai"; import { Editor } from "@tiptap/core"; +import type { HocuspocusProvider } from "@hocuspocus/provider"; import { PageEditMode } from "@/features/user/types/user.types.ts"; import type { DictationUnavailableReason } from "@/features/dictation/dictation-status"; export const pageEditorAtom = atom(null); +// #370 — the active page's collab provider, published by the page editor so the +// header menu can emit the "save-version" stateless signal (Cmd+S / button). +// Null when the page is read-only / collab isn't connected. A typed initial +// value (rather than an explicit generic) keeps jotai's overload resolution on +// the writable PrimitiveAtom branch. +const initialCollabProvider: HocuspocusProvider | null = null; +export const collabProviderAtom = atom(initialCollabProvider); + export const titleEditorAtom = atom(null); export const readOnlyEditorAtom = atom(null); diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index e1244ee5..60949e8a 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -31,11 +31,18 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { + collabProviderAtom, currentPageEditModeAtom, dictationAvailabilityAtom, pageEditorAtom, yjsConnectionStatusAtom, } from "@/features/editor/atoms/editor-atoms"; +import { notifications } from "@mantine/notifications"; +import { + VERSION_SAVED_MESSAGE_TYPE, + type VersionSavedMessage, + saveVersionPending, +} from "@/features/page-history/version-messages"; import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom"; import { activeCommentIdAtom, @@ -123,6 +130,7 @@ export default function PageEditor({ const [currentUser] = useAtom(currentUserAtom); const [, setEditor] = useAtom(pageEditorAtom); + const setCollabProvider = useSetAtom(collabProviderAtom); const [, setAsideState] = useAtom(asideStateAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); @@ -180,6 +188,24 @@ export default function PageEditor({ const onStatelessHandler = ({ payload }: onStatelessParameters) => { try { const message = JSON.parse(payload); + // #370 — a version was saved somewhere; live-refresh the history panel + // on every client. Only the client that pressed Save (tracked by the + // module-level flag) shows the confirmation toast. + if (message?.type === VERSION_SAVED_MESSAGE_TYPE) { + const versionMsg = message as VersionSavedMessage; + queryClient.invalidateQueries({ + queryKey: ["page-history-list"], + }); + if (saveVersionPending.current) { + saveVersionPending.current = false; + notifications.show({ + message: versionMsg.alreadySaved + ? t("Already saved as the latest version") + : t("Version saved"), + }); + } + return; + } if (message?.type !== "page.updated" || !message.updatedAt) return; const pageData = queryClient.getQueryData(["pages", slugId]); if (pageData) { @@ -237,12 +263,16 @@ export default function PageEditor({ local.on("synced", onLocalSyncedHandler); providersRef.current = { socket, local, remote }; + // #370 — publish the provider so the header menu can emit save-version. + setCollabProvider(remote); setProvidersReady(true); } else { + setCollabProvider(providersRef.current.remote); setProvidersReady(true); } // Only destroy on final unmount return () => { + setCollabProvider(null); providersRef.current?.socket.destroy(); providersRef.current?.remote.destroy(); providersRef.current?.local.destroy(); diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index bc810eca..6953a161 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -1,4 +1,11 @@ -import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; +import { + Text, + Group, + UnstyledButton, + Avatar, + Tooltip, + Badge, +} from "@mantine/core"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx"; import { formattedDate } from "@/lib/time"; @@ -7,10 +14,30 @@ import clsx from "clsx"; import { IPageHistory } from "@/features/page-history/types/page.types"; import { memo, useCallback } from "react"; import { useSetAtom } from "jotai"; +import { useTranslation } from "react-i18next"; import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; const MAX_VISIBLE_AVATARS = 5; +/** + * #370 — map a snapshot's intentionality tier to its badge. `version: true` + * marks the intentional points (manual / agent); autosaves (boundary / idle / + * legacy null) are non-versions and get dimmed in the list. + */ +type HistoryKindMeta = { labelKey: string; color: string; version: boolean }; +export function historyKindMeta(kind?: string | null): HistoryKindMeta { + switch (kind) { + case "manual": + return { labelKey: "Saved", color: "blue", version: true }; + case "agent": + return { labelKey: "Agent version", color: "violet", version: true }; + case "boundary": + return { labelKey: "Boundary", color: "gray", version: false }; + default: // "idle" | null | undefined (legacy autosave) + return { labelKey: "Autosave", color: "gray", version: false }; + } +} + interface HistoryItemProps { historyItem: IPageHistory; index: number; @@ -29,6 +56,8 @@ const HistoryItem = memo(function HistoryItem({ isActive, }: HistoryItemProps) { const setHistoryModalOpen = useSetAtom(historyAtoms); + const { t } = useTranslation(); + const kindMeta = historyKindMeta(historyItem.kind); const handleClick = useCallback(() => { onSelect(historyItem.id, index); @@ -49,8 +78,20 @@ const HistoryItem = memo(function HistoryItem({ onMouseEnter={handleMouseEnter} onMouseLeave={onHoverEnd} className={clsx(classes.history, { [classes.active]: isActive })} + // #370 — dim autosnapshots so intentional versions stand out. + style={{ opacity: kindMeta.version ? 1 : 0.55 }} > - {formattedDate(new Date(historyItem.createdAt))} + + {formattedDate(new Date(historyItem.createdAt))} + + {t(kindMeta.labelKey)} + + {hasContributors ? ( diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index 4024901b..78598fb1 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -9,7 +9,7 @@ import { historyAtoms, } from "@/features/page-history/atoms/history-atoms"; import { useAtom, useSetAtom } from "jotai"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button, ScrollArea, @@ -17,6 +17,8 @@ import { Divider, Loader, Center, + Switch, + Text, } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useHistoryRestore } from "@/features/page-history/hooks"; @@ -47,6 +49,28 @@ function HistoryList({ pageId }: Props) { [pageHistoryData], ); + // #370 — "only versions" filter: hide autosnapshots (idle/boundary/legacy + // null), keep only intentional points (manual/agent). Filtering is over the + // already-loaded pages; the diff/restore still targets the true previous + // snapshot, so items carry their index within the FULL list. + const [onlyVersions, setOnlyVersions] = useState(false); + const isVersion = useCallback( + (kind?: string | null) => kind === "manual" || kind === "agent", + [], + ); + const originalIndexById = useMemo(() => { + const map = new Map(); + historyItems.forEach((item, index) => map.set(item.id, index)); + return map; + }, [historyItems]); + const visibleItems = useMemo( + () => + onlyVersions + ? historyItems.filter((item) => isVersion(item.kind)) + : historyItems, + [historyItems, onlyVersions, isVersion], + ); + const loadMoreRef = useRef(null); const prefetchTimeoutRef = useRef | null>(null); @@ -128,12 +152,30 @@ function HistoryList({ pageId }: Props) { return (
+ + setOnlyVersions(e.currentTarget.checked)} + label={t("Only versions")} + /> + + - {historyItems.map((historyItem, index) => ( + {onlyVersions && visibleItems.length === 0 && ( +
+ + {t("No saved versions yet.")} + +
+ )} + {visibleItems.map((historyItem) => (