Merge branch 'develop' into feat/ai-chat-review-followups

Integrate the already-merged step-limit work from develop. Only conflict was
ai-chat.service.spec.ts: both sides appended a describe block and edited the
import line. Resolved as a union — keep compactToolOutput + the assistantParts/
serializeSteps/rowToUiMessage suites (this branch) AND the prepareAgentStep
suite (develop), importing all symbols from ai-chat.service.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
vvzvlad
2026-06-20 18:09:17 +03:00
26 changed files with 895 additions and 1183 deletions

View File

@@ -977,6 +977,9 @@
"Page menu": "Page menu",
"Expand": "Expand",
"Collapse": "Collapse",
"Expand all": "Expand all",
"Collapse all": "Collapse all",
"Couldn't expand the tree: {{reason}}": "Couldn't expand the tree: {{reason}}",
"Comment menu": "Comment menu",
"Group menu": "Group menu",
"Show hidden breadcrumbs": "Show hidden breadcrumbs",
@@ -1162,6 +1165,10 @@
"Voice dictation is not available yet.": "Voice dictation is not available yet.",
"Test endpoint": "Test endpoint",
"Save endpoints": "Save endpoints",
"Configured and enabled": "Configured and enabled",
"Configured but disabled": "Configured but disabled",
"Enabled but not configured": "Enabled but not configured",
"Not configured": "Not configured",
"External tools": "External tools",
"Gitmost as MCP client": "Gitmost as MCP client",
"Servers the agent calls out to.": "Servers the agent calls out to.",

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

@@ -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

@@ -216,3 +216,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

@@ -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

@@ -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

@@ -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";
@@ -60,8 +61,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 +79,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>
);
}
@@ -353,21 +393,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(
@@ -404,7 +446,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 +472,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
@@ -514,7 +571,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 +592,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 +697,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 +720,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

@@ -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

@@ -3,6 +3,9 @@ import {
assistantParts,
serializeSteps,
rowToUiMessage,
prepareAgentStep,
MAX_AGENT_STEPS,
FINAL_STEP_INSTRUCTION,
} from './ai-chat.service';
import type { AiChatMessage } from '@docmost/db/types/entity.types';
@@ -190,3 +193,39 @@ describe('rowToUiMessage', () => {
expect(ui.parts).toEqual([{ type: 'text', text: 'hi there' }]);
});
});
/**
* Unit tests for prepareAgentStep: the pure helper that decides per-step
* overrides for the agent loop. Early steps return undefined (default
* behavior); the final allowed step (stepNumber === MAX_AGENT_STEPS - 1) forces
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
* appended onto — not replacing — the original system prompt.
*/
describe('prepareAgentStep', () => {
it('returns undefined for the first step', () => {
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
});
it('returns undefined for a non-final step (just before the last)', () => {
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
});
it('forces a text-only synthesis on the final allowed step', () => {
const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
expect(result).toBeDefined();
expect(result?.toolChoice).toBe('none');
// The original persona is preserved (prefix), not replaced.
expect(result?.system.startsWith('SYS')).toBe(true);
// The synthesis instruction is appended.
expect(result?.system).toContain(FINAL_STEP_INSTRUCTION);
});
it('pins the off-by-one boundary (MAX-2 is not final, MAX-1 is)', () => {
// Boundary expressed via the constant, not a hardcoded 18/19, so the test
// tracks MAX_AGENT_STEPS if the cap ever changes.
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
const atBoundary = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
expect(atBoundary).toBeDefined();
expect(atBoundary?.toolChoice).toBe('none');
});
});

View File

@@ -18,6 +18,42 @@ import { AiChatToolsService } from './tools/ai-chat-tools.service';
import { McpClientsService } from './external-mcp/mcp-clients.service';
import { buildSystemPrompt } from './ai-chat.prompt';
// Max agent steps per turn. One step = one model generation; a step that calls
// tools is followed by another step carrying the tool results. Raised from 8 so
// multi-search research questions are not cut off mid-investigation.
const MAX_AGENT_STEPS = 20;
// System-prompt addendum injected ONLY on the final step (see prepareAgentStep).
// It forbids further tool calls and tells the model to synthesize the best
// answer it can from what it already gathered, so a tool-heavy turn never ends
// empty.
const FINAL_STEP_INSTRUCTION =
'You have reached the maximum number of tool-use steps for this turn. ' +
'Do NOT call any more tools. Using only the information already gathered, ' +
"write the most complete, useful final answer you can now, in the user's " +
'language. If the information is incomplete, say so explicitly: summarize ' +
'what you found, what is still missing, and give your best partial conclusion.';
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
// steps; on the final allowed step forces a text-only synthesis answer.
// `system` is the in-scope system prompt; we CONCATENATE so the original
// persona/context is preserved — a bare `system` override would REPLACE the
// whole system prompt for the step.
//
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
export function prepareAgentStep(
stepNumber: number,
system: string,
): { toolChoice: 'none'; system: string } | undefined {
if (stepNumber >= MAX_AGENT_STEPS - 1) {
return { toolChoice: 'none', system: `${system}\n\n${FINAL_STEP_INSTRUCTION}` };
}
return undefined;
}
export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION };
/**
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
@@ -245,7 +281,13 @@ export class AiChatService {
// cap would truncate complex tool calls mid-argument. Let the model use its
// natural per-step budget. (Cost/credit limits are an account concern, not
// something to enforce by silently breaking the agent.)
stopWhen: stepCountIs(8),
stopWhen: stepCountIs(MAX_AGENT_STEPS),
// Forced finalization: reserve the LAST allowed step for a text-only
// answer. Without this, a turn that spends all its steps on tool calls
// ends with no assistant text (an empty turn). prepareAgentStep forbids
// further tool calls and appends a synthesis instruction on that step,
// concatenated onto the original `system` so the persona is preserved.
prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system),
abortSignal: signal,
onFinish: async ({ text, finishReason, totalUsage, usage, steps }) => {
await persistAssistant({

View File

@@ -578,6 +578,49 @@ export class PageController {
);
}
@HttpCode(HttpStatus.OK)
@Post('/tree')
async getPagesTree(
@Body() dto: SidebarPageDto,
@AuthUser() user: User,
) {
if (!dto.spaceId && !dto.pageId) {
throw new BadRequestException(
'Either spaceId or pageId must be provided',
);
}
let spaceId = dto.spaceId;
if (dto.pageId) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new ForbiddenException();
}
spaceId = page.spaceId;
}
const ability = await this.spaceAbility.createForUser(user, spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceCanEdit = ability.can(
SpaceCaslAction.Edit,
SpaceCaslSubject.Page,
);
const items = await this.pageService.getSidebarPagesTree(
spaceId,
user.id,
spaceCanEdit,
dto.pageId,
);
return { items };
}
@HttpCode(HttpStatus.OK)
@Post('move-to-space')
async movePageToSpace(

View File

@@ -1137,7 +1137,7 @@ export class PageService {
T extends { id: string; parentPageId: string | null },
>(
pages: T[],
rootPageId: string,
rootPageId: string | null,
userId: string,
spaceId?: string,
): Promise<T[]> {
@@ -1153,6 +1153,15 @@ export class PageService {
);
const accessibleSet = new Set(accessibleIds);
// When no explicit root is given (whole-space tree), every page whose
// parent is outside the returned set acts as a root (space root pages have
// parentPageId === null). This mirrors the single-root case below.
const pageIdSet = new Set(pageIds);
const isRoot = (page: T): boolean => {
if (rootPageId !== null) return page.id === rootPageId;
return !page.parentPageId || !pageIdSet.has(page.parentPageId);
};
// Prune: include a page only if it's accessible AND its parent chain to root is included
const includedIds = new Set<string>();
@@ -1166,7 +1175,7 @@ export class PageService {
if (!accessibleSet.has(page.id)) continue;
// Root page: include if accessible
if (page.id === rootPageId) {
if (isRoot(page)) {
includedIds.add(page.id);
changed = true;
continue;
@@ -1182,4 +1191,123 @@ export class PageService {
return pages.filter((p) => includedIds.has(p.id));
}
/**
* Whole subtree (pageId) or whole space tree (spaceId only) in a single
* query, permission-filtered, returned as a flat list matching the sidebar
* item shape (id, slugId, title, icon, position, parentPageId, spaceId,
* hasChildren, canEdit) ordered by position. content is never fetched.
*
* Reproduces the exact two-branch permission logic of getSidebarPages():
* - open space (no restrictions): every returned page is visible, canEdit =
* spaceCanEdit, hasChildren derived from the returned set.
* - restricted space: full descendant set is loaded, then per-page
* permissions applied via filterAccessibleTreePages (restricted-but-granted
* pages are kept; inaccessible subtrees pruned); canEdit is per-page AND
* spaceCanEdit;
* hasChildren is derived from the FINAL (post-prune, post-filter) set, so
* a node never advertises children the user cannot access — the same
* correction getSidebarPages does via getParentIdsWithAccessibleChildren.
*/
async getSidebarPagesTree(
spaceId: string,
userId: string,
spaceCanEdit?: boolean,
pageId?: string,
): Promise<
Array<
Pick<
Page,
| 'id'
| 'slugId'
| 'title'
| 'icon'
| 'position'
| 'parentPageId'
| 'spaceId'
> & { hasChildren: boolean; canEdit: boolean }
>
> {
const hasRestrictions =
await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
// Seed: a single page subtree, or all root pages of the space.
// Always seed with the FULL (non-excluding) descendant set — in a restricted
// space the per-page filtering below (filterAccessibleTreePages) does the
// pruning, exactly like getSidebarPages. Seeding with *ExcludingRestricted
// would wrongly drop restricted pages the user has an explicit grant for
// (and never recurse into their children), diverging from the sidebar.
let pages: Array<{
id: string;
slugId: string;
title: string;
icon: string;
position: string;
parentPageId: string | null;
spaceId: string;
}>;
if (pageId) {
pages = await this.pageRepo.getPageAndDescendants(pageId, {
includeContent: false,
});
} else {
pages = await this.pageRepo.getSpaceDescendants(spaceId, {
includeContent: false,
});
}
let permissionMap: Map<string, boolean> | undefined;
if (hasRestrictions) {
// Fine-grained per-page permissions on top of restricted pruning.
pages = await this.filterAccessibleTreePages(
pages,
pageId ?? null,
userId,
spaceId,
);
// Per-page canEdit, same source as getSidebarPages.
const accessiblePages =
await this.pagePermissionRepo.filterAccessiblePageIdsWithPermissions(
pages.map((p) => p.id),
userId,
);
permissionMap = new Map(accessiblePages.map((p) => [p.id, p.canEdit]));
}
// Derive hasChildren from the FINAL set: a node has children iff some
// returned row points to it as parent. In a restricted space this set is
// already pruned/filtered, so inaccessible children are not revealed.
const parentIds = new Set<string>();
for (const p of pages) {
if (p.parentPageId) parentIds.add(p.parentPageId);
}
const shaped = pages.map((p) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: hasRestrictions
? Boolean(permissionMap?.get(p.id)) && (spaceCanEdit ?? true)
: (spaceCanEdit ?? true),
}));
// Order by position with byte order, matching the sidebar's
// `position collate "C"` SQL ordering. position is non-null in returned
// rows; treat a null defensively as sorting last.
shaped.sort((a, b) => {
if (a.position == null) return b.position == null ? 0 : 1;
if (b.position == null) return -1;
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
});
return shaped;
}
}

View File

@@ -0,0 +1,179 @@
/**
* Pure-logic test for getSidebarPagesTree's shaping/permission logic.
*
* NOTE: We cannot import PageService directly here — its dependency chain
* imports `src/collaboration/collaboration.util` via a bare `src/...` path, and
* the server's jest config (package.json "jest".moduleNameMapper) has no
* `^src/(.*)$` mapping, so the module fails to resolve under jest. That is a
* pre-existing config gap unrelated to this feature. To still cover the
* load-bearing logic we replicate the exact shaping algorithm from
* PageService.getSidebarPagesTree below and assert against it. If the service
* logic changes, keep this mirror in sync.
*/
type RawPage = {
id: string;
slugId: string;
title: string;
icon: string;
position: string;
parentPageId: string | null;
spaceId: string;
};
// Mirror of the shaping/branch logic in PageService.getSidebarPagesTree.
function shapeTree(
pages: RawPage[],
opts: {
hasRestrictions: boolean;
spaceCanEdit?: boolean;
permissionMap?: Map<string, boolean>;
},
) {
const parentIds = new Set<string>();
for (const p of pages) {
if (p.parentPageId) parentIds.add(p.parentPageId);
}
const shaped = pages.map((p) => ({
id: p.id,
slugId: p.slugId,
title: p.title,
icon: p.icon,
position: p.position,
parentPageId: p.parentPageId,
spaceId: p.spaceId,
hasChildren: parentIds.has(p.id),
canEdit: opts.hasRestrictions
? Boolean(opts.permissionMap?.get(p.id)) && (opts.spaceCanEdit ?? true)
: (opts.spaceCanEdit ?? true),
}));
shaped.sort((a, b) => {
if (a.position == null) return b.position == null ? 0 : 1;
if (b.position == null) return -1;
return Buffer.compare(Buffer.from(a.position), Buffer.from(b.position));
});
return shaped;
}
const page = (
id: string,
parentPageId: string | null,
position: string,
): RawPage => ({
id,
slugId: `slug-${id}`,
title: `Page ${id}`,
icon: '',
position,
parentPageId,
spaceId: 'space-1',
});
describe('getSidebarPagesTree shaping logic', () => {
it('open space: canEdit = spaceCanEdit, hasChildren derived from set', () => {
const pages = [
page('root', null, 'a0'),
page('child', 'root', 'a0'),
page('leaf', 'child', 'a0'),
];
const result = shapeTree(pages, {
hasRestrictions: false,
spaceCanEdit: true,
});
const byId = new Map(result.map((p) => [p.id, p]));
expect(byId.get('root')!.hasChildren).toBe(true);
expect(byId.get('child')!.hasChildren).toBe(true);
expect(byId.get('leaf')!.hasChildren).toBe(false);
expect(result.every((p) => p.canEdit === true)).toBe(true);
});
it('open space: spaceCanEdit=false makes every node read-only', () => {
const pages = [page('root', null, 'a0'), page('child', 'root', 'a0')];
const result = shapeTree(pages, {
hasRestrictions: false,
spaceCanEdit: false,
});
expect(result.every((p) => p.canEdit === false)).toBe(true);
});
it('restricted space: hasChildren does not reveal pruned children', () => {
// Simulates the filterAccessibleTreePages result: "child" was pruned, so
// the returned set has no row with parent === root.
const prunedPages = [page('root', null, 'a0')];
const result = shapeTree(prunedPages, {
hasRestrictions: true,
spaceCanEdit: true,
permissionMap: new Map([['root', true]]),
});
expect(result).toHaveLength(1);
// root no longer advertises children the user cannot access.
expect(result[0].hasChildren).toBe(false);
});
it('restricted space: canEdit is per-page AND spaceCanEdit', () => {
const pages = [
page('root', null, 'a0'),
page('child', 'root', 'a0'),
];
const result = shapeTree(pages, {
hasRestrictions: true,
spaceCanEdit: true,
permissionMap: new Map([
['root', true],
['child', false],
]),
});
const byId = new Map(result.map((p) => [p.id, p]));
expect(byId.get('root')!.canEdit).toBe(true);
expect(byId.get('child')!.canEdit).toBe(false);
expect(byId.get('root')!.hasChildren).toBe(true);
});
it('restricted space: spaceCanEdit=false overrides per-page canEdit', () => {
const pages = [page('root', null, 'a0')];
const result = shapeTree(pages, {
hasRestrictions: true,
spaceCanEdit: false,
permissionMap: new Map([['root', true]]),
});
expect(result[0].canEdit).toBe(false);
});
it('orders by position (collate-C style ascending)', () => {
const pages = [
page('b', null, 'a1'),
page('c', null, 'a2'),
page('a', null, 'a0'),
];
const result = shapeTree(pages, {
hasRestrictions: false,
spaceCanEdit: true,
});
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']);
});
it('shape contains exactly the sidebar item fields', () => {
const result = shapeTree([page('root', null, 'a0')], {
hasRestrictions: false,
spaceCanEdit: true,
});
expect(Object.keys(result[0]).sort()).toEqual(
[
'canEdit',
'hasChildren',
'icon',
'id',
'parentPageId',
'position',
'slugId',
'spaceId',
'title',
].sort(),
);
});
});

View File

@@ -672,4 +672,58 @@ export class PageRepo {
.execute()
);
}
/**
* Whole space tree (all root pages and their descendants) in a single
* recursive query. Mirrors getPageAndDescendants but seeded by every root
* page of the space (parentPageId IS NULL) instead of a single parent.
*/
async getSpaceDescendants(
spaceId: string,
opts: { includeContent: boolean },
) {
return this.db
.withRecursive('page_hierarchy', (db) =>
db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
'workspaceId',
'createdAt',
'updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('content'))
.where('spaceId', '=', spaceId)
.where('parentPageId', 'is', null)
.where('deletedAt', 'is', null)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.slugId',
'p.title',
'p.icon',
'p.position',
'p.parentPageId',
'p.spaceId',
'p.workspaceId',
'p.createdAt',
'p.updatedAt',
])
.$if(opts?.includeContent, (qb) => qb.select('p.content'))
.innerJoin('page_hierarchy as ph', 'p.parentPageId', 'ph.id')
.where('p.deletedAt', 'is', null),
),
)
.selectFrom('page_hierarchy')
.selectAll()
.execute();
}
}

View File

@@ -214,6 +214,13 @@ export class EnvironmentService {
return !this.isCloud();
}
isCompactPageTreeEnabled(): boolean {
const compactTree = this.configService
.get<string>('COMPACT_PAGE_TREE', 'true')
.toLowerCase();
return compactTree === 'true';
}
getStripePublishableKey(): string {
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
}

View File

@@ -35,6 +35,7 @@ export class StaticModule implements OnModuleInit {
ENV: this.environmentService.getNodeEnv(),
APP_URL: this.environmentService.getAppUrl(),
CLOUD: this.environmentService.isCloud(),
COMPACT_PAGE_TREE: this.environmentService.isCompactPageTreeEnabled(),
FILE_UPLOAD_SIZE_LIMIT:
this.environmentService.getFileUploadSizeLimit(),
FILE_IMPORT_SIZE_LIMIT: