Merge remote-tracking branch 'gitea/develop' into feat/page-templates

# Conflicts:
#	apps/server/src/integrations/throttle/throttle.module.ts
#	apps/server/src/integrations/throttle/throttler-names.ts
This commit is contained in:
claude_code
2026-06-20 20:18:42 +03:00
130 changed files with 9951 additions and 3095 deletions

View File

@@ -13,6 +13,15 @@ export const activeAiChatIdAtom = atom(null as string | null);
// Whether the floating AI chat window is open. Non-persistent (resets per session).
export const aiChatWindowOpenAtom = atom<boolean>(false);
/**
* The agent role selected for the NEXT new chat. `null` = "Universal assistant"
* (no role). Consulted ONLY when creating a chat (its first message): the server
* persists it to ai_chats.role_id and the role is immutable afterwards. Reset to
* null when starting a new chat. It does NOT affect already-created chats.
*/
// Cast default for the same jotai overload reason as activeAiChatIdAtom above.
export const selectedAiRoleIdAtom = atom(null as string | null);
// The AI chat composer draft (text typed but not yet sent). Held here — OUTSIDE
// ChatThread — so it survives the thread remount that happens when a brand-new
// chat adopts its freshly created id after the first turn finishes. If it lived

View File

@@ -6,7 +6,7 @@ import {
useRef,
useState,
} from "react";
import { Group, Loader, Tooltip } from "@mantine/core";
import { Group, Loader, Select, Tooltip } from "@mantine/core";
import {
IconArrowsDiagonal,
IconCheck,
@@ -25,6 +25,7 @@ import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
@@ -32,6 +33,7 @@ import {
AI_CHATS_RQ_KEY,
useAiChatMessagesQuery,
useAiChatsQuery,
useAiRolesQuery,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
@@ -102,6 +104,8 @@ export default function AiChatWindow() {
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
// The role chosen for the next new chat (null = universal assistant).
const [selectedRoleId, setSelectedRoleId] = useAtom(selectedAiRoleIdAtom);
// History section starts collapsed (matches the former panel's behavior).
const [historyOpen, setHistoryOpen] = useState(false);
@@ -123,6 +127,16 @@ export default function AiChatWindow() {
const adoptNewChat = useRef(false);
const { data: chats } = useAiChatsQuery();
// Roles for the new-chat picker (any member may list them). Only fetched while
// the window is open.
const { data: roles } = useAiRolesQuery(windowOpen);
// The new-chat picker only offers ENABLED roles. The list endpoint returns
// all live roles (so the admin settings section can manage disabled ones), so
// we filter to `enabled` here, client-side, for the composer picker only.
const enabledRoles = useMemo(
() => (roles ?? []).filter((r) => r.enabled === true),
[roles],
);
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
@@ -144,7 +158,9 @@ export default function AiChatWindow() {
setActiveChatId(null);
setHistoryOpen(false);
setDraft("");
}, [setActiveChatId, setDraft]);
// Default the picker back to "Universal assistant" for the fresh chat.
setSelectedRoleId(null);
}, [setActiveChatId, setDraft, setSelectedRoleId]);
const selectChat = useCallback(
(chatId: string): void => {
@@ -343,6 +359,15 @@ export default function AiChatWindow() {
/>
<span className={classes.title}>{t("AI chat")}</span>
{/* Role badge for the active chat (emoji + name). Shown only when the
chat is bound to a role that still exists. */}
{activeChat?.roleName && (
<span className={classes.badge} title={t("Agent role")}>
{activeChat.roleEmoji ? `${activeChat.roleEmoji} ` : ""}
{activeChat.roleName}
</span>
)}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{contextTokens > 0 && (
<Tooltip label={t("Current context size")} withArrow>
@@ -400,7 +425,16 @@ export default function AiChatWindow() {
>
<div
className={classes.historyHeader}
role="button"
tabIndex={0}
aria-expanded={historyOpen}
onClick={() => setHistoryOpen((o) => !o)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setHistoryOpen((o) => !o);
}
}}
>
<IconChevronDown
size={12}
@@ -432,6 +466,29 @@ export default function AiChatWindow() {
)}
</div>
{/* Role picker — only for a NEW chat (before it is created). Once the
chat exists, its role is fixed and shown as a header badge instead.
Defaults to "Universal assistant" (no role). */}
{activeChatId === null && (enabledRoles?.length ?? 0) > 0 && (
<div style={{ padding: "4px 8px 0" }}>
<Select
size="xs"
label={t("Agent role")}
value={selectedRoleId ?? ""}
onChange={(value) => setSelectedRoleId(value || null)}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
data={[
{ value: "", label: t("Universal assistant") },
...enabledRoles.map((r) => ({
value: r.id,
label: `${r.emoji ? `${r.emoji} ` : ""}${r.name}`,
})),
]}
/>
</div>
)}
{/* body: active chat thread */}
<div className={classes.body}>
{waitingForHistory ? (
@@ -444,6 +501,8 @@ export default function AiChatWindow() {
chatId={activeChatId}
initialRows={activeChatId ? messageRows : []}
openPage={openPage}
// Honoured only for a new chat; null = universal assistant.
roleId={activeChatId === null ? selectedRoleId : null}
onTurnFinished={onTurnFinished}
/>
)}

View File

@@ -25,6 +25,10 @@ interface ChatThreadProps {
/** The page currently open in the workspace, or null on a non-page route.
* Sent with each turn so the agent knows what "this page" refers to. */
openPage?: OpenPageContext | null;
/** The agent role selected for a NEW chat (null = universal assistant). Sent
* in the request body so the server persists it on chat creation; ignored by
* the server for existing chats (the role is read from the chat row). */
roleId?: string | null;
/** Called when a turn finishes; the parent refreshes the chat list and, for
* a new chat, adopts the freshly created chat id. */
onTurnFinished: () => void;
@@ -61,6 +65,7 @@ export default function ChatThread({
chatId,
initialRows,
openPage,
roleId,
onTurnFinished,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -84,6 +89,12 @@ export default function ChatThread({
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
openPageRef.current = openPage ?? null;
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
// FIRST request of a brand-new chat uses it (the server persists it then and
// ignores it for existing chats), but sending it on every send is harmless.
const roleIdRef = useRef<string | null>(roleId ?? null);
roleIdRef.current = roleId ?? null;
// Stable `useChat` store key for the lifetime of THIS mount.
//
// CRITICAL: `useChat` (@ai-sdk/react) re-creates its internal `Chat` store
@@ -119,6 +130,9 @@ export default function ChatThread({
...body,
chatId: chatIdRef.current,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
messages,
},
}),
@@ -134,6 +148,13 @@ export default function ChatThread({
messages: initialMessages,
transport,
onFinish: () => onTurnFinished(),
// In AI SDK v6 `onFinish` does NOT fire when the stream errors, so a brand
// new chat that fails on its first turn would never invalidate the chat list
// nor adopt the server-created chat id (the server still creates the row and
// saves the error message). Run the same post-turn path on error so the
// failed chat appears in history immediately instead of after a manual
// refresh. The error itself is still surfaced via `error` below.
onError: () => onTurnFinished(),
});
const isStreaming = status === "submitted" || status === "streaming";

View File

@@ -115,11 +115,28 @@ export default function ConversationList({
classes.conversationItem,
isActive && classes.conversationItemActive,
)}
role="button"
tabIndex={0}
onClick={() => onSelect(chat.id)}
onKeyDown={(e) => {
// Activate on Enter/Space like a native button; the inner menu
// button stops propagation so its own keys never reach this row.
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(chat.id);
}
}}
>
<Text size="sm" lineClamp={1} style={{ flex: 1 }}>
{chat.title || t("Untitled chat")}
</Text>
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{chat.roleName && (
<Text size="sm" span title={chat.roleName} style={{ flex: "none" }}>
{chat.roleEmoji || "🤖"}
</Text>
)}
<Text size="sm" lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{chat.title || t("Untitled chat")}
</Text>
</Group>
<Menu shadow="md" width={180} position="bottom-end">
<Menu.Target>
<ActionIcon

View File

@@ -3,7 +3,7 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import { ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
@@ -12,11 +12,6 @@ interface MessageItemProps {
message: UIMessage;
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
/**
* Render a single UIMessage by iterating its `parts`:
* - `text` parts -> sanitized markdown.

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
@@ -11,11 +12,6 @@ interface MessageListProps {
isStreaming: boolean;
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
// Distance (px) from the bottom within which the viewport still counts as
// "pinned" — absorbs sub-pixel rounding and small content jitter.
const BOTTOM_THRESHOLD = 40;

View File

@@ -8,18 +8,26 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { notifications } from "@mantine/notifications";
import {
createAiRole,
deleteAiChat,
deleteAiRole,
getAiChatMessages,
getAiChats,
getAiRoles,
renameAiChat,
updateAiRole,
} from "@/features/ai-chat/services/ai-chat-service.ts";
import {
IAiChat,
IAiChatMessageRow,
IAiRole,
IAiRoleCreate,
IAiRoleUpdate,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"];
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages",
chatId,
@@ -114,3 +122,79 @@ export function useDeleteAiChatMutation() {
},
});
}
/**
* List the workspace's agent roles. Available to any workspace member (used by
* the chat-creation role picker and the admin management section). `enabled`
* lets callers gate the fetch (e.g. only fetch in the settings section).
*/
export function useAiRolesQuery(enabled: boolean = true) {
return useQuery<IAiRole[], Error>({
queryKey: AI_ROLES_RQ_KEY,
queryFn: () => getAiRoles(),
enabled,
});
}
export function useCreateAiRoleMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRole, Error, IAiRoleCreate>({
mutationFn: (data) => createAiRole(data),
onSuccess: () => {
notifications.show({ message: t("Created successfully") });
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useUpdateAiRoleMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRole, Error, IAiRoleUpdate>({
mutationFn: (data) => updateAiRole(data),
onSuccess: () => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// The role badge denormalized onto the chat list may have changed.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useDeleteAiRoleMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<{ success: true }, Error, string>({
mutationFn: (id) => deleteAiRole(id),
onSuccess: () => {
notifications.show({ message: t("Deleted successfully") });
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}

View File

@@ -5,6 +5,9 @@ import {
IAiChatListParams,
IAiChatMessageRow,
IAiChatMessagesParams,
IAiRole,
IAiRoleCreate,
IAiRoleUpdate,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
@@ -46,3 +49,33 @@ export async function renameAiChat(data: {
export async function deleteAiChat(chatId: string): Promise<void> {
await api.post("/ai-chat/delete", { chatId });
}
/**
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
* member (for the chat-creation picker); create/update/delete are admin-only
* (the server enforces this). Same `{ data }` unwrap convention as above.
*/
/** List the workspace's agent roles. */
export async function getAiRoles(): Promise<IAiRole[]> {
const req = await api.post<IAiRole[]>("/ai-chat/roles");
return req.data;
}
/** Create a role (admin). */
export async function createAiRole(data: IAiRoleCreate): Promise<IAiRole> {
const req = await api.post<IAiRole>("/ai-chat/roles/create", data);
return req.data;
}
/** Update a role (admin). */
export async function updateAiRole(data: IAiRoleUpdate): Promise<IAiRole> {
const req = await api.post<IAiRole>("/ai-chat/roles/update", data);
return req.data;
}
/** Soft-delete a role (admin). */
export async function deleteAiRole(id: string): Promise<{ success: true }> {
const req = await api.post<{ success: true }>("/ai-chat/roles/delete", { id });
return req.data;
}

View File

@@ -13,6 +13,63 @@ export interface IAiChat {
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
// The agent role bound to this chat, if any (immutable after creation).
roleId?: string | null;
// Denormalized via a JOIN in the chat list response (the bound role's badge).
// Null when the chat has no role or the role was soft-deleted.
roleName?: string | null;
roleEmoji?: string | null;
}
/** Supported model drivers (mirrors the server `AI_DRIVERS`). */
export type AiRoleDriver = "openai" | "gemini" | "ollama";
/** Optional per-role model override (mirrors `model_config`). */
export interface IAiRoleModelConfig {
driver?: AiRoleDriver;
chatModel?: string;
}
/**
* An agent role (mirrors the server role views). A role replaces the agent's
* persona (instructions) and may optionally override the model. The safety
* framework is always still applied server-side.
*
* The list endpoint returns the FULL view to admins and a reduced picker view to
* ordinary members, so the admin-only fields (`instructions`, `modelConfig`,
* `createdAt`, `updatedAt`) are optional here — present only for admins.
*/
export interface IAiRole {
id: string;
name: string;
emoji: string | null;
description: string | null;
instructions?: string;
modelConfig?: IAiRoleModelConfig | null;
enabled: boolean;
createdAt?: string;
updatedAt?: string;
}
/** Admin create payload for a role. */
export interface IAiRoleCreate {
name: string;
emoji?: string;
description?: string;
instructions: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
}
/** Admin update payload for a role (partial). */
export interface IAiRoleUpdate {
id: string;
name?: string;
emoji?: string;
description?: string;
instructions?: string;
modelConfig?: IAiRoleModelConfig | null;
enabled?: boolean;
}
/**

View File

@@ -5,9 +5,11 @@
*
* A tool part's `type` is `tool-${toolName}` (AI SDK v6 static tool parts) and
* its `state` is one of input-streaming / input-available / output-available /
* output-error (we only surface running / done / error). The server tools are:
* searchPages, getPage, createPage, updatePageContent, renamePage, movePage,
* deletePage, createComment, resolveComment — see ai-chat-tools.service.ts.
* output-error (we only surface running / done / error). The full toolset the
* server exposes lives in `ai-chat-tools.service.ts` (the agent now exposes the
* complete Docmost toolset); friendly action-log labels exist ONLY for the
* tools listed in `toolLabelKey` below — every other tool falls through to the
* generic "Ran tool {{name}}" label.
*/
/** A tool UI part as it arrives from `useChat` / persisted history. */
@@ -38,6 +40,11 @@ export interface ToolCitation {
href: string;
}
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
export function isToolPart(type: string): boolean {
return type.startsWith("tool-") || type === "dynamic-tool";
}
/** Extract the tool name from a part `type` of `tool-${name}` (or dynamic). */
export function getToolName(part: ToolUiPart): string {
if (part.type === "dynamic-tool") return part.toolName ?? "";

View File

@@ -116,8 +116,8 @@ function CommentListItem({
}
return (
<Box ref={ref} pb="xs">
<Group>
<Box ref={ref} pb={6}>
<Group gap="xs">
<CustomAvatar
size="sm"
avatarUrl={comment.creator.avatarUrl}
@@ -126,7 +126,7 @@ function CommentListItem({
<div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={500} lineClamp={1}>
<Text size="xs" fw={500} lineClamp={1}>
{comment.creator.name}
</Text>
@@ -177,7 +177,7 @@ function CommentListItem({
tabIndex={0}
aria-label={t("Jump to comment selection")}
>
<Text size="sm">{comment?.selection}</Text>
<Text size="xs">{comment?.selection}</Text>
</Box>
)}

View File

@@ -121,8 +121,8 @@ function CommentListWithTabs() {
<Paper
shadow="sm"
radius="md"
p="sm"
mb="sm"
p="xs"
mb="xs"
withBorder
key={comment.id}
data-comment-id={comment.id}
@@ -145,7 +145,7 @@ function CommentListWithTabs() {
{!comment.resolvedAt && canComment && (
<>
<Divider my={4} />
<Divider my={2} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}

View File

@@ -1,15 +1,11 @@
.wrapper {
padding: var(--mantine-spacing-md);
}
.focused-thread {
border: 2px solid #8d7249;
}
.textSelection {
margin-top: 4px;
margin-top: 2px;
border-left: 2px solid var(--mantine-color-gray-6);
padding: 8px;
padding: 6px;
background: var(--mantine-color-gray-light);
cursor: pointer;
overflow-wrap: break-word;
@@ -32,6 +28,9 @@
box-shadow: 0 0 0 2px var(--mantine-color-blue-3);
}
/* Denser comments: override the global 16px ProseMirror body size with 14px
and tighten the rhythm vs. the comment header. Scoped to the comment
editor only - the page editor is unaffected. */
.ProseMirror :global(.ProseMirror){
border-radius: var(--mantine-radius-sm);
max-width: 100%;
@@ -39,7 +38,9 @@
word-break: break-word;
padding-left: 6px;
padding-right: 6px;
margin-top: 10px;
font-size: var(--mantine-font-size-sm);
line-height: 1.4;
margin-top: 4px;
margin-bottom: 2px;
}

View File

@@ -360,6 +360,16 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
queryKey,
(old) => {
if (!old) return old;
// Idempotency guard: the server now self-echoes addTreeNode back to the
// author, so this writer can run twice for one create (mutation onSuccess
// + socket echo). Skip the append if the page is already in the cache to
// avoid a duplicate node / duplicate React key.
const exists = old.pages.some((page) =>
page.items.some((item) => item.id === newPage.id),
);
if (exists) return old;
return {
...old,
pages: old.pages.map((page, index) => {

View File

@@ -92,6 +92,14 @@ export async function getAllSidebarPages(
};
}
export async function getSpaceTree(params: {
spaceId: string;
pageId?: string;
}): Promise<IPage[]> {
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
return req.data.items;
}
export async function getPageBreadcrumbs(
pageId: string,
): Promise<Partial<IPage[]>> {

View File

@@ -16,6 +16,11 @@ import { treeModel } from '../model/tree-model';
import { DocTreeRow } from './doc-tree-row';
import styles from '../styles/tree.module.css';
// Page-tree row heights. STANDARD is the safe default density; COMPACT is the
// denser layout gated behind the COMPACT_PAGE_TREE feature flag.
export const ROW_HEIGHT_STANDARD = 32;
export const ROW_HEIGHT_COMPACT = 26;
export type RenderRowProps<T extends object> = {
node: TreeNode<T>;
level: number;
@@ -122,11 +127,11 @@ function DocTreeInner<T extends object>(
selectedId,
renderRow,
indentPerLevel = 8,
// Compact vertical density: each virtualized row occupies exactly this
// many px (the virtualizer stride). Row content is ~22px (18px icon /
// 14px text / 20px action icons), so 26px keeps a small, even gap between
// nodes without clipping. Lower => denser tree.
rowHeight = 26,
// Each virtualized row occupies exactly this many px (the virtualizer
// stride). Default is standard density (32px); the denser compact layout
// (26px) is opt-in and driven by the COMPACT_PAGE_TREE feature flag in
// consumers. Lower => denser tree.
rowHeight = ROW_HEIGHT_STANDARD,
onMove,
onToggle,
onSelect,

View File

@@ -1,8 +1,17 @@
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
fetchAllAncestorChildren,
useGetRootSidebarPagesQuery,
@@ -16,13 +25,23 @@ import {
buildTree,
buildTreeWithChildren,
mergeRootTrees,
collectAllIds,
collectBranchIds,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { getPageBreadcrumbs } from "@/features/page/services/page-service.ts";
import {
getPageBreadcrumbs,
getSpaceTree,
} from "@/features/page/services/page-service.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { extractPageSlugId } from "@/lib";
import { DocTree } from "./doc-tree";
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
import {
DocTree,
ROW_HEIGHT_COMPACT,
ROW_HEIGHT_STANDARD,
} from "./doc-tree";
import { SpaceTreeRow } from "./space-tree-row";
interface SpaceTreeProps {
@@ -30,10 +49,21 @@ interface SpaceTreeProps {
readOnly: boolean;
}
export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
export type SpaceTreeApi = {
expandAll: () => Promise<void>;
collapseAll: () => void;
isExpanding: boolean;
};
const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
{ spaceId, readOnly },
ref,
) {
const { t } = useTranslation();
const { pageSlug } = useParams();
const compactTree = isCompactPageTreeEnabled();
const [data, setData] = useAtom(treeDataAtom);
const [isExpanding, setIsExpanding] = useState(false);
const { handleMove } = useTreeMutation(spaceId);
const {
data: pagesData,
@@ -186,6 +216,64 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
[data, spaceId],
);
const expandAll = useCallback(async () => {
const startSpaceId = spaceIdRef.current;
setIsExpanding(true);
try {
// One request: the entire space tree, permission-filtered server-side.
const items = await getSpaceTree({ spaceId: startSpaceId });
// Space switched mid-flight — abort merge/expand.
if (spaceIdRef.current !== startSpaceId) return;
const fullTree = buildTreeWithChildren(buildTree(items));
setData((prev) => {
// Replace current-space nodes with the full tree; keep other spaces intact.
const others = prev.filter((n) => n?.spaceId !== startSpaceId);
return [...others, ...fullTree];
});
// Open every branch node (node with children) of the current space only.
const branchIds = collectBranchIds(fullTree);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
} catch (err: any) {
// Never swallow: log full error + surface the real reason.
console.error("[tree] expandAll failed", err);
notifications.show({
color: "red",
message: t("Couldn't expand the tree: {{reason}}", {
reason:
err?.response?.data?.message ?? err?.message ?? String(err),
}),
});
} finally {
setIsExpanding(false);
}
}, [setData, setOpenTreeNodes, t]);
const collapseAll = useCallback(() => {
// The open-map is shared across spaces; collapse only current-space ids so
// other spaces' expanded state is left intact.
const ids = collectAllIds(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
}, [filteredData, setOpenTreeNodes]);
useImperativeHandle(
ref,
() => ({ expandAll, collapseAll, isExpanding }),
[expandAll, collapseAll, isExpanding],
);
// Stable callbacks for DocTree. Without these, every parent render recreates
// the props and tears down every row's draggable/dropTarget subscription,
// defeating memo(DocTreeRow).
@@ -219,6 +307,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
renderRow={renderRow}
onMove={handleMove}
onToggle={handleToggle}
rowHeight={compactTree ? ROW_HEIGHT_COMPACT : ROW_HEIGHT_STANDARD}
readOnly={readOnly}
disableDrag={disableDragDrop}
disableDrop={disableDragDrop}
@@ -228,4 +317,6 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
)}
</div>
);
}
});
export default SpaceTree;

View File

@@ -19,7 +19,6 @@ import {
} from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getSpaceUrl } from "@/lib/config.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
export type UseTreeMutation = {
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
@@ -41,12 +40,11 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
const { spaceSlug, pageSlug } = useParams();
const emit = useQueryEmit();
const handleMove = useCallback(
async (sourceId: string, op: DropOp) => {
const before = store.get(treeDataAtom);
const { tree: after, result } = treeModel.move(before, sourceId, op);
const { tree: after } = treeModel.move(before, sourceId, op);
if (after === before) return;
const payload = dropOpToMovePayload(before, sourceId, op);
@@ -112,22 +110,12 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
pageData,
);
setTimeout(() => {
emit({
operation: "moveTreeNode",
spaceId: spaceId,
payload: {
id: sourceId,
parentId: payload.parentPageId,
oldParentId,
index: result.index,
position: payload.position,
pageData,
},
});
}, 50);
// Realtime broadcast is now server-authoritative: the server emits
// `moveTreeNode` to the space room on PAGE_MOVED. The old client relay
// (emit + setTimeout(50)) was removed; the optimistic local update above
// stays for instant feedback to the author.
},
[setData, store, movePageMutation, spaceId, emit, t],
[setData, store, movePageMutation, spaceId, t],
);
const handleCreate = useCallback(
@@ -166,20 +154,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
lastIndex = parent?.children?.length ?? 0;
}
setData((prev) => treeModel.insert(prev, parentId, newNode, lastIndex));
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId,
payload: {
parentId,
index: lastIndex,
data: newNode,
},
});
}, 50);
// Idempotent by id: the tree is server-authoritative and the server's
// `addTreeNode` broadcast (now ~ms over same-origin) can win the race and
// insert this node before this optimistic update runs. Inserting again
// un-guarded would duplicate the row in the author's sidebar. Mirror the
// `addTreeNode` socket guard: skip when the node already exists. The
// optimistic node's id IS the real created page id (createdPage.id), so
// the ids match exactly regardless of which path runs first.
setData((prev) => {
if (treeModel.find(prev, newNode.id)) return prev;
return treeModel.insert(prev, parentId, newNode, lastIndex);
});
// Realtime broadcast is now server-authoritative: the server emits
// `addTreeNode` to the space room on PAGE_CREATED. The old client relay
// (emit + setTimeout(50)) was removed; the optimistic insert above stays
// for instant feedback to the author (the server event is idempotent and
// a no-op for the author whose node already exists).
const pageUrl = buildPageUrl(
spaceSlug,
createdPage.slugId,
@@ -187,7 +178,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
);
navigate(pageUrl);
},
[spaceId, createPageMutation, setData, store, emit, navigate, spaceSlug],
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
);
const handleRename = useCallback(
@@ -238,19 +229,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
navigate(getSpaceUrl(spaceSlug));
}
setTimeout(() => {
if (!node) return;
emit({
operation: "deleteTreeNode",
spaceId,
payload: { node },
});
}, 50);
// Realtime broadcast is now server-authoritative: the server emits
// `deleteTreeNode` to the space room on PAGE_SOFT_DELETED. The old
// client relay (emit + setTimeout(50)) was removed; the optimistic
// removal above stays for instant feedback to the author.
} catch (error) {
console.error("Failed to delete page:", error);
}
},
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug, emit, spaceId],
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
);
return { handleMove, handleCreate, handleRename, handleDelete };

View File

@@ -128,6 +128,260 @@ describe('treeModel.insert', () => {
});
});
describe('treeModel.insertByPosition', () => {
// Server-authoritative broadcasts ship the node's fractional `position`; the
// receiver inserts among already-loaded siblings ordered by `position`.
type P = TreeNode<{ name: string; position?: string }>;
const roots: P[] = [
{ id: 'a', name: 'A', position: 'a0' },
{ id: 'b', name: 'B', position: 'a2' },
{ id: 'c', name: 'C', position: 'a4' },
];
it('inserts a root node in position order (middle)', () => {
const node: P = { id: 'x', name: 'X', position: 'a3' };
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'x', 'c']);
});
it('inserts a root node at the front when its position sorts first', () => {
const node: P = { id: 'x', name: 'X', position: 'a-' };
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(['x', 'a', 'b', 'c']);
});
it('appends a root node when its position sorts last', () => {
const node: P = { id: 'x', name: 'X', position: 'a9' };
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
});
it('produces the same order regardless of which siblings are loaded', () => {
// Client 1 loaded all siblings; client 2 only loaded a subset. The inserted
// node lands in a consistent relative position for both.
const full: P[] = roots;
const partial: P[] = [roots[0], roots[2]]; // a, c (b not loaded)
const node: P = { id: 'x', name: 'X', position: 'a3' };
expect(
treeModel.insertByPosition(full, null, node).map((n) => n.id),
).toEqual(['a', 'b', 'x', 'c']);
expect(
treeModel.insertByPosition(partial, null, node).map((n) => n.id),
).toEqual(['a', 'x', 'c']);
});
it('inserts a child in position order under the parent', () => {
const tree: P[] = [
{
id: 'p',
name: 'P',
position: 'a0',
children: [
{ id: 'p1', name: 'P1', position: 'a0' },
{ id: 'p2', name: 'P2', position: 'a2' },
],
},
];
const node: P = { id: 'p15', name: 'P1.5', position: 'a1' };
const t = treeModel.insertByPosition(tree, 'p', node);
expect(treeModel.find(t, 'p')?.children?.map((n) => n.id)).toEqual([
'p1', 'p15', 'p2',
]);
});
it('appends when the new node has no position', () => {
const node: P = { id: 'x', name: 'X' };
const t = treeModel.insertByPosition(roots, null, node);
expect(t.map((n) => n.id)).toEqual(['a', 'b', 'c', 'x']);
});
});
// addTreeNode idempotency: the receiver early-returns when the node id already
// exists, so re-delivery (or the author's optimistic node) is never duplicated.
// This guards the find-then-skip contract insertByPosition relies on.
describe('addTreeNode idempotency (find-then-skip)', () => {
type P = TreeNode<{ name: string; position?: string }>;
const applyAddTreeNode = (tree: P[], node: P): P[] => {
if (treeModel.find(tree, node.id)) return tree;
return treeModel.insertByPosition(tree, null, node);
};
it('does not insert a duplicate when the id already exists', () => {
const tree: P[] = [{ id: 'a', name: 'A', position: 'a0' }];
const node: P = { id: 'a', name: 'A again', position: 'a5' };
const t1 = applyAddTreeNode(tree, node);
expect(t1).toBe(tree);
expect(t1.map((n) => n.id)).toEqual(['a']);
});
it('inserts once, then is a no-op on repeat delivery', () => {
let tree: P[] = [{ id: 'a', name: 'A', position: 'a0' }];
const node: P = { id: 'x', name: 'X', position: 'a5' };
tree = applyAddTreeNode(tree, node);
expect(tree.map((n) => n.id)).toEqual(['a', 'x']);
const again = applyAddTreeNode(tree, node);
expect(again).toBe(tree);
expect(again.filter((n) => n.id === 'x')).toHaveLength(1);
});
});
// handleCreate optimistic-insert idempotency: the author's optimistic insert is
// now guarded by `treeModel.find` (same contract as the addTreeNode socket
// handler) because the server's broadcast can win the race and insert the node
// first. Whichever runs first inserts; the second is a no-op. Exactly one row.
describe('handleCreate optimistic-insert idempotency (find-then-skip)', () => {
// Mirrors the guarded optimistic insert in use-tree-mutation handleCreate.
const applyOptimisticInsert = (
tree: N[],
parentId: string | null,
node: N,
index: number,
): N[] => {
if (treeModel.find(tree, node.id)) return tree;
return treeModel.insert(tree, parentId, node, index);
};
// Mirrors the addTreeNode socket handler guard.
const applyAddTreeNode = (tree: N[], parentId: string | null, node: N): N[] => {
if (treeModel.find(tree, node.id)) return tree;
return treeModel.insert(tree, parentId, node);
};
const created: N = { id: 'new', name: '' };
it('optimistic insert is a no-op when server addTreeNode already inserted it', () => {
// Reverse-of-reverse race: server wins.
const afterServer = applyAddTreeNode(fixture, null, created);
expect(afterServer.filter((n) => n.id === 'new')).toHaveLength(1);
const afterOptimistic = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
expect(afterOptimistic).toBe(afterServer); // skipped
expect(afterOptimistic.filter((n) => n.id === 'new')).toHaveLength(1);
});
it('server addTreeNode is a no-op when optimistic insert already ran (optimistic-first)', () => {
const afterOptimistic = applyOptimisticInsert(fixture, null, created, fixture.length);
expect(afterOptimistic.filter((n) => n.id === 'new')).toHaveLength(1);
const afterServer = applyAddTreeNode(afterOptimistic, null, created);
expect(afterServer).toBe(afterOptimistic); // skipped
expect(afterServer.filter((n) => n.id === 'new')).toHaveLength(1);
});
it('inserts exactly once when only the optimistic path runs', () => {
const t = applyOptimisticInsert(fixture, 'a', { id: 'a3', name: '' }, 2);
expect(treeModel.find(t, 'a')?.children?.filter((n) => n.id === 'a3')).toHaveLength(1);
});
});
// moveTreeNode socket-handler semantics: the receiver must place the moved node
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a
// moved node's title/icon/chevron stay correct. This mirrors the reducer in
// use-tree-socket.ts so the contract is unit-tested without rendering the hook.
describe('moveTreeNode handler (place by position + apply pageData)', () => {
type P = TreeNode<{
name: string;
position?: string;
icon?: string;
hasChildren?: boolean;
parentPageId?: string | null;
}>;
const applyMoveTreeNode = (
tree: P[],
payload: {
id: string;
parentId: string | null;
position: string;
pageData?: { title?: string | null; icon?: string | null; hasChildren?: boolean };
},
): P[] => {
if (!treeModel.find(tree, payload.id)) return tree;
const placed = treeModel.placeByPosition(tree, payload.id, {
parentId: payload.parentId,
position: payload.position,
});
if (placed === tree) return treeModel.remove(tree, payload.id);
const patch: Partial<P> = {
position: payload.position,
parentPageId: payload.parentId,
} as Partial<P>;
const pd = payload.pageData;
if (pd) {
if (pd.title !== undefined) (patch as { name?: string }).name = pd.title ?? '';
if (pd.icon !== undefined) (patch as { icon?: string }).icon = pd.icon ?? undefined;
if (pd.hasChildren !== undefined)
(patch as { hasChildren?: boolean }).hasChildren = pd.hasChildren;
}
return treeModel.update(placed, payload.id, patch);
};
const tree: P[] = [
{
id: 'dst',
name: 'DST',
position: 'a0',
children: [
{ id: 'c1', name: 'C1', position: 'a1' },
{ id: 'c2', name: 'C2', position: 'a3' },
{ id: 'c3', name: 'C3', position: 'a5' },
],
},
{ id: 'src', name: 'SRC', position: 'a9' },
];
it('lands the moved node in the correct MIDDLE slot, not at index 0', () => {
const t = applyMoveTreeNode(tree, {
id: 'src',
parentId: 'dst',
position: 'a4',
});
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
'c1', 'c2', 'src', 'c3',
]);
});
it('lands the moved node at the END when position sorts last', () => {
const t = applyMoveTreeNode(tree, {
id: 'src',
parentId: 'dst',
position: 'a8',
});
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
'c1', 'c2', 'c3', 'src',
]);
});
it('applies pageData (title/icon/hasChildren) to the moved node', () => {
const t = applyMoveTreeNode(tree, {
id: 'src',
parentId: 'dst',
position: 'a4',
pageData: { title: 'Renamed', icon: '🔥', hasChildren: true },
});
const moved = treeModel.find(t, 'src');
expect(moved?.name).toBe('Renamed');
expect(moved?.icon).toBe('🔥');
expect(moved?.hasChildren).toBe(true);
expect(moved?.position).toBe('a4');
});
it('falls back to removing the node when the destination parent is not loaded', () => {
const t = applyMoveTreeNode(tree, {
id: 'src',
parentId: 'not-loaded',
position: 'a4',
});
expect(treeModel.find(t, 'src')).toBeNull();
});
});
describe('treeModel.remove', () => {
it('removes a leaf', () => {
const t = treeModel.remove(fixture, 'a2');
@@ -240,6 +494,118 @@ describe('treeModel.place', () => {
});
});
describe('treeModel.placeByPosition', () => {
// Server-authoritative `moveTreeNode` ships the moved node's fractional
// `position`; the receiver must sort it into the correct slot among the new
// siblings — NOT drop it at index 0.
type P = TreeNode<{ name: string; position?: string }>;
const tree: P[] = [
{
id: 'dst',
name: 'DST',
position: 'a0',
children: [
{ id: 'c1', name: 'C1', position: 'a1' },
{ id: 'c2', name: 'C2', position: 'a3' },
{ id: 'c3', name: 'C3', position: 'a5' },
],
},
{ id: 'src', name: 'SRC', position: 'a9' },
];
it('places the moved node in the MIDDLE of new siblings by position', () => {
const t = treeModel.placeByPosition(tree, 'src', {
parentId: 'dst',
position: 'a4',
});
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
'c1', 'c2', 'src', 'c3',
]);
});
it('places the moved node at the END when its position sorts last', () => {
const t = treeModel.placeByPosition(tree, 'src', {
parentId: 'dst',
position: 'a8',
});
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
'c1', 'c2', 'c3', 'src',
]);
});
it('places the moved node at the FRONT only when its position sorts first', () => {
const t = treeModel.placeByPosition(tree, 'src', {
parentId: 'dst',
position: 'a0',
});
expect(treeModel.find(t, 'dst')?.children?.map((n) => n.id)).toEqual([
'src', 'c1', 'c2', 'c3',
]);
});
it('stamps the authoritative position onto the moved node', () => {
const t = treeModel.placeByPosition(tree, 'src', {
parentId: 'dst',
position: 'a4',
});
expect(treeModel.find(t, 'src')?.position).toBe('a4');
});
it('reorders within the same parent by position (not to index 0)', () => {
const same: P[] = [
{
id: 'p',
name: 'P',
position: 'a0',
children: [
{ id: 'x', name: 'X', position: 'a1' },
{ id: 'y', name: 'Y', position: 'a2' },
{ id: 'z', name: 'Z', position: 'a3' },
],
},
];
// Move x to between y and z.
const t = treeModel.placeByPosition(same, 'x', {
parentId: 'p',
position: 'a25',
});
expect(treeModel.find(t, 'p')?.children?.map((n) => n.id)).toEqual([
'y', 'x', 'z',
]);
});
it('returns same array reference for unknown source', () => {
expect(
treeModel.placeByPosition(tree, 'ghost', { parentId: 'dst', position: 'a4' }),
).toBe(tree);
});
it('returns same array reference when destination parent is not loaded', () => {
expect(
treeModel.placeByPosition(tree, 'src', { parentId: 'ghost', position: 'a4' }),
).toBe(tree);
});
it('moves a node to root by position', () => {
const roots: P[] = [
{ id: 'r1', name: 'R1', position: 'a1' },
{ id: 'r2', name: 'R2', position: 'a5' },
{
id: 'rp',
name: 'RP',
position: 'a7',
children: [{ id: 'child', name: 'CHILD', position: 'a1' }],
},
];
const t = treeModel.placeByPosition(roots, 'child', {
parentId: null,
position: 'a3',
});
expect(t.map((n) => n.id)).toEqual(['r1', 'child', 'r2', 'rp']);
});
});
describe('treeModel.move', () => {
it('reorder-before within same parent: moves source to target index', () => {
const { tree: t, result } = treeModel.move(fixture, 'a2', {

View File

@@ -98,6 +98,35 @@ export const treeModel = {
return touched ? out : tree;
},
// Position-aware insert for server-authoritative broadcasts. The server does
// not know each receiver's local index (clients have different loaded sets and
// the root list is paginated), so it sends the node's fractional `position`.
// We insert among the already-loaded siblings ordered by `position` so the
// order is consistent across clients regardless of which nodes they loaded.
// Falls back to appending when `position` is missing.
insertByPosition<T extends { position?: string }>(
tree: TreeNode<T>[],
parentId: string | null,
node: TreeNode<T>,
): TreeNode<T>[] {
const index = (siblings: TreeNode<T>[]): number => {
const pos = node.position;
if (pos == null) return siblings.length;
// First sibling whose position sorts after the new node's position.
const at = siblings.findIndex(
(s) => s.position != null && s.position > pos,
);
return at === -1 ? siblings.length : at;
};
if (parentId === null) {
return treeModel.insert(tree, null, node, index(tree));
}
const parent = treeModel.find(tree, parentId);
const kids = (parent?.children as TreeNode<T>[] | undefined) ?? [];
return treeModel.insert(tree, parentId, node, index(kids));
},
remove<T extends object>(tree: TreeNode<T>[], id: string): TreeNode<T>[] {
let touched = false;
const walk = (nodes: TreeNode<T>[]): TreeNode<T>[] => {
@@ -186,6 +215,30 @@ export const treeModel = {
return treeModel.insert(removed, to.parentId, source, to.index);
},
// Position-aware move for server-authoritative `moveTreeNode` broadcasts. Like
// `place`, but instead of an absolute index (which the sender computed against
// its own loaded set), it inserts the moved node among the destination's
// already-loaded siblings ordered by the node's fractional `position`. This
// keeps the visible order correct for every receiver — `place(..., index: 0)`
// would wrongly drop the node at the TOP of its new sibling list.
// Returns the same array reference (like `place`) when the source is missing
// or the destination parent isn't loaded on this client, so callers can detect
// that and fall back to removing the node.
placeByPosition<T extends { position?: string }>(
tree: TreeNode<T>[],
sourceId: string,
to: { parentId: string | null; position?: string },
): TreeNode<T>[] {
const source = treeModel.find(tree, sourceId);
if (!source) return tree;
if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree;
const removed = treeModel.remove(tree, sourceId);
// Reuse the same position-ordered insertion as `insertByPosition` by
// stamping the authoritative position onto the moved node first.
const positioned = { ...source, position: to.position } as TreeNode<T>;
return treeModel.insertByPosition(removed, to.parentId, positioned);
},
move<T extends object>(
tree: TreeNode<T>[],
sourceId: string,

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { buildTree } from "./utils";
import type { IPage } from "@/features/page/types/page.types.ts";
function page(id: string, position: string): IPage {
return {
id,
slugId: `slug-${id}`,
title: id.toUpperCase(),
icon: "",
position,
hasChildren: false,
spaceId: "space-1",
parentPageId: null as unknown as string,
} as IPage;
}
describe("buildTree", () => {
it("builds one node per unique page", () => {
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
expect(tree.map((n) => n.id)).toEqual(["a", "b"]);
});
it("dedups a duplicate id so the tree has no duplicate node", () => {
// A realtime cache write could append a page twice; buildTree must not emit
// two references to the same node (which would crash the sidebar render with
// a duplicate React key).
const tree = buildTree([
page("a", "a1"),
page("b", "a2"),
page("a", "a1"), // duplicate id
]);
expect(tree).toHaveLength(2);
expect(tree.map((n) => n.id).sort()).toEqual(["a", "b"]);
// No id appears more than once.
const ids = tree.map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length);
});
});

View File

@@ -30,7 +30,14 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
};
});
// Defense-in-depth: a duplicate id in `pages` would push two references to the
// same node, producing a duplicate React key that crashes the sidebar render.
// Track ids we've already pushed and skip repeats so a stray duplicate from a
// realtime cache write can never break the tree.
const seen = new Set<string>();
pages.forEach((page) => {
if (seen.has(page.id)) return;
seen.add(page.id);
tree.push(pageMap[page.id]);
});
@@ -217,3 +224,33 @@ export function mergeRootTrees(
return sortPositionKeys(merged);
}
// Collect every node id in the tree (roots, branches, leaves). Used by
// collapseAll to clear the open-state map for all current-space nodes.
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
ids.push(n.id);
if (n.children?.length) walk(n.children);
}
};
walk(nodes);
return ids;
}
// Collect ids of branch nodes (nodes that have children). Used by expandAll to
// open every branch in the open-state map; leaves need no entry.
export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
if (n.children?.length) {
ids.push(n.id);
walk(n.children);
}
}
};
walk(nodes);
return ids;
}

View File

@@ -0,0 +1,233 @@
import { useMemo, useRef, useState } from "react";
import { generateId } from "ai";
import {
ActionIcon,
Affix,
Alert,
Box,
Group,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import {
IconAlertTriangle,
IconArrowUp,
IconSparkles,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
interface ShareAiWidgetProps {
/** The share id (or key) the assistant is scoped to. */
shareId: string;
/** The page the reader currently has open (context for "this page"). */
pageId: string;
}
/** Concatenate the visible text parts of a UIMessage. */
function messageText(message: UIMessage): string {
return (message.parts ?? [])
.filter(
(p): p is { type: "text"; text: string } =>
p?.type === "text" && typeof (p as { text?: string }).text === "string",
)
.map((p) => p.text)
.join("");
}
/**
* Lightweight, EPHEMERAL "Ask AI" widget for a public shared page.
*
* A stripped version of the authenticated chat: text input only, no chat list,
* no history, no persistence, no voice input. The transcript lives only in
* memory (this component's `useChat` store) and is sent with `credentials:
* "omit"` to the anonymous `/api/shares/ai/stream` endpoint. The server stores
* nothing.
*/
export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
// Stable per-mount store key (see ai-chat ChatThread for the rationale on why
// useChat needs a stable, non-undefined id to avoid re-creating its store).
const storeIdRef = useRef<string>(`share-ai-${generateId()}`);
const transport = useMemo(
() =>
new DefaultChatTransport<UIMessage>({
api: "/api/shares/ai/stream",
// Anonymous endpoint: never send cookies/credentials.
credentials: "omit",
prepareSendMessagesRequest: ({ messages, body }) => ({
body: {
...body,
shareId,
pageId,
messages,
},
}),
}),
[shareId, pageId],
);
const { messages, sendMessage, status, stop, error } = useChat({
id: storeIdRef.current,
transport,
});
const isStreaming = status === "submitted" || status === "streaming";
const handleSend = () => {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
void sendMessage({ text });
};
if (!open) {
return (
// Offset 80px from the bottom so the FAB stacks ABOVE the bottom-right
// "Powered by Gitmost" branding button (share-branding.tsx) without
// overlapping it.
<Affix position={{ bottom: 80, right: 20 }}>
<Tooltip label={t("Ask AI")} position="left">
<ActionIcon
size="xl"
radius="xl"
variant="filled"
aria-label={t("Ask AI")}
onClick={() => setOpen(true)}
>
<IconSparkles size={22} />
</ActionIcon>
</Tooltip>
</Affix>
);
}
return (
<Affix position={{ bottom: 80, right: 20 }}>
<Paper
shadow="md"
radius="md"
withBorder
style={{
width: 360,
maxWidth: "calc(100vw - 40px)",
height: 480,
maxHeight: "calc(100vh - 100px)",
display: "flex",
flexDirection: "column",
}}
>
<Group
justify="space-between"
p="xs"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
<Group gap="xs">
<IconSparkles size={18} />
<Text fw={600} size="sm">
{t("Ask AI")}
</Text>
</Group>
<ActionIcon
variant="subtle"
aria-label={t("Close")}
onClick={() => setOpen(false)}
>
<IconX size={18} />
</ActionIcon>
</Group>
<ScrollArea style={{ flex: 1 }} p="sm" scrollbarSize={6} type="scroll">
{messages.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" mt="lg">
{t("Ask a question about this documentation.")}
</Text>
) : (
<Stack gap="sm">
{messages.map((message) => (
<Box
key={message.id}
style={{
alignSelf:
message.role === "user" ? "flex-end" : "flex-start",
maxWidth: "85%",
}}
>
<Paper
p="xs"
radius="md"
bg={
message.role === "user"
? "var(--mantine-color-blue-light)"
: "var(--mantine-color-default-hover)"
}
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{messageText(message) ||
(isStreaming ? t("Thinking…") : "")}
</Text>
</Paper>
</Box>
))}
</Stack>
)}
{error && (
<Alert
variant="light"
color="red"
icon={<IconAlertTriangle size={16} />}
mt="sm"
title={t("Something went wrong")}
>
{t("The assistant is unavailable right now. Please try again.")}
</Alert>
)}
</ScrollArea>
<Group
gap="xs"
p="xs"
align="flex-end"
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
>
<Textarea
style={{ flex: 1 }}
autosize
minRows={1}
maxRows={4}
placeholder={t("Ask a question…")}
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<ActionIcon
size="lg"
radius="xl"
variant="filled"
aria-label={isStreaming ? t("Stop") : t("Send")}
onClick={isStreaming ? () => stop() : handleSend}
disabled={!isStreaming && input.trim().length === 0}
>
{isStreaming ? <IconX size={18} /> : <IconArrowUp size={18} />}
</ActionIcon>
</Group>
</Paper>
</Affix>
);
}

View File

@@ -2,14 +2,17 @@ import { Affix, Button } from "@mantine/core";
export default function ShareBranding() {
return (
// Pinned to the bottom-RIGHT corner. The AI assistant FAB
// (share-ai-widget.tsx) is stacked ABOVE this with a higher `bottom`
// offset, so the two Affix elements never overlap.
<Affix position={{ bottom: 20, right: 20 }}>
<Button
variant="default"
component="a"
target="_blank"
href="https://docmost.com?ref=public-share"
href="https://github.com/vvzvlad/gitmost?ref=public-share"
>
Powered by Docmost
Powered by Gitmost
</Button>
</Affix>
);

View File

@@ -25,7 +25,10 @@ import {
DocTree,
type DocTreeApi,
type RenderRowProps,
ROW_HEIGHT_COMPACT,
ROW_HEIGHT_STANDARD,
} from "@/features/page/tree/components/doc-tree";
import { isCompactPageTreeEnabled } from "@/lib/config.ts";
import { openSharedTreeNodesAtom } from "@/features/share/atoms/open-shared-tree-nodes-atom";
interface SharedTreeProps {
@@ -36,6 +39,7 @@ export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
const { t } = useTranslation();
const treeRef = useRef<DocTreeApi | null>(null);
const { pageSlug } = useParams();
const compactTree = isCompactPageTreeEnabled();
const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom);
const currentNodeId = extractPageSlugId(pageSlug);
@@ -100,6 +104,7 @@ export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
renderRow={SharedTreeRow}
onMove={noopMove}
onToggle={handleToggle}
rowHeight={compactTree ? ROW_HEIGHT_COMPACT : ROW_HEIGHT_STANDARD}
getDragLabel={getDragLabel}
aria-label={t("Pages")}
/>

View File

@@ -42,6 +42,9 @@ export interface ISharedPage extends IShare {
sharedPage: { id: string; slugId: string; title: string; icon: string };
};
features?: string[];
// Whether the anonymous public-share AI assistant is enabled for the
// workspace (server-resolved). Gates the "Ask AI" widget.
aiAssistant?: boolean;
}
export interface IShareForPage extends IShare {

View File

@@ -7,6 +7,8 @@ import {
} from "@mantine/core";
import {
IconArrowDown,
IconChevronsDown,
IconChevronsUp,
IconDots,
IconEye,
IconEyeOff,
@@ -23,14 +25,16 @@ import {
useUnwatchSpaceMutation,
} from "@/features/space/queries/space-watcher-query.ts";
import classes from "./space-sidebar.module.css";
import React from "react";
import React, { useRef } from "react";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import { Link, useParams } from "react-router-dom";
import clsx from "clsx";
import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import SpaceTree, {
SpaceTreeApi,
} from "@/features/page/tree/components/space-tree.tsx";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
@@ -57,6 +61,7 @@ export function SpaceSidebar() {
const spaceRules = space?.membership?.permissions;
const spaceAbility = useSpaceAbility(spaceRules);
const { handleCreate } = useTreeMutation(space?.id ?? "");
const treeRef = useRef<SpaceTreeApi | null>(null);
if (!space) {
return <></>;
@@ -100,6 +105,7 @@ export function SpaceSidebar() {
SpaceCaslSubject.Page,
)}
onSpaceSettings={openSettings}
treeRef={treeRef}
/>
{spaceAbility.can(
@@ -122,6 +128,7 @@ export function SpaceSidebar() {
<div className={classes.pages}>
<SpaceTree
ref={treeRef}
spaceId={space.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
@@ -145,13 +152,25 @@ interface SpaceMenuProps {
spaceId: string;
canManagePages: boolean;
onSpaceSettings: () => void;
treeRef: React.RefObject<SpaceTreeApi | null>;
}
function SpaceMenu({
spaceId,
canManagePages,
onSpaceSettings,
treeRef,
}: SpaceMenuProps) {
const { t } = useTranslation();
const handleExpandAll = () => {
// Fire-and-forget: expandAll already surfaces its own error notification.
// The menu closes on click (consistent with Collapse all), so there is no
// in-menu loading state to track here.
treeRef.current?.expandAll();
};
const handleCollapseAll = () => {
treeRef.current?.collapseAll();
};
const { spaceSlug } = useParams();
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
@@ -201,6 +220,22 @@ function SpaceMenu({
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleExpandAll}
leftSection={<IconChevronsDown size={16} />}
>
{t("Expand all")}
</Menu.Item>
<Menu.Item
onClick={handleCollapseAll}
leftSection={<IconChevronsUp size={16} />}
>
{t("Collapse all")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={handleToggleFavorite}
leftSection={

View File

@@ -54,13 +54,17 @@ export const useTreeSocket = () => {
break;
case "addTreeNode":
setTreeData((prev) => {
// Idempotent: the author already inserted the node optimistically,
// and a node may be re-delivered — never insert a duplicate id.
if (treeModel.find(prev, event.payload.data.id)) return prev;
const newParentId = event.payload.parentId as string | null;
let next = treeModel.insert(
// Insert by `position` among already-loaded siblings (not the
// sender's absolute index) so order is consistent across clients
// with different loaded sets.
let next = treeModel.insertByPosition(
prev,
newParentId,
event.payload.data,
event.payload.index,
);
// Mirror the emitter: flip new parent's hasChildren to true so
// the chevron renders on the receiver.
@@ -80,22 +84,50 @@ export const useTreeSocket = () => {
(sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = event.payload.parentId as string | null;
const placed = treeModel.place(prev, event.payload.id, {
// Place the node by its fractional `position` among the new
// siblings — NOT by the sender's absolute `index` (the sender
// computed that against its own loaded set, which differs from
// this receiver's). Using the position keeps the visible order
// correct on every client; placing at `index: 0` would wrongly
// drop reordered/moved nodes at the top of their new sibling list.
const placed = treeModel.placeByPosition(prev, event.payload.id, {
parentId: newParentId,
index: event.payload.index,
position: event.payload.position,
});
// `place` silently returns the same reference if the destination
// parent isn't loaded on this client. Falling back to removing the
// source keeps the UI consistent (the source will reappear when
// the user expands the new parent and lazy-load fetches it).
// `placeByPosition` silently returns the same reference if the
// destination parent isn't loaded on this client. Falling back to
// removing the source keeps the UI consistent (the source will
// reappear when the user expands the new parent and lazy-load
// fetches it).
if (placed === prev) {
return treeModel.remove(prev, event.payload.id);
}
let next = treeModel.update(placed, event.payload.id, {
// Apply the authoritative node fields the move payload carries
// (`pageData`) so receivers don't keep a stale title/icon/chevron
// on the moved node. `placeByPosition` already set `position`.
const pageData = event.payload.pageData as
| {
title?: string | null;
icon?: string | null;
hasChildren?: boolean;
}
| undefined;
const patch: Partial<SpaceTreeNode> = {
position: event.payload.position,
parentPageId: newParentId,
} as Partial<SpaceTreeNode>);
// Honest type: a root move has a null parent, so this is
// `string | null`, not always `string`.
parentPageId: newParentId as string | null,
};
if (pageData) {
// The tree node stores the title as `name`.
if (pageData.title !== undefined) patch.name = pageData.title ?? "";
if (pageData.icon !== undefined)
patch.icon = pageData.icon ?? undefined;
if (pageData.hasChildren !== undefined)
patch.hasChildren = pageData.hasChildren;
}
let next = treeModel.update(placed, event.payload.id, patch);
// Mirror the emitter's hasChildren bookkeeping so both clients
// converge to the same chevron state.

View File

@@ -0,0 +1,209 @@
import { useEffect } from "react";
import { z } from "zod/v4";
import {
Button,
Group,
Select,
Stack,
Switch,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { useTranslation } from "react-i18next";
import {
useCreateAiRoleMutation,
useUpdateAiRoleMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import {
IAiRole,
IAiRoleCreate,
IAiRoleUpdate,
} from "@/features/ai-chat/types/ai-chat.types.ts";
// Supported drivers for the optional model override (mirrors server AI_DRIVERS).
// "" => use the workspace default driver/model.
const DRIVER_OPTIONS = [
{ value: "", label: "Workspace default" },
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
{ value: "ollama", label: "Ollama" },
];
const formSchema = z.object({
name: z.string().min(1),
emoji: z.string(),
description: z.string(),
instructions: z.string().min(1),
// "" => no driver override (use the workspace driver).
driver: z.enum(["", "openai", "gemini", "ollama"]),
chatModel: z.string(),
enabled: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
interface AiAgentRoleFormProps {
// When provided, edits an existing role; otherwise creates one.
role?: IAiRole;
onClose: () => void;
}
export default function AiAgentRoleForm({
role,
onClose,
}: AiAgentRoleFormProps) {
const { t } = useTranslation();
const isEdit = Boolean(role);
const createMutation = useCreateAiRoleMutation();
const updateMutation = useUpdateAiRoleMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
name: role?.name ?? "",
emoji: role?.emoji ?? "",
description: role?.description ?? "",
instructions: role?.instructions ?? "",
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
chatModel: role?.modelConfig?.chatModel ?? "",
enabled: role?.enabled ?? true,
},
});
// Re-hydrate when the target role changes (reusing the modal).
useEffect(() => {
form.setValues({
name: role?.name ?? "",
emoji: role?.emoji ?? "",
description: role?.description ?? "",
instructions: role?.instructions ?? "",
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
chatModel: role?.modelConfig?.chatModel ?? "",
enabled: role?.enabled ?? true,
});
form.resetDirty();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [role?.id]);
// Build the model override payload: null when neither a driver nor a model id
// is set (use the workspace default).
function resolveModelConfig(values: FormValues) {
const driver = values.driver || undefined;
const chatModel = values.chatModel.trim() || undefined;
if (!driver && !chatModel) return null;
return { driver, chatModel };
}
async function handleSubmit(values: FormValues) {
const modelConfig = resolveModelConfig(values);
if (isEdit && role) {
const payload: IAiRoleUpdate = {
id: role.id,
name: values.name,
emoji: values.emoji,
description: values.description,
instructions: values.instructions,
modelConfig,
enabled: values.enabled,
};
await updateMutation.mutateAsync(payload);
} else {
const payload: IAiRoleCreate = {
name: values.name,
emoji: values.emoji || undefined,
description: values.description || undefined,
instructions: values.instructions,
modelConfig,
enabled: values.enabled,
};
await createMutation.mutateAsync(payload);
}
onClose();
}
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Stack>
<TextInput
label={t("Role name")}
placeholder={t("e.g. Proofreader")}
{...form.getInputProps("name")}
/>
<TextInput
label={t("Emoji")}
description={t("Optional. Shown as the chat badge.")}
maxLength={8}
{...form.getInputProps("emoji")}
/>
<TextInput
label={t("Description")}
description={t("Optional. A short note about what this role does.")}
{...form.getInputProps("description")}
/>
<Textarea
label={t("Instructions")}
description={t(
"The built-in safety framework is always added automatically.",
)}
autosize
minRows={4}
maxRows={14}
{...form.getInputProps("instructions")}
/>
<Group grow align="flex-start">
<Select
label={t("Model provider override")}
description={t("Optional. Defaults to the workspace provider.")}
data={DRIVER_OPTIONS}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
{...form.getInputProps("driver")}
/>
<TextInput
label={t("Model override")}
description={t("Optional. Defaults to the workspace model.")}
placeholder={t("e.g. gpt-4o-mini")}
{...form.getInputProps("chatModel")}
/>
</Group>
<Text size="xs" c="dimmed" mt={-8}>
{t(
"If you choose a different provider, it must already be configured in AI settings.",
)}
</Text>
<Switch
label={t("Enabled")}
checked={form.values.enabled}
onChange={(event) =>
form.setFieldValue("enabled", event.currentTarget.checked)
}
/>
<Group justify="flex-end" mt="sm">
<Button type="button" variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
type="button"
onClick={() => handleSubmit(form.values)}
disabled={isSaving || !form.isValid()}
loading={isSaving}
>
{t("Save")}
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,175 @@
import { useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Modal,
Paper,
Stack,
Switch,
Text,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
useAiRolesQuery,
useDeleteAiRoleMutation,
useUpdateAiRoleMutation,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import AiAgentRoleForm from "./ai-agent-role-form.tsx";
/**
* Admin section: list / add / edit / delete reusable agent roles. A role
* replaces the agent's persona (instructions) and may optionally override the
* model; the safety framework is always still applied. The add/edit form lives
* in `AiAgentRoleForm`, opened in a modal.
*/
export default function AiAgentRoles() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const { data: roles, isLoading } = useAiRolesQuery(isAdmin);
const updateMutation = useUpdateAiRoleMutation();
const deleteMutation = useDeleteAiRoleMutation();
const [opened, { open, close }] = useDisclosure(false);
// The role being edited; undefined => the modal is in "create" mode.
const [editing, setEditing] = useState<IAiRole | undefined>(undefined);
if (!isAdmin) {
return (
<Text size="sm" c="dimmed">
{t("Only workspace admins can manage AI provider settings.")}
</Text>
);
}
function openCreate() {
setEditing(undefined);
open();
}
function openEdit(role: IAiRole) {
setEditing(role);
open();
}
function confirmDelete(role: IAiRole) {
modals.openConfirmModal({
title: t("Delete role"),
children: (
<Text size="sm">
{t("Are you sure you want to delete this role?")}
</Text>
),
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => deleteMutation.mutate(role.id),
});
}
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" align="center" wrap="nowrap">
<Box
w={9}
h={9}
bg="green.6"
style={{ borderRadius: "50%", flex: "none" }}
/>
<Text fw={600}>{t("Agent roles")}</Text>
</Group>
<Button
leftSection={<IconPlus size={16} />}
variant="default"
size="xs"
onClick={openCreate}
>
{t("Add role")}
</Button>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{t(
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
)}
</Text>
{!isLoading && (!roles || roles.length === 0) && (
<Text size="sm" c="dimmed" mt="sm">
{t("No roles configured")}
</Text>
)}
<Stack gap="xs" mt="sm">
{roles?.map((role) => (
<Group key={role.id} justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
{role.emoji ? `${role.emoji} ` : ""}
{role.name}
</Text>
{role.modelConfig?.chatModel && (
<Badge size="xs" variant="light">
{role.modelConfig.chatModel}
</Badge>
)}
</Group>
{role.description && (
<Text size="xs" c="dimmed" truncate>
{role.description}
</Text>
)}
</Stack>
<Group gap="xs" wrap="nowrap">
<Switch
size="sm"
checked={role.enabled}
aria-label={t("Enabled")}
onChange={(event) =>
updateMutation.mutate({
id: role.id,
enabled: event.currentTarget.checked,
})
}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => openEdit(role)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => confirmDelete(role)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
))}
</Stack>
<Modal
opened={opened}
onClose={close}
title={editing ? t("Edit role") : t("Add role")}
size="lg"
>
{/* Remount the form per target so its internal state re-hydrates. */}
<AiAgentRoleForm key={editing?.id ?? "new"} role={editing} onClose={close} />
</Modal>
</Paper>
);
}

View File

@@ -47,6 +47,21 @@ interface AiMcpServerFormProps {
onClose: () => void;
}
// Build the form's field values from a (possibly undefined) server. Used both
// for the initial mount and for re-hydration when the modal is reused for a
// different server, so the two stay in sync. authHeader is always empty: it is
// a write-only secret buffer never echoed back from the server.
function buildInitialValues(server?: IAiMcpServer): FormValues {
return {
name: server?.name ?? "",
transport: server?.transport ?? "http",
url: server?.url ?? "",
authHeader: "",
toolAllowlist: server?.toolAllowlist ?? [],
enabled: server?.enabled ?? true,
};
}
// Tavily preset (§8.10): the API key goes in the Authorization HEADER, not the URL.
const TAVILY_PRESET = {
name: "Tavily",
@@ -72,26 +87,12 @@ export default function AiMcpServerForm({
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {
name: server?.name ?? "",
transport: server?.transport ?? "http",
url: server?.url ?? "",
authHeader: "",
toolAllowlist: server?.toolAllowlist ?? [],
enabled: server?.enabled ?? true,
},
initialValues: buildInitialValues(server),
});
// Re-hydrate when the target server changes (e.g. reusing the modal).
useEffect(() => {
form.setValues({
name: server?.name ?? "",
transport: server?.transport ?? "http",
url: server?.url ?? "",
authHeader: "",
toolAllowlist: server?.toolAllowlist ?? [],
enabled: server?.enabled ?? true,
});
form.setValues(buildInitialValues(server));
form.resetDirty();
setHasHeaders(server?.hasHeaders ?? false);
setHeadersCleared(false);

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { resolveCardStatus } from './ai-provider-settings';
describe('resolveCardStatus', () => {
it('returns "off" when not configured and not enabled', () => {
expect(resolveCardStatus(false, false)).toBe('off');
});
it('returns "warning" when enabled but not configured (misconfig, not silent "off")', () => {
expect(resolveCardStatus(false, true)).toBe('warning');
});
it('returns "configured" when configured but disabled', () => {
expect(resolveCardStatus(true, false)).toBe('configured');
});
it('returns "ready" when configured and enabled', () => {
expect(resolveCardStatus(true, true)).toBe('ready');
});
});

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { z } from "zod/v4";
import {
Anchor,
ActionIcon,
Badge,
Box,
Button,
@@ -15,12 +15,13 @@ import {
Text,
Textarea,
TextInput,
Tooltip,
useMantineTheme,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { IconPencil } from "@tabler/icons-react";
import { IconPencil, IconX } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
@@ -37,6 +38,8 @@ import {
IAiSettingsUpdate,
SttApiStyle,
} from "@/features/workspace/services/ai-settings-service.ts";
import { useAiRolesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
import { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
import AiMcpServers from "./ai-mcp-servers.tsx";
// No driver field: every endpoint is OpenAI-compatible, so the form carries only
@@ -44,6 +47,11 @@ import AiMcpServers from "./ai-mcp-servers.tsx";
// (empty means "leave unchanged" unless explicitly cleared).
const formSchema = z.object({
chatModel: z.string(),
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
publicShareChatModel: z.string(),
// Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona.
publicShareAssistantRoleId: z.string(),
embeddingModel: z.string(),
baseUrl: z.string(),
// Embedding-specific base URL. Empty means "use the chat base URL".
@@ -60,8 +68,15 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
// Status of an endpoint card, drives the little status dot color.
type CardStatus = "ok" | "error" | "idle";
// Four-state endpoint health shown by the header dot. Derived synchronously
// from the form values + feature toggle — never from a network probe (the
// "Test endpoint" button still surfaces the live probe result as text).
// "ready" (green) — required fields filled AND the feature is ON
// "configured"(yellow) — required fields filled but the feature is OFF
// "off" (gray) — required fields missing (nothing to enable)
// "warning" (orange) — feature is ON but required fields are missing
// (a real misconfiguration: it won't work as-is)
type CardStatus = "ready" | "configured" | "off" | "warning";
// Resolve a "Base URL + path" hint defensively: trim a single trailing slash
// off the base, then append the path. Empty base falls back to `fallback`
@@ -71,21 +86,53 @@ function resolveUrl(base: string, path: string, fallback = ""): string {
return `${trimmed}${path}`;
}
// Small colored dot used in each card header.
function StatusDot({ status }: { status: CardStatus }) {
// Pure + unit-testable. `configured` = the endpoint has the fields it needs
// to work; `enabled` = the workspace feature toggle for this endpoint is ON.
// The "enabled && !configured" case is surfaced as "warning" instead of "off"
// so a misconfiguration (feature on, endpoint not filled) is not hidden.
export function resolveCardStatus(
configured: boolean,
enabled: boolean,
): CardStatus {
if (configured) return enabled ? "ready" : "configured";
return enabled ? "warning" : "off";
}
// Translate the dot's tooltip label. Kept in one place so all three endpoint
// cards share identical wording.
function cardStatusLabel(status: CardStatus, t: (k: string) => string): string {
switch (status) {
case "ready":
return t("Configured and enabled");
case "configured":
return t("Configured but disabled");
case "warning":
return t("Enabled but not configured");
default:
return t("Not configured");
}
}
// Small colored dot used in each card header, with a tooltip label so the
// state is readable without relying on color alone (colorblind access).
function StatusDot({ status, label }: { status: CardStatus; label: string }) {
const theme = useMantineTheme();
const color =
status === "ok"
status === "ready"
? theme.colors.green[6]
: status === "error"
? theme.colors.red[6]
: theme.colors.gray[5];
: status === "configured"
? theme.colors.yellow[6]
: status === "warning"
? theme.colors.orange[6]
: theme.colors.gray[5];
return (
<Box
w={9}
h={9}
style={{ borderRadius: "50%", background: color, flex: "none" }}
/>
<Tooltip label={label} position="top" withArrow>
<Box
w={9}
h={9}
style={{ borderRadius: "50%", background: color, flex: "none" }}
/>
</Tooltip>
);
}
@@ -103,6 +150,10 @@ export default function AiProviderSettings() {
const embedTest = useTestAiConnectionMutation();
const sttTest = useTestAiConnectionMutation();
// Agent roles drive the public-share assistant identity picker. Admin-gated
// (the component returns early for non-admins), same as the AI settings query.
const { data: roles } = useAiRolesQuery(isAdmin);
// Workspace-level feature toggles live in the card headers.
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [chatEnabled, setChatEnabled] = useState<boolean>(
@@ -114,9 +165,17 @@ export default function AiProviderSettings() {
const [dictationEnabled, setDictationEnabled] = useState<boolean>(
workspace?.settings?.ai?.dictation ?? false,
);
const [publicShareAssistantEnabled, setPublicShareAssistantEnabled] =
useState<boolean>(
workspace?.settings?.ai?.publicShareAssistant ?? false,
);
const [chatToggleLoading, setChatToggleLoading] = useState(false);
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
const [
publicShareAssistantToggleLoading,
setPublicShareAssistantToggleLoading,
] = useState(false);
// Whether a key is currently stored server-side (drives the placeholder).
const [hasApiKey, setHasApiKey] = useState(false);
@@ -136,6 +195,8 @@ export default function AiProviderSettings() {
validate: zod4Resolver(formSchema),
initialValues: {
chatModel: "",
publicShareChatModel: "",
publicShareAssistantRoleId: "",
embeddingModel: "",
baseUrl: "",
embeddingBaseUrl: "",
@@ -155,6 +216,8 @@ export default function AiProviderSettings() {
if (!settings) return;
form.setValues({
chatModel: settings.chatModel ?? "",
publicShareChatModel: settings.publicShareChatModel ?? "",
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
embeddingModel: settings.embeddingModel ?? "",
baseUrl: settings.baseUrl ?? "",
embeddingBaseUrl: settings.embeddingBaseUrl ?? "",
@@ -181,6 +244,12 @@ export default function AiProviderSettings() {
// Everything is OpenAI-compatible.
driver: "openai",
chatModel: values.chatModel,
// Cheap model id for the anonymous public-share assistant; empty falls
// back to chatModel server-side.
publicShareChatModel: values.publicShareChatModel,
// Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona server-side.
publicShareAssistantRoleId: values.publicShareAssistantRoleId,
embeddingModel: values.embeddingModel,
// The embedding base URL is optional; empty falls back to the chat base
// URL server-side.
@@ -344,6 +413,37 @@ export default function AiProviderSettings() {
}
}
// Optimistic toggle for the anonymous public-share AI assistant
// (settings.ai.publicShareAssistant). When off, the public endpoint 404s.
async function handleTogglePublicShareAssistant(value: boolean) {
setPublicShareAssistantToggleLoading(true);
const previous = publicShareAssistantEnabled;
setPublicShareAssistantEnabled(value);
try {
const updated = await updateWorkspace({
aiPublicShareAssistant: value,
});
setWorkspace({
...updated,
settings: {
...updated.settings,
ai: { ...updated.settings?.ai, publicShareAssistant: value },
},
});
notifications.show({ message: t("Updated successfully") });
} catch (err) {
setPublicShareAssistantEnabled(previous);
const message = (err as { response?: { data?: { message?: string } } })
?.response?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
} finally {
setPublicShareAssistantToggleLoading(false);
}
}
// Admins only — match the previous behavior.
if (!isAdmin) {
return (
@@ -353,21 +453,23 @@ export default function AiProviderSettings() {
);
}
const chatStatus: CardStatus = chatTest.data
? chatTest.data.ok
? "ok"
: "error"
: "idle";
const embedStatus: CardStatus = embedTest.data
? embedTest.data.ok
? "ok"
: "error"
: "idle";
const sttStatus: CardStatus = sttTest.data
? sttTest.data.ok
? "ok"
: "error"
: "idle";
// Per-endpoint "configured" predicate, derived from the LIVE form values
// (the dot reacts as the admin types). A key is NOT required — local
// servers (Ollama, speaches) work without one. Embeddings and Voice
// inherit the chat base URL when their own is empty (see resolveUrl).
const v = form.values;
const chatBase = v.baseUrl.trim();
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
const embedConfigured =
v.embeddingModel.trim() !== "" &&
(v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
const sttConfigured =
v.sttModel.trim() !== "" &&
(v.sttBaseUrl.trim() !== "" || chatBase !== "");
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
const sttStatus = resolveCardStatus(sttConfigured, dictationEnabled);
const chatResolved = resolveUrl(form.values.baseUrl, "/chat/completions");
const embedResolved = resolveUrl(
@@ -383,6 +485,34 @@ export default function AiProviderSettings() {
const monoFont = "ui-monospace, Menlo, monospace";
// Public-share assistant identity options: a leading "built-in persona" entry
// (empty value, the server default) plus every enabled agent role. If the saved
// role was since disabled it is filtered out of the enabled list, so surface it
// explicitly (labeled "disabled") instead of letting the Select render a blank
// field for a still-stored id.
const selectedRoleId = form.values.publicShareAssistantRoleId;
const enabledRoles = (roles ?? []).filter((r: IAiRole) => r.enabled);
const selectedDisabledRole =
selectedRoleId.length > 0 &&
!enabledRoles.some((r: IAiRole) => r.id === selectedRoleId)
? (roles ?? []).find((r: IAiRole) => r.id === selectedRoleId)
: undefined;
const roleOptions = [
{ value: "", label: t("Built-in assistant persona") },
...enabledRoles.map((r: IAiRole) => ({
value: r.id,
label: r.emoji ? `${r.emoji} ${r.name}` : r.name,
})),
...(selectedDisabledRole
? [
{
value: selectedDisabledRole.id,
label: `${selectedDisabledRole.emoji ? `${selectedDisabledRole.emoji} ` : ""}${selectedDisabledRole.name} (${t("disabled")})`,
},
]
: []),
];
return (
<Stack mt="sm">
{/* Section header */}
@@ -404,7 +534,7 @@ export default function AiProviderSettings() {
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" align="center" wrap="nowrap">
<StatusDot status={chatStatus} />
<StatusDot status={chatStatus} label={cardStatusLabel(chatStatus, t)} />
<Text fw={600}>{t("Chat / LLM")}</Text>
<Badge size="sm" variant="light" color="gray">
{t("root")}
@@ -430,19 +560,34 @@ export default function AiProviderSettings() {
disabled={isLoading}
{...form.getInputProps("chatModel")}
/>
<Stack gap={4}>
<PasswordInput
label={t("API key")}
placeholder={hasApiKey ? t("•••• set") : ""}
autoComplete="off"
{...form.getInputProps("apiKey")}
/>
{hasApiKey && (
<Anchor component="button" type="button" c="red" size="xs" onClick={handleClearKey}>
{t("Clear")}
</Anchor>
)}
</Stack>
{/* The key field is write-only: the stored key never loads back, so the
built-in visibility toggle reveals nothing. Replace it with a Clear
action in the right section. Passing rightSection suppresses the eye
(Mantine). While typing a new key (buffer non-empty) fall back to
the default eye so the user can verify what they typed. */}
<PasswordInput
label={t("API key")}
placeholder={hasApiKey ? t("•••• set") : ""}
autoComplete="off"
rightSection={
hasApiKey && form.values.apiKey.length === 0 ? (
<Tooltip label={t("Clear")} position="top" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
aria-label={t("Clear")}
type="button"
onClick={handleClearKey}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
) : undefined
}
rightSectionPointerEvents="all"
{...form.getInputProps("apiKey")}
/>
</Group>
<TextInput
@@ -455,6 +600,50 @@ export default function AiProviderSettings() {
{t("Resolves to {{url}}", { url: chatResolved })}
</Text>
{/* Anonymous public-share assistant: a single master toggle + an
optional cheaper model id. Reuses this card's driver/URL/key. */}
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
<Text fw={600} size="sm">
{t("Public share assistant")}
</Text>
<Switch
label={t("Enabled")}
labelPosition="left"
checked={publicShareAssistantEnabled}
disabled={publicShareAssistantToggleLoading}
onChange={(e) =>
handleTogglePublicShareAssistant(e.currentTarget.checked)
}
/>
</Group>
<Text size="xs" c="dimmed" mt={4} mb="xs">
{t(
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
)}
</Text>
<TextInput
label={t("Public assistant model")}
placeholder={t("Defaults to the chat model")}
disabled={isLoading || !publicShareAssistantEnabled}
{...form.getInputProps("publicShareChatModel")}
/>
<Text size="xs" c="dimmed" mt={4}>
{t(
"Optional cheaper model id for the public assistant. Empty uses the chat model above.",
)}
</Text>
<Select
mt="sm"
label={t("Assistant identity")}
description={t(
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
)}
data={roleOptions}
allowDeselect={false}
disabled={isLoading || !publicShareAssistantEnabled}
{...form.getInputProps("publicShareAssistantRoleId")}
/>
<Group mt="md" align="center">
<Button
variant="default"
@@ -514,7 +703,7 @@ export default function AiProviderSettings() {
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" align="center" wrap="nowrap">
<StatusDot status={embedStatus} />
<StatusDot status={embedStatus} label={cardStatusLabel(embedStatus, t)} />
<Text fw={600}>{t("Embeddings")}</Text>
</Group>
<Switch
@@ -535,29 +724,38 @@ export default function AiProviderSettings() {
disabled={isLoading}
{...form.getInputProps("embeddingModel")}
/>
<Stack gap={4}>
<PasswordInput
label={t("Embedding API key")}
placeholder={
hasEmbeddingApiKey
? t("•••• set")
: t("Leave empty to use the chat API key")
}
autoComplete="off"
{...form.getInputProps("embeddingApiKey")}
/>
{hasEmbeddingApiKey && (
<Anchor
component="button"
type="button"
c="red"
size="xs"
onClick={handleClearEmbeddingKey}
>
{t("Clear")}
</Anchor>
)}
</Stack>
{/* The key field is write-only: the stored key never loads back, so the
built-in visibility toggle reveals nothing. Replace it with a Clear
action in the right section. Passing rightSection suppresses the eye
(Mantine). While typing a new key (buffer non-empty) fall back to
the default eye so the user can verify what they typed. */}
<PasswordInput
label={t("Embedding API key")}
placeholder={
hasEmbeddingApiKey
? t("•••• set")
: t("Leave empty to use the chat API key")
}
autoComplete="off"
rightSection={
hasEmbeddingApiKey && form.values.embeddingApiKey.length === 0 ? (
<Tooltip label={t("Clear")} position="top" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
aria-label={t("Clear")}
type="button"
onClick={handleClearEmbeddingKey}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
) : undefined
}
rightSectionPointerEvents="all"
{...form.getInputProps("embeddingApiKey")}
/>
</Group>
<TextInput
@@ -631,7 +829,7 @@ export default function AiProviderSettings() {
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" align="center" wrap="nowrap">
<StatusDot status={sttStatus} />
<StatusDot status={sttStatus} label={cardStatusLabel(sttStatus, t)} />
<Text fw={600}>{t("Voice / STT")}</Text>
</Group>
<Switch
@@ -654,29 +852,38 @@ export default function AiProviderSettings() {
disabled={isLoading}
{...form.getInputProps("sttModel")}
/>
<Stack gap={4}>
<PasswordInput
label={t("API key")}
placeholder={
hasSttApiKey
? t("•••• set")
: t("Leave empty to use the chat API key")
}
autoComplete="off"
{...form.getInputProps("sttApiKey")}
/>
{hasSttApiKey && (
<Anchor
component="button"
type="button"
c="red"
size="xs"
onClick={handleClearSttKey}
>
{t("Clear")}
</Anchor>
)}
</Stack>
{/* The key field is write-only: the stored key never loads back, so the
built-in visibility toggle reveals nothing. Replace it with a Clear
action in the right section. Passing rightSection suppresses the eye
(Mantine). While typing a new key (buffer non-empty) fall back to
the default eye so the user can verify what they typed. */}
<PasswordInput
label={t("API key")}
placeholder={
hasSttApiKey
? t("•••• set")
: t("Leave empty to use the chat API key")
}
autoComplete="off"
rightSection={
hasSttApiKey && form.values.sttApiKey.length === 0 ? (
<Tooltip label={t("Clear")} position="top" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
aria-label={t("Clear")}
type="button"
onClick={handleClearSttKey}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
) : undefined
}
rightSectionPointerEvents="all"
{...form.getInputProps("sttApiKey")}
/>
</Group>
<Select

View File

@@ -16,6 +16,11 @@ export type SttApiStyle = "multipart" | "json";
export interface IAiSettings {
driver?: AiDriver;
chatModel?: string;
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona.
publicShareAssistantRoleId?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
@@ -42,6 +47,10 @@ export interface IAiSettings {
export interface IAiSettingsUpdate {
driver?: AiDriver;
chatModel?: string;
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty =
// built-in locked persona.
publicShareAssistantRoleId?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;

View File

@@ -25,6 +25,7 @@ export interface IWorkspace {
mcpEnabled?: boolean;
aiChat?: boolean;
aiDictation?: boolean;
aiPublicShareAssistant?: boolean;
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
@@ -48,6 +49,7 @@ export interface IWorkspaceAiSettings {
mcp?: boolean;
chat?: boolean;
dictation?: boolean;
publicShareAssistant?: boolean;
}
export interface IWorkspaceSharingSettings {

View File

@@ -43,6 +43,10 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD"));
}
export function isCompactPageTreeEnabled(): boolean {
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
}
export function getAvatarUrl(
avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR,

View File

@@ -1,6 +1,7 @@
import SettingsTitle from "@/components/settings/settings-title.tsx";
import McpSettings from "@/features/workspace/components/settings/components/mcp-settings.tsx";
import AiProviderSettings from "@/features/workspace/components/settings/components/ai-provider-settings.tsx";
import AiAgentRoles from "@/features/workspace/components/settings/components/ai-agent-roles.tsx";
import { useTranslation } from "react-i18next";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
@@ -20,6 +21,13 @@ export default function AiSettings() {
<SettingsTitle title={t("AI")} />
{isAdmin && <AiProviderSettings />}
{isAdmin && (
<>
<Divider my="lg" />
<AiAgentRoles />
</>
)}
<Divider my="lg" />
<McpSettings />

View File

@@ -8,6 +8,7 @@ import ReadonlyPageEditor from "@/features/editor/readonly-page-editor.tsx";
import { extractPageSlugId } from "@/lib";
import { Error404 } from "@/components/ui/error-404.tsx";
import ShareBranding from "@/features/share/components/share-branding.tsx";
import ShareAiWidget from "@/features/share/components/share-ai-widget.tsx";
import { useAtomValue } from "jotai";
import {
sharedPageFullWidthAtom,
@@ -74,6 +75,12 @@ export default function SharedPage() {
</Container>
{data && !shareId && !(data.features?.length > 0) && <ShareBranding />}
{/* Anonymous "Ask AI" widget — only when the workspace enables the
public-share assistant (server-resolved flag on /shares/page-info). */}
{data?.aiAssistant && data.share?.id && data.page?.id && (
<ShareAiWidget shareId={data.share.id} pageId={data.page.id} />
)}
</div>
);
}