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) => (