feat(#370): page-history intentionality tiers — kind column + intentional/idle/boundary triggers (PR-1 core)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Editor | null>(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<Editor | null>(null);
|
||||
|
||||
export const readOnlyEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
@@ -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<IPage>(["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();
|
||||
|
||||
@@ -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 }}
|
||||
>
|
||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Text size="sm">{formattedDate(new Date(historyItem.createdAt))}</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant={kindMeta.version ? "filled" : "light"}
|
||||
color={kindMeta.color}
|
||||
>
|
||||
{t(kindMeta.labelKey)}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} wrap="nowrap" mt={4}>
|
||||
{hasContributors ? (
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<HTMLDivElement>(null);
|
||||
const prefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -128,12 +152,30 @@ function HistoryList({ pageId }: Props) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Group px="xs" py={6} justify="flex-end">
|
||||
<Switch
|
||||
size="xs"
|
||||
checked={onlyVersions}
|
||||
onChange={(e) => setOnlyVersions(e.currentTarget.checked)}
|
||||
label={t("Only versions")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<ScrollArea h={620} w="100%" type="scroll" scrollbarSize={5}>
|
||||
{historyItems.map((historyItem, index) => (
|
||||
{onlyVersions && visibleItems.length === 0 && (
|
||||
<Center py="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("No saved versions yet.")}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
{visibleItems.map((historyItem) => (
|
||||
<HistoryItem
|
||||
key={historyItem.id}
|
||||
historyItem={historyItem}
|
||||
index={index}
|
||||
// Index within the FULL list so diff/restore uses the true previous
|
||||
// snapshot, not the previous visible one.
|
||||
index={originalIndexById.get(historyItem.id) ?? 0}
|
||||
onSelect={handleSelect}
|
||||
onHover={handleHover}
|
||||
onHoverEnd={clearPrefetchTimeout}
|
||||
|
||||
@@ -24,6 +24,10 @@ export interface IPageHistory {
|
||||
updatedAt: string;
|
||||
lastUpdatedBy: IPageHistoryUser;
|
||||
contributors?: IPageHistoryUser[];
|
||||
// #370 — intentionality tier: 'manual'/'agent' are versions (intentional
|
||||
// points), 'idle'/'boundary' are autosnapshots; null/undefined = legacy
|
||||
// autosave. Derived server-side, drives the history badge + "versions" filter.
|
||||
kind?: "manual" | "agent" | "idle" | "boundary" | null;
|
||||
// Provenance markers copied off the page row when the snapshot was saved.
|
||||
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
||||
// (when present) deep-links to the chat that produced the edit.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* #370 — page-version stateless wire formats. Kept in one place so the client
|
||||
* emitter (Save hotkey / button) and the client listener (page-editor) agree
|
||||
* with the server (PersistenceExtension) on the message shapes.
|
||||
*/
|
||||
|
||||
/** Client → server: "save a version now". The server derives the tier
|
||||
* (manual/agent) from the signed connection actor, never from this payload. */
|
||||
export const SAVE_VERSION_MESSAGE_TYPE = "save-version";
|
||||
|
||||
/** Server → all clients: a version was saved (or promoted / already existed). */
|
||||
export const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
|
||||
|
||||
export interface VersionSavedMessage {
|
||||
type: typeof VERSION_SAVED_MESSAGE_TYPE;
|
||||
historyId: string;
|
||||
kind: "manual" | "agent";
|
||||
/** True when the latest snapshot was already a manual version (a no-op save). */
|
||||
alreadySaved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-component coordination flag so only the client that pressed Save shows
|
||||
* the confirmation toast, while every other client silently refreshes its
|
||||
* history panel on the broadcast. A module-level ref avoids stale-closure
|
||||
* pitfalls in the editor's long-lived stateless handler.
|
||||
*/
|
||||
export const saveVersionPending = { current: false };
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
IconArrowRight,
|
||||
IconArrowsHorizontal,
|
||||
IconClockHour4,
|
||||
IconDeviceFloppy,
|
||||
IconDots,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
IconTrash,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
@@ -39,9 +40,14 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import ExportModal from "@/components/common/export-modal";
|
||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
collabProviderAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
SAVE_VERSION_MESSAGE_TYPE,
|
||||
saveVersionPending,
|
||||
} from "@/features/page-history/version-messages.ts";
|
||||
import { formattedDate } from "@/lib/time.ts";
|
||||
import { PageEditModeToggle } from "@/features/user/components/page-state-pref.tsx";
|
||||
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||
@@ -72,9 +78,34 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
});
|
||||
const isDeleted = !!page?.deletedAt;
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const collabProvider = useAtomValue(collabProviderAtom);
|
||||
// Community public-sharing entry point (replaces the removed EE PageShareModal)
|
||||
const workspaceSharingDisabled = workspace?.settings?.sharing?.disabled === true;
|
||||
|
||||
// #370 — explicit "save a version" (Cmd+S / Save button). One path for the
|
||||
// human; the server derives the tier from the signed actor. Readers can't save
|
||||
// (the button is hidden and the collab connection is read-only server-side).
|
||||
const handleSaveVersion = useCallback(() => {
|
||||
if (readOnly || !collabProvider) return;
|
||||
// Flag this client as the initiator so only it shows the confirmation toast;
|
||||
// a safety timeout clears it if no broadcast comes back (e.g. offline).
|
||||
saveVersionPending.current = true;
|
||||
window.setTimeout(() => {
|
||||
saveVersionPending.current = false;
|
||||
}, 5000);
|
||||
collabProvider.sendStateless(
|
||||
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
|
||||
);
|
||||
}, [readOnly, collabProvider]);
|
||||
|
||||
// mod+S must also block the browser's "Save page" dialog. `triggerOnContent-
|
||||
// Editable` + empty ignore-list so it fires while typing in the editor/title.
|
||||
useHotkeys(
|
||||
[["mod+S", handleSaveVersion, { preventDefault: true }]],
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
[
|
||||
[
|
||||
@@ -133,15 +164,16 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<PageActionMenu readOnly={readOnly} />
|
||||
<PageActionMenu readOnly={readOnly} onSaveVersion={handleSaveVersion} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface PageActionMenuProps {
|
||||
readOnly?: boolean;
|
||||
onSaveVersion?: () => void;
|
||||
}
|
||||
function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
function PageActionMenu({ readOnly, onSaveVersion }: PageActionMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
@@ -302,6 +334,20 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
|
||||
{!readOnly && (
|
||||
<Menu.Item
|
||||
leftSection={<IconDeviceFloppy size={16} />}
|
||||
onClick={onSaveVersion}
|
||||
rightSection={
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("Ctrl+S")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{t("Save version")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} />}
|
||||
onClick={openHistoryModal}
|
||||
|
||||
Reference in New Issue
Block a user