-
@@ -455,7 +454,9 @@ export default function PageEditor({
{editor &&
!editorIsEditable &&
(editable || canComment) &&
- providersRef.current &&
}
+ providersRef.current && (
+
+ )}
{showCommentPopup && (
)}
diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx
index 308ae3f70..364f845c4 100644
--- a/apps/client/src/features/editor/title-editor.tsx
+++ b/apps/client/src/features/editor/title-editor.tsx
@@ -94,10 +94,6 @@ export function TitleEditor({
},
handleDOMEvents: {
keydown: (_view, event) => {
- if (platformModifierKey(event) && event.code === "KeyS") {
- event.preventDefault();
- return true;
- }
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
@@ -176,8 +172,11 @@ export function TitleEditor({
useEffect(() => {
if (!titleEditor) return;
+ if (pageEditMode === PageEditMode.Read) {
+ debounceUpdate.flush();
+ }
titleEditor.setEditable(editable && pageEditMode === PageEditMode.Edit);
- }, [pageEditMode, titleEditor, editable]);
+ }, [pageEditMode, titleEditor, editable, debounceUpdate]);
const openSearchDialog = () => {
const event = new CustomEvent("openFindDialogFromEditor", {});
diff --git a/apps/client/src/features/group/queries/group-query.ts b/apps/client/src/features/group/queries/group-query.ts
index 01f4509e8..5b05626a3 100644
--- a/apps/client/src/features/group/queries/group-query.ts
+++ b/apps/client/src/features/group/queries/group-query.ts
@@ -5,7 +5,7 @@ import {
UseQueryResult,
keepPreviousData,
} from "@tanstack/react-query";
-import { IGroup } from "@/features/group/types/group.types";
+import { IGroup, IGroupTreeNode } from "@/features/group/types/group.types";
import {
addGroupMember,
createGroup,
@@ -13,6 +13,7 @@ import {
getGroupById,
getGroupMembers,
getGroups,
+ getGroupTree,
removeGroupMember,
updateGroup,
} from "@/features/group/services/group-service";
@@ -46,6 +47,14 @@ export function useGetGroupsQuery(
return query;
}
+export function useGroupTreeQuery(): UseQueryResult
{
+ return useQuery({
+ queryKey: ["groupTree"],
+ queryFn: () => getGroupTree(),
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
export function useGroupQuery(groupId: string): UseQueryResult {
return useQuery({
queryKey: ["group", groupId],
diff --git a/apps/client/src/features/group/services/group-service.ts b/apps/client/src/features/group/services/group-service.ts
index 480254bff..b03dc701a 100644
--- a/apps/client/src/features/group/services/group-service.ts
+++ b/apps/client/src/features/group/services/group-service.ts
@@ -1,5 +1,5 @@
import api from "@/lib/api-client";
-import { IGroup } from "@/features/group/types/group.types";
+import { IGroup, IGroupTreeNode } from "@/features/group/types/group.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
@@ -10,6 +10,11 @@ export async function getGroups(
return req.data;
}
+export async function getGroupTree(): Promise {
+ const req = await api.post("/groups/tree");
+ return req.data;
+}
+
export async function getGroupById(groupId: string): Promise {
const req = await api.post("/groups/info", { groupId });
return req.data as IGroup;
diff --git a/apps/client/src/features/group/types/group.types.ts b/apps/client/src/features/group/types/group.types.ts
index 2b12f0907..14a3ae929 100644
--- a/apps/client/src/features/group/types/group.types.ts
+++ b/apps/client/src/features/group/types/group.types.ts
@@ -10,3 +10,21 @@ export interface IGroup {
updatedAt: Date;
memberCount: number;
}
+
+export interface IGroupTreeMember {
+ id: string;
+ name: string;
+ email: string;
+ avatarUrl: string;
+}
+
+export interface IGroupTreeNode {
+ id: string;
+ name: string;
+ displayName: string;
+ memberCount: number;
+ isDefault: boolean;
+ isExternal: boolean;
+ children: IGroupTreeNode[];
+ members: IGroupTreeMember[];
+}
diff --git a/apps/client/src/features/label/components/label-picker.test.ts b/apps/client/src/features/label/components/label-picker.test.ts
new file mode 100644
index 000000000..823271e3e
--- /dev/null
+++ b/apps/client/src/features/label/components/label-picker.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { isValidLabelName } from "./label-picker";
+
+describe("isValidLabelName", () => {
+ it.each(["中文", "项目-计划", "项目_2026", "release-版本2", "équipe"])(
+ "accepts Unicode label %s",
+ (name) => {
+ expect(isValidLabelName(name)).toBe(true);
+ },
+ );
+
+ it.each(["~中文", "项目 计划", "项目!", "😀", ""])(
+ "rejects invalid label %s",
+ (name) => {
+ expect(isValidLabelName(name)).toBe(false);
+ },
+ );
+});
diff --git a/apps/client/src/features/label/components/label-picker.tsx b/apps/client/src/features/label/components/label-picker.tsx
index 0bd5e6ada..99392491b 100644
--- a/apps/client/src/features/label/components/label-picker.tsx
+++ b/apps/client/src/features/label/components/label-picker.tsx
@@ -16,10 +16,10 @@ type LabelPickerProps = {
onClose: () => void;
};
-const NAME_PATTERN = /^[a-z0-9_-][a-z0-9_~-]*$/;
+const NAME_PATTERN = /^[\p{L}\p{N}_-][\p{L}\p{N}_~-]*$/u;
const MAX_LABEL_NAME_LENGTH = 100;
-function isValidLabelName(name: string): boolean {
+export function isValidLabelName(name: string): boolean {
return (
name.length > 0 &&
name.length <= MAX_LABEL_NAME_LENGTH &&
diff --git a/apps/client/src/features/page-history/atoms/history-atoms.ts b/apps/client/src/features/page-history/atoms/history-atoms.ts
index 2acf163d5..8045a74ad 100644
--- a/apps/client/src/features/page-history/atoms/history-atoms.ts
+++ b/apps/client/src/features/page-history/atoms/history-atoms.ts
@@ -7,3 +7,12 @@ export const highlightChangesAtom = atom(true);
export type DiffCounts = { added: number; deleted: number; total: number };
export const diffCountsAtom = atom(null);
+
+export type DiffState =
+ | "idle"
+ | "loadingPrevious"
+ | "computing"
+ | "ready"
+ | "error"
+ | "tooLarge";
+export const diffStateAtom = atom("idle");
diff --git a/apps/client/src/features/page-history/components/css/history.module.css b/apps/client/src/features/page-history/components/css/history.module.css
index 12be8bc34..47fc32a23 100644
--- a/apps/client/src/features/page-history/components/css/history.module.css
+++ b/apps/client/src/features/page-history/components/css/history.module.css
@@ -77,3 +77,16 @@
flex: 1;
padding: rem(16px) rem(40px);
}
+
+.diffProgressBar {
+ display: flex;
+ align-items: center;
+ gap: rem(8px);
+ padding: rem(8px) rem(12px);
+ margin-bottom: rem(8px);
+ border-radius: var(--mantine-radius-sm);
+ background-color: light-dark(
+ var(--mantine-color-gray-0),
+ var(--mantine-color-dark-6)
+ );
+}
diff --git a/apps/client/src/features/page-history/components/history-editor.tsx b/apps/client/src/features/page-history/components/history-editor.tsx
index d071abc3e..82e4fbb08 100644
--- a/apps/client/src/features/page-history/components/history-editor.tsx
+++ b/apps/client/src/features/page-history/components/history-editor.tsx
@@ -1,193 +1,315 @@
import "@/features/editor/styles/index.css";
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { mainExtensions } from "@/features/editor/extensions/extensions";
import { Title } from "@mantine/core";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import historyClasses from "./css/history.module.css";
-import { recreateTransform } from "@docmost/editor-ext";
-import { DOMSerializer, Node } from "@tiptap/pm/model";
-import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
-import { useAtom } from "jotai";
+import { DOMSerializer, Node, Schema } from "@tiptap/pm/model";
+import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
+ DiffCounts,
diffCountsAtom,
+ diffStateAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
+import type { DiffChange } from "@/features/page-history/workers/diff.worker";
+import { IPageHistoryDiff } from "@/features/page-history/types/page.types";
export interface HistoryEditorProps {
title: string;
content: any;
previousContent?: any;
+ prevHistoryId?: string;
+ persistedDiff?: IPageHistoryDiff;
+ isDiffLoading: boolean;
+ isPreviousLoading: boolean;
+ isPreviousError: boolean;
+}
+
+function extractSchemaSpec(schema: Schema) {
+ const nodes: Record = {};
+ schema.spec.nodes.forEach((name: string, spec: any) => {
+ nodes[name] = {
+ content: spec.content,
+ group: spec.group,
+ inline: spec.inline,
+ atom: spec.atom,
+ attrs: spec.attrs,
+ marks: spec.marks,
+ defining: spec.defining,
+ isolating: spec.isolating,
+ selectable: spec.selectable,
+ draggable: spec.draggable,
+ code: spec.code,
+ whitespace: spec.whitespace,
+ };
+ });
+ const marks: Record = {};
+ schema.spec.marks?.forEach((name: string, spec: any) => {
+ marks[name] = {
+ attrs: spec.attrs,
+ inclusive: spec.inclusive,
+ excludes: spec.excludes,
+ group: spec.group,
+ spanning: spec.spanning,
+ };
+ });
+ return { nodes, marks, topNode: schema.spec.topNode };
+}
+
+function buildDecorations(
+ schema: Schema,
+ content: any,
+ changes: DiffChange[],
+): DecorationSet {
+ const newContent = Node.fromJSON(schema, content);
+ const decorations: Decoration[] = [];
+ let changeIndex = 0;
+
+ for (const change of changes) {
+ if (change.fromB < change.toB) {
+ changeIndex++;
+ const idx = String(changeIndex);
+
+ if (change.addedSpecialNode) {
+ decorations.push(
+ Decoration.node(
+ change.addedSpecialNode.pos,
+ change.addedSpecialNode.nodeEnd,
+ { class: "history-diff-node-added", "data-diff-index": idx },
+ ),
+ );
+ } else {
+ decorations.push(
+ Decoration.inline(change.fromB, change.toB, {
+ class: "history-diff-added",
+ "data-diff-index": idx,
+ }),
+ );
+ }
+ }
+
+ if (change.fromA < change.toA) {
+ changeIndex++;
+ const idx = String(changeIndex);
+
+ if (change.deletedSpecialNode) {
+ const nodeJSON = change.deletedSpecialNode.nodeJSON;
+ decorations.push(
+ Decoration.widget(change.fromB, () => {
+ const wrapper = document.createElement("div");
+ wrapper.className = "history-diff-node-deleted";
+ wrapper.setAttribute("data-diff-index", idx);
+ const serializer = DOMSerializer.fromSchema(schema);
+ const node = schema.nodeFromJSON(nodeJSON);
+ const dom = serializer.serializeNode(node);
+ wrapper.appendChild(dom);
+ return wrapper;
+ }),
+ );
+ } else if (change.deletedText) {
+ const text = change.deletedText;
+ decorations.push(
+ Decoration.widget(change.fromB, () => {
+ const span = document.createElement("span");
+ span.className = "history-diff-deleted";
+ span.setAttribute("data-diff-index", idx);
+ span.textContent = text;
+ return span;
+ }),
+ );
+ }
+ }
+ }
+
+ return DecorationSet.create(newContent, decorations);
}
export function HistoryEditor({
title,
content,
previousContent,
+ prevHistoryId,
+ persistedDiff,
+ isDiffLoading,
+ isPreviousLoading,
+ isPreviousError,
}: HistoryEditorProps) {
- const [highlightChanges] = useAtom(highlightChangesAtom);
+ const highlightChanges = useAtomValue(highlightChangesAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
+ const diffState = useAtomValue(diffStateAtom);
+ const setDiffState = useSetAtom(diffStateAtom);
+
+ const updateDiffCounts = (v: DiffCounts | null) => {
+ // @ts-ignore - Jotai atom type inference limitation
+ setDiffCounts(v);
+ };
+ const decorationSetRef = useRef(DecorationSet.empty);
const editor = useEditor({
extensions: mainExtensions,
editable: false,
});
+ // Effect 1: immediate content render
useEffect(() => {
if (!editor || !content) return;
+ decorationSetRef.current = DecorationSet.empty;
+ editor.commands.setContent(content);
+ }, [editor, content]);
- let decorationSet = DecorationSet.empty;
- let addedCount = 0;
- let deletedCount = 0;
+ // Effect 2: use persisted diff first and fall back to the Web Worker
+ useEffect(() => {
+ if (!editor || !content || !prevHistoryId) {
+ setDiffState("idle");
+ updateDiffCounts(null);
+ decorationSetRef.current = DecorationSet.empty;
+ return;
+ }
- if (previousContent) {
+ const applyResult = (
+ changes: DiffChange[],
+ addedCount: number,
+ deletedCount: number,
+ ) => {
try {
- const schema = editor.schema;
- const oldContent = Node.fromJSON(schema, previousContent);
- const newContent = Node.fromJSON(schema, content);
-
- const tr = recreateTransform(oldContent, newContent, {
- complexSteps: false,
- wordDiffs: true,
- simplifyDiff: true,
- });
-
- const changeSet = ChangeSet.create(oldContent).addSteps(
- tr.doc,
- tr.mapping.maps,
- [],
+ const total = addedCount + deletedCount;
+ decorationSetRef.current = buildDecorations(
+ editor.schema,
+ content,
+ changes,
);
- const changes = simplifyChanges(changeSet.changes, newContent);
-
- editor.commands.setContent(content);
-
- const specialNodeTypes = new Set([
- "image",
- "attachment",
- "video",
- "excalidraw",
- "drawio",
- "mermaid",
- "mathBlock",
- "mathInline",
- "table",
- "details",
- "callout",
- ]);
-
- const decorations: Decoration[] = [];
- let changeIndex = 0;
-
- for (const change of changes) {
- if (change.toB > change.fromB) {
- changeIndex++;
- const currentIndex = changeIndex;
- let foundSpecialNode: { node: Node; pos: number } | null = null;
- newContent.nodesBetween(change.fromB, change.toB, (node, pos) => {
- if (specialNodeTypes.has(node.type.name)) {
- const nodeEnd = pos + node.nodeSize;
- if (change.fromB <= pos && change.toB >= nodeEnd) {
- foundSpecialNode = { node, pos };
- return false;
- }
- }
- });
-
- if (foundSpecialNode) {
- const nodeEnd =
- foundSpecialNode.pos + foundSpecialNode.node.nodeSize;
- decorations.push(
- Decoration.node(foundSpecialNode.pos, nodeEnd, {
- class: "history-diff-node-added",
- "data-diff-index": String(currentIndex),
- }),
- );
- } else {
- decorations.push(
- Decoration.inline(change.fromB, change.toB, {
- class: "history-diff-added",
- "data-diff-index": String(currentIndex),
- }),
- );
- }
- addedCount += 1;
- }
- if (change.toA > change.fromA) {
- changeIndex++;
- const currentIndex = changeIndex;
- let foundDeletedNode: { node: Node; pos: number } | null = null;
- oldContent.nodesBetween(change.fromA, change.toA, (node, pos) => {
- if (specialNodeTypes.has(node.type.name)) {
- const nodeEnd = pos + node.nodeSize;
- if (change.fromA <= pos && change.toA >= nodeEnd) {
- foundDeletedNode = { node, pos };
- return false;
- }
- }
- });
-
- if (foundDeletedNode) {
- decorations.push(
- Decoration.widget(change.fromB, () => {
- const wrapper = document.createElement("div");
- wrapper.className = "history-diff-node-deleted";
- wrapper.setAttribute("data-diff-index", String(currentIndex));
- const serializer = DOMSerializer.fromSchema(schema);
- const dom = serializer.serializeNode(foundDeletedNode!.node);
- wrapper.appendChild(dom);
- return wrapper;
- }),
- );
- } else {
- const deletedText = oldContent.textBetween(
- change.fromA,
- change.toA,
- "",
- );
- if (deletedText) {
- decorations.push(
- Decoration.widget(change.fromB, () => {
- const span = document.createElement("span");
- span.className = "history-diff-deleted";
- span.setAttribute("data-diff-index", String(currentIndex));
- span.textContent = deletedText;
- return span;
- }),
- );
- }
- }
- deletedCount += 1;
- }
- }
-
- decorationSet = DecorationSet.create(newContent, decorations);
- } catch (e) {
- console.error("History diff failed:", e);
- editor.commands.setContent(content);
+ updateDiffCounts({ added: addedCount, deleted: deletedCount, total });
+ setDiffState("ready");
+ } catch (error) {
+ console.error("History diff decorations failed:", error);
+ decorationSetRef.current = DecorationSet.empty;
+ updateDiffCounts(null);
+ setDiffState("error");
}
- } else {
- editor.commands.setContent(content);
+ };
+
+ if (
+ persistedDiff?.status === "ready" &&
+ persistedDiff.fromHistoryId === prevHistoryId
+ ) {
+ applyResult(
+ persistedDiff.changes,
+ persistedDiff.addedCount,
+ persistedDiff.deletedCount,
+ );
+ return;
}
- const total = addedCount + deletedCount;
- // @ts-ignore
- setDiffCounts({ added: addedCount, deleted: deletedCount, total });
+ if (persistedDiff?.status === "too_large") {
+ decorationSetRef.current = DecorationSet.empty;
+ updateDiffCounts(null);
+ setDiffState("tooLarge");
+ return;
+ }
+
+ if (
+ isDiffLoading ||
+ persistedDiff?.status === "pending" ||
+ persistedDiff?.status === "running"
+ ) {
+ decorationSetRef.current = DecorationSet.empty;
+ updateDiffCounts(null);
+ setDiffState("computing");
+ return;
+ }
+ if (isPreviousLoading || !previousContent) {
+ decorationSetRef.current = DecorationSet.empty;
+ updateDiffCounts(null);
+ setDiffState(isPreviousError ? "error" : "loadingPrevious");
+ return;
+ }
+
+ setDiffState("computing");
+
+ let worker: Worker;
+ try {
+ worker = new Worker(
+ new URL("../workers/diff.worker.ts", import.meta.url),
+ { type: "module" },
+ );
+ } catch (error) {
+ console.error("History diff worker creation failed:", error);
+ decorationSetRef.current = DecorationSet.empty;
+ updateDiffCounts(null);
+ setDiffState("error");
+ return;
+ }
+
+ worker.onmessage = (e: MessageEvent) => {
+ if (e.data.type === "result") {
+ const { changes, addedCount, deletedCount } = e.data;
+ applyResult(changes, addedCount, deletedCount);
+ } else {
+ console.error("History diff worker failed:", e.data.message);
+ decorationSetRef.current = DecorationSet.empty;
+ updateDiffCounts(null);
+ setDiffState("error");
+ }
+ worker.terminate();
+ };
+
+ worker.onerror = (err) => {
+ console.error("History diff worker error:", err);
+ decorationSetRef.current = DecorationSet.empty;
+ updateDiffCounts(null);
+ setDiffState("error");
+ worker.terminate();
+ };
+
+ worker.onmessageerror = (err) => {
+ console.error("History diff worker message error:", err);
+ decorationSetRef.current = DecorationSet.empty;
+ updateDiffCounts(null);
+ setDiffState("error");
+ worker.terminate();
+ };
+
+ try {
+ const schemaSpec = extractSchemaSpec(editor.schema);
+ worker.postMessage({ schemaSpec, previousContent, content });
+ } catch (error) {
+ console.error("History diff worker start failed:", error);
+ decorationSetRef.current = DecorationSet.empty;
+ updateDiffCounts(null);
+ setDiffState("error");
+ worker.terminate();
+ }
+
+ return () => {
+ worker.terminate();
+ };
+ }, [
+ editor,
+ content,
+ previousContent,
+ prevHistoryId,
+ persistedDiff,
+ isDiffLoading,
+ isPreviousLoading,
+ isPreviousError,
+ setDiffCounts,
+ setDiffState,
+ ]);
+
+ // Effect 3: apply/toggle decorations
+ useEffect(() => {
+ if (!editor) return;
editor.setOptions({
editorProps: {
...editor.options.editorProps,
decorations: () =>
- highlightChanges ? decorationSet : DecorationSet.empty,
+ highlightChanges ? decorationSetRef.current : DecorationSet.empty,
},
});
- }, [
- title,
- content,
- editor,
- previousContent,
- highlightChanges,
- setDiffCounts,
- ]);
+ }, [editor, highlightChanges, diffState]);
return (
diff --git a/apps/client/src/features/page-history/components/history-modal-body.tsx b/apps/client/src/features/page-history/components/history-modal-body.tsx
index 5f7d511d9..8eae75abe 100644
--- a/apps/client/src/features/page-history/components/history-modal-body.tsx
+++ b/apps/client/src/features/page-history/components/history-modal-body.tsx
@@ -1,6 +1,7 @@
import {
ActionIcon,
Group,
+ Loader,
Paper,
ScrollArea,
Switch,
@@ -13,6 +14,7 @@ import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
+ diffStateAtom,
highlightChangesAtom,
} from "@/features/page-history/atoms/history-atoms";
import HistoryView from "@/features/page-history/components/history-view";
@@ -36,6 +38,7 @@ export default function HistoryModalBody({ pageId }: Props) {
const activeHistoryPrevId = useAtomValue(activeHistoryPrevIdAtom);
const [highlightChanges, setHighlightChanges] = useAtom(highlightChangesAtom);
const diffCounts = useAtomValue(diffCountsAtom);
+ const diffState = useAtomValue(diffStateAtom);
useHistoryReset(pageId);
const { currentChangeIndex, handlePrevChange, handleNextChange } =
@@ -57,6 +60,36 @@ export default function HistoryModalBody({ pageId }: Props) {
viewportRef={scrollViewportRef}
>
+ {activeHistoryId &&
+ (diffState === "computing" ||
+ diffState === "loadingPrevious") && (
+
+
+
+ {diffState === "loadingPrevious"
+ ? t("Loading previous version...")
+ : t("Analyzing changes...")}
+
+
+ )}
+ {activeHistoryId && diffState === "error" && (
+
+
+ {t(
+ "Unable to analyze changes. The document is still available.",
+ )}
+
+
+ )}
+ {activeHistoryId && diffState === "tooLarge" && (
+
+
+ {t(
+ "This version is too large for detailed change highlighting.",
+ )}
+
+
+ )}
{activeHistoryId &&
}
@@ -75,12 +108,24 @@ export default function HistoryModalBody({ pageId }: Props) {
}}
>
- setHighlightChanges(e.currentTarget.checked)}
- styles={{ label: { userSelect: "none", whiteSpace: "nowrap" } }}
- />
+
+ setHighlightChanges(e.currentTarget.checked)}
+ styles={{
+ label: { userSelect: "none", whiteSpace: "nowrap" },
+ }}
+ />
+ {(diffState === "computing" ||
+ diffState === "loadingPrevious") && }
+
{highlightChanges && diffCounts && diffCounts.total > 0 && (
diff --git a/apps/client/src/features/page-history/components/history-view.tsx b/apps/client/src/features/page-history/components/history-view.tsx
index ed8a41f9a..15489daaf 100644
--- a/apps/client/src/features/page-history/components/history-view.tsx
+++ b/apps/client/src/features/page-history/components/history-view.tsx
@@ -1,4 +1,7 @@
-import { usePageHistoryQuery } from "@/features/page-history/queries/page-history-query";
+import {
+ usePageHistoryDiffQuery,
+ usePageHistoryQuery,
+} from "@/features/page-history/queries/page-history-query";
import { HistoryEditor } from "@/features/page-history/components/history-editor";
import { useTranslation } from "react-i18next";
import { useAtomValue } from "jotai";
@@ -17,13 +20,22 @@ function HistoryView() {
isLoading: isLoadingCurrent,
isError: isErrorCurrent,
} = usePageHistoryQuery(historyId);
+ const {
+ data: diffData,
+ isLoading: isLoadingDiff,
+ isError: isErrorDiff,
+ } = usePageHistoryDiffQuery(historyId, !!prevHistoryId);
+ const shouldLoadPrevious =
+ isErrorDiff ||
+ diffData?.status === "failed" ||
+ (diffData?.status === "ready" && diffData.fromHistoryId !== prevHistoryId);
const {
data: prevData,
isLoading: isLoadingPrev,
isError: isErrorPrev,
- } = usePageHistoryQuery(prevHistoryId);
+ } = usePageHistoryQuery(prevHistoryId, shouldLoadPrevious);
- if (isLoadingCurrent || isLoadingPrev) {
+ if (isLoadingCurrent) {
return <>>;
}
@@ -37,6 +49,11 @@ function HistoryView() {
content={data.content}
title={data.title}
previousContent={!isErrorPrev ? prevData?.content : undefined}
+ prevHistoryId={prevHistoryId}
+ persistedDiff={diffData}
+ isDiffLoading={isLoadingDiff}
+ isPreviousLoading={isLoadingPrev}
+ isPreviousError={isErrorPrev}
/>
);
diff --git a/apps/client/src/features/page-history/hooks/use-history-reset.ts b/apps/client/src/features/page-history/hooks/use-history-reset.ts
index 15ae05874..f6b00eabe 100644
--- a/apps/client/src/features/page-history/hooks/use-history-reset.ts
+++ b/apps/client/src/features/page-history/hooks/use-history-reset.ts
@@ -4,21 +4,26 @@ import {
activeHistoryIdAtom,
activeHistoryPrevIdAtom,
diffCountsAtom,
+ diffStateAtom,
} from "@/features/page-history/atoms/history-atoms";
-/**
- * Resets history state when pageId changes.
- * Clears active selection and diff counts.
- */
export function useHistoryReset(pageId: string) {
const [, setActiveHistoryId] = useAtom(activeHistoryIdAtom);
const [, setActiveHistoryPrevId] = useAtom(activeHistoryPrevIdAtom);
const [, setDiffCounts] = useAtom(diffCountsAtom);
+ const [, setDiffState] = useAtom(diffStateAtom);
useEffect(() => {
setActiveHistoryId("");
setActiveHistoryPrevId("");
// @ts-ignore
setDiffCounts(null);
- }, [pageId, setActiveHistoryId, setActiveHistoryPrevId, setDiffCounts]);
+ setDiffState("idle");
+ }, [
+ pageId,
+ setActiveHistoryId,
+ setActiveHistoryPrevId,
+ setDiffCounts,
+ setDiffState,
+ ]);
}
diff --git a/apps/client/src/features/page-history/queries/page-history-query.ts b/apps/client/src/features/page-history/queries/page-history-query.ts
index 0fbfc6c9e..3d33d1e01 100644
--- a/apps/client/src/features/page-history/queries/page-history-query.ts
+++ b/apps/client/src/features/page-history/queries/page-history-query.ts
@@ -7,9 +7,13 @@ import {
} from "@tanstack/react-query";
import {
getPageHistoryById,
+ getPageHistoryDiff,
getPageHistoryList,
} from "@/features/page-history/services/page-history-service";
-import { IPageHistory } from "@/features/page-history/types/page.types";
+import {
+ IPageHistory,
+ IPageHistoryDiff,
+} from "@/features/page-history/types/page.types";
import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main";
@@ -38,11 +42,30 @@ export function usePageHistoryListQuery(
export function usePageHistoryQuery(
historyId: string,
+ enabled = true,
): UseQueryResult {
return useQuery({
queryKey: ["page-history", historyId],
queryFn: () => getPageHistoryById(historyId),
- enabled: !!historyId,
+ enabled: enabled && !!historyId,
staleTime: HISTORY_STALE_TIME,
});
}
+
+export function usePageHistoryDiffQuery(
+ historyId: string,
+ enabled: boolean,
+): UseQueryResult {
+ return useQuery({
+ queryKey: ["page-history-diff", historyId],
+ queryFn: () => getPageHistoryDiff(historyId),
+ enabled: enabled && !!historyId,
+ retry: false,
+ refetchOnWindowFocus: false,
+ staleTime: 0,
+ refetchInterval: (query) => {
+ const status = query.state.data?.status;
+ return status === "pending" || status === "running" ? 1000 : false;
+ },
+ });
+}
diff --git a/apps/client/src/features/page-history/services/page-history-service.ts b/apps/client/src/features/page-history/services/page-history-service.ts
index 0e38118b9..cb0080530 100644
--- a/apps/client/src/features/page-history/services/page-history-service.ts
+++ b/apps/client/src/features/page-history/services/page-history-service.ts
@@ -1,5 +1,8 @@
import api from "@/lib/api-client";
-import { IPageHistory } from "@/features/page-history/types/page.types";
+import {
+ IPageHistory,
+ IPageHistoryDiff,
+} from "@/features/page-history/types/page.types";
import { IPagination } from "@/lib/types.ts";
export async function getPageHistoryList(
@@ -21,3 +24,12 @@ export async function getPageHistoryById(
});
return req.data;
}
+
+export async function getPageHistoryDiff(
+ historyId: string,
+): Promise {
+ const req = await api.post("/pages/history/diff", {
+ historyId,
+ });
+ return req.data;
+}
diff --git a/apps/client/src/features/page-history/types/page.types.ts b/apps/client/src/features/page-history/types/page.types.ts
index 45b2e1f82..f1619421d 100644
--- a/apps/client/src/features/page-history/types/page.types.ts
+++ b/apps/client/src/features/page-history/types/page.types.ts
@@ -20,3 +20,34 @@ export interface IPageHistory {
lastUpdatedBy: IPageHistoryUser;
contributors?: IPageHistoryUser[];
}
+
+export type PageHistoryDiffStatus =
+ | "pending"
+ | "running"
+ | "ready"
+ | "failed"
+ | "too_large";
+
+export interface IPageHistoryDiffChange {
+ fromA: number;
+ toA: number;
+ fromB: number;
+ toB: number;
+ deletedText: string;
+ addedSpecialNode: { pos: number; nodeEnd: number } | null;
+ deletedSpecialNode: { nodeJSON: unknown } | null;
+}
+
+export interface IPageHistoryDiff {
+ status: PageHistoryDiffStatus;
+ fromHistoryId: string | null;
+ toHistoryId: string;
+ algorithmVersion: string;
+ schemaVersion: string;
+ fromContentHash?: string | null;
+ toContentHash?: string | null;
+ changes: IPageHistoryDiffChange[];
+ addedCount: number;
+ deletedCount: number;
+ errorCode?: string | null;
+}
diff --git a/apps/client/src/features/page-history/workers/diff.worker.ts b/apps/client/src/features/page-history/workers/diff.worker.ts
new file mode 100644
index 000000000..02e68c968
--- /dev/null
+++ b/apps/client/src/features/page-history/workers/diff.worker.ts
@@ -0,0 +1,43 @@
+import { Schema } from "@tiptap/pm/model";
+import { createHistoryDiff, HistoryDiffChange } from "@docmost/editor-ext";
+
+export type DiffChange = HistoryDiffChange;
+
+export interface DiffWorkerResult {
+ type: "result";
+ changes: DiffChange[];
+ addedCount: number;
+ deletedCount: number;
+}
+
+export interface DiffWorkerError {
+ type: "error";
+ message: string;
+}
+
+self.onmessage = (e: MessageEvent) => {
+ const { schemaSpec, previousContent, content } = e.data;
+
+ try {
+ const schema = new Schema(schemaSpec);
+ const { changes, addedCount, deletedCount } = createHistoryDiff(
+ schema,
+ previousContent,
+ content,
+ );
+
+ const msg: DiffWorkerResult = {
+ type: "result",
+ changes,
+ addedCount,
+ deletedCount,
+ };
+ (self as any).postMessage(msg);
+ } catch (err: any) {
+ const msg: DiffWorkerError = {
+ type: "error",
+ message: err?.message ?? "Unknown error",
+ };
+ (self as any).postMessage(msg);
+ }
+};
diff --git a/apps/client/src/features/page-visit/components/recent-page-visits.module.css b/apps/client/src/features/page-visit/components/recent-page-visits.module.css
new file mode 100644
index 000000000..4c25770d1
--- /dev/null
+++ b/apps/client/src/features/page-visit/components/recent-page-visits.module.css
@@ -0,0 +1,94 @@
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--mantine-spacing-sm);
+ padding: 10px 12px 6px;
+}
+
+.headerTitle {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0));
+}
+
+.retention {
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ background: light-dark(
+ var(--mantine-color-gray-1),
+ var(--mantine-color-dark-6)
+ );
+ border-radius: var(--mantine-radius-xl);
+ padding: 3px 9px;
+ white-space: nowrap;
+}
+
+.iconTile {
+ width: 32px;
+ height: 32px;
+ display: grid;
+ place-items: center;
+ flex: 0 0 32px;
+ border: 1px solid
+ light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
+ border-radius: var(--mantine-radius-md);
+ background: light-dark(
+ var(--mantine-color-white),
+ var(--mantine-color-dark-7)
+ );
+}
+
+.content {
+ min-width: 0;
+ flex: 1;
+}
+
+.title {
+ color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0));
+}
+
+.meta {
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+}
+
+.time {
+ flex: 0 0 auto;
+ color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
+ white-space: nowrap;
+}
+
+.empty {
+ min-height: 230px;
+ padding: 42px 24px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+}
+
+.emptyIcon {
+ color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
+ background: light-dark(
+ var(--mantine-color-gray-1),
+ var(--mantine-color-dark-6)
+ );
+ border-radius: 50%;
+ padding: 12px;
+ margin-bottom: var(--mantine-spacing-sm);
+}
+
+.skeleton {
+ padding: 6px 12px 12px;
+}
+
+@media (max-width: 36em) {
+ .header {
+ padding-inline: 8px;
+ }
+
+ .time {
+ display: none;
+ }
+}
diff --git a/apps/client/src/features/page-visit/components/recent-page-visits.test.tsx b/apps/client/src/features/page-visit/components/recent-page-visits.test.tsx
new file mode 100644
index 000000000..95b846b4d
--- /dev/null
+++ b/apps/client/src/features/page-visit/components/recent-page-visits.test.tsx
@@ -0,0 +1,104 @@
+import { MantineProvider } from "@mantine/core";
+import { Spotlight } from "@mantine/spotlight";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import { RecentPageVisits } from "./recent-page-visits";
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock("@/lib/time", () => ({
+ timeAgo: () => "5 minutes ago",
+}));
+
+describe("RecentPageVisits", () => {
+ beforeAll(() => {
+ globalThis.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation(() => ({
+ matches: false,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ })),
+ });
+ });
+
+ it("presents recent pages as keyboard-selectable search actions", () => {
+ const now = new Date();
+ render(
+
+
+ undefined}>
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("Recently viewed")).toBeTruthy();
+ expect(screen.getByText("Last 30 days")).toBeTruthy();
+ expect(screen.getByText("Today")).toBeTruthy();
+ expect(screen.getByText("Launch plan")).toBeTruthy();
+ expect(screen.getByText("Product")).toBeTruthy();
+ expect(
+ screen.getByRole("link", { name: /Launch plan/ }).getAttribute("href"),
+ ).toContain("/s/product/p/");
+ });
+
+ it("shows a deliberate empty state instead of a search prompt", () => {
+ render(
+
+
+ undefined}>
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("No recently viewed pages")).toBeTruthy();
+ expect(
+ screen.getByText("Pages you open will appear here for 30 days."),
+ ).toBeTruthy();
+ });
+});
diff --git a/apps/client/src/features/page-visit/components/recent-page-visits.tsx b/apps/client/src/features/page-visit/components/recent-page-visits.tsx
new file mode 100644
index 000000000..a6637fcdb
--- /dev/null
+++ b/apps/client/src/features/page-visit/components/recent-page-visits.tsx
@@ -0,0 +1,140 @@
+import { Group, Skeleton, Stack, Text, VisuallyHidden } from "@mantine/core";
+import { Spotlight } from "@mantine/spotlight";
+import { IconClock, IconHistory } from "@tabler/icons-react";
+import { isToday, isYesterday } from "date-fns";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { buildPageUrl } from "@/features/page/page.utils";
+import { getPageIcon } from "@/lib";
+import { timeAgo } from "@/lib/time";
+import { IRecentPageVisit } from "../types/page-visit.types";
+import classes from "./recent-page-visits.module.css";
+
+interface RecentPageVisitsProps {
+ items: IRecentPageVisit[];
+ isLoading: boolean;
+ isError: boolean;
+ showSpace: boolean;
+}
+
+type VisitGroup = "Today" | "Yesterday" | "Earlier";
+
+function groupVisits(items: IRecentPageVisit[]) {
+ const groups: Record = {
+ Today: [],
+ Yesterday: [],
+ Earlier: [],
+ };
+
+ items.forEach((item) => {
+ const date = new Date(item.lastVisitedAt);
+ if (isToday(date)) groups.Today.push(item);
+ else if (isYesterday(date)) groups.Yesterday.push(item);
+ else groups.Earlier.push(item);
+ });
+
+ return groups;
+}
+
+export function RecentPageVisits({
+ items,
+ isLoading,
+ isError,
+ showSpace,
+}: RecentPageVisitsProps) {
+ const { t } = useTranslation();
+ const groups = groupVisits(items);
+
+ return (
+ <>
+
+
+
+
+ {t("Recently viewed")}
+
+
+
+ {t("Last 30 days")}
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+
+
+
+
+
+
+ ))}
+
+ ) : isError || items.length === 0 ? (
+
+
+
+ {isError
+ ? t("Failed to load recently viewed pages")
+ : t("No recently viewed pages")}
+
+ {!isError && (
+
+ {t("Pages you open will appear here for 30 days.")}
+
+ )}
+
+ ) : (
+ (Object.entries(groups) as [VisitGroup, IRecentPageVisit[]][]).map(
+ ([label, visits]) =>
+ visits.length > 0 && (
+
+ {t(label)}
+ {visits.map((visit) => (
+
+
+
+ {getPageIcon(visit.page.icon || undefined)}
+
+
+
+ {visit.page.title || t("Untitled")}
+
+ {showSpace && (
+
+ {visit.space.name}
+
+ )}
+
+
+ {timeAgo(new Date(visit.lastVisitedAt))}
+
+
+
+ ))}
+
+ ),
+ )
+ )}
+ >
+ );
+}
diff --git a/apps/client/src/features/page-visit/queries/page-visit-query.ts b/apps/client/src/features/page-visit/queries/page-visit-query.ts
new file mode 100644
index 000000000..1d5473a55
--- /dev/null
+++ b/apps/client/src/features/page-visit/queries/page-visit-query.ts
@@ -0,0 +1,16 @@
+import { useQuery } from "@tanstack/react-query";
+import { getRecentPageVisits } from "../services/page-visit-service";
+
+export const RECENT_PAGE_VISITS_QUERY_KEY = "recent-page-visits";
+
+export function useRecentPageVisitsQuery(
+ spaceId: string | undefined,
+ enabled: boolean,
+) {
+ return useQuery({
+ queryKey: [RECENT_PAGE_VISITS_QUERY_KEY, spaceId],
+ queryFn: () => getRecentPageVisits({ spaceId, limit: 15 }),
+ enabled,
+ staleTime: 30_000,
+ });
+}
diff --git a/apps/client/src/features/page-visit/services/page-visit-service.test.ts b/apps/client/src/features/page-visit/services/page-visit-service.test.ts
new file mode 100644
index 000000000..b6bf8413e
--- /dev/null
+++ b/apps/client/src/features/page-visit/services/page-visit-service.test.ts
@@ -0,0 +1,32 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import api from "@/lib/api-client";
+import { getRecentPageVisits, recordPageVisit } from "./page-visit-service";
+
+vi.mock("@/lib/api-client", () => ({
+ default: { post: vi.fn() },
+}));
+
+describe("page visit service", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it("records the page only through the explicit visit endpoint", async () => {
+ vi.mocked(api.post).mockResolvedValue({ data: undefined });
+
+ await recordPageVisit("page-1");
+
+ expect(api.post).toHaveBeenCalledWith("/page-visits", { pageId: "page-1" });
+ });
+
+ it("loads recent visits using the active space scope", async () => {
+ const items = [{ id: "visit-1" }];
+ vi.mocked(api.post).mockResolvedValue({ data: { items } });
+
+ await expect(
+ getRecentPageVisits({ spaceId: "space-1", limit: 15 }),
+ ).resolves.toEqual(items);
+ expect(api.post).toHaveBeenCalledWith("/page-visits/recent", {
+ spaceId: "space-1",
+ limit: 15,
+ });
+ });
+});
diff --git a/apps/client/src/features/page-visit/services/page-visit-service.ts b/apps/client/src/features/page-visit/services/page-visit-service.ts
new file mode 100644
index 000000000..b47b9422a
--- /dev/null
+++ b/apps/client/src/features/page-visit/services/page-visit-service.ts
@@ -0,0 +1,17 @@
+import api from "@/lib/api-client";
+import { IRecentPageVisit } from "../types/page-visit.types";
+
+export async function recordPageVisit(pageId: string): Promise {
+ await api.post("/page-visits", { pageId });
+}
+
+export async function getRecentPageVisits(params: {
+ spaceId?: string;
+ limit?: number;
+}): Promise {
+ const response = await api.post<{ items: IRecentPageVisit[] }>(
+ "/page-visits/recent",
+ params,
+ );
+ return response.data.items;
+}
diff --git a/apps/client/src/features/page-visit/types/page-visit.types.ts b/apps/client/src/features/page-visit/types/page-visit.types.ts
new file mode 100644
index 000000000..45233561d
--- /dev/null
+++ b/apps/client/src/features/page-visit/types/page-visit.types.ts
@@ -0,0 +1,16 @@
+export interface IRecentPageVisit {
+ id: string;
+ pageId: string;
+ lastVisitedAt: string;
+ page: {
+ title: string | null;
+ icon: string | null;
+ slugId: string;
+ };
+ space: {
+ id: string;
+ name: string | null;
+ slug: string;
+ logo: string | null;
+ };
+}
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index ab097bbef..8df0d2f30 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -1,4 +1,12 @@
-import { ActionIcon, Group, Menu, Text, ThemeIcon, Tooltip } from "@mantine/core";
+import {
+ ActionIcon,
+ Badge,
+ Group,
+ Menu,
+ Text,
+ ThemeIcon,
+ Tooltip,
+} from "@mantine/core";
import {
IconArrowRight,
IconArrowsHorizontal,
@@ -17,6 +25,10 @@ import {
IconStarFilled,
IconTrash,
IconWifiOff,
+ IconAlertCircle,
+ IconCircleCheck,
+ IconLoader2,
+ IconCircle,
} from "@tabler/icons-react";
import React, { useEffect, useRef, useState } from "react";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
@@ -28,6 +40,8 @@ import { useParams } from "react-router-dom";
import {
usePageQuery,
usePublishPageKnowledgeMutation,
+ usePagePublishCooldownQuery,
+ usePageCompileStatusQuery,
} from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
@@ -44,7 +58,7 @@ import {
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { formattedDate } from "@/lib/time.ts";
-import { PageEditModeToggle } from "@/features/editor/components/page-edit-mode-toggle.tsx";
+import { PageEditModeControls } from "@/features/editor/components/page-edit-mode-toggle.tsx";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
@@ -63,6 +77,21 @@ import {
useUnwatchPageMutation,
} from "@/features/page/queries/watcher-query";
+const PUBLISH_COOLDOWNS = [10 * 60_000, 30 * 60_000, 2 * 60 * 60_000];
+const publishCooldownKey = (pageId: string) =>
+ `page-publish-cooldown:${pageId}`;
+
+function formatCooldown(ms: number) {
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ if (minutes >= 60) {
+ const hours = Math.floor(minutes / 60);
+ return `${hours}h${minutes % 60 ? ` ${minutes % 60}m` : ""}`;
+ }
+ return `${minutes}m${seconds ? ` ${seconds}s` : ""}`;
+}
+
interface PageHeaderMenuProps {
readOnly?: boolean;
}
@@ -105,7 +134,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<>
- {!readOnly && }
+ {!readOnly && }
@@ -169,6 +198,57 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
const watchPage = useWatchPageMutation();
const unwatchPage = useUnwatchPageMutation();
const publishPageKnowledge = usePublishPageKnowledgeMutation();
+ const { data: serverCooldown, refetch: refetchCooldown } =
+ usePagePublishCooldownQuery(page?.id);
+ const { data: compileStatus, refetch: refetchCompileStatus } =
+ usePageCompileStatusQuery(page?.id);
+ const [publishCooldown, setPublishCooldown] = useState<{
+ expiresAt: number;
+ step: number;
+ } | null>(null);
+ const [now, setNow] = useState(() => Date.now());
+ const publishCooldownRemaining = publishCooldown
+ ? publishCooldown.expiresAt - now
+ : 0;
+ useEffect(() => {
+ if (serverCooldown?.expiresAt) {
+ setPublishCooldown({
+ expiresAt: serverCooldown.expiresAt,
+ step: serverCooldown.step,
+ });
+ }
+ }, [serverCooldown]);
+ const publishCoolingDown = publishCooldownRemaining > 0;
+ const isCompiling = compileStatus?.status === "compiling";
+
+ useEffect(() => {
+ setPublishCooldown(null);
+ if (!page?.id) return;
+ try {
+ const stored = localStorage.getItem(publishCooldownKey(page.id));
+ if (!stored) return;
+ const value = JSON.parse(stored) as { expiresAt?: number; step?: number };
+ if (value.expiresAt && value.step && value.expiresAt > Date.now()) {
+ setPublishCooldown({ expiresAt: value.expiresAt, step: value.step });
+ } else {
+ localStorage.removeItem(publishCooldownKey(page.id));
+ }
+ } catch {
+ // Ignore unavailable or malformed local storage.
+ }
+ }, [page?.id]);
+
+ useEffect(() => {
+ if (!publishCoolingDown) {
+ if (publishCooldown?.step === PUBLISH_COOLDOWNS.length) {
+ localStorage.removeItem(publishCooldownKey(page?.id ?? ""));
+ setPublishCooldown(null);
+ }
+ return;
+ }
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, [publishCoolingDown, publishCooldown, page?.id]);
const handleCopyLink = () => {
const pageUrl =
@@ -212,20 +292,94 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
};
const handlePublishPage = () => {
- if (!page?.id || publishPageKnowledge.isPending) return;
+ if (
+ !page?.id ||
+ publishPageKnowledge.isPending ||
+ publishCoolingDown ||
+ isCompiling
+ )
+ return;
publishPageKnowledge.mutate(page.id, {
onSuccess: () => {
+ const step = (publishCooldown?.step ?? 0) + 1;
+ const expiresAt =
+ Date.now() +
+ PUBLISH_COOLDOWNS[Math.min(step - 1, PUBLISH_COOLDOWNS.length - 1)];
+ setPublishCooldown({ expiresAt, step });
+ localStorage.setItem(
+ publishCooldownKey(page.id),
+ JSON.stringify({ expiresAt, step }),
+ );
notifications.show({ message: t("Page publish started") });
+ void refetchCompileStatus();
},
- onError: () => {
+ onError: (error: any) => {
+ if (error?.response?.status === 429) {
+ void refetchCooldown();
+ }
notifications.show({
- message: t("Failed to publish page"),
+ message:
+ error?.response?.data?.message || t("Failed to publish page"),
color: "red",
});
},
});
};
+ const compileBadge = (() => {
+ const status = compileStatus?.status ?? "not_compiled";
+ const config = {
+ completed: {
+ color: "green",
+ label: t("Compiled"),
+ icon: ,
+ },
+ compiling: {
+ color: "blue",
+ label: t("Compiling"),
+ icon: ,
+ },
+ failed: {
+ color: "red",
+ label: t("Compilation failed"),
+ icon: ,
+ },
+ not_compiled: {
+ color: "gray",
+ label: t("Not compiled"),
+ icon: ,
+ },
+ outdated: {
+ color: "gray",
+ label: t("Needs recompilation"),
+ icon: ,
+ },
+ }[status] ?? {
+ color: "gray",
+ label: t("Not compiled"),
+ icon: ,
+ };
+ return (
+
+
+ {config.label}
+
+
+ );
+ })();
+
return (
<>
+
) : (
)
@@ -294,9 +451,18 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
}
onClick={handlePublishPage}
- disabled={publishPageKnowledge.isPending}
+ disabled={
+ publishPageKnowledge.isPending ||
+ publishCoolingDown ||
+ isCompiling
+ }
+ rightSection={compileBadge}
>
- {t("Publish now")}
+ {publishCoolingDown
+ ? t("Publish available in {{time}}", {
+ time: formatCooldown(publishCooldownRemaining),
+ })
+ : t("Publish now")}
)}
diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts
index a1df3f9f6..dee922e2d 100644
--- a/apps/client/src/features/page/queries/page-query.ts
+++ b/apps/client/src/features/page/queries/page-query.ts
@@ -22,6 +22,8 @@ import {
getDeletedPages,
restorePage,
publishPageKnowledge,
+ getPagePublishCooldown,
+ getPageCompileStatus,
} from "@/features/page/services/page-service";
import {
IMovePage,
@@ -123,6 +125,25 @@ export function usePublishPageKnowledgeMutation() {
});
}
+export function usePagePublishCooldownQuery(pageId?: string) {
+ return useQuery({
+ queryKey: ["page-publish-cooldown", pageId],
+ queryFn: () => getPagePublishCooldown(pageId!),
+ enabled: !!pageId,
+ refetchInterval: 60_000,
+ });
+}
+
+export function usePageCompileStatusQuery(pageId?: string) {
+ return useQuery({
+ queryKey: ["page-compile-status", pageId],
+ queryFn: () => getPageCompileStatus(pageId!),
+ enabled: !!pageId,
+ refetchInterval: (query) =>
+ query.state.data?.status === "compiling" ? 5_000 : false,
+ });
+}
+
export function useRemovePageMutation() {
const { t } = useTranslation();
return useMutation({
@@ -249,7 +270,10 @@ export function useRestorePageMutation() {
queryClient.setQueryData(["pages", restoredPage.slugId], merge);
},
onError: (error) => {
- notifications.show({ message: t("Failed to restore page"), color: "red" });
+ notifications.show({
+ message: t("Failed to restore page"),
+ color: "red",
+ });
},
});
}
@@ -260,10 +284,10 @@ export function useGetSidebarPagesQuery(
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
enabled: !!data?.pageId || !!data?.spaceId,
- queryFn: ({ pageParam }) => getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
+ queryFn: ({ pageParam }) =>
+ getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
initialPageParam: undefined,
- getNextPageParam: (lastPage) =>
- lastPage.meta?.nextCursor ?? undefined,
+ getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -271,11 +295,14 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId],
queryFn: async ({ pageParam }) => {
- return getSidebarPages({ spaceId: data.spaceId, cursor: pageParam, limit: 100 });
+ return getSidebarPages({
+ spaceId: data.spaceId,
+ cursor: pageParam,
+ limit: 100,
+ });
},
initialPageParam: undefined,
- getNextPageParam: (lastPage) =>
- lastPage.meta?.nextCursor ?? undefined,
+ getNextPageParam: (lastPage) => lastPage.meta?.nextCursor ?? undefined,
});
}
@@ -313,11 +340,15 @@ export function useRecentChangesQuery(spaceId?: string) {
});
}
-export function useCreatedByQuery(params?: { userId?: string; spaceId?: string }) {
+export function useCreatedByQuery(params?: {
+ userId?: string;
+ spaceId?: string;
+}) {
const { userId, spaceId } = params ?? {};
return useInfiniteQuery({
queryKey: ["pages-created-by-user", { userId, spaceId }],
- queryFn: ({ pageParam }) => getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
+ queryFn: ({ pageParam }) =>
+ getCreatedByPages({ userId, spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts
index f4e30b0cd..fc90ad738 100644
--- a/apps/client/src/features/page/services/page-service.ts
+++ b/apps/client/src/features/page/services/page-service.ts
@@ -7,13 +7,13 @@ import {
IPage,
IPageInput,
SidebarPagesParams,
-} from '@/features/page/types/page.types';
+} from "@/features/page/types/page.types";
import { QueryParams } from "@/lib/types";
import { IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver";
import { InfiniteData } from "@tanstack/react-query";
-import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
-import { IAttachment } from '@/features/attachments/types/attachment.types.ts';
+import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
+import { IAttachment } from "@/features/attachments/types/attachment.types.ts";
export async function createPage(data: Partial): Promise {
const req = await api.post("/pages/create", data);
@@ -41,6 +41,47 @@ export interface PublishPageKnowledgeResult {
knowledgeGeneration: number;
}
+export interface PagePublishCooldown {
+ step: number;
+ expiresAt: number | null;
+}
+
+export type PageCompileStatus =
+ | "completed"
+ | "compiling"
+ | "failed"
+ | "not_compiled"
+ | "outdated";
+
+export interface PageCompileStatusResult {
+ status: PageCompileStatus;
+ runId?: string;
+ startedAt?: string;
+ finishedAt?: string;
+ failedAt?: string;
+ errorMessage?: string;
+ sourceVersion?: number;
+ publishedSourceVersion?: number;
+}
+
+export async function getPageCompileStatus(
+ pageId: string,
+): Promise {
+ const req = await api.get(
+ `/llm-wiki/pages/${pageId}/compile-status`,
+ );
+ return req.data;
+}
+
+export async function getPagePublishCooldown(
+ pageId: string,
+): Promise {
+ const req = await api.get(
+ `/llm-wiki/pages/${pageId}/publish-cooldown`,
+ );
+ return req.data;
+}
+
export async function publishPageKnowledge(
pageId: string,
): Promise {
@@ -50,7 +91,10 @@ export async function publishPageKnowledge(
return req.data;
}
-export async function deletePage(pageId: string, permanentlyDelete = false): Promise {
+export async function deletePage(
+ pageId: string,
+ permanentlyDelete = false,
+): Promise {
await api.post("/pages/delete", { pageId, permanentlyDelete });
}
@@ -95,7 +139,11 @@ export async function getAllSidebarPages(
const pageParams: (string | undefined)[] = [];
do {
- const req = await api.post("/pages/sidebar-pages", { ...params, cursor, limit: 100 });
+ const req = await api.post("/pages/sidebar-pages", {
+ ...params,
+ cursor,
+ limit: 100,
+ });
const data: IPagination = req.data;
pages.push(data);
diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx
index 1d2aa6a6e..67d3255f0 100644
--- a/apps/client/src/features/search/components/search-spotlight.tsx
+++ b/apps/client/src/features/search/components/search-spotlight.tsx
@@ -1,7 +1,7 @@
-import { Spotlight } from "@mantine/spotlight";
+import { Spotlight, useSpotlight } from "@mantine/spotlight";
import { IconSearch } from "@tabler/icons-react";
import { Group, VisuallyHidden } from "@mantine/core";
-import React, { useState, useMemo } from "react";
+import React, { useState, useMemo, useEffect } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { searchSpotlightStore } from "../constants.ts";
@@ -11,6 +11,8 @@ import {
} from "./search-spotlight-filters.tsx";
import { useUnifiedSearch } from "../hooks/use-unified-search.ts";
import { SearchResultItem } from "./search-result-item.tsx";
+import { useRecentPageVisitsQuery } from "@/features/page-visit/queries/page-visit-query";
+import { RecentPageVisits } from "@/features/page-visit/components/recent-page-visits";
interface SearchSpotlightProps {
spaceId?: string;
@@ -18,6 +20,8 @@ interface SearchSpotlightProps {
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
+ const { opened } = useSpotlight(searchSpotlightStore);
+ const isRecentMode = query.trim().length === 0;
const [debouncedSearchQuery] = useDebouncedValue(query, 300);
const [filters, setFilters] = useState({
contentType: "page",
@@ -53,6 +57,15 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
}, [debouncedSearchQuery, filters]);
const { data: searchResults, isLoading } = useUnifiedSearch(searchParams);
+ const {
+ data: recentVisits = [],
+ isLoading: isRecentLoading,
+ isError: isRecentError,
+ } = useRecentPageVisitsQuery(spaceId, opened && isRecentMode);
+
+ useEffect(() => {
+ if (!opened) setQuery("");
+ }, [opened]);
// Determine result type for rendering
const isAttachmentSearch = filters.contentType === "attachment";
@@ -92,19 +105,17 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
/>
-
-
-
+ {!isRecentMode && (
+
+
+
+ )}
- {query.length > 0 && !isLoading
+ {!isRecentMode && !isLoading
? resultItems.length === 0
? t("No results found")
: t("{{count}} results found", { count: resultItems.length })
@@ -112,15 +123,21 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
- {query.length === 0 && resultItems.length === 0 && (
- {t("Start typing to search...")}
+ {isRecentMode ? (
+
+ ) : (
+ <>
+ {!isLoading && resultItems.length === 0 && (
+ {t("No results found...")}
+ )}
+ {resultItems.length > 0 && <>{resultItems}>}
+ >
)}
-
- {query.length > 0 && !isLoading && resultItems.length === 0 && (
- {t("No results found...")}
- )}
-
- {resultItems.length > 0 && <>{resultItems}>}
>
diff --git a/apps/client/src/features/space/components/add-space-members-modal.tsx b/apps/client/src/features/space/components/add-space-members-modal.tsx
index 79910ae3a..f48b47f8b 100644
--- a/apps/client/src/features/space/components/add-space-members-modal.tsx
+++ b/apps/client/src/features/space/components/add-space-members-modal.tsx
@@ -2,7 +2,7 @@ import { Button, Divider, Group, Modal, Stack } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import React, { useId, useState } from "react";
import { useAddSpaceMemberMutation } from "@/features/space/queries/space-query.ts";
-import { MultiMemberSelect } from "@/features/space/components/multi-member-select.tsx";
+import { OrgTreePicker } from "@/features/space/components/org-tree-picker.tsx";
import { SpaceMemberRole } from "@/features/space/components/space-member-role.tsx";
import { SpaceRole } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
@@ -52,7 +52,7 @@ export default function AddSpaceMembersModal({
return (
<>
{t("Add space members")}
-
+
@@ -63,7 +63,10 @@ export default function AddSpaceMembersModal({
-
+
void;
+}
+
+interface SelectedItem {
+ id: string;
+ label: string;
+ type: "user" | "group";
+}
+
+function filterTree(
+ nodes: IGroupTreeNode[],
+ query: string,
+): { filtered: IGroupTreeNode[]; matchedIds: Set } {
+ const lowerQuery = query.toLowerCase();
+ const matchedIds = new Set();
+
+ function walk(node: IGroupTreeNode): IGroupTreeNode | null {
+ const selfMatch = node.displayName.toLowerCase().includes(lowerQuery) ||
+ node.name.toLowerCase().includes(lowerQuery);
+
+ const filteredChildren: IGroupTreeNode[] = [];
+ for (const child of node.children) {
+ const result = walk(child);
+ if (result) filteredChildren.push(result);
+ }
+
+ if (selfMatch || filteredChildren.length > 0) {
+ if (filteredChildren.length > 0) {
+ matchedIds.add(node.id);
+ }
+ return { ...node, children: filteredChildren };
+ }
+ return null;
+ }
+
+ const filtered: IGroupTreeNode[] = [];
+ for (const node of nodes) {
+ const result = walk(node);
+ if (result) filtered.push(result);
+ }
+ return { filtered, matchedIds };
+}
+
+function GroupTreeNode({
+ node,
+ depth,
+ selectedIds,
+ expandedIds,
+ onToggle,
+ onSelect,
+}: {
+ node: IGroupTreeNode;
+ depth: number;
+ selectedIds: Set;
+ expandedIds: Set;
+ onToggle: (id: string) => void;
+ onSelect: (id: string, type: "user" | "group") => void;
+}) {
+ const expanded = expandedIds.has(node.id);
+ const checked = selectedIds.has(`group-${node.id}`);
+ const indent = 16 + depth * 32;
+
+ return (
+ <>
+
+
+ {
+ e.stopPropagation();
+ onToggle(node.id);
+ }}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ onSelect(node.id, "group")}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+ {node.displayName}
+
+
+ {node.memberCount}
+
+
+
+
+ {expanded && (
+ <>
+ {node.children.map((child) => (
+
+ ))}
+ {node.members.map((user: IGroupTreeMember) => (
+
+
+
+ onSelect(user.id, "user")}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+
+ {user.name}
+
+ {user.email && (
+
+ {user.email}
+
+ )}
+
+
+
+ ))}
+ >
+ )}
+ >
+ );
+}
+
+function UserSearchResults({
+ query,
+ selectedIds,
+ onSelect,
+}: {
+ query: string;
+ selectedIds: Set;
+ onSelect: (id: string, type: "user" | "group") => void;
+}) {
+ const { t } = useTranslation();
+ const { data, isLoading } = useSearchSuggestionsQuery({
+ query,
+ includeUsers: true,
+ includeGroups: false,
+ });
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const users = data?.users ?? [];
+ if (!users.length) return null;
+
+ return (
+ <>
+
+ {t("Users")}
+
+ {users.map((user) => (
+
+
+
+ onSelect(user.id, "user")}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+
+ {user.name}
+
+ {user.email && (
+
+ {user.email}
+
+ )}
+
+
+
+ ))}
+ >
+ );
+}
+
+export function OrgTreePicker({ value, onChange }: OrgTreePickerProps) {
+ const { t } = useTranslation();
+ const [searchValue, setSearchValue] = useState("");
+ const [debouncedQuery] = useDebouncedValue(searchValue, 300);
+ const [manualExpanded, setManualExpanded] = useState>(
+ new Set(),
+ );
+ const [opened, setOpened] = useState(false);
+ const { data: tree, isLoading: treeLoading } = useGroupTreeQuery();
+
+ const selectedIds = useMemo(() => new Set(value), [value]);
+
+ const { displayTree, autoExpandedIds } = useMemo(() => {
+ if (!tree) return { displayTree: [], autoExpandedIds: new Set() };
+ if (!debouncedQuery) {
+ return { displayTree: tree, autoExpandedIds: new Set() };
+ }
+ const { filtered, matchedIds } = filterTree(tree, debouncedQuery);
+ return { displayTree: filtered, autoExpandedIds: matchedIds };
+ }, [tree, debouncedQuery]);
+
+ const expandedIds = useMemo(() => {
+ const merged = new Set(manualExpanded);
+ for (const id of autoExpandedIds) merged.add(id);
+ return merged;
+ }, [manualExpanded, autoExpandedIds]);
+
+ const selectedItems: SelectedItem[] = useMemo(() => {
+ const items: SelectedItem[] = [];
+ for (const v of value) {
+ if (v.startsWith("group-")) {
+ const groupId = v.slice(6);
+ const label = findGroupLabel(tree ?? [], groupId);
+ items.push({ id: v, label: label ?? groupId, type: "group" });
+ } else if (v.startsWith("user-")) {
+ items.push({ id: v, label: v.slice(5), type: "user" });
+ }
+ }
+ return items;
+ }, [value, tree]);
+
+ const handleToggle = useCallback((id: string) => {
+ setManualExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+
+ const handleSelect = useCallback(
+ (id: string, type: "user" | "group") => {
+ const key = `${type}-${id}`;
+ if (selectedIds.has(key)) {
+ onChange(value.filter((v) => v !== key));
+ } else {
+ onChange([...value, key]);
+ }
+ },
+ [value, onChange, selectedIds],
+ );
+
+ const handleRemove = useCallback(
+ (id: string) => {
+ onChange(value.filter((v) => v !== id));
+ },
+ [value, onChange],
+ );
+
+ return (
+
+
+ {t("Add members")}
+
+
+ {selectedItems.length > 0 && (
+
+ {selectedItems.map((item) => (
+
+ ) : undefined
+ }
+ rightSection={
+ handleRemove(item.id)}
+ />
+ }
+ >
+ {item.label}
+
+ ))}
+
+ )}
+
+
+
+ }
+ value={searchValue}
+ onChange={(e) => setSearchValue(e.currentTarget.value)}
+ onFocus={() => setOpened(true)}
+ variant="filled"
+ />
+
+
+
+
+ {treeLoading ? (
+
+
+
+ ) : displayTree.length > 0 ? (
+ <>
+ {displayTree.map((node) => (
+
+ ))}
+ {debouncedQuery && (
+
+ )}
+ >
+ ) : debouncedQuery ? (
+ <>
+
+ {t("No groups found")}
+
+
+ >
+ ) : (
+
+ {t("No groups")}
+
+ )}
+
+
+
+
+ );
+}
+
+function findGroupLabel(
+ nodes: IGroupTreeNode[],
+ id: string,
+): string | undefined {
+ for (const node of nodes) {
+ if (node.id === id) return node.displayName;
+ const found = findGroupLabel(node.children, id);
+ if (found) return found;
+ }
+ return undefined;
+}
diff --git a/apps/client/src/features/space/components/settings-modal.test.tsx b/apps/client/src/features/space/components/settings-modal.test.tsx
deleted file mode 100644
index 1dee82c5f..000000000
--- a/apps/client/src/features/space/components/settings-modal.test.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { MantineProvider } from "@mantine/core";
-import { fireEvent, render, screen } from "@testing-library/react";
-import { beforeAll, describe, expect, it, vi } from "vitest";
-import SpaceSettingsModal from "./settings-modal";
-
-vi.mock("react-i18next", () => ({
- useTranslation: () => ({ t: (key: string) => key }),
-}));
-
-vi.mock("@/features/space/queries/space-query.ts", () => ({
- useSpaceQuery: () => ({
- data: {
- id: "space-1",
- name: "Engineering",
- slug: "engineering",
- settings: {},
- membership: { permissions: [] },
- },
- isLoading: false,
- }),
- useUpdateSpaceMutation: () => ({ mutateAsync: vi.fn() }),
-}));
-
-vi.mock("@/features/space/permissions/use-space-ability.ts", () => ({
- useSpaceAbility: () => ({ can: () => true, cannot: () => false }),
-}));
-
-vi.mock("@/hooks/use-user-role.tsx", () => ({
- default: () => ({ isOwner: false }),
-}));
-
-vi.mock("@/features/space/components/space-members.tsx", () => ({
- default: () => null,
-}));
-
-vi.mock("@/features/space/components/add-space-members-modal.tsx", () => ({
- default: () => null,
-}));
-
-vi.mock("@/features/space/components/space-details.tsx", () => ({
- default: () => null,
-}));
-
-vi.mock("@/features/space/components/space-security-settings.tsx", () => ({
- default: () => null,
-}));
-
-describe("SpaceSettingsModal compilation review", () => {
- beforeAll(() => {
- global.ResizeObserver = class ResizeObserver {
- observe() {}
- unobserve() {}
- disconnect() {}
- };
- Object.defineProperty(window, "matchMedia", {
- writable: true,
- value: vi.fn().mockImplementation((query) => ({
- matches: false,
- media: query,
- onchange: null,
- addListener: vi.fn(),
- removeListener: vi.fn(),
- addEventListener: vi.fn(),
- removeEventListener: vi.fn(),
- dispatchEvent: vi.fn(),
- })),
- });
- });
-
- it("shows a default-off compilation review toggle to space settings managers", () => {
- render(
-
-
- ,
- );
-
- fireEvent.click(screen.getByRole("tab", { name: "Settings" }));
-
- const toggle = screen.getByRole("switch", {
- name: "Toggle compilation review",
- });
- expect((toggle as HTMLInputElement).checked).toBe(false);
- });
-});
diff --git a/apps/client/src/features/space/components/settings-modal.tsx b/apps/client/src/features/space/components/settings-modal.tsx
index 14cabc0fa..ce7b4c1b6 100644
--- a/apps/client/src/features/space/components/settings-modal.tsx
+++ b/apps/client/src/features/space/components/settings-modal.tsx
@@ -12,7 +12,6 @@ import {
} from "@/features/space/permissions/permissions.type.ts";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
-import SpaceCompilationReviewToggle from "@/ee/llm-wiki/components/space-compilation-review-toggle";
interface SpaceSettingsModalProps {
spaceId: string;
@@ -78,9 +77,6 @@ export default function SpaceSettingsModal({
- {canManageSettings && space && (
-
- )}
diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.test.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.test.tsx
index 969ccb402..f0952f854 100644
--- a/apps/client/src/features/space/components/sidebar/space-sidebar.test.tsx
+++ b/apps/client/src/features/space/components/sidebar/space-sidebar.test.tsx
@@ -5,10 +5,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { SpaceSidebar } from "./space-sidebar";
const featureAccess = vi.hoisted(() => ({ enabled: true }));
-const reviewAccess = vi.hoisted(() => ({
- enabled: false,
- canManageSettings: true,
-}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
@@ -40,11 +36,7 @@ vi.mock("@/features/space/queries/space-query.ts", () => ({
name: "AIM",
slug: "aim",
logo: null,
- settings: {
- knowledge: {
- compilationReviewEnabled: reviewAccess.enabled,
- },
- },
+ settings: {},
membership: { permissions: [] },
},
}),
@@ -52,8 +44,8 @@ vi.mock("@/features/space/queries/space-query.ts", () => ({
vi.mock("@/features/space/permissions/use-space-ability.ts", () => ({
useSpaceAbility: () => ({
- can: () => reviewAccess.canManageSettings,
- cannot: () => !reviewAccess.canManageSettings,
+ can: () => true,
+ cannot: () => false,
}),
}));
@@ -137,63 +129,6 @@ describe("SpaceSidebar", () => {
beforeEach(() => {
featureAccess.enabled = true;
- reviewAccess.enabled = false;
- reviewAccess.canManageSettings = true;
- });
-
- it("hides compilation review until it is enabled for the space", () => {
- render(
-
-
-
- } />
-
-
- ,
- );
-
- expect(
- screen.queryByRole("link", { name: "Compilation content review" }),
- ).toBeNull();
- });
-
- it("shows compilation review to space settings managers when enabled", () => {
- reviewAccess.enabled = true;
-
- render(
-
-
-
- } />
-
-
- ,
- );
-
- expect(
- screen
- .getByRole("link", { name: "Compilation content review" })
- .getAttribute("href"),
- ).toBe("/s/aim/review");
- });
-
- it("hides compilation review from non-admin space members", () => {
- reviewAccess.enabled = true;
- reviewAccess.canManageSettings = false;
-
- render(
-
-
-
- } />
-
-
- ,
- );
-
- expect(
- screen.queryByRole("link", { name: "Compilation content review" }),
- ).toBeNull();
});
it("does not show the relationship graph entry in the space navigation", () => {
diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
index 02f99d2ce..5c8d8860b 100644
--- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
+++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
@@ -8,7 +8,6 @@ import {
} from "@mantine/core";
import {
IconArrowDown,
- IconChecklist,
IconDots,
IconEye,
IconEyeOff,
@@ -79,13 +78,6 @@ export function SpaceSidebar() {
return <>>;
}
- const canManageSettings = spaceAbility.can(
- SpaceCaslAction.Manage,
- SpaceCaslSubject.Settings,
- );
- const compilationReviewEnabled =
- space.settings?.knowledge?.compilationReviewEnabled === true;
-
function handleCreatePage() {
handleCreate(null);
}
@@ -151,34 +143,6 @@ export function SpaceSidebar() {
- {canManageSettings && compilationReviewEnabled && (
-