Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdb6f39a8e | |||
| 6475cb81e0 | |||
| 51925e955f | |||
| 8978d69f3e | |||
| d78b985062 | |||
| a4fc6c7f64 | |||
| c252068672 | |||
| 68caf8157a | |||
| cb9c5dda59 | |||
| e431b33bb1 |
@@ -202,6 +202,13 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Default 900000 (15 min).
|
# Default 900000 (15 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||||
|
|
||||||
|
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
||||||
|
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
||||||
|
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
|
||||||
|
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
|
||||||
|
# active" behavior.
|
||||||
|
# AI_CHAT_DEFERRED_TOOLS=true
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^3.0.208",
|
"@ai-sdk/react": "^3.0.208",
|
||||||
|
"@braintree/sanitize-url": "7.1.2",
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
||||||
|
|||||||
+58
-24
@@ -1,38 +1,72 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
import Layout from "@/components/layouts/global/layout.tsx";
|
||||||
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
|
|
||||||
|
// ShareLayout is route-split: its ShareShell chrome pulls in the table of
|
||||||
|
// contents (and thus TipTap), so keeping it out of the eager graph removes the
|
||||||
|
// editor engine from startup for authenticated users too.
|
||||||
|
const ShareLayout = lazy(
|
||||||
|
() => import("@/features/share/components/share-layout.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth / entry pages stay eager: they are the first paint for an unauthenticated
|
||||||
|
// visitor (e.g. /login) and are already small, so code-splitting them would only
|
||||||
|
// add a cold-chunk round trip to the most common cold-start path.
|
||||||
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
||||||
import LoginPage from "@/pages/auth/login";
|
import LoginPage from "@/pages/auth/login";
|
||||||
import Home from "@/pages/dashboard/home";
|
|
||||||
import Page from "@/pages/page/page";
|
|
||||||
import AccountSettings from "@/pages/settings/account/account-settings";
|
|
||||||
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
|
|
||||||
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
|
|
||||||
import AiSettings from "@/pages/settings/workspace/ai-settings";
|
|
||||||
import Groups from "@/pages/settings/group/groups";
|
|
||||||
import GroupInfo from "./pages/settings/group/group-info";
|
|
||||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
|
||||||
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
|
||||||
import SpaceHome from "@/pages/space/space-home.tsx";
|
|
||||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
|
||||||
import Layout from "@/components/layouts/global/layout.tsx";
|
|
||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
import PasswordReset from "./pages/auth/password-reset";
|
||||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
|
||||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
|
||||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
|
||||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
// Heavy / leaf pages are route-split with React.lazy so their code (most
|
||||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
|
||||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
// the page editor and the readonly share editor pull in) is fetched only when
|
||||||
import LabelPage from "@/pages/label/label-page";
|
// the matching route is actually visited. The <Suspense> boundaries live inside
|
||||||
|
// each Layout (around its <Outlet/>), so the app shell stays mounted while a
|
||||||
|
// route chunk loads.
|
||||||
|
const Home = lazy(() => import("@/pages/dashboard/home"));
|
||||||
|
const Page = lazy(() => import("@/pages/page/page"));
|
||||||
|
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
|
||||||
|
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
|
||||||
|
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
|
||||||
|
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
|
||||||
|
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
||||||
|
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
|
||||||
|
|
||||||
|
const AccountSettings = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-settings"),
|
||||||
|
);
|
||||||
|
const AccountPreferences = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||||
|
);
|
||||||
|
const WorkspaceSettings = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||||
|
);
|
||||||
|
const AiSettings = lazy(() => import("@/pages/settings/workspace/ai-settings"));
|
||||||
|
const WorkspaceMembers = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-members"),
|
||||||
|
);
|
||||||
|
const Groups = lazy(() => import("@/pages/settings/group/groups"));
|
||||||
|
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
|
||||||
|
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
|
||||||
|
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useTrackOrigin();
|
useTrackOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="100vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
@@ -83,6 +117,6 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="*" element={<Error404 />} />
|
<Route path="*" element={<Error404 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||||
|
|
||||||
|
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||||
|
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||||
|
// recovery UI, no reload). A false negative on a real chunk failure re-blanks the
|
||||||
|
// app; a false positive would auto-reload on an ordinary error. Pin both sides.
|
||||||
|
describe("isChunkLoadError", () => {
|
||||||
|
it("detects the ChunkLoadError name", () => {
|
||||||
|
expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"Failed to fetch dynamically imported module: https://x/assets/index-abc.js",
|
||||||
|
"error loading dynamically imported module",
|
||||||
|
"Importing a module script failed.",
|
||||||
|
])("detects the dynamic-import failure message %#", (message) => {
|
||||||
|
expect(isChunkLoadError({ name: "TypeError", message })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive on the message", () => {
|
||||||
|
expect(
|
||||||
|
isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{ name: "TypeError", message: "Cannot read properties of undefined" },
|
||||||
|
{ message: "Network request failed" },
|
||||||
|
new Error("some ordinary render error"),
|
||||||
|
])("returns false for a non-chunk error %#", (err) => {
|
||||||
|
expect(isChunkLoadError(err)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||||
|
|
||||||
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
|
// replaces the hashed chunks, a tab left open on the old index.html requests a
|
||||||
|
// chunk URL that now 404s, and React.lazy rejects. Browsers / Vite surface these
|
||||||
|
// with a ChunkLoadError name or one of these messages.
|
||||||
|
export function isChunkLoadError(error: unknown): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
const name = (error as { name?: string }).name ?? "";
|
||||||
|
const message = (error as { message?: string }).message ?? "";
|
||||||
|
return (
|
||||||
|
name === "ChunkLoadError" ||
|
||||||
|
/Failed to fetch dynamically imported module/i.test(message) ||
|
||||||
|
/error loading dynamically imported module/i.test(message) ||
|
||||||
|
/Importing a module script failed/i.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(error: unknown) {
|
||||||
|
if (!isChunkLoadError(error)) return;
|
||||||
|
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||||
|
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
||||||
|
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
||||||
|
// flag is already set we fall through to the manual recovery UI below.
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
||||||
|
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||||
|
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root-level boundary that sits ABOVE every route-level Suspense boundary so a
|
||||||
|
// lazy route/component chunk failure is caught here instead of unmounting the
|
||||||
|
// whole tree into a blank white screen. Per-feature ErrorBoundaries (page.tsx,
|
||||||
|
// transclusion, page-embed) remain in place underneath for their local errors.
|
||||||
|
export function ChunkLoadErrorBoundary({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
onError={handleError}
|
||||||
|
fallbackRender={({ error }) => {
|
||||||
|
const chunk = isChunkLoadError(error);
|
||||||
|
return (
|
||||||
|
<Center h="100vh" p="md">
|
||||||
|
<Stack align="center" gap="sm" maw={420}>
|
||||||
|
<Text fw={600}>
|
||||||
|
{chunk ? "A new version is available" : "Something went wrong"}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{chunk
|
||||||
|
? "Please reload the page to load the latest version."
|
||||||
|
: "An unexpected error occurred. Reloading the page may help."}
|
||||||
|
</Text>
|
||||||
|
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
import { AppShell, Container } from "@mantine/core";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
import {
|
import {
|
||||||
APP_NAVBAR_ID,
|
APP_NAVBAR_ID,
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||||
@@ -14,8 +15,6 @@ import {
|
|||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
|
||||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
|
||||||
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
@@ -23,6 +22,21 @@ import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
|||||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||||
|
|
||||||
|
// Lazily load the AI chat window so the AI SDK runtime it pulls in is fetched
|
||||||
|
// only after the user first opens the chat, instead of for every authenticated
|
||||||
|
// user on load. The window itself renders null while closed, so there is no
|
||||||
|
// behavior difference — it simply is not mounted until first opened.
|
||||||
|
const AiChatWindow = React.lazy(
|
||||||
|
() => import("@/features/ai-chat/components/ai-chat-window.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The right aside hosts the comment panel and table of contents, both of which
|
||||||
|
// pull in TipTap. It only ever renders on page routes, so lazy-loading it keeps
|
||||||
|
// the whole editor engine out of the eager global-shell startup graph.
|
||||||
|
const Aside = React.lazy(
|
||||||
|
() => import("@/components/layouts/global/aside.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -37,6 +51,15 @@ export default function GlobalAppShell({
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef(null);
|
const sidebarRef = useRef(null);
|
||||||
|
|
||||||
|
// Latch: once the AI chat window has been opened, keep it mounted so an
|
||||||
|
// in-flight stream is never torn down. Before the first open the AI chat chunk
|
||||||
|
// is never fetched.
|
||||||
|
const aiChatOpen = useAtomValue(aiChatWindowOpenAtom);
|
||||||
|
const [aiChatEverOpened, setAiChatEverOpened] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiChatOpen) setAiChatEverOpened(true);
|
||||||
|
}, [aiChatOpen]);
|
||||||
|
|
||||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
mouseDownEvent.preventDefault();
|
mouseDownEvent.preventDefault();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
@@ -160,13 +183,21 @@ export default function GlobalAppShell({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Aside />
|
<Suspense fallback={null}>
|
||||||
|
<Aside />
|
||||||
|
</Suspense>
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
{/* Floating AI chat window. Mounted once globally on first open; it is
|
||||||
and self-hides when closed, so its place in the tree is not critical. */}
|
position: fixed and self-hides when closed, so its place in the tree is
|
||||||
<AiChatWindow />
|
not critical. Kept mounted after the first open so a live stream is not
|
||||||
|
aborted. */}
|
||||||
|
{aiChatEverOpened && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AiChatWindow />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||||
createPageWithRecording on window.gitmost so the native host can
|
createPageWithRecording on window.gitmost so the native host can
|
||||||
create a page with a recording even when no page editor is open. */}
|
create a page with a recording even when no page editor is open. */}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { Suspense, useEffect } from "react";
|
||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet, useParams } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -8,10 +10,39 @@ export default function Layout() {
|
|||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
|
// Warm the (now route-split) editor chunk during idle time on authenticated
|
||||||
|
// routes, so the first navigation to a page renders from cache instead of a
|
||||||
|
// cold chunk fetch. Best-effort: gated on requestIdleCallback and never blocks
|
||||||
|
// startup — the dynamic import mirrors the App.tsx route lazy loader so both
|
||||||
|
// resolve to the same chunk.
|
||||||
|
useEffect(() => {
|
||||||
|
const ric =
|
||||||
|
typeof window !== "undefined" && (window as any).requestIdleCallback;
|
||||||
|
const warm = () => {
|
||||||
|
// Best-effort prefetch: a failed warm-up (offline, stale 404) is harmless
|
||||||
|
// and must not surface as an unhandledrejection.
|
||||||
|
void import("@/pages/page/page").catch(() => {});
|
||||||
|
};
|
||||||
|
if (ric) {
|
||||||
|
const id = ric(warm);
|
||||||
|
return () => (window as any).cancelIdleCallback?.(id);
|
||||||
|
}
|
||||||
|
const timer = setTimeout(warm, 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
<SearchSpotlight spaceId={space?.id} />
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// The fallback path renders the full TipTap editor; stub it so we can assert the
|
||||||
|
// safety valve fired without pulling in the editor stack.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor-fallback" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mention rendering hits react-query; stub the page/share queries so the mention
|
||||||
|
// case renders in isolation.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CommentContentView } from "./comment-content-view";
|
||||||
|
|
||||||
|
function renderView(content: string | object) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommentContentView content={content} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = (content: any[]) => JSON.stringify({ type: "doc", content });
|
||||||
|
const para = (content: any[]) => ({ type: "paragraph", content });
|
||||||
|
const text = (t: string, marks?: any[]) => ({ type: "text", text: t, marks });
|
||||||
|
|
||||||
|
describe("CommentContentView", () => {
|
||||||
|
it("renders paragraphs as <p> with text", () => {
|
||||||
|
const { container } = renderView(doc([para([text("Hello world")])]));
|
||||||
|
expect(screen.getByText("Hello world")).toBeDefined();
|
||||||
|
expect(container.querySelector("p")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the read-only CommentEditor DOM nesting for CSS parity", () => {
|
||||||
|
const { container } = renderView(doc([para([text("x")])]));
|
||||||
|
// outer .commentEditor > .ProseMirror (module) > .ProseMirror (global) > p
|
||||||
|
const globalPm = container.querySelector("div.ProseMirror > p");
|
||||||
|
expect(globalPm).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the bold mark as <strong>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("bold", [{ type: "bold" }])])]),
|
||||||
|
);
|
||||||
|
const el = container.querySelector("strong");
|
||||||
|
expect(el?.textContent).toBe("bold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the italic mark as <em>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("it", [{ type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("em")?.textContent).toBe("it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the strike mark as <s>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("st", [{ type: "strike" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("s")?.textContent).toBe("st");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the underline mark as <u> (not the editor fallback)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("un", [{ type: "underline" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("u")?.textContent).toBe("un");
|
||||||
|
// Underline is a supported mark, so no degrade to the editor fallback.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the code mark as <code>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("co", [{ type: "code" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("code")?.textContent).toBe("co");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the link mark as an anchor with safe rel/target", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "https://example.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a?.getAttribute("href")).toBe("https://example.com");
|
||||||
|
expect(a?.getAttribute("target")).toBe("_blank");
|
||||||
|
expect(a?.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a javascript: link href (stored XSS) while keeping the text", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "javascript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
// No navigable javascript: href — attribute is absent (or empty).
|
||||||
|
expect(a?.getAttribute("href")).toBeFalsy();
|
||||||
|
// The link text is still rendered.
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a control-char-obfuscated javascript: href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{ type: "link", attrs: { href: "java\tscript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a data: link href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
attrs: { href: "data:text/html,<script>alert(1)</script>" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a mailto: link href (allowlisted scheme)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("mail", [
|
||||||
|
{ type: "link", attrs: { href: "mailto:a@b.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"mailto:a@b.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a relative link href (no scheme, not a script vector)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("rel", [{ type: "link", attrs: { href: "/some/path" } }]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"/some/path",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests multiple marks on one text node", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("x", [{ type: "bold" }, { type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
// bold wraps italic (or vice versa) — both elements exist around the text.
|
||||||
|
expect(container.querySelector("strong")).not.toBeNull();
|
||||||
|
expect(container.querySelector("em")).not.toBeNull();
|
||||||
|
expect(screen.getByText("x")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hardBreak as <br/>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("a"), { type: "hardBreak" }, text("b")])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("br")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a user mention as a styled span", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: { label: "Alice", entityType: "user", entityId: "u1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("@Alice")).toBeDefined();
|
||||||
|
// No fallback to the editor.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a page mention as a link", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: {
|
||||||
|
label: "Some Page",
|
||||||
|
entityType: "page",
|
||||||
|
slugId: "pg1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Some Page")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a legacy plain-text (non-JSON) string as plain text", () => {
|
||||||
|
renderView("just a legacy string");
|
||||||
|
expect(screen.getByText("just a legacy string")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for an unknown node type", () => {
|
||||||
|
renderView(doc([{ type: "codeBlock", content: [text("x")] }]));
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for malformed JSON", () => {
|
||||||
|
renderView('{"type":"doc","content":[');
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from "react";
|
||||||
|
import classes from "./comment.module.css";
|
||||||
|
import { MentionContent } from "@/features/editor/components/mention/mention-view";
|
||||||
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
|
||||||
|
// Static, editor-free renderer of a comment body (ProseMirror JSON). It walks the
|
||||||
|
// document and emits plain DOM, avoiding the cost of a full TipTap/ProseMirror
|
||||||
|
// instance per comment (the panel used to spin up 400+ editors on mount).
|
||||||
|
//
|
||||||
|
// The supported node/mark set MUST mirror what CommentEditor enables
|
||||||
|
// (StarterKit + Mention + LinkExtension). Anything outside that set makes the
|
||||||
|
// whole comment degrade to the read-only CommentEditor via the fallback below,
|
||||||
|
// so we never show a half-rendered comment.
|
||||||
|
|
||||||
|
// Sentinel thrown when we hit a node/mark we don't know how to render statically.
|
||||||
|
// Caught at the top level to trigger the CommentEditor fallback for the whole comment.
|
||||||
|
class UnknownNodeError extends Error {}
|
||||||
|
|
||||||
|
// Protocol allowlist mirroring @tiptap/extension-link's default (the read-only
|
||||||
|
// CommentEditor path relies on it to blank javascript:/data: hrefs). The static
|
||||||
|
// renderer must apply the SAME sanitization because the backend stores comment
|
||||||
|
// content verbatim and React does not neutralize javascript: in an href.
|
||||||
|
const ALLOWED_URI_SCHEMES = /^(?:https?|ftps?|mailto|tel|callto|sms|cid|xmpp):/i;
|
||||||
|
|
||||||
|
function safeHref(href: unknown): string | undefined {
|
||||||
|
if (typeof href !== "string") return undefined;
|
||||||
|
// Strip control chars/whitespace that could smuggle a scheme past the test
|
||||||
|
// (e.g. "java\tscript:").
|
||||||
|
const cleaned = href.replace(/[\u0000-\u0020]/g, "").trim();
|
||||||
|
// Allow relative/anchor/protocol-relative links (no scheme) — not script vectors.
|
||||||
|
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return href;
|
||||||
|
return ALLOWED_URI_SCHEMES.test(cleaned) ? href : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMMark {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMNode {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
content?: PMNode[];
|
||||||
|
text?: string;
|
||||||
|
marks?: PMMark[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap a text node's string in its marks (marks nest, e.g. bold + italic).
|
||||||
|
function renderMarks(
|
||||||
|
text: React.ReactNode,
|
||||||
|
marks: PMMark[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!marks || marks.length === 0) return text;
|
||||||
|
|
||||||
|
return marks.reduce<React.ReactNode>((acc, mark, i) => {
|
||||||
|
const key = `${keyPrefix}-m${i}`;
|
||||||
|
switch (mark.type) {
|
||||||
|
case "bold":
|
||||||
|
return <strong key={key}>{acc}</strong>;
|
||||||
|
case "italic":
|
||||||
|
return <em key={key}>{acc}</em>;
|
||||||
|
case "strike":
|
||||||
|
return <s key={key}>{acc}</s>;
|
||||||
|
case "underline":
|
||||||
|
// StarterKit enables the Underline extension by default (Mod-u) and
|
||||||
|
// CommentEditor does not disable it, so real comments can carry this
|
||||||
|
// mark. Render it here rather than degrading the whole comment.
|
||||||
|
return <u key={key}>{acc}</u>;
|
||||||
|
case "code":
|
||||||
|
return <code key={key}>{acc}</code>;
|
||||||
|
case "link": {
|
||||||
|
// LinkExtension (TiptapLink) opens links in a new tab; keep the same
|
||||||
|
// safe rel semantics the editor produces. Sanitize the href against the
|
||||||
|
// extension's protocol allowlist — a disallowed scheme (javascript:,
|
||||||
|
// data:) yields undefined so the anchor is non-navigable but still shows
|
||||||
|
// its text, matching how extension-link blanks a bad href.
|
||||||
|
const href = safeHref(mark.attrs?.href);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
>
|
||||||
|
{acc}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown mark type: ${mark.type}`);
|
||||||
|
}
|
||||||
|
}, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: PMNode, key: string): React.ReactNode {
|
||||||
|
switch (node.type) {
|
||||||
|
case "paragraph":
|
||||||
|
return <p key={key}>{renderChildren(node.content, key)}</p>;
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
{renderMarks(node.text ?? "", node.marks, key)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
case "hardBreak":
|
||||||
|
return <br key={key} />;
|
||||||
|
case "mention":
|
||||||
|
return (
|
||||||
|
<span key={key} style={{ display: "inline" }}>
|
||||||
|
<MentionContent attrs={node.attrs as any} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown node type: ${node.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChildren(
|
||||||
|
content: PMNode[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!content) return null;
|
||||||
|
return content.map((child, i) => renderNode(child, `${keyPrefix}-${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduce the exact DOM nesting the read-only CommentEditor renders so the
|
||||||
|
// scoped CSS in comment.module.css (which targets
|
||||||
|
// `.commentEditor .ProseMirror :global(.ProseMirror)` and `.ProseMirror p`)
|
||||||
|
// applies pixel-for-pixel. Read-only => no data-editable / data-surface attrs.
|
||||||
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={classes.commentEditor}>
|
||||||
|
<div className={classes.ProseMirror}>
|
||||||
|
<div className="ProseMirror">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentContentViewProps {
|
||||||
|
content: string | object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentContentView({ content }: CommentContentViewProps) {
|
||||||
|
// Degrade this single comment to the old editor-based render (safety valve).
|
||||||
|
const fallback = () => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(
|
||||||
|
"CommentContentView: unsupported comment content, falling back to editor",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <CommentEditor defaultContent={content} editable={false} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc: unknown = content;
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
// Looks like it was meant to be JSON but is malformed -> safety-valve fallback.
|
||||||
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
// Otherwise it's a legacy plain-text comment: render as a single paragraph.
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{content}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-stringified / legacy plain-text stored as a JSON string.
|
||||||
|
if (typeof doc === "string") {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{doc}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pmDoc = doc as PMNode;
|
||||||
|
if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
|
||||||
|
throw new UnknownNodeError("Not a ProseMirror doc");
|
||||||
|
}
|
||||||
|
return <Shell>{renderChildren(pmDoc.content, "n")}</Shell>;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnknownNodeError) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommentContentView;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
@@ -9,10 +9,11 @@ import { IComment } from "@/features/comment/types/comment.types";
|
|||||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
const applyMutateAsync = vi.fn();
|
const applyMutateAsync = vi.fn();
|
||||||
const dismissMutateAsync = vi.fn();
|
const dismissMutateAsync = vi.fn();
|
||||||
|
const updateMutateAsync = vi.fn();
|
||||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
|
||||||
useApplySuggestionMutation: () => ({
|
useApplySuggestionMutation: () => ({
|
||||||
mutateAsync: applyMutateAsync,
|
mutateAsync: applyMutateAsync,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
@@ -23,9 +24,51 @@ vi.mock("@/features/comment/queries/comment-query", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The document the mocked editor emits via onUpdate when the edit form is open.
|
||||||
|
// Duplicated inside the mock factory (below) to keep the factory self-contained.
|
||||||
|
const EDITED_DOC = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
|
||||||
default: () => <div data-testid="comment-editor" />,
|
// so the edit->save/cancel flow can be driven without a live editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
default: ({ onUpdate, onSave }: any) => (
|
||||||
|
<div data-testid="comment-editor">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-update"
|
||||||
|
onClick={() => onUpdate?.(doc)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-save"
|
||||||
|
onClick={() => onSave?.()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// CommentContentView (used for the read-only body) imports the mention view,
|
||||||
|
// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
|
||||||
|
// renders in isolation without the app entry side-effect.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import CommentListItem from "./comment-list-item";
|
import CommentListItem from "./comment-list-item";
|
||||||
@@ -286,3 +329,132 @@ describe("canShowDismiss predicate", () => {
|
|||||||
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
|
||||||
|
const body = (t: string) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The edit menu item is gated on the viewer owning the comment
|
||||||
|
// (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
|
||||||
|
// so seed localStorage to make the viewer the owner (creatorId "user-1").
|
||||||
|
beforeEach(() => {
|
||||||
|
updateMutateAsync.mockClear();
|
||||||
|
localStorage.setItem(
|
||||||
|
"currentUser",
|
||||||
|
JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openEditor() {
|
||||||
|
// Open the comment menu, then click "Edit comment" to toggle into edit mode.
|
||||||
|
fireEvent.click(screen.getByLabelText("Comment menu"));
|
||||||
|
fireEvent.click(await screen.findByText("Edit comment"));
|
||||||
|
// Edit form (mocked editor + actions) is now mounted.
|
||||||
|
await screen.findByTestId("comment-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("saves the edited content and, on cache update, shows the new body", async () => {
|
||||||
|
const { rerender } = renderItem(
|
||||||
|
baseComment({ content: body("original body") }),
|
||||||
|
);
|
||||||
|
// Static body first.
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Editor emits an update (populates editContentRef), then Save is clicked.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
// mutateAsync is called with the stringified edited doc.
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(EDITED_DOC),
|
||||||
|
});
|
||||||
|
|
||||||
|
// On success the form closes (isEditing -> false); the static body renders
|
||||||
|
// from the comment.content prop again.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate the cache invalidation swapping in a new comment object with the
|
||||||
|
// updated content — the static body reflects it.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentListItem
|
||||||
|
comment={baseComment({ content: body("updated body after save") })}
|
||||||
|
pageId="page-1"
|
||||||
|
canComment={true}
|
||||||
|
canEdit={true}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("updated body after save")).toBeDefined();
|
||||||
|
expect(screen.queryByText("original body")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel restores the static body and does not call the update mutation", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Type something (editContentRef set), then cancel.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
// Editor unmounts, static body restored, no save happened.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
expect(updateMutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
|
||||||
|
// Cancel path clears editContentRef...
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...so re-opening and saving WITHOUT an update falls back to comment.content.
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(body("original body")),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — read-only body renders statically", () => {
|
||||||
|
it("renders the comment body as static text without a TipTap editor", () => {
|
||||||
|
renderItem(
|
||||||
|
baseComment({
|
||||||
|
content: JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Hello static world" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Body text is present...
|
||||||
|
expect(screen.getByText("Hello static world")).toBeDefined();
|
||||||
|
// ...and it did NOT go through the (mocked) CommentEditor instance.
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
import CommentContentView from "@/features/comment/components/comment-content-view";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||||
@@ -50,7 +51,6 @@ function CommentListItem({
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
const [content, setContent] = useState<string>(comment.content);
|
|
||||||
const editContentRef = useRef<any>(null);
|
const editContentRef = useRef<any>(null);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
@@ -78,22 +78,16 @@ function CommentListItem({
|
|||||||
const isOwnerOrAdmin =
|
const isOwnerOrAdmin =
|
||||||
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setContent(comment.content);
|
|
||||||
}, [comment]);
|
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const commentToUpdate = {
|
const commentToUpdate = {
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
content: JSON.stringify(editContentRef.current ?? content),
|
content: JSON.stringify(editContentRef.current ?? comment.content),
|
||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
if (editContentRef.current) {
|
editContentRef.current = null;
|
||||||
setContent(editContentRef.current);
|
|
||||||
editContentRef.current = null;
|
|
||||||
}
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update comment:", error);
|
console.error("Failed to update comment:", error);
|
||||||
@@ -350,11 +344,11 @@ function CommentListItem({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<CommentEditor defaultContent={content} editable={false} />
|
<CommentContentView content={comment.content} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={content}
|
defaultContent={comment.content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||||
onSave={handleUpdateComment}
|
onSave={handleUpdateComment}
|
||||||
@@ -374,4 +368,6 @@ function CommentListItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CommentListItem;
|
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
|
||||||
|
// comment's object identity) re-renders that one thread, not all ~356 items.
|
||||||
|
export default React.memo(CommentListItem);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
|
||||||
|
// the lazy reply editor's mount transition can be observed without the editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
|
||||||
|
// pulled in transitively so importing the module is side-effect free.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
// space-query -> main.tsx (createRoot) is another module side effect; stub it.
|
||||||
|
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||||
|
useGetSpaceBySlugQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildChildrenByParent,
|
||||||
|
CommentEditorWithActions,
|
||||||
|
} from "./comment-list-with-tabs";
|
||||||
|
|
||||||
|
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||||
|
({ id, parentCommentId }) as IComment;
|
||||||
|
|
||||||
|
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||||
|
it("returns an empty map for undefined or empty input", () => {
|
||||||
|
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||||
|
expect(buildChildrenByParent([]).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not index a top-level comment (parentCommentId null)", () => {
|
||||||
|
const map = buildChildrenByParent([c("p1", null)]);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
expect(map.has("p1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups replies under the correct parent, including reply-to-reply nesting", () => {
|
||||||
|
const p1 = c("p1", null);
|
||||||
|
const r1 = c("r1", "p1");
|
||||||
|
const r2 = c("r2", "r1"); // a reply to a reply
|
||||||
|
const map = buildChildrenByParent([p1, r1, r2]);
|
||||||
|
expect(map.get("p1")).toEqual([r1]);
|
||||||
|
expect(map.get("r1")).toEqual([r2]);
|
||||||
|
// The top-level comment itself is never a key.
|
||||||
|
expect(map.has("p1") && map.get("p1")?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still groups a reply whose parent is not present in items", () => {
|
||||||
|
const orphan = c("o1", "missing-parent");
|
||||||
|
const map = buildChildrenByParent([orphan]);
|
||||||
|
expect(map.get("missing-parent")).toEqual([orphan]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves insertion order among sibling replies", () => {
|
||||||
|
const map = buildChildrenByParent([
|
||||||
|
c("a", "p1"),
|
||||||
|
c("b", "p1"),
|
||||||
|
c("d", "p1"),
|
||||||
|
]);
|
||||||
|
expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderReplyEditor() {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentEditorWithActions commentId="c-1" onSave={vi.fn()} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CommentEditorWithActions — lazy reply editor activation", () => {
|
||||||
|
it("shows only the stub initially (no editor instance mounted)", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
expect(screen.getByRole("button")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
// The stub button is replaced by the editor subtree.
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor when the stub receives focus", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.focus(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor on Enter keydown of the stub", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,6 @@ import CommentActions from "@/features/comment/components/comment-actions";
|
|||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
|
||||||
|
// thread. Replies whose parent is not in `items` are still grouped under their
|
||||||
|
// parentCommentId (they simply won't be reached by the top-level walk).
|
||||||
|
// Exported for unit testing.
|
||||||
|
export function buildChildrenByParent(
|
||||||
|
items: IComment[] | undefined,
|
||||||
|
): Map<string, IComment[]> {
|
||||||
|
const m = new Map<string, IComment[]>();
|
||||||
|
for (const c of items ?? []) {
|
||||||
|
if (c.parentCommentId) {
|
||||||
|
const arr = m.get(c.parentCommentId);
|
||||||
|
if (arr) arr.push(c);
|
||||||
|
else m.set(c.parentCommentId, [c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -46,7 +63,9 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
isError,
|
isError,
|
||||||
} = useCommentsQuery({ pageId: page?.id });
|
} = useCommentsQuery({ pageId: page?.id });
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
// mutateAsync is a stable reference across renders; depend on it (not the
|
||||||
|
// mutation object) so the reply/comment callbacks stay stable.
|
||||||
|
const createCommentAsync = createCommentMutation.mutateAsync;
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canEdit = page?.permissions?.canEdit ?? false;
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
@@ -75,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
return { activeComments: active, resolvedComments: resolved };
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
}, [comments]);
|
}, [comments]);
|
||||||
|
|
||||||
|
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||||
|
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
||||||
|
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
||||||
|
const childrenByParent = useMemo(
|
||||||
|
() => buildChildrenByParent(comments?.items),
|
||||||
|
[comments?.items],
|
||||||
|
);
|
||||||
|
|
||||||
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||||
|
|
||||||
const handleAddPageComment = useCallback(
|
const handleAddPageComment = useCallback(
|
||||||
async (_commentId: string, content: string) => {
|
async (_commentId: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
setIsPageCommentLoading(true);
|
setIsPageCommentLoading(true);
|
||||||
const createdComment = await createCommentMutation.mutateAsync({
|
const createdComment = await createCommentAsync({
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
});
|
});
|
||||||
@@ -100,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
setIsPageCommentLoading(false);
|
setIsPageCommentLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
const handleAddReply = useCallback(
|
||||||
async (commentId: string, content: string) => {
|
async (commentId: string, content: string) => {
|
||||||
|
// Pending state lives inside CommentEditorWithActions so sending a reply
|
||||||
|
// does not churn renderComments and re-render the whole list.
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
|
||||||
const commentData = {
|
const commentData = {
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
parentCommentId: commentId,
|
parentCommentId: commentId,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
};
|
};
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
await createCommentAsync(commentData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to post comment:", error);
|
console.error("Failed to post comment:", error);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
const renderComments = useCallback(
|
||||||
@@ -143,7 +169,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={space?.membership?.role}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -158,16 +184,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<CommentEditorWithActions
|
<CommentEditorWithActions
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
onSave={handleAddReply}
|
onSave={handleAddReply}
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
comments,
|
childrenByParent,
|
||||||
handleAddReply,
|
handleAddReply,
|
||||||
isLoading,
|
page?.id,
|
||||||
space?.membership?.role,
|
space?.membership?.role,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -203,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="open"
|
defaultValue="open"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
// Default to not mounting an inactive tab (the heavy Resolved list stays
|
||||||
|
// unmounted while Open is shown). The Open panel overrides this with its
|
||||||
|
// own keepMounted (below) so an in-progress reply/edit draft survives an
|
||||||
|
// Open -> Resolved -> Open switch.
|
||||||
|
keepMounted={false}
|
||||||
style={{
|
style={{
|
||||||
flex: "1 1 auto",
|
flex: "1 1 auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -261,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<div style={{ paddingBottom: "8px" }}>
|
<div style={{ paddingBottom: "8px" }}>
|
||||||
<Tabs.Panel value="open" pt="xs">
|
{/* keepMounted keeps the Open panel alive even while Resolved is
|
||||||
|
active, so a lazily-mounted reply editor's draft (and an
|
||||||
|
in-progress edit) is not discarded on tab switch. */}
|
||||||
|
<Tabs.Panel value="open" pt="xs" keepMounted>
|
||||||
{activeComments.length === 0 ? (
|
{activeComments.length === 0 ? (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
@@ -307,7 +340,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ChildCommentsProps {
|
interface ChildCommentsProps {
|
||||||
comments: IPagination<IComment>;
|
childrenByParent: Map<string, IComment[]>;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
@@ -315,24 +348,18 @@ interface ChildCommentsProps {
|
|||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
const ChildComments = ({
|
const ChildComments = ({
|
||||||
comments,
|
childrenByParent,
|
||||||
parentId,
|
parentId,
|
||||||
pageId,
|
pageId,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
userSpaceRole,
|
userSpaceRole,
|
||||||
}: ChildCommentsProps) => {
|
}: ChildCommentsProps) => {
|
||||||
const getChildComments = useCallback(
|
const children = childrenByParent.get(parentId) ?? [];
|
||||||
(parentId: string) =>
|
|
||||||
comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === parentId,
|
|
||||||
),
|
|
||||||
[comments.items],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{getChildComments(parentId).map((childComment) => (
|
{children.map((childComment) => (
|
||||||
<div key={childComment.id}>
|
<div key={childComment.id}>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={childComment}
|
comment={childComment}
|
||||||
@@ -342,7 +369,7 @@ const ChildComments = ({
|
|||||||
userSpaceRole={userSpaceRole}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={childComment.id}
|
parentId={childComment.id}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -357,22 +384,61 @@ const ChildComments = ({
|
|||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
const MemoizedChildComments = memo(ChildComments);
|
||||||
|
|
||||||
const CommentEditorWithActions = ({
|
export const CommentEditorWithActions = ({
|
||||||
commentId,
|
commentId,
|
||||||
onSave,
|
onSave,
|
||||||
isLoading,
|
|
||||||
placeholder = undefined,
|
placeholder = undefined,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Lazily mount the TipTap reply editor: until the user interacts with the
|
||||||
|
// stub, no editor instance is created for this thread. Once mounted it stays
|
||||||
|
// mounted so the draft is preserved.
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
const { ref, focused } = useFocusWithin();
|
const { ref, focused } = useFocusWithin();
|
||||||
const commentEditorRef = useRef(null);
|
const commentEditorRef = useRef(null);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const activate = useCallback(() => setMounted(true), []);
|
||||||
onSave(commentId, content);
|
|
||||||
setContent("");
|
const handleSave = useCallback(async () => {
|
||||||
commentEditorRef.current?.clearContent();
|
try {
|
||||||
|
setIsSending(true);
|
||||||
|
await onSave(commentId, content);
|
||||||
|
setContent("");
|
||||||
|
commentEditorRef.current?.clearContent();
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
}, [commentId, content, onSave]);
|
}, [commentId, content, onSave]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={activate}
|
||||||
|
onFocus={activate}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px",
|
||||||
|
fontSize: "var(--mantine-font-size-sm)",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: "var(--mantine-color-placeholder)",
|
||||||
|
cursor: "text",
|
||||||
|
borderRadius: "var(--mantine-radius-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{placeholder || t("Reply...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -381,8 +447,9 @@ const CommentEditorWithActions = ({
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
editable={true}
|
editable={true}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export function useCommentsQuery(params: ICommentParams) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
isLoading: query.isLoading || query.hasNextPage,
|
// Paint the first page as soon as it arrives instead of blocking until every
|
||||||
|
// page has loaded; the background effect above keeps streaming the rest
|
||||||
|
// (tab counts grow as pages arrive).
|
||||||
|
isLoading: query.isLoading,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { Editor } from "@tiptap/core";
|
// Type-only: these atoms only hold an Editor reference for typing. A value
|
||||||
|
// import would drag the whole @tiptap/core engine into the eager graph of every
|
||||||
|
// shell component that reads one of these atoms.
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
|
|
||||||
|
// Lazily load the drawio bubble menu so it is split out of the editor chunk and
|
||||||
|
// fetched only when an editable editor is mounted (mirrors excalidraw-menu-lazy).
|
||||||
|
const DrawioMenu = lazy(
|
||||||
|
() => import("@/features/editor/components/drawio/drawio-menu.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DrawioMenuLazy(props: EditorMenuProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DrawioMenu {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the drawio node view so the heavy react-drawio embed runtime is
|
||||||
|
// split into its own chunk and fetched only when a drawio diagram is actually
|
||||||
|
// rendered (mirrors excalidraw-view-lazy).
|
||||||
|
const DrawioView = lazy(
|
||||||
|
() => import("@/features/editor/components/drawio/drawio-view.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DrawioViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DrawioView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the KaTeX-backed block math view so the katex chunk is fetched
|
||||||
|
// only when a document actually contains a math node (mirrors the mermaid/
|
||||||
|
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||||
|
// crashing or blocking the whole editor: while it loads we render the raw
|
||||||
|
// LaTeX source as a node-sized placeholder.
|
||||||
|
const MathBlockView = lazy(
|
||||||
|
() => import("@/features/editor/components/math/math-block.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MathBlockViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div data-katex="true">{props.node.attrs.text}</div>}>
|
||||||
|
<MathBlockView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the KaTeX-backed inline math view so the katex chunk is fetched
|
||||||
|
// only when a document actually contains a math node (mirrors the mermaid/
|
||||||
|
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||||
|
// crashing or blocking the whole editor: while it loads we render the raw
|
||||||
|
// LaTeX source as a node-sized placeholder.
|
||||||
|
const MathInlineView = lazy(
|
||||||
|
() => import("@/features/editor/components/math/math-inline.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MathInlineViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<span data-katex="true">{props.node.attrs.text}</span>}>
|
||||||
|
<MathInlineView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,9 +11,19 @@ import {
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
interface MentionAttrs {
|
||||||
const { node } = props;
|
label?: string;
|
||||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
slugId?: string;
|
||||||
|
anchorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentational mention renderer (no NodeViewWrapper). Shared by the editor
|
||||||
|
// NodeView (MentionView) and the static comment renderer (CommentContentView)
|
||||||
|
// so mention click/nav/icon behavior stays identical outside of an editor.
|
||||||
|
export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||||
|
const { label, entityType, slugId, anchorId } = attrs;
|
||||||
const isPageMention = entityType === "page";
|
const isPageMention = entityType === "page";
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
@@ -56,7 +66,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
<>
|
||||||
{entityType === "user" && (
|
{entityType === "user" && (
|
||||||
<Text className={classes.userMention} component="span">
|
<Text className={classes.userMention} component="span">
|
||||||
@{label}
|
@{label}
|
||||||
@@ -139,6 +149,14 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentionView(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||||
|
<MentionContent attrs={props.node.attrs} />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ import {
|
|||||||
createResizeHandle,
|
createResizeHandle,
|
||||||
buildResizeClasses,
|
buildResizeClasses,
|
||||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block-lazy.tsx";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
||||||
@@ -90,7 +90,7 @@ import VideoView from "@/features/editor/components/video/video-view.tsx";
|
|||||||
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { getDefaultStore } from "jotai";
|
import { getDefaultStore } from "jotai";
|
||||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
|
||||||
import { Editor } from "@tiptap/core";
|
// Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
|
||||||
|
// so this always-mounted global bridge does not statically import
|
||||||
|
// @hocuspocus/provider — that import pulls Yjs (and, through a shared chunk, the
|
||||||
|
// whole TipTap engine) into the eager startup graph. yjsConnectionStatusAtom
|
||||||
|
// already stores these raw status strings.
|
||||||
|
const YJS_STATUS_CONNECTED = "connected";
|
||||||
|
// Type-only: importing Editor as a type keeps @tiptap/core (the whole editor
|
||||||
|
// engine) out of the eager global-shell graph — the bridge only uses it for
|
||||||
|
// annotations/casts, never as a runtime value.
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -16,15 +25,19 @@ import {
|
|||||||
getSidebarPages,
|
getSidebarPages,
|
||||||
} from "@/features/page/services/page-service.ts";
|
} from "@/features/page/services/page-service.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import {
|
// Types are erased at build time, so importing them does not pull the module's
|
||||||
|
// runtime (which drags in @tiptap + the editor-ext barrel). The actual recording
|
||||||
|
// helpers are dynamically imported at call time inside createPageWithRecording,
|
||||||
|
// keeping the editor engine out of the eager global-shell startup graph — the
|
||||||
|
// bridge is mounted for every authenticated user but recording is a rare,
|
||||||
|
// native-host-driven action.
|
||||||
|
import type {
|
||||||
GitmostBridge,
|
GitmostBridge,
|
||||||
GitmostCreatePagePayload,
|
GitmostCreatePagePayload,
|
||||||
GitmostCreatePageResult,
|
GitmostCreatePageResult,
|
||||||
GitmostListPagesPayload,
|
GitmostListPagesPayload,
|
||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
|
||||||
gitmostUploadFileToEditor,
|
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
// How long to wait for a freshly-navigated page's editor to mount, become
|
// How long to wait for a freshly-navigated page's editor to mount, become
|
||||||
@@ -57,7 +70,7 @@ function gitmostWaitForEditor(
|
|||||||
!editor.isDestroyed &&
|
!editor.isDestroyed &&
|
||||||
editor.isEditable &&
|
editor.isEditable &&
|
||||||
editorPageId === pageId &&
|
editorPageId === pageId &&
|
||||||
yjsStatus === WebSocketStatus.Connected;
|
yjsStatus === YJS_STATUS_CONNECTED;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
resolve(editor);
|
resolve(editor);
|
||||||
return;
|
return;
|
||||||
@@ -171,6 +184,12 @@ export default function GitmostGlobalBridge() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the recording helpers on demand (see the import note above). This
|
||||||
|
// is the only place they are needed, so the @tiptap/editor-ext code they
|
||||||
|
// pull in stays out of the eager startup graph.
|
||||||
|
const { gitmostDecodePayloadToFile, gitmostUploadFileToEditor } =
|
||||||
|
await import("@/features/editor/gitmost/gitmost-recording.ts");
|
||||||
|
|
||||||
// Validate/decode the recording BEFORE creating the page so a bad
|
// Validate/decode the recording BEFORE creating the page so a bad
|
||||||
// payload never leaves an empty junk page behind. Per the createPage
|
// payload never leaves an empty junk page behind. Per the createPage
|
||||||
// error contract, any decode failure collapses to "insert-failed" (the
|
// error contract, any decode failure collapses to "insert-failed" (the
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu-lazy";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
import ShareShell from "@/features/share/components/share-shell.tsx";
|
||||||
|
|
||||||
export default function ShareLayout() {
|
export default function ShareLayout() {
|
||||||
return (
|
return (
|
||||||
<ShareShell>
|
<ShareShell>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</ShareShell>
|
</ShareShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
||||||
// polyfilled to support execCommand fallback
|
// polyfilled to support execCommand fallback
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { execCommandCopy } from "@docmost/editor-ext";
|
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
|
||||||
|
|
||||||
export type UseClipboardOptions = {
|
export type UseClipboardOptions = {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
import { castToBoolean } from "@/lib/utils.tsx";
|
import { castToBoolean } from "@/lib/utils.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { sanitizeUrl } from "@docmost/editor-ext";
|
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Client-local execCommand copy fallback (previously imported from
|
||||||
|
// @docmost/editor-ext). It lives here so the ubiquitous useClipboard / CopyButton
|
||||||
|
// path does not pull in the editor-ext barrel — and with it the whole TipTap
|
||||||
|
// engine — through the eager startup graph. Behavior is identical to the
|
||||||
|
// editor-ext helper it replaces.
|
||||||
|
export function execCommandCopy(text: string): void {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
textarea.style.top = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sanitizeUrl } from "./sanitize-url";
|
||||||
|
|
||||||
|
// `sanitizeUrl` is a byte-identical client-local copy of editor-ext's wrapper
|
||||||
|
// around @braintree/sanitize-url: it maps the sanitizer's "about:blank" XSS
|
||||||
|
// sentinel to "". These assertions mirror editor-ext's own security-contract
|
||||||
|
// test so the extracted copy keeps the same guarantees.
|
||||||
|
describe("sanitizeUrl", () => {
|
||||||
|
it("blocks dangerous schemes (returns empty string)", () => {
|
||||||
|
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
|
||||||
|
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
|
||||||
|
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
|
||||||
|
// Case / whitespace obfuscation must not slip past the sanitizer.
|
||||||
|
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty / undefined input", () => {
|
||||||
|
expect(sanitizeUrl(undefined)).toBe("");
|
||||||
|
expect(sanitizeUrl("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows safe https, relative file and mailto URLs", () => {
|
||||||
|
expect(sanitizeUrl("https://example.com/page")).toMatch(
|
||||||
|
/^https:\/\/example\.com\/page/,
|
||||||
|
);
|
||||||
|
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
|
||||||
|
expect(sanitizeUrl("mailto:user@example.com")).toBe(
|
||||||
|
"mailto:user@example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
|
||||||
|
|
||||||
|
// Client-local copy of editor-ext's sanitizeUrl wrapper. Importing it from the
|
||||||
|
// editor-ext barrel dragged the whole TipTap engine into the eager startup graph
|
||||||
|
// via the app-wide config module (getFileUrl). This keeps the exact same
|
||||||
|
// behavior (braintree sanitize + normalize "about:blank" -> "") without that
|
||||||
|
// dependency.
|
||||||
|
export function sanitizeUrl(url: string | undefined): string {
|
||||||
|
if (!url) return "";
|
||||||
|
|
||||||
|
const sanitized = braintreeSanitizeUrl(url);
|
||||||
|
|
||||||
|
// Return an empty string instead of "about:blank".
|
||||||
|
return sanitized === "about:blank" ? "" : sanitized;
|
||||||
|
}
|
||||||
+60
-27
@@ -13,15 +13,14 @@ import { ModalsProvider } from "@mantine/modals";
|
|||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
|
||||||
import {
|
import {
|
||||||
getPostHogHost,
|
getPostHogHost,
|
||||||
getPostHogKey,
|
getPostHogKey,
|
||||||
isCloud,
|
isCloud,
|
||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
import posthog from "posthog-js";
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -34,31 +33,65 @@ export const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCloud() && isPostHogEnabled) {
|
|
||||||
posthog.init(getPostHogKey(), {
|
|
||||||
api_host: getPostHogHost(),
|
|
||||||
defaults: "2025-05-24",
|
|
||||||
disable_session_recording: true,
|
|
||||||
capture_pageleave: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
root.render(
|
function renderApp() {
|
||||||
<BrowserRouter>
|
root.render(
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<BrowserRouter>
|
||||||
<ModalsProvider>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<ModalsProvider>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<QueryClientProvider client={queryClient}>
|
||||||
<HelmetProvider>
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
<PostHogProvider client={posthog}>
|
<HelmetProvider>
|
||||||
<App />
|
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||||
</PostHogProvider>
|
404 after a deploy is caught and recovered here instead of
|
||||||
</HelmetProvider>
|
blanking the whole app. */}
|
||||||
</QueryClientProvider>
|
<ChunkLoadErrorBoundary>
|
||||||
</ModalsProvider>
|
<App />
|
||||||
</MantineProvider>
|
</ChunkLoadErrorBoundary>
|
||||||
</BrowserRouter>,
|
</HelmetProvider>
|
||||||
);
|
</QueryClientProvider>
|
||||||
|
</ModalsProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</BrowserRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initAnalytics() {
|
||||||
|
// posthog-js is only pulled in for cloud deployments with analytics enabled, so
|
||||||
|
// self-hosted builds never download it. The gate is kept identical to the
|
||||||
|
// previous eager code so cloud analytics behavior is unchanged; the import is
|
||||||
|
// simply deferred behind it.
|
||||||
|
//
|
||||||
|
// Crucially this runs AFTER the immediate first render below, so first paint is
|
||||||
|
// never gated on the analytics chunk. Any failure (network, stale 404, or an
|
||||||
|
// ad-blocker blocking a chunk named "posthog") is swallowed so the user keeps a
|
||||||
|
// working app without analytics instead of a permanently blank page.
|
||||||
|
//
|
||||||
|
// NOTE: we init the posthog SINGLETON only and do NOT wrap the tree in
|
||||||
|
// <PostHogProvider>. The app has zero consumers of the PostHog React context
|
||||||
|
// (no usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given
|
||||||
|
// an already-initialized `client` is a no-op — all capture goes through the
|
||||||
|
// singleton. Re-rendering to attach the provider would only REMOUNT the whole
|
||||||
|
// App (running every mount effect twice and dropping local state / focus /
|
||||||
|
// in-progress input on cloud cold-load) for no functional gain.
|
||||||
|
if (!(isCloud() && isPostHogEnabled)) return;
|
||||||
|
try {
|
||||||
|
const { default: posthog } = await import("posthog-js");
|
||||||
|
posthog.init(getPostHogKey(), {
|
||||||
|
api_host: getPostHogHost(),
|
||||||
|
defaults: "2025-05-24",
|
||||||
|
disable_session_recording: true,
|
||||||
|
capture_pageleave: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Analytics failed to load — degrade gracefully; the app already rendered.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint immediately for everyone (self-hosted stays exactly as instant as before,
|
||||||
|
// cloud no longer blocks on the analytics import). The posthog singleton is
|
||||||
|
// initialized after, without re-rendering the tree.
|
||||||
|
renderApp();
|
||||||
|
void initAnalytics();
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ export default defineConfig(({ mode }) => {
|
|||||||
name: "vendor-mantine",
|
name: "vendor-mantine",
|
||||||
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
||||||
},
|
},
|
||||||
|
// NOTE: TipTap/ProseMirror/Yjs are intentionally NOT force-grouped
|
||||||
|
// into a single vendor chunk. Doing so backfires: rolldown co-locates
|
||||||
|
// a small module shared with the (eager) react-i18next runtime into
|
||||||
|
// that group chunk, which then drags the whole ~590KB editor engine
|
||||||
|
// into the eager modulepreload graph. Left to the default splitting,
|
||||||
|
// the editor engine stays in lazily-loaded chunks pulled only by the
|
||||||
|
// route-split editor/share pages. KaTeX is safe to group (nothing
|
||||||
|
// eager references it).
|
||||||
|
// KaTeX in its own stable chunk; loaded on demand by the lazy math
|
||||||
|
// node views (never in the startup path).
|
||||||
|
{
|
||||||
|
name: "vendor-katex",
|
||||||
|
test: /[\\/]node_modules[\\/]katex[\\/]/,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { buildSystemPrompt, buildMcpToolingBlock } from './ai-chat.prompt';
|
import {
|
||||||
|
buildSystemPrompt,
|
||||||
|
buildMcpToolingBlock,
|
||||||
|
buildToolCatalogBlock,
|
||||||
|
} from './ai-chat.prompt';
|
||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -396,3 +400,62 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
|||||||
expect(opens).toBe(1);
|
expect(opens).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading — the <tool_catalog> block builder and its
|
||||||
|
* gating inside buildSystemPrompt.
|
||||||
|
*/
|
||||||
|
describe('buildToolCatalogBlock (#332)', () => {
|
||||||
|
const catalog = [
|
||||||
|
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||||
|
{ name: 'transformPage', catalogLine: 'transformPage — run a JS transform.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders nothing when the feature is disabled', () => {
|
||||||
|
expect(buildToolCatalogBlock(catalog, false)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when the catalog is empty', () => {
|
||||||
|
expect(buildToolCatalogBlock([], true)).toBe('');
|
||||||
|
expect(buildToolCatalogBlock(undefined, true)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the verbatim header + each deferred catalogLine when enabled', () => {
|
||||||
|
const block = buildToolCatalogBlock(catalog, true);
|
||||||
|
expect(block).toContain('<tool_catalog note="deferred tools;');
|
||||||
|
expect(block).toContain('NEVER tell the user you lack a capability');
|
||||||
|
expect(block).toContain('Deferred tools (name — purpose):');
|
||||||
|
expect(block).toContain('- createPage — create a new page.');
|
||||||
|
expect(block).toContain('- transformPage — run a JS transform.');
|
||||||
|
expect(block).toContain('</tool_catalog>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSystemPrompt <tool_catalog> gating (#332)', () => {
|
||||||
|
const workspace = { name: 'Acme' } as unknown as Workspace;
|
||||||
|
const catalog = [
|
||||||
|
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('omits the catalog when the toggle is off (unchanged behavior)', () => {
|
||||||
|
const prompt = buildSystemPrompt({
|
||||||
|
workspace,
|
||||||
|
deferredToolsEnabled: false,
|
||||||
|
toolCatalog: catalog,
|
||||||
|
});
|
||||||
|
expect(prompt).not.toContain('<tool_catalog');
|
||||||
|
expect(prompt).not.toContain('createPage — create a new page.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the catalog (deferred lines only) when enabled', () => {
|
||||||
|
const prompt = buildSystemPrompt({
|
||||||
|
workspace,
|
||||||
|
deferredToolsEnabled: true,
|
||||||
|
toolCatalog: catalog,
|
||||||
|
});
|
||||||
|
expect(prompt).toContain('<tool_catalog');
|
||||||
|
expect(prompt).toContain('createPage — create a new page.');
|
||||||
|
// A core tool line is never in the catalog (the caller passes deferred only).
|
||||||
|
expect(prompt).not.toContain('searchPages —');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
||||||
|
import type { ToolCatalogEntry } from './tools/tool-tiers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default agent persona used when the admin has not configured a custom system
|
* Default agent persona used when the admin has not configured a custom system
|
||||||
@@ -183,6 +184,55 @@ export interface BuildSystemPromptInput {
|
|||||||
* block (unchanged page, page not open, or first turn).
|
* block (unchanged page, page not open, or first turn).
|
||||||
*/
|
*/
|
||||||
pageChanged?: { title: string; diff: string } | null;
|
pageChanged?: { title: string; diff: string } | null;
|
||||||
|
/**
|
||||||
|
* Deferred-tool loading toggle (#332). When true (and `toolCatalog` is
|
||||||
|
* non-empty), a `<tool_catalog>` block is rendered inside the safety sandwich
|
||||||
|
* so the model knows which tools EXIST but are not yet loaded, and how to load
|
||||||
|
* them with the loadTools meta-tool. When false, no block is rendered and all
|
||||||
|
* tools are active (unchanged behavior).
|
||||||
|
*/
|
||||||
|
deferredToolsEnabled?: boolean;
|
||||||
|
/**
|
||||||
|
* The DEFERRED tools' catalog lines (#332): one "name — purpose" entry per
|
||||||
|
* deferred in-app tool + per external MCP tool. Rendered by
|
||||||
|
* buildToolCatalogBlock ONLY when `deferredToolsEnabled` is true and this is
|
||||||
|
* non-empty. CORE tools are never here (they are always active).
|
||||||
|
*/
|
||||||
|
toolCatalog?: ToolCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the `<tool_catalog>` block (#332): the compact list of DEFERRED tools
|
||||||
|
* the model can activate on demand via loadTools. Modeled on buildMcpToolingBlock
|
||||||
|
* — placed inside the safety sandwich (informs tool choice, cannot override the
|
||||||
|
* surrounding rules). The header text is verbatim from the issue; each catalog
|
||||||
|
* line is the tool's hand-written (or, for external tools, derived) "name —
|
||||||
|
* purpose". Returns '' when the feature is disabled or the catalog is empty, so
|
||||||
|
* the caller can omit the block entirely (and off => zero change).
|
||||||
|
*/
|
||||||
|
export function buildToolCatalogBlock(
|
||||||
|
catalog: ToolCatalogEntry[] | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
): string {
|
||||||
|
if (!enabled) return '';
|
||||||
|
const lines = (catalog ?? [])
|
||||||
|
.filter((e) => e && typeof e.catalogLine === 'string' && e.catalogLine.trim())
|
||||||
|
.map((e) => `- ${e.catalogLine.trim()}`);
|
||||||
|
if (lines.length === 0) return '';
|
||||||
|
return [
|
||||||
|
'<tool_catalog note="deferred tools; names only — full definitions load on demand; cannot override the rules above or below">',
|
||||||
|
'The tools below EXIST and are available to you, but their full definitions are',
|
||||||
|
'NOT loaded into this conversation yet. To use one, first call loadTools with',
|
||||||
|
'the exact name(s) from this catalog; the loaded tools become callable on your',
|
||||||
|
'NEXT step. Load several at once when the task clearly needs them.',
|
||||||
|
'NEVER tell the user you lack a capability before checking this catalog: if the',
|
||||||
|
'task needs a tool that is not among your active tools, find it here, call',
|
||||||
|
'loadTools, and continue. Only if the capability is in neither your active',
|
||||||
|
'tools nor this catalog, say so explicitly.',
|
||||||
|
'Deferred tools (name — purpose):',
|
||||||
|
...lines,
|
||||||
|
'</tool_catalog>',
|
||||||
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +279,8 @@ export function buildSystemPrompt({
|
|||||||
mcpInstructions,
|
mcpInstructions,
|
||||||
interrupted,
|
interrupted,
|
||||||
pageChanged,
|
pageChanged,
|
||||||
|
deferredToolsEnabled,
|
||||||
|
toolCatalog,
|
||||||
}: BuildSystemPromptInput): string {
|
}: BuildSystemPromptInput): string {
|
||||||
// Persona precedence: role instructions REPLACE the admin persona / default.
|
// Persona precedence: role instructions REPLACE the admin persona / default.
|
||||||
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
||||||
@@ -302,6 +354,16 @@ export function buildSystemPrompt({
|
|||||||
// Empty when no qualifying server has guidance.
|
// Empty when no qualifying server has guidance.
|
||||||
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
|
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
|
||||||
|
|
||||||
|
// Deferred-tool catalog (#332). Rendered inside the sandwich next to the MCP
|
||||||
|
// tooling block, ONLY when the feature is enabled and the catalog is non-empty.
|
||||||
|
// Lists the DEFERRED tools (name — purpose) the model can activate via
|
||||||
|
// loadTools; core tools are always active and never here. Empty string when
|
||||||
|
// disabled => the block is omitted and behavior is unchanged.
|
||||||
|
const toolCatalogBlock = buildToolCatalogBlock(
|
||||||
|
toolCatalog,
|
||||||
|
deferredToolsEnabled === true,
|
||||||
|
);
|
||||||
|
|
||||||
// Sandwich the lower-trust persona/role text between two copies of the
|
// Sandwich the lower-trust persona/role text between two copies of the
|
||||||
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
||||||
// and followed by the safety rules. The persona is delimited with explicit
|
// and followed by the safety rules. The persona is delimited with explicit
|
||||||
@@ -316,6 +378,7 @@ export function buildSystemPrompt({
|
|||||||
'</role_persona>',
|
'</role_persona>',
|
||||||
context,
|
context,
|
||||||
mcpTooling,
|
mcpTooling,
|
||||||
|
toolCatalogBlock,
|
||||||
SAFETY_FRAMEWORK,
|
SAFETY_FRAMEWORK,
|
||||||
]
|
]
|
||||||
.filter((part) => part !== '')
|
.filter((part) => part !== '')
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe('AiChatService.resolveRoleForRequest', () => {
|
|||||||
aiAgentRoleRepo as never,
|
aiAgentRoleRepo as never,
|
||||||
{} as never, // pageRepo
|
{} as never, // pageRepo
|
||||||
{} as never, // pageAccess
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
);
|
);
|
||||||
return { service, aiChatRepo, aiAgentRoleRepo };
|
return { service, aiChatRepo, aiAgentRoleRepo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
|||||||
{} as never, // aiAgentRoleRepo
|
{} as never, // aiAgentRoleRepo
|
||||||
{} as never, // pageRepo
|
{} as never, // pageRepo
|
||||||
{} as never, // pageAccess
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
);
|
);
|
||||||
return { service, aiChatMessageRepo };
|
return { service, aiChatMessageRepo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,23 +217,78 @@ describe('rowToUiMessage', () => {
|
|||||||
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
||||||
* appended onto — not replacing — the original system prompt.
|
* appended onto — not replacing — the original system prompt.
|
||||||
*/
|
*/
|
||||||
|
// Narrowing helpers for the prepareAgentStep union return type.
|
||||||
|
const asLockdown = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||||
|
r as { toolChoice: 'none'; system: string };
|
||||||
|
const asActive = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||||
|
r as { activeTools: string[] };
|
||||||
|
|
||||||
describe('prepareAgentStep', () => {
|
describe('prepareAgentStep', () => {
|
||||||
it('returns undefined for the first step', () => {
|
// --- toggle OFF (default): unchanged behavior ---
|
||||||
|
it('returns undefined for the first step (toggle off)', () => {
|
||||||
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
|
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for a non-final step (just before the last)', () => {
|
it('returns undefined for a non-final step (toggle off)', () => {
|
||||||
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forces a text-only synthesis on the final allowed step', () => {
|
it('forces a text-only synthesis on the final allowed step (toggle off)', () => {
|
||||||
const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
|
const result = asLockdown(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result?.toolChoice).toBe('none');
|
expect(result.toolChoice).toBe('none');
|
||||||
// The original persona is preserved (prefix), not replaced.
|
// The original persona is preserved (prefix), not replaced.
|
||||||
expect(result?.system.startsWith('SYS')).toBe(true);
|
expect(result.system.startsWith('SYS')).toBe(true);
|
||||||
// The synthesis instruction is appended.
|
// The synthesis instruction is appended.
|
||||||
expect(result?.system).toContain(FINAL_STEP_INSTRUCTION);
|
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT narrow activeTools when the toggle is off', () => {
|
||||||
|
const result = prepareAgentStep(0, 'SYS', new Set(['createPage']), false);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- toggle ON (#332): deferred tool visibility ---
|
||||||
|
it('a non-final step exposes CORE + loadTools + activatedTools', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
const result = asActive(prepareAgentStep(0, 'SYS', activated, true));
|
||||||
|
expect(result.activeTools).toContain('searchPages'); // core
|
||||||
|
expect(result.activeTools).toContain('searchInPage'); // #330, core
|
||||||
|
expect(result.activeTools).toContain('editPageText'); // core
|
||||||
|
expect(result.activeTools).toContain('loadTools'); // meta-tool
|
||||||
|
// No deferred tool is active before it is loaded.
|
||||||
|
expect(result.activeTools).not.toContain('createPage');
|
||||||
|
expect(result.activeTools).not.toContain('transformPage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adding a name to activatedTools makes it appear on the next step', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
// Before loading: createPage is not active.
|
||||||
|
expect(
|
||||||
|
asActive(prepareAgentStep(1, 'SYS', activated, true)).activeTools,
|
||||||
|
).not.toContain('createPage');
|
||||||
|
// loadTools grows the SAME set…
|
||||||
|
activated.add('createPage');
|
||||||
|
// …so the next step sees it.
|
||||||
|
const next = asActive(prepareAgentStep(2, 'SYS', activated, true));
|
||||||
|
expect(next.activeTools).toContain('createPage');
|
||||||
|
expect(next.activeTools).toContain('loadTools');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an array for activatedTools too', () => {
|
||||||
|
const result = asActive(prepareAgentStep(0, 'SYS', ['transformPage'], true));
|
||||||
|
expect(result.activeTools).toContain('transformPage');
|
||||||
|
expect(result.activeTools).toContain('loadTools');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('final-step lockdown WINS even when the toggle is on', () => {
|
||||||
|
const result = asLockdown(
|
||||||
|
prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS', new Set(['createPage']), true),
|
||||||
|
);
|
||||||
|
// The lockdown shape (toolChoice none + synthesis) — not the activeTools shape.
|
||||||
|
expect(result.toolChoice).toBe('none');
|
||||||
|
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||||
|
expect((result as unknown as { activeTools?: string[] }).activeTools).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||||
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||||
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||||
|
import {
|
||||||
|
CORE_TOOL_KEYS,
|
||||||
|
CORE_TOOL_SET,
|
||||||
|
LOAD_TOOLS_NAME,
|
||||||
|
makeLoadToolsTool,
|
||||||
|
buildExternalToolCatalog,
|
||||||
|
} from './tools/tool-tiers';
|
||||||
import { computePageChange } from './page-change/page-change.util';
|
import { computePageChange } from './page-change/page-change.util';
|
||||||
import { roleModelOverride } from './roles/role-model-config';
|
import { roleModelOverride } from './roles/role-model-config';
|
||||||
import {
|
import {
|
||||||
@@ -54,24 +62,52 @@ const FINAL_STEP_INSTRUCTION =
|
|||||||
'language. If the information is incomplete, say so explicitly: summarize ' +
|
'language. If the information is incomplete, say so explicitly: summarize ' +
|
||||||
'what you found, what is still missing, and give your best partial conclusion.';
|
'what you found, what is still missing, and give your best partial conclusion.';
|
||||||
|
|
||||||
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
|
// Pure, unit-testable: decide per-step overrides. Two responsibilities:
|
||||||
// steps; on the final allowed step forces a text-only synthesis answer.
|
// 1. Final-step lockdown (always): on the final allowed step force a text-only
|
||||||
|
// synthesis answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION). This WINS —
|
||||||
|
// it takes precedence over the deferred-tool narrowing below.
|
||||||
|
// 2. Deferred tool visibility (#332): when `deferredEnabled` and NOT the final
|
||||||
|
// step, expose only the CORE tools + loadTools + whatever loadTools has
|
||||||
|
// activated so far this turn (`activatedTools`), via `activeTools`. Deferred
|
||||||
|
// tools stay in the <tool_catalog> until the model loads them.
|
||||||
|
// When `deferredEnabled` is false the behavior is unchanged: undefined on normal
|
||||||
|
// steps (all tools active), lockdown on the final step.
|
||||||
|
//
|
||||||
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
||||||
// persona/context is preserved — a bare `system` override would REPLACE the
|
// persona/context is preserved — a bare `system` override would REPLACE the
|
||||||
// whole system prompt for the step.
|
// whole system prompt for the step. `activatedTools` is PER-TURN mutable state
|
||||||
|
// owned by the streaming loop (a closure Set grown by loadTools); it is passed
|
||||||
|
// in (not module-global, not persisted) so this stays a pure function of its
|
||||||
|
// arguments.
|
||||||
//
|
//
|
||||||
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
||||||
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
||||||
export function prepareAgentStep(
|
export function prepareAgentStep(
|
||||||
stepNumber: number,
|
stepNumber: number,
|
||||||
system: string,
|
system: string,
|
||||||
): { toolChoice: 'none'; system: string } | undefined {
|
activatedTools: ReadonlySet<string> | readonly string[] = [],
|
||||||
|
deferredEnabled = false,
|
||||||
|
):
|
||||||
|
| { toolChoice: 'none'; system: string }
|
||||||
|
| { activeTools: string[] }
|
||||||
|
| undefined {
|
||||||
|
// Final-step lockdown WINS (applies regardless of the deferred toggle).
|
||||||
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
||||||
return {
|
return {
|
||||||
toolChoice: 'none',
|
toolChoice: 'none',
|
||||||
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Deferred tool loading: narrow this step's visible tools to CORE + loadTools
|
||||||
|
// + the tools already activated this turn.
|
||||||
|
if (deferredEnabled) {
|
||||||
|
const activated = Array.isArray(activatedTools)
|
||||||
|
? activatedTools
|
||||||
|
: [...activatedTools];
|
||||||
|
return {
|
||||||
|
activeTools: [...CORE_TOOL_KEYS, LOAD_TOOLS_NAME, ...activated],
|
||||||
|
};
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +242,9 @@ export class AiChatService implements OnModuleInit {
|
|||||||
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pageAccess: PageAccessService,
|
private readonly pageAccess: PageAccessService,
|
||||||
|
// Reads the AI_CHAT_DEFERRED_TOOLS toggle (#332). Injected last so existing
|
||||||
|
// positional constructor callers (tests) only append one stub.
|
||||||
|
private readonly environment: EnvironmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -625,9 +664,25 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// Build the system prompt + Docmost toolset. If either throws after the
|
// Build the system prompt + Docmost toolset. If either throws after the
|
||||||
// external MCP lease was taken above, release the lease before rethrowing so
|
// external MCP lease was taken above, release the lease before rethrowing so
|
||||||
// the leased transports are not leaked (#185 review).
|
// the leased transports are not leaked (#185 review).
|
||||||
|
// Deferred tool loading toggle (#332). When ON, the model sees a compact
|
||||||
|
// <tool_catalog> and only CORE tools + loadTools are active each step; other
|
||||||
|
// tools (fat/rare in-app tools + ALL external MCP tools) load on demand. When
|
||||||
|
// OFF, every tool is active and nothing below changes.
|
||||||
|
const deferredEnabled = this.environment.isAiChatDeferredToolsEnabled();
|
||||||
|
|
||||||
let system: string;
|
let system: string;
|
||||||
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
|
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
|
||||||
try {
|
try {
|
||||||
|
// Assemble the deferred catalog for the system prompt: hand-written lines
|
||||||
|
// for the in-app deferred tools + a derived line for each external MCP tool
|
||||||
|
// (also deferred by default). Only built when the feature is enabled.
|
||||||
|
const toolCatalog = deferredEnabled
|
||||||
|
? [
|
||||||
|
...(await this.tools.getInAppDeferredCatalog()),
|
||||||
|
...buildExternalToolCatalog(external.tools),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
system = buildSystemPrompt({
|
system = buildSystemPrompt({
|
||||||
workspace,
|
workspace,
|
||||||
adminPrompt: resolved?.systemPrompt,
|
adminPrompt: resolved?.systemPrompt,
|
||||||
@@ -644,6 +699,10 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// Detected between-turns human edit to the open page (#274): adds the
|
// Detected between-turns human edit to the open page (#274): adds the
|
||||||
// page_changed note + unified diff so the agent doesn't overwrite it.
|
// page_changed note + unified diff so the agent doesn't overwrite it.
|
||||||
pageChanged,
|
pageChanged,
|
||||||
|
// Deferred tool loading (#332): renders the <tool_catalog> block (only
|
||||||
|
// when enabled + non-empty) so the model can activate deferred tools.
|
||||||
|
deferredToolsEnabled: deferredEnabled,
|
||||||
|
toolCatalog,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pass the resolved chatId so the write tools can mint provenance tokens
|
// Pass the resolved chatId so the write tools can mint provenance tokens
|
||||||
@@ -664,7 +723,31 @@ export class AiChatService implements OnModuleInit {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = { ...external.tools, ...docmostTools };
|
// Base toolset: external MCP tools + Docmost in-app tools (Docmost wins on a
|
||||||
|
// name clash — external are namespaced, so no clash is expected).
|
||||||
|
const baseTools = { ...external.tools, ...docmostTools };
|
||||||
|
|
||||||
|
// Deferred tool loading state (#332), scoped to THIS streaming loop:
|
||||||
|
// - `activatedTools` is per-TURN mutable state — a fresh closure Set created
|
||||||
|
// per streamText call, NOT module-global and NOT persisted, so a new turn
|
||||||
|
// starts cold. loadTools.execute adds to it; prepareAgentStep reads it to
|
||||||
|
// widen `activeTools` on the NEXT step.
|
||||||
|
// - `validDeferredNames` = every tool that is NOT core (the in-app deferred
|
||||||
|
// tools + ALL external MCP tools), computed from the ACTUAL toolset so an
|
||||||
|
// external tool is loadable by its namespaced name. loadTools rejects any
|
||||||
|
// name outside this set.
|
||||||
|
const activatedTools = new Set<string>();
|
||||||
|
const validDeferredNames = new Set<string>(
|
||||||
|
Object.keys(baseTools).filter((k) => !CORE_TOOL_SET.has(k)),
|
||||||
|
);
|
||||||
|
// Add the loadTools meta-tool ONLY when the feature is enabled; when off the
|
||||||
|
// toolset and behavior are exactly as before.
|
||||||
|
const tools = deferredEnabled
|
||||||
|
? {
|
||||||
|
...baseTools,
|
||||||
|
[LOAD_TOOLS_NAME]: makeLoadToolsTool(activatedTools, validDeferredNames),
|
||||||
|
}
|
||||||
|
: baseTools;
|
||||||
|
|
||||||
// Accumulate the turn's streamed output so a provider error / disconnect can
|
// Accumulate the turn's streamed output so a provider error / disconnect can
|
||||||
// persist the PARTIAL answer the user already saw — the SDK's onError/onAbort
|
// persist the PARTIAL answer the user already saw — the SDK's onError/onAbort
|
||||||
@@ -799,7 +882,8 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// ends with no assistant text (an empty turn). prepareAgentStep forbids
|
// ends with no assistant text (an empty turn). prepareAgentStep forbids
|
||||||
// further tool calls and appends a synthesis instruction on that step,
|
// further tool calls and appends a synthesis instruction on that step,
|
||||||
// concatenated onto the original `system` so the persona is preserved.
|
// concatenated onto the original `system` so the persona is preserved.
|
||||||
prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system),
|
prepareStep: ({ stepNumber }) =>
|
||||||
|
prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled),
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
onChunk: ({ chunk }) => {
|
onChunk: ({ chunk }) => {
|
||||||
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
|
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { resolveCurrentPageResult } from './current-page.util';
|
|||||||
import { parseNodeArg } from './parse-node-arg';
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
import { modelFriendlyInput } from './model-friendly-input';
|
import { modelFriendlyInput } from './model-friendly-input';
|
||||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
||||||
|
import {
|
||||||
|
buildInAppDeferredCatalog,
|
||||||
|
type ToolCatalogEntry,
|
||||||
|
} from './tool-tiers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
||||||
@@ -123,6 +127,18 @@ export class AiChatToolsService {
|
|||||||
return client.exportPageMarkdown(pageId);
|
return client.exportPageMarkdown(pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the IN-APP deferred <tool_catalog> entries (#332): one "name — purpose"
|
||||||
|
* line per DEFERRED tool, merging the per-layer INLINE_TOOL_TIERS with the
|
||||||
|
* shared registry's own catalogLine. Loads @docmost/mcp for the shared specs
|
||||||
|
* (memoized). Core tools are always active and are NOT listed here. External
|
||||||
|
* MCP tools are catalogued separately by the caller (they are runtime-scoped).
|
||||||
|
*/
|
||||||
|
async getInAppDeferredCatalog(): Promise<ToolCatalogEntry[]> {
|
||||||
|
const { sharedToolSpecs } = await loadDocmostMcp();
|
||||||
|
return buildInAppDeferredCatalog(sharedToolSpecs);
|
||||||
|
}
|
||||||
|
|
||||||
async forUser(
|
async forUser(
|
||||||
user: User,
|
user: User,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|||||||
@@ -241,6 +241,11 @@ export interface SharedToolSpec {
|
|||||||
mcpName: string;
|
mcpName: string;
|
||||||
inAppKey: string;
|
inAppKey: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
// Deferred-tool metadata (#332). Optional in this mirror so an older/stale
|
||||||
|
// @docmost/mcp build (pre-#332) still type-checks; the in-app catalog builder
|
||||||
|
// reads them defensively. The external /mcp server ignores both fields.
|
||||||
|
tier?: 'core' | 'deferred';
|
||||||
|
catalogLine?: string;
|
||||||
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
|
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
|
||||||
// its own zod (v4) and the MCP package its own (v3) into the same builder.
|
// its own zod (v4) and the MCP package its own (v3) into the same builder.
|
||||||
buildShape?: (z: any) => Record<string, unknown>;
|
buildShape?: (z: any) => Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import {
|
||||||
|
CORE_TOOL_KEYS,
|
||||||
|
CORE_TOOL_SET,
|
||||||
|
LOAD_TOOLS_NAME,
|
||||||
|
LOAD_TOOLS_DESCRIPTION,
|
||||||
|
INLINE_TOOL_TIERS,
|
||||||
|
buildInAppDeferredCatalog,
|
||||||
|
buildExternalToolCatalog,
|
||||||
|
shortenForCatalog,
|
||||||
|
applyLoadTools,
|
||||||
|
} from './tool-tiers';
|
||||||
|
// The real shared registry, imported from source (same approach as the
|
||||||
|
// SHARED_TOOL_SPECS contract spec) so the tier metadata is checked against
|
||||||
|
// exactly what @docmost/mcp ships.
|
||||||
|
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
|
||||||
|
// For the live-toolset partition test (F3): the REAL adapter, so the catalog is
|
||||||
|
// checked against the tools AiChatToolsService.forUser() actually builds — not a
|
||||||
|
// static list that could drift from it.
|
||||||
|
import { AiChatToolsService } from './ai-chat-tools.service';
|
||||||
|
import * as loader from './docmost-client.loader';
|
||||||
|
import type { DocmostClientLike } from './docmost-client.loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading — tier metadata, catalog assembly, and the
|
||||||
|
* loadTools meta-tool. Pure units; no Nest graph, no @docmost/mcp build (the
|
||||||
|
* registry is imported from TS source).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('tool tier metadata (#332)', () => {
|
||||||
|
it('core set is the documented 13 + searchInPage (14)', () => {
|
||||||
|
expect(CORE_TOOL_KEYS).toHaveLength(14);
|
||||||
|
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
||||||
|
// loadTools is a meta-tool, not a normal core key.
|
||||||
|
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARED_TOOL_SPECS tier agrees with CORE_TOOL_SET for every shared tool', () => {
|
||||||
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||||
|
const isCoreByTier = spec.tier === 'core';
|
||||||
|
const isCoreByList = CORE_TOOL_SET.has(key);
|
||||||
|
expect(isCoreByTier).toBe(isCoreByList);
|
||||||
|
// Every spec carries a non-empty catalogLine (core tools too).
|
||||||
|
expect(typeof spec.catalogLine).toBe('string');
|
||||||
|
expect(spec.catalogLine.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every INLINE tool tier agrees with CORE_TOOL_SET and has a catalogLine', () => {
|
||||||
|
for (const [key, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||||
|
expect(meta.tier === 'core').toBe(CORE_TOOL_SET.has(key));
|
||||||
|
expect(meta.catalogLine.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildInAppDeferredCatalog (#332)', () => {
|
||||||
|
const catalog = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never);
|
||||||
|
const names = catalog.map((e) => e.name);
|
||||||
|
|
||||||
|
it('includes deferred tools from BOTH the inline map and the shared registry', () => {
|
||||||
|
expect(names).toContain('transformPage'); // inline deferred
|
||||||
|
expect(names).toContain('getPageJson'); // shared deferred
|
||||||
|
expect(names).toContain('patchNode'); // shared deferred
|
||||||
|
expect(names).toContain('createPage'); // inline deferred
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NEVER lists a core tool', () => {
|
||||||
|
for (const core of CORE_TOOL_KEYS) {
|
||||||
|
expect(names).not.toContain(core);
|
||||||
|
}
|
||||||
|
// spot-check a couple that are core in each source.
|
||||||
|
expect(names).not.toContain('searchInPage'); // shared core
|
||||||
|
expect(names).not.toContain('searchPages'); // inline core
|
||||||
|
expect(names).not.toContain('editPageText'); // shared core
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders every entry as a "name — purpose" line', () => {
|
||||||
|
// Non-empty catalog (the length is pinned structurally by the live-toolset
|
||||||
|
// partition test below, not by a magic constant that rots on every new tool).
|
||||||
|
expect(catalog.length).toBeGreaterThan(0);
|
||||||
|
for (const entry of catalog) {
|
||||||
|
expect(entry.catalogLine).toMatch(/ — /);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F3 — the deferred <tool_catalog> is built from STATIC metadata (INLINE_TOOL_TIERS
|
||||||
|
* + SHARED_TOOL_SPECS), but the loadable-by-name set is derived at RUNTIME from the
|
||||||
|
* actual toolset (`Object.keys(baseTools)` in ai-chat.service.ts). Those two must
|
||||||
|
* agree or a tool becomes loadable-but-invisible (agent thinks it doesn't exist) or
|
||||||
|
* catalogued-but-phantom. INLINE_TOOL_TIERS is a plain hand-maintained Record with
|
||||||
|
* no compile-time link to the tools AiChatToolsService.forUser() builds, so nothing
|
||||||
|
* else catches that drift. This test uses forUser()'s LIVE keys as the source of
|
||||||
|
* truth (mirroring ai-chat-tools.service.spec.ts's loader mock) and asserts a
|
||||||
|
* two-way partition against buildInAppDeferredCatalog — replacing the old magic
|
||||||
|
* toHaveLength(28), so a tool added to forUser() without a catalog line (or a
|
||||||
|
* catalog line without a real tool) fails the suite instead of silently vanishing.
|
||||||
|
*/
|
||||||
|
describe('deferred catalog ↔ live forUser() toolset partition (#332, F3)', () => {
|
||||||
|
let toolKeys: string[];
|
||||||
|
const catalogNames = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never).map(
|
||||||
|
(e) => e.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Intercept the ESM loader so forUser() builds against the TS-source shared
|
||||||
|
// specs (no @docmost/mcp build) and never touches the network.
|
||||||
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||||
|
DocmostClient: function () {
|
||||||
|
return {} as DocmostClientLike;
|
||||||
|
} as unknown as loader.DocmostClientCtor,
|
||||||
|
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
|
||||||
|
});
|
||||||
|
const service = new AiChatToolsService(
|
||||||
|
{
|
||||||
|
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||||
|
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||||
|
} as never,
|
||||||
|
{} as never, // aiService — not exercised while merely BUILDING the tools
|
||||||
|
{} as never, // pageEmbeddingRepo
|
||||||
|
{} as never, // spaceMemberRepo
|
||||||
|
{} as never, // pagePermissionRepo
|
||||||
|
// sandboxStore: forUser() eagerly calls asSink() to wire the stash tool.
|
||||||
|
{
|
||||||
|
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
const tools = await service.forUser(
|
||||||
|
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||||
|
'session-1',
|
||||||
|
'ws-1',
|
||||||
|
'chat-1',
|
||||||
|
);
|
||||||
|
toolKeys = Object.keys(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a non-trivial toolset (sanity: the mock actually built tools)', () => {
|
||||||
|
expect(toolKeys.length).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every non-core live tool is present in the catalog (no capability silently hidden)', () => {
|
||||||
|
// forUser() does not itself add loadTools (ai-chat.service does), but guard
|
||||||
|
// anyway. Every remaining non-core key MUST have a catalog line.
|
||||||
|
const catalogSet = new Set(catalogNames);
|
||||||
|
const missing = toolKeys.filter(
|
||||||
|
(k) => !CORE_TOOL_SET.has(k) && k !== LOAD_TOOLS_NAME && !catalogSet.has(k),
|
||||||
|
);
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every catalog entry corresponds to a real, non-core live tool (no phantom)', () => {
|
||||||
|
const liveSet = new Set(toolKeys);
|
||||||
|
const phantom = catalogNames.filter(
|
||||||
|
(n) => !liveSet.has(n) || CORE_TOOL_SET.has(n),
|
||||||
|
);
|
||||||
|
expect(phantom).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildExternalToolCatalog + shortenForCatalog (#332)', () => {
|
||||||
|
it('derives a short "name — purpose" line from each external tool description', () => {
|
||||||
|
const catalog = buildExternalToolCatalog({
|
||||||
|
tavily_search: { description: 'Search the web for fresh results. More detail here.' },
|
||||||
|
tavily_extract: { description: '' },
|
||||||
|
});
|
||||||
|
expect(catalog).toEqual([
|
||||||
|
{ name: 'tavily_search', catalogLine: 'tavily_search — Search the web for fresh results.' },
|
||||||
|
{ name: 'tavily_extract', catalogLine: 'tavily_extract — external tool' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps a very long description', () => {
|
||||||
|
const long = 'x'.repeat(500);
|
||||||
|
expect(shortenForCatalog(long).length).toBeLessThanOrEqual(140);
|
||||||
|
expect(shortenForCatalog(long).endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyLoadTools (#332)', () => {
|
||||||
|
const valid = new Set(['createPage', 'transformPage', 'tavily_search']);
|
||||||
|
|
||||||
|
it('adds valid names to the activated set and returns { loaded }', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
const result = applyLoadTools(['createPage', 'tavily_search'], activated, valid);
|
||||||
|
expect(result).toEqual({ loaded: ['createPage', 'tavily_search'] });
|
||||||
|
expect(activated.has('createPage')).toBe(true);
|
||||||
|
expect(activated.has('tavily_search')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown name with an error listing the valid deferred names', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
expect(() => applyLoadTools(['nope'], activated, valid)).toThrow(/unknown tool name/i);
|
||||||
|
try {
|
||||||
|
applyLoadTools(['nope'], activated, valid);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as Error).message;
|
||||||
|
// Lists every valid name (sorted).
|
||||||
|
expect(msg).toContain('createPage');
|
||||||
|
expect(msg).toContain('transformPage');
|
||||||
|
expect(msg).toContain('tavily_search');
|
||||||
|
}
|
||||||
|
// Nothing is activated on a rejected call.
|
||||||
|
expect(activated.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a non-array / empty input (loads nothing)', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
expect(applyLoadTools(undefined, activated, valid)).toEqual({ loaded: [] });
|
||||||
|
expect(applyLoadTools([], activated, valid)).toEqual({ loaded: [] });
|
||||||
|
expect(activated.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadTools description is the verbatim issue text', () => {
|
||||||
|
expect(LOAD_TOOLS_DESCRIPTION).toContain('only ACTIVATES them');
|
||||||
|
expect(LOAD_TOOLS_DESCRIPTION).toContain('callable on your NEXT step');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editorial "Corrector" scenario is fully served by CORE (#332)', () => {
|
||||||
|
it('read + comment + edit + search need no loadTools', () => {
|
||||||
|
// A Corrector role reads a page, searches within it, edits text, and leaves
|
||||||
|
// inline comments — every tool it needs is core, so it never has to load a
|
||||||
|
// deferred tool.
|
||||||
|
const needed = [
|
||||||
|
'getCurrentPage',
|
||||||
|
'getPage',
|
||||||
|
'searchPages',
|
||||||
|
'searchInPage',
|
||||||
|
'editPageText',
|
||||||
|
'createComment',
|
||||||
|
'listComments',
|
||||||
|
'getComment',
|
||||||
|
'resolveComment',
|
||||||
|
];
|
||||||
|
for (const t of needed) {
|
||||||
|
expect(CORE_TOOL_SET.has(t)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { tool, type Tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { SharedToolSpec } from './docmost-client.loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferred tool loading for the in-app AI chat (#332).
|
||||||
|
*
|
||||||
|
* The agent otherwise sends ALL ~41 tool definitions on EVERY model call every
|
||||||
|
* step, bloating context. Instead we split the in-app tools into two tiers:
|
||||||
|
*
|
||||||
|
* - CORE (hot, always active): frequent OR tiny tools whose full schema is
|
||||||
|
* always visible, plus the `loadTools` meta-tool. Deferring a one-line tool is
|
||||||
|
* pure loss, so tiny tools stay core even if rare.
|
||||||
|
* - DEFERRED (loaded on demand): the fat/rare tools + ALL external MCP tools by
|
||||||
|
* default. The model sees only a compact <tool_catalog> (name — purpose) and
|
||||||
|
* calls `loadTools(names)` to ACTIVATE a tool's full schema for the NEXT step
|
||||||
|
* (one extra round-trip on first use).
|
||||||
|
*
|
||||||
|
* This module is the single source of truth for the IN-APP tiering:
|
||||||
|
* - CORE_TOOL_KEYS / CORE_TOOL_SET — the authoritative core list (used by
|
||||||
|
* prepareAgentStep to build per-step `activeTools`).
|
||||||
|
* - INLINE_TOOL_TIERS — tier + catalogLine for the per-layer INLINE tools (the
|
||||||
|
* ones NOT in @docmost/mcp's SHARED_TOOL_SPECS, which carry their own).
|
||||||
|
* - buildInAppDeferredCatalog / buildExternalToolCatalog — assemble the
|
||||||
|
* <tool_catalog> deferred lines.
|
||||||
|
* - applyLoadTools / makeLoadToolsTool — the loadTools meta-tool.
|
||||||
|
*
|
||||||
|
* The tier/catalogLine fields on SHARED_TOOL_SPECS are IN-APP metadata only; the
|
||||||
|
* external /mcp server ignores them and exposes every tool normally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A single rendered <tool_catalog> line: the tool name + its "name — purpose". */
|
||||||
|
export interface ToolCatalogEntry {
|
||||||
|
/** Exact tool name the model must pass to loadTools. */
|
||||||
|
name: string;
|
||||||
|
/** Hand-written (in-app) or derived (external) "name — purpose" line. */
|
||||||
|
catalogLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools. `searchInPage`
|
||||||
|
* (#330) is added to core on top of the issue's original tier list: it is
|
||||||
|
* frequent for the editorial roles this feature targets. `loadTools` is active
|
||||||
|
* too but is not a normal tool key (it is added to activeTools separately).
|
||||||
|
*/
|
||||||
|
export const CORE_TOOL_KEYS = [
|
||||||
|
'searchPages',
|
||||||
|
'listPages',
|
||||||
|
'listSpaces',
|
||||||
|
'getWorkspace',
|
||||||
|
'getCurrentPage',
|
||||||
|
'getPage',
|
||||||
|
'getOutline',
|
||||||
|
'getNode',
|
||||||
|
'createComment',
|
||||||
|
'getComment',
|
||||||
|
'listComments',
|
||||||
|
'resolveComment',
|
||||||
|
'editPageText',
|
||||||
|
// #330 search_in_page — frequent for editorial sweeps; core despite predating
|
||||||
|
// the issue's tier list.
|
||||||
|
'searchInPage',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** O(1) membership test for the core tier. */
|
||||||
|
export const CORE_TOOL_SET: ReadonlySet<string> = new Set(CORE_TOOL_KEYS);
|
||||||
|
|
||||||
|
/** The meta-tool name (always active alongside the core tools when enabled). */
|
||||||
|
export const LOAD_TOOLS_NAME = 'loadTools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loadTools description — VERBATIM from issue #332. Tells the model that the
|
||||||
|
* catalog names EXIST, that loadTools only ACTIVATES them (callable next step),
|
||||||
|
* and to load several at once.
|
||||||
|
*/
|
||||||
|
export const LOAD_TOOLS_DESCRIPTION =
|
||||||
|
'loadTools — Load the full definitions of deferred tools from the <tool_catalog>\n' +
|
||||||
|
'block in your instructions. Pass the EXACT tool names from the catalog; this\n' +
|
||||||
|
'call only ACTIVATES them and returns { loaded: [...] } — the tools become\n' +
|
||||||
|
'callable on your NEXT step. Load several names in one call when the task clearly\n' +
|
||||||
|
'needs them. Unknown names are rejected with the list of valid ones.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier + catalogLine for the INLINE ai-chat tools — those defined per-layer in
|
||||||
|
* ai-chat-tools.service.ts and NOT present in @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
|
* (which carries its own tier/catalogLine). Together with the shared registry
|
||||||
|
* this describes every in-app tool. catalogLine is present for core tools too
|
||||||
|
* (uniformity), but only DEFERRED tools are rendered into the catalog.
|
||||||
|
*/
|
||||||
|
export const INLINE_TOOL_TIERS: Record<
|
||||||
|
string,
|
||||||
|
{ tier: 'core' | 'deferred'; catalogLine: string }
|
||||||
|
> = {
|
||||||
|
// --- core inline ---
|
||||||
|
searchPages: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'searchPages — hybrid semantic + keyword search across the wiki.',
|
||||||
|
},
|
||||||
|
getCurrentPage: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||||
|
},
|
||||||
|
getPage: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||||
|
},
|
||||||
|
listPages: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||||
|
},
|
||||||
|
listComments: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'listComments — list all comments on a page (including resolved).',
|
||||||
|
},
|
||||||
|
getComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getComment — fetch a single comment by id.',
|
||||||
|
},
|
||||||
|
createComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'createComment — add an inline comment (optionally with a suggested edit).',
|
||||||
|
},
|
||||||
|
resolveComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- deferred inline ---
|
||||||
|
createPage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
||||||
|
},
|
||||||
|
updatePageContent: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
||||||
|
},
|
||||||
|
renamePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "renamePage — change a page's title only (body untouched).",
|
||||||
|
},
|
||||||
|
movePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
||||||
|
},
|
||||||
|
deletePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
||||||
|
},
|
||||||
|
listSidebarPages: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"listSidebarPages — list a space's root pages or a page's direct children.",
|
||||||
|
},
|
||||||
|
getTable: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||||
|
},
|
||||||
|
checkNewComments: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'checkNewComments — find comments in a space created after a timestamp.',
|
||||||
|
},
|
||||||
|
getPageHistory: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||||
|
},
|
||||||
|
exportPageMarkdown: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||||
|
},
|
||||||
|
updatePageJson: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
||||||
|
},
|
||||||
|
tableInsertRow: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
||||||
|
},
|
||||||
|
tableDeleteRow: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
||||||
|
},
|
||||||
|
tableUpdateCell: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
||||||
|
},
|
||||||
|
sharePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
||||||
|
},
|
||||||
|
transformPage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the <tool_catalog> deferred lines for the IN-APP tools by merging the
|
||||||
|
* two metadata sources: the per-layer INLINE_TOOL_TIERS and the shared registry
|
||||||
|
* (SHARED_TOOL_SPECS, loaded at runtime). Only DEFERRED tools are included; core
|
||||||
|
* tools are always active and never appear in the catalog. Pure — the caller
|
||||||
|
* passes the loaded specs so this stays unit-testable.
|
||||||
|
*/
|
||||||
|
export function buildInAppDeferredCatalog(
|
||||||
|
sharedToolSpecs: Record<string, SharedToolSpec>,
|
||||||
|
): ToolCatalogEntry[] {
|
||||||
|
const entries: ToolCatalogEntry[] = [];
|
||||||
|
// Inline deferred tools (hand-written lines).
|
||||||
|
for (const [name, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||||
|
if (meta.tier === 'deferred') {
|
||||||
|
entries.push({ name, catalogLine: meta.catalogLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shared deferred tools (line comes from the registry's own catalogLine).
|
||||||
|
for (const [name, spec] of Object.entries(sharedToolSpecs)) {
|
||||||
|
if (spec.tier === 'deferred' && spec.catalogLine) {
|
||||||
|
entries.push({ name, catalogLine: spec.catalogLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap an external tool's (untrusted) description into a short catalog purpose.
|
||||||
|
* External MCP tools have no hand-written catalogLine, so we derive one from the
|
||||||
|
* first sentence of the description, hard-capped. Whitespace is collapsed.
|
||||||
|
*/
|
||||||
|
export function shortenForCatalog(description: string, max = 140): string {
|
||||||
|
const flat = description.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!flat) return 'external tool';
|
||||||
|
// Prefer the first sentence if it is reasonably short.
|
||||||
|
const firstSentence = flat.split(/(?<=[.!?])\s/)[0];
|
||||||
|
const base =
|
||||||
|
firstSentence.length > 0 && firstSentence.length <= max
|
||||||
|
? firstSentence
|
||||||
|
: flat;
|
||||||
|
return base.length > max ? `${base.slice(0, max - 1).trimEnd()}…` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build catalog lines for the EXTERNAL MCP tools (all deferred by default,
|
||||||
|
* #332). Their names are the namespaced tool keys; the purpose is derived from
|
||||||
|
* each tool's own description (no hand-written line exists). Pure.
|
||||||
|
*/
|
||||||
|
export function buildExternalToolCatalog(
|
||||||
|
externalTools: Record<string, { description?: string } | undefined>,
|
||||||
|
): ToolCatalogEntry[] {
|
||||||
|
return Object.entries(externalTools).map(([name, t]) => ({
|
||||||
|
name,
|
||||||
|
catalogLine: `${name} — ${shortenForCatalog(t?.description ?? '')}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure core of the loadTools meta-tool. Validates the requested names against
|
||||||
|
* the per-turn set of valid deferred names, ADDS the valid ones to the caller's
|
||||||
|
* mutable `activatedTools` set (so they become callable next step), and returns
|
||||||
|
* `{ loaded }`. An unknown name throws a clear error listing the valid deferred
|
||||||
|
* names — surfaced to the model as a tool error so it can retry.
|
||||||
|
*/
|
||||||
|
export function applyLoadTools(
|
||||||
|
names: unknown,
|
||||||
|
activatedTools: Set<string>,
|
||||||
|
validDeferredNames: ReadonlySet<string>,
|
||||||
|
): { loaded: string[] } {
|
||||||
|
const requested = Array.isArray(names)
|
||||||
|
? names.filter((n): n is string => typeof n === 'string')
|
||||||
|
: [];
|
||||||
|
const unknown = requested.filter((n) => !validDeferredNames.has(n));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
const valid = [...validDeferredNames].sort().join(', ');
|
||||||
|
throw new Error(
|
||||||
|
`loadTools: unknown tool name(s): ${unknown.join(', ')}. ` +
|
||||||
|
`Valid deferred tools are: ${valid || '(none)'}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const n of requested) activatedTools.add(n);
|
||||||
|
return { loaded: requested };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the loadTools AI-SDK tool bound to THIS turn's mutable state: the
|
||||||
|
* `activatedTools` set (grown by execute, read by prepareAgentStep next step)
|
||||||
|
* and the `validDeferredNames` set (every non-core tool in this turn's toolset,
|
||||||
|
* incl. external MCP). Created per streamText call — never module-global.
|
||||||
|
*/
|
||||||
|
export function makeLoadToolsTool(
|
||||||
|
activatedTools: Set<string>,
|
||||||
|
validDeferredNames: ReadonlySet<string>,
|
||||||
|
): Tool {
|
||||||
|
return tool({
|
||||||
|
description: LOAD_TOOLS_DESCRIPTION,
|
||||||
|
inputSchema: z.object({
|
||||||
|
names: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe(
|
||||||
|
'EXACT deferred tool names from the <tool_catalog> to activate for ' +
|
||||||
|
'your next step.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ names }) =>
|
||||||
|
applyLoadTools(names, activatedTools, validDeferredNames),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -261,6 +261,21 @@ export class EnvironmentService {
|
|||||||
return disable === 'true';
|
return disable === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferred tool loading for the in-app AI chat (#332). When enabled, the agent
|
||||||
|
* sees a compact <tool_catalog> and only CORE tools + the loadTools meta-tool
|
||||||
|
* are active each step; deferred tools (the fat/rare ones + all external MCP
|
||||||
|
* tools) load on demand. Defaults to ENABLED — the issue treats deferred
|
||||||
|
* loading as the new behavior; set AI_CHAT_DEFERRED_TOOLS=false to restore the
|
||||||
|
* old "all tools always active" behavior.
|
||||||
|
*/
|
||||||
|
isAiChatDeferredToolsEnabled(): boolean {
|
||||||
|
const enabled = this.configService
|
||||||
|
.get<string>('AI_CHAT_DEFERRED_TOOLS', 'true')
|
||||||
|
.toLowerCase();
|
||||||
|
return enabled === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
getPostHogHost(): string {
|
getPostHogHost(): string {
|
||||||
return this.configService.get<string>('POSTHOG_HOST');
|
return this.configService.get<string>('POSTHOG_HOST');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
@@ -146,6 +148,9 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
{} as any, // aiAgentRoleRepo (role is pre-resolved + passed in)
|
{} as any, // aiAgentRoleRepo (role is pre-resolved + passed in)
|
||||||
{} as any, // pageRepo (only used when body.openPage is set)
|
{} as any, // pageRepo (only used when body.openPage is set)
|
||||||
{} as any, // pageAccess (idem)
|
{} as any, // pageAccess (idem)
|
||||||
|
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
||||||
|
// harness so the toolset/behavior is exactly as before.
|
||||||
|
{ isAiChatDeferredToolsEnabled: () => false } as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,4 +320,174 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading, the ON path. The riskiest property is that the
|
||||||
|
* per-turn `activatedTools` Set is created FRESH inside each stream() call, so a
|
||||||
|
* tool a previous turn activated via loadTools is NOT still active when the next
|
||||||
|
* turn starts — the new turn begins "cold" (CORE + loadTools only). The unit
|
||||||
|
* tests only exercise pure prepareAgentStep with hand-fed Sets; this pins the
|
||||||
|
* real wiring end-to-end (loadTools.execute -> activatedTools -> prepareStep ->
|
||||||
|
* per-step activeTools) against the real streamText loop, and proves there is no
|
||||||
|
* cross-turn leak. We drive a MockLanguageModelV3 whose step 1 calls
|
||||||
|
* loadTools(['createPage']) and assert, via the model's recorded per-step
|
||||||
|
* CallOptions.tools (the AI SDK filters the provider tool list by activeTools),
|
||||||
|
* that the deferred tool becomes active on the SAME turn's next step but NOT on a
|
||||||
|
* fresh turn's first step.
|
||||||
|
*/
|
||||||
|
describe('deferred tool loading ON — per-turn activation, no leak (#332)', () => {
|
||||||
|
// A stub deferred (non-core) tool the agent can activate. Its execute is never
|
||||||
|
// called — the model only needs to SEE it become active — but it must be a
|
||||||
|
// valid AI-SDK tool so the SDK includes it in a step's tool list once active.
|
||||||
|
const createPageStub = tool({
|
||||||
|
description: 'create a new page',
|
||||||
|
inputSchema: z.object({ title: z.string() }),
|
||||||
|
execute: async () => ({ id: 'p-stub' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A CORE tool in the toolset, so a cold step shows CORE tools ARE active while
|
||||||
|
// the deferred createPage is not. `searchPages` is in CORE_TOOL_SET.
|
||||||
|
const searchPagesStub = tool({
|
||||||
|
description: 'search the wiki',
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same lifecycle harness as buildService() above, but with deferred loading ON
|
||||||
|
// and a toolset that exposes exactly one deferred tool (createPage) so it is
|
||||||
|
// catalogued + loadable-by-name. Kept separate so the OFF scenarios are
|
||||||
|
// untouched.
|
||||||
|
function buildDeferredService(): AiChatService {
|
||||||
|
return new AiChatService(
|
||||||
|
{ getChatModel: async () => null } as any,
|
||||||
|
aiChatRepo,
|
||||||
|
msgRepo,
|
||||||
|
{} as any,
|
||||||
|
{ resolve: async () => null } as any,
|
||||||
|
{
|
||||||
|
forUser: async () => ({
|
||||||
|
searchPages: searchPagesStub,
|
||||||
|
createPage: createPageStub,
|
||||||
|
}),
|
||||||
|
getInAppDeferredCatalog: async () => [
|
||||||
|
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
mcpClients as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
// #332: deferred tool loading ON — the property under test.
|
||||||
|
{ isAiChatDeferredToolsEnabled: () => true } as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive ONE stream() turn against `model` and wait for the assistant row to
|
||||||
|
// settle (mirrors runStream, but builds the deferred-ON service).
|
||||||
|
async function runDeferredTurn(
|
||||||
|
model: MockLanguageModelV3,
|
||||||
|
chatId: string,
|
||||||
|
body: any,
|
||||||
|
): Promise<void> {
|
||||||
|
closeCalls = 0;
|
||||||
|
const service = buildDeferredService();
|
||||||
|
const { res, cleanup } = await makeRealResponse();
|
||||||
|
try {
|
||||||
|
await service.stream({
|
||||||
|
user: { id: userId, workspaceId } as any,
|
||||||
|
workspace: { id: workspaceId, name: 'WS' } as any,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body,
|
||||||
|
res: { raw: res } as any,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: model as any,
|
||||||
|
role: null,
|
||||||
|
} as any);
|
||||||
|
await waitFor(async () => {
|
||||||
|
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
|
||||||
|
return rows.some(
|
||||||
|
(r) =>
|
||||||
|
r.role === 'assistant' &&
|
||||||
|
['completed', 'error', 'aborted'].includes(r.status as string),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitFor(() => closeCalls > 0, { timeoutMs: 5_000 });
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool names the provider actually received for a recorded step (activeTools
|
||||||
|
// filters this list, so it reflects what was active that step).
|
||||||
|
const toolNames = (call: any): string[] =>
|
||||||
|
((call?.tools ?? []) as any[]).map((t) => t?.name).filter(Boolean);
|
||||||
|
|
||||||
|
// A model that, on step 1, calls loadTools(['createPage']); on step 2, answers.
|
||||||
|
function loadThenAnswerModel(): MockLanguageModelV3 {
|
||||||
|
let step = 0;
|
||||||
|
return new MockLanguageModelV3({
|
||||||
|
doStream: async () => {
|
||||||
|
const n = step++;
|
||||||
|
if (n === 0) {
|
||||||
|
return {
|
||||||
|
stream: convertArrayToReadableStream([
|
||||||
|
{ type: 'stream-start', warnings: [] },
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'lt1',
|
||||||
|
toolName: 'loadTools',
|
||||||
|
input: JSON.stringify({ names: ['createPage'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'finish',
|
||||||
|
finishReason: 'tool-calls',
|
||||||
|
usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 },
|
||||||
|
},
|
||||||
|
] as any),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { stream: successStream() };
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('activates a deferred tool for the SAME turn, and a NEW turn starts cold (no leak)', async () => {
|
||||||
|
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||||
|
|
||||||
|
// --- Turn 1: loadTools(createPage) on step 1, then answer on step 2. ---
|
||||||
|
const model1 = loadThenAnswerModel();
|
||||||
|
await runDeferredTurn(model1, chatId, {
|
||||||
|
chatId,
|
||||||
|
messages: [userUiMessage('Make me a page')],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The turn ran at least two steps (the load round-trip + the answer).
|
||||||
|
expect(model1.doStreamCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const step1Tools = toolNames(model1.doStreamCalls[0]);
|
||||||
|
const step2Tools = toolNames(model1.doStreamCalls[1]);
|
||||||
|
|
||||||
|
// Step 1 starts cold: CORE tools + the loadTools meta-tool are active, but
|
||||||
|
// the deferred createPage is NOT yet.
|
||||||
|
expect(step1Tools).toContain('loadTools');
|
||||||
|
expect(step1Tools).toContain('searchPages'); // a CORE tool, always active
|
||||||
|
expect(step1Tools).not.toContain('createPage');
|
||||||
|
// Step 2 of the SAME turn sees the just-activated deferred tool.
|
||||||
|
expect(step2Tools).toContain('createPage');
|
||||||
|
|
||||||
|
// --- Turn 2 on the SAME chat: must start cold again. ---
|
||||||
|
const model2 = new MockLanguageModelV3({
|
||||||
|
doStream: async () => ({ stream: successStream() }),
|
||||||
|
} as any);
|
||||||
|
await runDeferredTurn(model2, chatId, {
|
||||||
|
chatId,
|
||||||
|
messages: [userUiMessage('And another thing')],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextTurnFirstStep = toolNames(model2.doStreamCalls[0]);
|
||||||
|
expect(nextTurnFirstStep).toContain('loadTools');
|
||||||
|
// The activated set is per-turn: the prior turn's createPage did NOT leak,
|
||||||
|
// so the fresh turn's first step sees it deferred again.
|
||||||
|
expect(nextTurnFirstStep).not.toContain('createPage');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ export interface SharedToolSpec {
|
|||||||
inAppKey: string;
|
inAppKey: string;
|
||||||
/** Single canonical model-facing description used by both layers. */
|
/** Single canonical model-facing description used by both layers. */
|
||||||
description: string;
|
description: string;
|
||||||
|
/**
|
||||||
|
* Deferred-tool tier for the IN-APP agent (#332). 'core' tools are always
|
||||||
|
* active; 'deferred' tools are hidden behind the <tool_catalog> and loaded on
|
||||||
|
* demand via the loadTools meta-tool. This is an IN-APP concern only: the
|
||||||
|
* standalone /mcp server ignores this field and registers every tool normally
|
||||||
|
* (registerShared in index.ts reads mcpName/description/buildShape only).
|
||||||
|
*/
|
||||||
|
tier: 'core' | 'deferred';
|
||||||
|
/**
|
||||||
|
* Hand-written one-liner "name — purpose" shown in the in-app agent's
|
||||||
|
* <tool_catalog> for a DEFERRED tool (#332). Deliberately NOT derived from the
|
||||||
|
* description's first sentence — a concise, accurate purpose line. Present on
|
||||||
|
* every spec (core tools too) for uniformity; only deferred ones are rendered.
|
||||||
|
* Inert for the external /mcp server.
|
||||||
|
*/
|
||||||
|
catalogLine: string;
|
||||||
/**
|
/**
|
||||||
* Builds the tool's input schema as a plain object of zod fields (a
|
* Builds the tool's input schema as a plain object of zod fields (a
|
||||||
* ZodRawShape). Called with the consumer's own zod namespace. Omitted for
|
* ZodRawShape). Called with the consumer's own zod namespace. Omitted for
|
||||||
@@ -47,6 +63,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
mcpName: 'get_workspace',
|
mcpName: 'get_workspace',
|
||||||
inAppKey: 'getWorkspace',
|
inAppKey: 'getWorkspace',
|
||||||
description: 'Fetch metadata about the current workspace (name, settings).',
|
description: 'Fetch metadata about the current workspace (name, settings).',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getWorkspace — fetch current workspace metadata (name, settings).',
|
||||||
},
|
},
|
||||||
|
|
||||||
listSpaces: {
|
listSpaces: {
|
||||||
@@ -55,6 +73,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
description:
|
description:
|
||||||
'List the spaces the current user can access. Returns the array of ' +
|
'List the spaces the current user can access. Returns the array of ' +
|
||||||
'spaces (id, name, slug, ...).',
|
'spaces (id, name, slug, ...).',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'listSpaces — list the spaces the user can access (id, name, slug).',
|
||||||
},
|
},
|
||||||
|
|
||||||
listShares: {
|
listShares: {
|
||||||
@@ -62,6 +82,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
inAppKey: 'listShares',
|
inAppKey: 'listShares',
|
||||||
description:
|
description:
|
||||||
'List all public shares in the workspace with page titles and public URLs.',
|
'List all public shares in the workspace with page titles and public URLs.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'listShares — list all public shares in the workspace with their URLs.',
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- single-pageId read tools ---
|
// --- single-pageId read tools ---
|
||||||
@@ -74,6 +96,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'includes block ids, callouts, tables, link/image attributes) plus the ' +
|
'includes block ids, callouts, tables, link/image attributes) plus the ' +
|
||||||
'slugId used in URLs. Use the block ids it returns to make precise ' +
|
'slugId used in URLs. Use the block ids it returns to make precise ' +
|
||||||
'structural edits or surgical text edits without resending the page.',
|
'structural edits or surgical text edits without resending the page.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"getPageJson — get a page's raw ProseMirror JSON (lossless, with block ids).",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
@@ -88,6 +113,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'count) WITHOUT the full document body. Use it to locate sections/tables ' +
|
'count) WITHOUT the full document body. Use it to locate sections/tables ' +
|
||||||
'and grab block ids cheaply before fetching, patching or inserting ' +
|
'and grab block ids cheaply before fetching, patching or inserting ' +
|
||||||
'individual blocks.',
|
'individual blocks.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
"getOutline — compact outline of a page's top-level blocks with their ids.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
@@ -104,6 +132,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
|
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
|
||||||
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
||||||
'`#<index>` form for tables/rows/cells, which carry no id.',
|
'`#<index>` form for tables/rows/cells, which carry no id.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
"getNode — fetch one block's ProseMirror subtree by block id or #index.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
nodeId: z.string().min(1),
|
nodeId: z.string().min(1),
|
||||||
@@ -137,6 +168,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'caseSensitive:true to match case. Ideal for systematic ' +
|
'caseSensitive:true to match case. Ideal for systematic ' +
|
||||||
'editorial sweeps (unquoted "ё", straight quotes, "т.е.", stray units). An ' +
|
'editorial sweeps (unquoted "ё", straight quotes, "т.е.", stray units). An ' +
|
||||||
'invalid regex or an empty query returns a clear error to fix.',
|
'invalid regex or an empty query returns a clear error to fix.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'searchInPage — find every occurrence of a string/regex inside one page, with locations.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1).describe('ID of the page to search'),
|
pageId: z.string().min(1).describe('ID of the page to search'),
|
||||||
query: z
|
query: z
|
||||||
@@ -172,6 +206,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
description:
|
description:
|
||||||
'Remove a single block by its attrs.id (from the page outline or ' +
|
'Remove a single block by its attrs.id (from the page outline or ' +
|
||||||
'page-JSON view) WITHOUT resending the whole document.',
|
'page-JSON view) WITHOUT resending the whole document.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'deleteNode — remove a single content block by its block id.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
nodeId: z.string().min(1),
|
nodeId: z.string().min(1),
|
||||||
@@ -203,6 +239,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
|
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
|
||||||
'replacing the whole document for one-block structural edits. Reversible: ' +
|
'replacing the whole document for one-block structural edits. Reversible: ' +
|
||||||
'the previous version is kept in page history.',
|
'the previous version is kept in page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'patchNode — replace one block with a new ProseMirror node, keeping its id.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
||||||
nodeId: z
|
nodeId: z
|
||||||
@@ -245,6 +284,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||||
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'insertNode — insert a block before/after an anchor, or append at the end.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
node: z
|
node: z
|
||||||
@@ -278,6 +320,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
mcpName: 'unshare_page',
|
mcpName: 'unshare_page',
|
||||||
inAppKey: 'unsharePage',
|
inAppKey: 'unsharePage',
|
||||||
description: 'Remove the public share of a page (revokes the public URL).',
|
description: 'Remove the public share of a page (revokes the public URL).',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "unsharePage — revoke a page's public share (removes the public URL).",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1).describe('ID of the page to unshare'),
|
pageId: z.string().min(1).describe('ID of the page to unshare'),
|
||||||
}),
|
}),
|
||||||
@@ -295,6 +339,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
"`from`/`to` each accept a historyId, or null/'current' for the page's " +
|
"`from`/`to` each accept a historyId, or null/'current' for the page's " +
|
||||||
'current content (defaults: from=current, to=current — pass a historyId ' +
|
'current content (defaults: from=current, to=current — pass a historyId ' +
|
||||||
'from the page-history list to compare against the live page).',
|
'from the page-history list to compare against the live page).',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'diffPageVersions — diff two page versions and return the change set + summary.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
from: z
|
from: z
|
||||||
@@ -315,6 +362,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
"List a page's saved versions (Docmost auto-snapshots on every save), " +
|
"List a page's saved versions (Docmost auto-snapshots on every save), " +
|
||||||
'newest first, cursor-paginated. Returns { items, nextCursor }; each ' +
|
'newest first, cursor-paginated. Returns { items, nextCursor }; each ' +
|
||||||
"item's id is the historyId to pass to the page diff or restore tools.",
|
"item's id is the historyId to pass to the page diff or restore tools.",
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"listPageHistory — list a page's saved versions (newest first, paginated).",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
cursor: z
|
cursor: z
|
||||||
@@ -332,6 +382,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'as the page\'s current content (Docmost has no restore endpoint, so ' +
|
'as the page\'s current content (Docmost has no restore endpoint, so ' +
|
||||||
'this creates a NEW history snapshot — the restore is itself revertible). ' +
|
'this creates a NEW history snapshot — the restore is itself revertible). ' +
|
||||||
'Get the historyId from the page-history list.',
|
'Get the historyId from the page-history list.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'restorePageVersion — restore a page to a saved history version (revertible).',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
historyId: z.string().min(1),
|
historyId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
@@ -349,6 +402,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'thread records are NOT created/updated/deleted on the server by this ' +
|
'thread records are NOT created/updated/deleted on the server by this ' +
|
||||||
'tool — only the page body + inline comment marks are written; manage ' +
|
'tool — only the page body + inline comment marks are written; manage ' +
|
||||||
'comment threads via the comment tools/UI.',
|
'comment threads via the comment tools/UI.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"importPageMarkdown — replace a page's content from exported Docmost Markdown.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
markdown: z.string().min(1),
|
markdown: z.string().min(1),
|
||||||
@@ -365,6 +421,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'entirely server-side — the document is NOT sent through the model. The ' +
|
'entirely server-side — the document is NOT sent through the model. The ' +
|
||||||
'target keeps its own title and slug; only its body is replaced. Ideal ' +
|
'target keeps its own title and slug; only its body is replaced. Ideal ' +
|
||||||
"for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
|
"for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"copyPageContent — replace one page's body with a copy of another page's body.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
sourcePageId: z.string().min(1).describe('Page to copy content FROM'),
|
sourcePageId: z.string().min(1).describe('Page to copy content FROM'),
|
||||||
targetPageId: z
|
targetPageId: z
|
||||||
@@ -402,6 +461,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'page JSON and use a structural node patch/update to set its marks. ' +
|
'page JSON and use a structural node patch/update to set its marks. ' +
|
||||||
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
|
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
|
||||||
'world",replace:"Hello there"}] (crosses a bold boundary).',
|
'world",replace:"Hello there"}] (crosses a bold boundary).',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
"editPageText — surgical find/replace of plain text in a page, preserving ids/marks.",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().describe('ID of the page to edit'),
|
pageId: z.string().describe('ID of the page to edit'),
|
||||||
edits: z
|
edits: z
|
||||||
@@ -440,6 +502,9 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'server instance that created it: in a multi-replica deployment without ' +
|
'server instance that created it: in a multi-replica deployment without ' +
|
||||||
'sticky sessions a blob stored on one instance is not retrievable via the ' +
|
'sticky sessions a blob stored on one instance is not retrievable via the ' +
|
||||||
'sandbox URL on another (it 404s like an expired one).',
|
'sandbox URL on another (it 404s like an expired one).',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'stashPage — serialize a whole page to a short anonymous URL without loading its body.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Generated
+4
-1
@@ -269,6 +269,9 @@ importers:
|
|||||||
'@atlaskit/pragmatic-drag-and-drop-live-region':
|
'@atlaskit/pragmatic-drag-and-drop-live-region':
|
||||||
specifier: 1.3.4
|
specifier: 1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
|
'@braintree/sanitize-url':
|
||||||
|
specifier: 7.1.2
|
||||||
|
version: 7.1.2
|
||||||
'@casl/react':
|
'@casl/react':
|
||||||
specifier: 5.0.1
|
specifier: 5.0.1
|
||||||
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
||||||
@@ -16153,7 +16156,7 @@ snapshots:
|
|||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
std-env: 4.1.0
|
std-env: 4.1.0
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@27.4.0(@noble/hashes@2.0.1))(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
|
|
||||||
'@vitest/expect@4.1.6':
|
'@vitest/expect@4.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user