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:

View File

@@ -1,199 +0,0 @@
# Лимит шагов AI-агента (8 → 20) и принудительный финальный ответ
Контекст (симптом из реального чата): на узкий поисковый вопрос («Какой
процессор в первой версии Яндекс.Колонки?») агент сделал подряд ~8 вызовов
`Search_tavily_search` / `Search_tavily_extract` и **остановился без текстового
ответа** — ход завершился пустым. Пользователь отправил «?», что стартовало
новый ход с новым бюджетом, и агент продолжил. Причина — жёсткий потолок в
8 шагов на один ход агента: бюджет был израсходован на инструменты раньше, чем
модель дошла до шага с финальным текстом.
Хотим две вещи:
1. поднять лимит шагов с 8 до 20;
2. гарантировать непустой ответ — на последнем шаге принудительно запрещать
инструменты, чтобы модель синтезировала лучший ответ из уже собранного.
## Как сейчас устроен лимит (цепочка)
Единственная точка ограничения — `stopWhen` в вызове `streamText`:
- Импорт условия: `apps/server/src/core/ai-chat/ai-chat.service.ts:7`
(`stepCountIs` из `ai`).
- Потолок: `apps/server/src/core/ai-chat/ai-chat.service.ts:247`
`stopWhen: stepCountIs(8)` внутри `streamText({...})` (вызов начинается на
`:237`).
- Системный промпт, который уходит в `streamText({ system, ... })`, собирается
заранее в локальной переменной `system`:
`apps/server/src/core/ai-chat/ai-chat.service.ts:146-150`
(`buildSystemPrompt({...})`). Эта переменная в области видимости рядом с
вызовом `streamText` — её можно переиспользовать в `prepareStep`.
- Терминальные колбэки `onFinish` / `onError` / `onAbort`
(`ai-chat.service.ts:249-301`) сохраняют ответ ассистента через
`persistAssistant` (`:210-230`). При пустом ходе `onFinish` приходит с
`text === ''`, и в историю пишется пустое сообщение — это и видит пользователь
как «агент ничего не ответил».
### Что такое «шаг» (семантика AI SDK v6)
Один шаг = одна генерация модели. Если в шаге есть вызовы инструментов, они
выполняются, результат возвращается модели, и запускается следующий шаг.
`stopWhen: stepCountIs(N)` останавливает цикл, как только число завершённых
шагов достигает `N`. Цикл также завершается естественно, если модель сделала шаг
**без** вызова инструментов (выдала финальный текст).
Важно: `stepNumber` в `prepareStep` нумеруется с нуля; последний из `N` шагов —
это `stepNumber === N - 1`. Один шаг может содержать несколько параллельных
вызовов инструментов, поэтому `N` шагов ≠ всегда ровно `N` вызовов (в инциденте
они шли последовательно — получилось ровно 8).
## Решение (точечное, только сервер)
Файл: `apps/server/src/core/ai-chat/ai-chat.service.ts`.
1. Завести модульную константу вместо «магической» восьмёрки:
```ts
// 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 prepareStep). 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.';
```
2. Поднять потолок:
```ts
stopWhen: stepCountIs(MAX_AGENT_STEPS),
```
3. Добавить `prepareStep` в опции `streamText({...})` (рядом со `stopWhen`,
перед `abortSignal`). На последнем разрешённом шаге запрещаем инструменты
(`toolChoice: 'none'` → модель обязана выдать текст) и дополняем системный
промпт инструкцией синтеза. На остальных шагах ничего не возвращаем →
действуют дефолтные настройки:
```ts
// 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). On the final step we forbid further tool
// calls and append a synthesis instruction. `system` is the prompt built above
// (in scope here); we CONCATENATE so the original persona/context is preserved
// — a bare `system` override would REPLACE the whole system prompt for the step.
prepareStep: ({ stepNumber }) => {
if (stepNumber >= MAX_AGENT_STEPS - 1) {
return {
toolChoice: 'none',
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
};
}
return undefined; // default settings for all earlier steps
},
```
Итог: до 19 шагов модель свободно работает с инструментами, 20-й (последний)
шаг гарантированно текстовый. Если модель завершилась раньше естественным
образом — `prepareStep` для ранних шагов возвращает `undefined`, поведение не
меняется.
## Подтверждённые факты по API (установлено: `ai@6.0.207`)
Проверено по `node_modules/ai/dist/index.d.ts`:
- `prepareStep({ stepNumber, steps, model, messages }) => PrepareStepResult |
void` — колбэк опции `streamText`.
- `PrepareStepResult` (строки ~990-1019) содержит поля:
`model?`, `toolChoice?`, `activeTools?`, `system?`, `messages?` и др.
- `toolChoice?: ToolChoice<TOOLS>`, где
`ToolChoice = 'auto' | 'none' | 'required' | { type:'tool', toolName }`
(строка 126) — значит `toolChoice: 'none'` валидно и заставляет модель
отвечать текстом.
- `system?: string | SystemModelMessage | Array<SystemModelMessage>` — override
системного сообщения **для шага**; это полная замена, поэтому конкатенируем с
исходным `system`, а не пишем голую инструкцию.
- `stepNumber` нумеруется с нуля (док. пример: `if (stepNumber === 0) {...}`).
> ⚠️ При апгрейде до AI SDK v7 поле `system` в `prepareStep` переименовано в
> `instructions` (см. migration guide 7.0). На v6 (`^6.0.134`, фактически
> 6.0.207) корректно именно `system`. Учесть при будущем bump.
## Тонкие моменты / edge cases
- **Резерв ровно одного шага** — на 20-м шаге модель не сможет сделать ещё один
«дозапрос». Это осознанный компромисс: гарантированный ответ важнее одного
лишнего инструмента. Если захочется буфера — форсить на `stepNumber >=
MAX_AGENT_STEPS - 2` (зарезервировать 2 шага), но это режет полезную работу.
- **Естественное завершение** до последнего шага — не затрагивается: override
применяется только при `stepNumber >= MAX_AGENT_STEPS - 1`.
- **finishReason** последнего шага: при `toolChoice:'none'` модель выдаёт текст
без tool-calls → цикл завершается как `stop` (а не «оборвался на лимите»).
Пустых ходов больше не будет; `onFinish` получит непустой `text`.
- **Замена system** override-ом — единственная ловушка: НЕ потерять исходный
промпт. Переменная `system` (`ai-chat.service.ts:146`) в замыкании — берём её.
- **maxOutputTokens** на агенте намеренно не задан (коммент `:242-246`) — это
изменение его не трогает; токенов на финальный текстовый шаг достаточно.
- **Клиент не меняется**: рендер шагов и текста уже есть в
`apps/client/src/features/ai-chat/components/message-list.tsx`. Раньше пустой
ход показывался как ход без текста — после фикса будет нормальный ответ.
- **Внешние MCP-клиенты** (tavily и пр.) закрываются в терминальных колбэках
(`closeExternalClients`) — путь завершения не меняется, ликов не добавляем.
## Тестирование
- Цикл `streamText` целиком юнит-тестировать дорого. Рекомендуется вынести
логику выбора шага в чистую экспортируемую функцию (по образцу
`compactToolOutput`, который уже тестируется в `ai-chat.service.spec.ts`):
```ts
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
// steps, and forces a text-only synthesis on the final step.
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;
}
```
Тогда `prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system)`,
а тест проверяет: для `stepNumber < 19` → `undefined`; для `19` → объект с
`toolChoice === 'none'` и `system`, начинающимся с исходного промпта и
содержащим `FINAL_STEP_INSTRUCTION`.
## Альтернативы / возможные расширения (вне базового объёма)
- **Конфигурируемый лимит** — вынести `MAX_AGENT_STEPS` в настройку воркспейса
(admin → AI), как системный промпт (`AiSettingsService.resolve`). Сейчас же —
просто константа в коде.
- **UI-метка «ответ по неполным данным»** — если последний шаг был принудительным,
можно прокинуть флажок в metadata и показать бейдж в `message-list.tsx`. Не
обязательно для базовой фичи.
## Открытые вопросы (согласовать перед реализацией)
- [ ] Значение лимита: 20 — ок? (компромисс «глубина исследования» vs стоимость
токенов на ход.)
- [ ] Текст `FINAL_STEP_INSTRUCTION` — устраивает формулировка? Язык ответа
модель выбирает сама по контексту; инструкция на английском как и весь
системный промпт.
- [ ] Выносить ли логику шага в чистую функцию ради юнит-теста (рекомендуется),
или оставить инлайн в `prepareStep` без отдельного теста.
## Процесс
- Сейчас это только план; код НЕ менялся.
- Реализация — режим делегирования (по умолчанию): изменение логическое
(новый `prepareStep` + константы, >5 строк) → general-purpose кодеру, затем
обязательный прогон `review`.
- Не коммитить; в конце предложить сообщение коммита.

View File

@@ -1,224 +0,0 @@
# Индикатор-точка эндпоинта AI: «настроено / включено» вместо «результат теста»
## Контекст (симптом)
В админских настройках AI (Workspace settings → AI) у каждой карточки-эндпоинта
(«Chat / LLM», «Embeddings», «Voice / STT») слева от заголовка есть маленькая
цветная точка. Сейчас её цвет означает **результат последнего ручного теста**
кнопкой «Test endpoint», а не состояние настройки:
- зелёная — тест «Test endpoint» прошёл (`ok`);
- красная — тест упал (`error`);
- серая — тест ещё **не запускали** (`idle`).
Поэтому на текущем экране у «Embeddings» точка зелёная (по карточке нажимали
«Test endpoint» → «Connection successful»), а у «Voice / STT» — серая, **хотя
тумблер «Voice dictation» включён и эндпоинт настроен**. Тумблеры фич
(`chat` / `search` / `dictation`) и сам факт заполненности полей (модель +
Base URL) на цвет точки сейчас никак не влияют.
Хотим, чтобы точка читалась с одного взгляда как состояние эндпоинта, без
ручного теста:
- **зелёная** — корректно настроено **и** включено;
- **жёлтая** — настроено, но **не** включено;
- **серая** — выключено / не настроено (нечего включать).
## Как сейчас устроено (цепочка)
Всё в одном файле клиента:
`apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx`.
- Тип состояния точки: `type CardStatus = "ok" | "error" | "idle";`
— строка ~64.
- Компонент `StatusDot` (строки ~75-90) красит круг: `ok``green[6]`,
`error``red[6]`, иначе → `gray[5]`.
- Источник статуса — **только** мутации теста (строки ~356-370):
```ts
const chatStatus: CardStatus = chatTest.data
? (chatTest.data.ok ? "ok" : "error")
: "idle";
// аналогично embedStatus (embedTest), sttStatus (sttTest)
```
`chatTest` / `embedTest` / `sttTest` — это `useTestAiConnectionMutation()`
(строки ~101-104); их `data` появляется только после нажатия «Test endpoint».
- Точки рендерятся в заголовках трёх карточек: `<StatusDot status={chatStatus}/>`
(~407), `embedStatus` (~517), `sttStatus` (~634).
### Какие данные уже доступны в компоненте
Этого достаточно, чтобы вычислить «настроено» и «включено» синхронно, без сети:
- **Поля настройки** (живые, из формы) — `form.values`:
`chatModel`, `baseUrl`, `embeddingModel`, `embeddingBaseUrl`, `sttModel`,
`sttBaseUrl`, `sttApiStyle`, и write-only буферы ключей `apiKey`,
`embeddingApiKey`, `sttApiKey`.
- **Наличие сохранённых ключей** — состояния `hasApiKey`, `hasEmbeddingApiKey`,
`hasSttApiKey` (строки ~122-130), синхронизируются с сервером и обновляются
при «Clear» и сохранении.
- **Тумблеры фич** (персистятся в `workspace.settings.ai`) — `chatEnabled`
(`settings.ai.chat`, строка ~108), `searchEnabled` (`settings.ai.search`,
~111), `dictationEnabled` (`settings.ai.dictation`, ~114).
- **Семантика наследования** (важно для «настроено»): Embeddings и Voice
**наследуют Base URL и ключ от Chat**, если свои не заданы. Это прямо написано
в подзаголовке карточки Chat: «root endpoint — Embeddings and Voice inherit its
URL and key» (строка ~423), и реализовано в `resolveUrl(..., fallback)`
(~373-382). Значит у Embeddings/STT «свой Base URL» не обязателен.
## Решение (точечное, только клиент)
Файл: `apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx`.
Перепривязать цвет точки с «результата теста» на пару булевых признаков
**`configured` × `enabled`**. Результат теста остаётся как был — текстом рядом с
кнопкой («Connection successful» / ошибка), точку он больше не красит.
### 1. Новый тип состояния и чистый хелпер выбора цвета
```ts
// Three-state endpoint health shown by the header dot. Derived synchronously
// from the form + feature toggle — never from a network probe (the "Test
// endpoint" button still surfaces the live probe result as text).
// "ready" (green) — required fields are filled AND the feature is ON
// "configured" (yellow) — required fields are filled but the feature is OFF
// "off" (gray) — required fields missing (nothing to enable)
type CardStatus = "ready" | "configured" | "off";
// Pure + unit-testable. `configured` = the endpoint has everything it needs to
// work; `enabled` = the workspace feature toggle for this endpoint is ON.
function resolveCardStatus(configured: boolean, enabled: boolean): CardStatus {
if (!configured) return "off";
return enabled ? "ready" : "configured";
}
```
### 2. `StatusDot` — добавить жёлтый
```ts
function StatusDot({ status }: { status: CardStatus }) {
const theme = useMantineTheme();
const color =
status === "ready"
? theme.colors.green[6]
: status === "configured"
? theme.colors.yellow[6] // Mantine default palette has `yellow`
: theme.colors.gray[5];
return (
<Box w={9} h={9} style={{ borderRadius: "50%", background: color, flex: "none" }} />
);
}
```
### 3. Признак «настроено» для каждой карточки
Ключ (API key) считаем **необязательным** — локальные серверы (Ollama, speaches
/ faster-whisper-server) работают без ключа, поэтому требовать ключ нельзя.
«Настроено» = задана модель **и** есть Base URL (свой или унаследованный от Chat):
```ts
const v = form.values;
const chatBase = v.baseUrl.trim();
// Chat is the root: needs its own model + base URL.
const chatConfigured = v.chatModel.trim() !== "" && chatBase !== "";
// Embeddings / Voice inherit the chat base URL when their own is empty.
const embedConfigured =
v.embeddingModel.trim() !== "" && (v.embeddingBaseUrl.trim() !== "" || chatBase !== "");
const sttConfigured =
v.sttModel.trim() !== "" && (v.sttBaseUrl.trim() !== "" || chatBase !== "");
```
### 4. Заменить вывод статусов (строки ~356-370)
```ts
const chatStatus = resolveCardStatus(chatConfigured, chatEnabled);
const embedStatus = resolveCardStatus(embedConfigured, searchEnabled);
const sttStatus = resolveCardStatus(sttConfigured, dictationEnabled);
```
`chatTest` / `embedTest` / `sttTest` остаются для текстового результата под
кнопкой «Test endpoint» — их `data` просто больше не участвует в цвете точки.
### 5. (Рекомендуется) Tooltip на точке — цвет не должен быть единственным сигналом
Цвет в одиночку недоступен дальтоникам и неочевиден. Обернуть `StatusDot` в
Mantine `Tooltip` с текстовой расшифровкой (через `t(...)`), напр.:
`ready` → «Configured and enabled», `configured` → «Configured but disabled`»,
`off` → «Not configured». `Tooltip` уже используется в соседнем
`mcp-settings.tsx`, импорт из `@mantine/core`.
## Тонкие моменты / edge cases
- **Источник «настроено» — `form.values` (живой), а не persisted `settings`.**
Тогда точка реагирует прямо при наборе. Минус: тумблер (`*Enabled`) —
персистентный, поэтому после правки полей и **до** «Save endpoints» возможна
кратковременная рассинхронизация (поля изменены, но ещё не сохранены). Это
приемлемо и логично (точка показывает «то, что введено»). Альтернатива — брать
поля из `settings` (тогда точка отражает строго сохранённое состояние,
согласованно с тумблером) — см. «Альтернативы».
- **Включено, но НЕ настроено** (`enabled && !configured`): админ включил фичу, но
не заполнил эндпоинт — реальная мисконфигурация. По строгой трёхцветной схеме
это **серый**, что прячет проблему. Варианты: (а) оставить серым (буквально по
ТЗ); (б) **рекомендуется** — отдельный «warning»-цвет (красный/оранжевый) и
тултип «Enabled but not configured», т.к. фича включена и работать не будет.
Решить в «Открытых вопросах».
- **Судьба красного «тест упал».** Сейчас красный = упавший тест. В новой схеме
цвета красного нет. Падение теста по-прежнему видно текстом под кнопкой, так что
сигнал не теряется. Опционально можно сохранить красный как 4-е состояние-оверрайд
(если тест **явно** запускали и он упал) — но это усложняет модель; по умолчанию
не делаем.
- **`yellow` в теме Mantine** есть в дефолтной палитре (Mantine 8) — `yellow[6]`
валиден; кастомная тема в проекте палитру не переопределяет (использовать
`theme.colors.yellow[6]`).
- **Все три карточки** ведут себя единообразно (одна `StatusDot` + один хелпер),
включая «Chat / LLM», которой нет на скриншоте, но логика та же.
- **Оптимистичные тумблеры**: `*Enabled` обновляются оптимистично и
откатываются при ошибке (`handleToggle*`). Цвет точки следует за состоянием
тумблера автоматически (реактивный `useState`).
- **trim()**: значения могут содержать пробелы — сравнивать после `.trim()` (как
в `resolveUrl`).
## i18n
- Новые пользовательские строки (тексты тултипов) **только через `t(...)`** и
добавить ключи в каталог `apps/client/public/locales/en-US/translation.json`
(он английско-ключевой: ключ == значение, напр. `"Configured and enabled"`).
Если используется warning-вариант — добавить и его строку.
- Комментарии в коде — на английском (правило проекта).
## Тесты
- `resolveCardStatus` — чистая функция, легко юнит-тестируется (Vitest на
клиенте): `(false, *) → "off"`, `(true, true) → "ready"`, `(true, false) →
"configured"`. Если экспортировать `*Configured`-предикаты как чистые
функции от `form.values` — их тоже можно покрыть (особенно наследование Base
URL у Embeddings/STT).
- Запустить `pnpm --filter client lint` и `pnpm --filter client test`.
## Альтернативы / расширения (вне базового объёма)
- **Брать «настроено» из persisted `settings`** (а не `form.values`): точка строго
отражает сохранённое состояние, согласовано с персистентным тумблером, но не
реагирует на ввод до «Save». `settings` (`IAiSettings`) уже содержит
`chatModel`/`embeddingModel`/`baseUrl`/`embeddingBaseUrl`/`sttModel`/
`sttBaseUrl` + `hasApiKey`/`hasEmbeddingApiKey`/`hasSttApiKey`.
- **«настроено» = «тест прошёл»** вместо «поля заполнены»: точнее («корректно»),
но требует автопрогона теста на загрузке (сеть, латентность, лимиты провайдера)
— против идеи мгновенного индикатора. Не рекомендуется.
- **Учитывать ключ для облачных провайдеров**: если Base URL указывает на
публичный провайдер (OpenAI/OpenRouter), ключ де-факто обязателен. Можно
усложнить предикат (`configured` требует ключ, если host не локальный), но это
хрупкая эвристика — оставляем ключ необязательным.
## Открытые вопросы (согласовать перед реализацией)
- [ ] Случай «включено, но не настроено»: серый (буквально по ТЗ) или отдельный
warning-цвет (рекомендуется, чтобы не прятать мисконфигурацию)?
- [ ] Что значит «настроено»: «поля модель + Base URL заполнены» (рекомендуется,
ключ необязателен) — ок? Или требовать ещё и ключ?
- [ ] Источник полей: живой `form.values` (реактивно при вводе, рекомендуется)
или persisted `settings` (строго сохранённое состояние)?
- [ ] Добавлять ли `Tooltip` с текстовой расшифровкой (рекомендуется для
доступности) и сохранять ли красный как 4-е состояние «тест упал»?

View File

@@ -1,157 +0,0 @@
# Поле «API key»: убрать бесполезный «глазок», поставить Clear на его место
Статус: **план, код не менялся.** UI-задача на клиенте. Бэкенда не касается.
## Суть
В настройках AI-провайдера (Workspace settings → AI) у каждого из трёх
эндпоинтов есть поле `PasswordInput` для API-ключа. Когда ключ уже сохранён на
сервере, поле показывает плейсхолдер `•••• set`, а справа — встроенный в
Mantine `PasswordInput` тогл видимости («глазок»). Под полем отдельной строкой
висит ссылка **Clear**.
Проблема: **«глазок» бессмысленен.** Поле ключа — write-only буфер: реальный
ключ в него никогда не загружается (сервер отдаёт только факт «ключ есть»,
`hasApiKey`, см. `ai-provider-settings.tsx:120-130, 154-177`). Когда ключ
сохранён, буфер пустой → нажатие «глазка» показывает пустоту. Полезного смысла
нет.
Хотим: **в состоянии «ключ сохранён» показывать кнопку Clear прямо на месте
«глазка» (в правой секции поля), а не отдельной ссылкой снизу.** Сделать это во
**всех трёх эндпоинтах** (Chat / LLM, Embeddings, Voice / STT).
## Где править (точные места)
Один файл:
[ai-provider-settings.tsx](apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx)
Три одинаковых по структуре блока — `<Stack gap={4}>` с `PasswordInput` + ссылкой
`<Anchor>Clear</Anchor>` снизу:
1. **Chat / LLM** — строки ~433-445 (`apiKey`, `hasApiKey`, `handleClearKey`).
2. **Embeddings** — строки ~538-560 (`embeddingApiKey`, `hasEmbeddingApiKey`,
`handleClearEmbeddingKey`).
3. **Voice / STT** — строки ~657-679 (`sttApiKey`, `hasSttApiKey`,
`handleClearSttKey`).
Обработчики очистки (`handleClearKey` / `handleClearEmbeddingKey` /
`handleClearSttKey`, строки 239-255) и вся логика буферов/payload
(`buildPayload`, строки 179-222) — **остаются без изменений.** Меняется только
разметка трёх полей.
## Ключевой факт Mantine (подтверждён по докам)
У `PasswordInput`: **если передать свой `rightSection`, встроенный тогл
видимости («глазок») не рендерится** (Mantine docs, PasswordInput → «Usage
without visibility toggle»: *“When the `rightSection` prop is used, the
visibility toggle button is not rendered.”*).
То есть «поставить Clear на место глазка» = передать в `PasswordInput`
`rightSection` с кнопкой Clear. Отдельный костыль для скрытия глазка не нужен.
## Рекомендуемое поведение
Показывать Clear в правой секции **только когда ключ сохранён И буфер пуст**
(`hasApiKey && form.values.apiKey.length === 0`). Как только пользователь
начинает вводить НОВЫЙ ключ (буфер непустой) — возвращать дефолтный «глазок»:
вот тут он осмыслен (проверить, что набрал). После клика по Clear обработчик
ставит `hasApiKey=false``rightSection` снова `undefined` → поле становится
обычным пустым `PasswordInput` с глазком для ввода свежего ключа. Поведение
самосогласованное.
Альтернатива (проще, но грубее): показывать Clear всегда, пока `hasApiKey`
(без проверки буфера). Тогда при вводе нового поверх старого глазка не будет.
Допустимо, но теряем удобную проверку набранного. Рекомендуется вариант с
проверкой буфера.
## Эскиз правки (на примере Chat-поля; для двух других — аналогично)
Было:
```tsx
<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>
```
Стало:
```tsx
{/* 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. */}
<PasswordInput
label={t("API key")}
placeholder={hasApiKey ? t("•••• set") : ""}
autoComplete="off"
rightSection={
hasApiKey && form.values.apiKey.length === 0 ? (
<Tooltip label={t("Clear")}>
<ActionIcon
variant="subtle"
color="red"
size="sm"
aria-label={t("Clear")}
onClick={handleClearKey}
>
<IconX size={16} />
</ActionIcon>
</Tooltip>
) : undefined
}
rightSectionPointerEvents="all"
{...form.getInputProps("apiKey")}
/>
```
Изменения по каждому из трёх блоков:
- Убрать обёртку `<Stack gap={4}>…</Stack>` и ссылку `<Anchor>Clear</Anchor>`
снизу (Clear переезжает внутрь поля). После удаления `Stack` второй ребёнок
`<Group grow>` — сам `PasswordInput`; раскладка «Model | API key» в две
колонки сохраняется.
- Подставить свои переменные/обработчики: эндпоинт 2 — `hasEmbeddingApiKey` /
`embeddingApiKey` / `handleClearEmbeddingKey`; эндпоинт 3 — `hasSttApiKey` /
`sttApiKey` / `handleClearSttKey`.
## Тонкости / на что смотреть
- **Импорты.** Добавить `ActionIcon`, `Tooltip` из `@mantine/core` и `IconX`
из `@tabler/icons-react` (рядом с уже импортируемым `IconPencil`). После
переезда Clear внутрь поля `Anchor` может стать неиспользуемым — проверить и
убрать из импорта, иначе словим lint-ошибку `no-unused-vars`.
- **Кликабельность правой секции.** У `Input`/`PasswordInput` правая секция по
умолчанию не всегда принимает клики — задать `rightSectionPointerEvents="all"`,
чтобы клик по Clear срабатывал.
- **Тип кнопки.** `ActionIcon` рендерит `<button>` (по умолчанию `type="button"`).
Формы как `<form onSubmit>` тут нет — Save висит на отдельной `type="button"`
кнопке (строки 735-744), так что случайного сабмита не будет. Для надёжности
можно явно проставить `type="button"`.
- **i18n.** Новый строковый ключ не нужен: `t("Clear")` уже используется
(бывшая ссылка). Тултип и `aria-label` переиспользуют его. Плейсхолдер
`•••• set` не трогаем.
- **Ширина правой секции.** Иконка X помещается в штатный размер секции (как и
глазок). Если решат оставить именно слово «Clear» текстом вместо иконки —
понадобится `rightSectionWidth`, иначе текст обрежется. Рекомендуется
иконка + тултип (компактно, как глазок).
- **Доступность.** Обязателен `aria-label={t("Clear")}` на `ActionIcon` (иконка
без видимого текста).
## Опционально (вне «трёх эндпоинтов»)
Тот же паттерн «бесполезный глазок + Clear снизу» есть в форме внешнего
MCP-сервера —
[ai-mcp-server-form.tsx](apps/client/src/features/workspace/components/settings/components/ai-mcp-server-form.tsx)
(поле Authorization-заголовков, `PasswordInput` строка ~193, плейсхолдер
`•••• set` строка ~196, `Anchor`-Clear строки ~207-209, обработчик
`handleClearHeaders`). В запросе он не входит в «три эндпоинта», но логически
страдает тем же. Можно причесать заодно для единообразия — отдельным мелким
шагом, по той же схеме.

View File

@@ -1,181 +0,0 @@
# Панель комментариев: сделать плотнее (меньше воздуха, меньше шрифт)
Статус: **план, код не менялся.** Чисто UI-задача на клиенте (CSS + пропсы
Mantine). Бэкенда, схемы данных и логики не касается.
## Суть
Сейчас панель комментариев (правый aside, вкладка «Comments») выглядит
разреженной: крупные отступы между карточками и внутри них, большой межстрочный
интервал, тело комментария набрано базовым размером редактора (16px). На узкой
колонке это «съедает» вертикаль — на экран помещается мало комментариев, много
пустого места.
Хотим: **уплотнить раскладку** — уменьшить внутренние/внешние отступы карточек,
зазор «аватар ↔ текст», вертикальный ритм редактора — **и уменьшить шрифт**
тела комментария, имени автора и цитаты выделения. Цель — больше комментариев
на экран без потери читабельности.
## Где сейчас живёт «воздух» (точные места)
Вся вёрстка панели — в фиче `apps/client/src/features/comment/`.
### 1. Карточка комментария — [comment-list-with-tabs.tsx](apps/client/src/features/comment/components/comment-list-with-tabs.tsx)
- `renderComments`, обёртка каждого треда (~строки 121-129):
`<Paper shadow="sm" radius="md" p="sm" mb="sm" withBorder>``p="sm"` (12px
внутренний отступ) и `mb="sm"` (12px зазор между комментариями).
- Разделитель перед редактором ответа (~строка 148): `<Divider my={4} />`.
- Вкладки (`Tabs.Panel pt="xs"`, строки 226 и 245) и пустое состояние
(`<Center py="xl">`, строки 228 и 247) — второстепенные источники воздуха.
- Нижнее поле ввода `PageCommentInput` (строки ~361-405): `paddingTop` = `sm`,
`paddingBottom: 25`, аватар `marginTop: 10`, кнопка отправки спозиционирована
`bottom: 30`. Эти величины связаны (плавающая кнопка над полем) — трогать
осторожно.
### 2. Элемент комментария — [comment-list-item.tsx](apps/client/src/features/comment/components/comment-list-item.tsx)
- Внешняя обёртка (строка 119): `<Box ref={ref} pb="xs">` — 10px снизу у каждого
элемента (включая вложенные ответы).
- Шапка «аватар ↔ контент» (строка 120): `<Group>` **без** `gap` → дефолтный
`gap="md"` (16px) между аватаром и блоком с именем/телом. Это главный
горизонтальный «воздух».
- Имя автора (строка 129): `<Text size="sm" fw={500} lineClamp={1}>` — 14px.
- Время (строки 157-161): уже `<Text size="xs">` (12px) — оставить.
- Цитата выделения (строка 180): `<Text size="sm">{comment?.selection}</Text>`
14px, внутри блока `.textSelection`.
### 3. Стили — [comment.module.css](apps/client/src/features/comment/components/comment.module.css)
- `.textSelection` (строки 9-21): `margin-top: 4px`, `padding: 8px`.
- `.commentEditor .ProseMirror :global(.ProseMirror)` (строки 35-44):
`margin-top: 10px`, `margin-bottom: 2px`, паддинги 6px. **font-size не задан**
тело комментария наследует глобальный
`.ProseMirror { font-size: var(--mantine-font-size-md) }` (16px) из
[core.css:10](apps/client/src/features/editor/styles/core.css#L10).
- `.wrapper` (строки 1-3) — `padding: md`, **в коде не используется** (можно
игнорировать или удалить заодно).
### 4. Внешняя рамка панели (ВНИМАНИЕ: общая) — [aside.tsx](apps/client/src/components/layouts/global/aside.tsx)
- `<Box p="md">` (строка 47) и шапка `<Group ... mb="md">` с
`<Title order={2} size="h6">` (строки 50-51) дают 16px отступа по краям панели
и под заголовком. **Этот контейнер общий для трёх вкладок** aside
(`comments` / `toc` / `details`). Менять его — значит уплотнить заодно
«Содержание» и «Детали». См. «Открытые вопросы».
Шкалы Mantine в проекте без переопределений (`theme.ts` палитру/контраст меняет,
но не размеры): шрифт `xs=12px / sm=14px / md=16px`; spacing `xs=10 / sm=12 /
md=16`.
## Решение (точечное, в границах фичи comment)
Базовый объём — **только компоненты `features/comment/`**, чтобы вкладки
«Содержание»/«Детали» (общий `aside.tsx`) не задеть. Уплотнение рамки панели —
отдельный опциональный пункт (см. ниже).
### Правки по файлам
**`comment-list-with-tabs.tsx`**
- `<Paper>` в `renderComments`: `p="sm"``p="xs"`, `mb="sm"``mb="xs"`
(12 → 10px). `shadow="sm"`, `radius="md"`, `withBorder` — оставить.
- `<Divider my={4} />``my={2}`.
**`comment-list-item.tsx`**
- `<Box ref={ref} pb="xs">``pb={6}`.
- Шапка `<Group>` (аватар + контент, строка 120): добавить `gap="xs"`
(дефолтные 16px → 10px). НЕ трогать внутренние `<Group justify="space-between">`
и `<Group gap="xs">`, у них зазор уже задан.
- Имя: `<Text size="sm" ...>``size="xs"`. `fw={500}` и `lineClamp={1}`
оставить (см. «иерархия шрифта» ниже).
- Цитата: `<Text size="sm">{comment?.selection}</Text>``size="xs"`.
**`comment.module.css`**
- В `.ProseMirror :global(.ProseMirror)` добавить
`font-size: var(--mantine-font-size-sm);` (16 → 14px) и `line-height: 1.4;`,
заменить `margin-top: 10px``margin-top: 4px`. Остальные декларации
(`border-radius`, `max-width`, `white-space`, `word-break`, паддинги,
`margin-bottom`) — без изменений.
- `.textSelection`: `margin-top: 4px``2px`, `padding: 8px``6px`.
### Эскиз (ключевой фрагмент CSS)
```css
.commentEditor {
/* ... */
.ProseMirror :global(.ProseMirror) {
border-radius: var(--mantine-radius-sm);
max-width: 100%;
white-space: pre-wrap;
word-break: break-word;
padding-left: 6px;
padding-right: 6px;
/* Denser comments: shrink body text from the global 16px ProseMirror size
to 14px and tighten the rhythm vs. the comment header. */
font-size: var(--mantine-font-size-sm);
line-height: 1.4;
margin-top: 4px; /* was 10px */
margin-bottom: 2px;
}
}
.textSelection {
margin-top: 2px; /* was 4px */
padding: 6px; /* was 8px */
/* ...остальное без изменений... */
}
```
## Тонкие моменты / edge cases
- **Не трогать `aside.tsx` в базовом объёме.** Его `p="md"` и шапка общие для
вкладок `toc`/`details` — правка уплотнит и их. Если это нежелательно, держать
изменения строго внутри `features/comment/`.
- **Иерархия шрифта (принято).** После правок: имя — `xs` (12px, `fw=500`),
тело — `sm` (14px), время — `xs` (12px). Тело крупнее имени — это норма
(имя/мета как «капс-лейбл», тело как основной текст).
- **`font-size` ставится на внутренний `:global(.ProseMirror)`,** т.к. размер
приходит из глобального правила `core.css`. Класс-модуль `.commentEditor`
скоупит переопределение только на редактор комментариев — основной редактор
страницы не затрагивается.
- **Тёмная тема.** Меняем только размеры/отступы, цвета берутся из токенов
Mantine — отдельной проверки палитры не требуется, но визуально глянуть стоит.
- **Вложенные ответы** рендерятся тем же `CommentListItem` → уплотнение `pb`,
`gap`, шрифтов применится и к ним автоматически (так и нужно).
- **Markdown/код в теле.** `pre`/`code`/списки внутри комментария наследуют
`font-size` от `.ProseMirror` контейнера — после `font-size: sm` они тоже
станут компактнее; проверить, что код-блоки не разъезжаются.
- **Цитата выделения кликабельна** (`role="button"`, переход к месту в тексте) —
уменьшение `padding`/`size` не должно сломать зону клика; визуально проверить.
- **Нижнее поле ввода** (`PageCommentInput`) с плавающей кнопкой: величины
`paddingBottom: 25` / `bottom: 30` связаны. В базовом объёме не трогаем; если
захотим уплотнить и его — менять обе согласованно и проверить, что кнопка
отправки не наезжает на текст.
## Тесты / проверка
- Прогнать `pnpm --filter client lint` и `pnpm --filter client test`
(изменения косметические — падений быть не должно).
- Ручная проверка во вкладке Comments: тред с длинным телом, тред с цитатой
выделения, вложенные ответы, режим редактирования, светлая/тёмная тема, узкая
ширина aside. Убедиться, что вкладки «Содержание»/«Детали» не изменились
(если `aside.tsx` не трогали).
## Опционально / расширения (вне базового объёма)
- **Уплотнить рамку панели** — `aside.tsx`: `p="md"``p="sm"`, шапка
`mb="md"``mb="sm"`. Даст ощутимо меньше воздуха по краям, **но затронет все
вкладки aside** (см. «Открытые вопросы»).
- **Компактные вкладки Tabs** — `Tabs.Panel pt="xs"``pt={6}`, бейджи счётчиков
уже `size="sm"`.
- **Удалить мёртвый `.wrapper`** из `comment.module.css` (не используется).
- **Уменьшить аватары** с `size="sm"` до `size="xs"` в `CommentListItem` и
`PageCommentInput` для ещё большей плотности (проверить, что инициалы/картинка
не мельчат до нечитаемости).
## Принятые решения
Решения зафиксированы — реализовать можно сразу, без доп. согласований:
- **Границы правки:** строго `features/comment/`. Общую рамку aside (`p="md"`,
шапка `mb="md"`) **не трогаем** — она общая с вкладками «Содержание»/«Детали»,
и правка задела бы их (см. «Опционально», если позже захотим уплотнить и их).
- **Шрифт тела:** `sm` (14px) — не мельче.
- **Иерархия:** имя `xs` (12px, `fw=500`), тело `sm` (14px), время `xs` (12px).
- **Нижнее поле ввода и размер аватаров:** оставляем как есть.

View File

@@ -1,301 +0,0 @@
# Дерево страниц: кнопки «Развернуть всё» / «Свернуть всё»
Статус: **план, код не менялся.** Фича клиент+сервер. По решению владельца выбран
**серверный путь**: эндпоинт отдаёт **всё поддерево/всё дерево спейса разом**
(«отдать всё»), а клиент за один-два запроса разворачивает дерево целиком. От
клиентского рекурсивного обхода по одному уровню — отказались (см. «Почему так»).
## Суть
В сайдбаре спейса (дерево «Pages») сейчас узлы разворачиваются/сворачиваются
только поодиночке кликом по шеврону. Есть шорткат `*` (разворачивает **сиблингов**
сфокусированного узла, паттерн WAI-ARIA tree), но глобального «развернуть/свернуть
всё дерево» нет.
Хотим: две команды в шапке дерева — **«Развернуть всё»** (раскрыть все ветки
текущего спейса) и **«Свернуть всё»** (схлопнуть до корней). Это навигационная
операция над видом — прав на запись не требует, доступна любому, кто видит спейс.
## Почему так (выбор архитектуры)
Дети узлов **загружаются лениво, по одному уровню**: у свёрнутой ветки
`hasChildren === true`, но `children === []`, а эндпоинт `/pages/sidebar-pages`
отдаёт **только прямых детей** одного `pageId`. «Развернуть всё» поверх такого
API = рекурсивный BFS на десятки-сотни HTTP-запросов (шторм запросов, лимиты,
долгий индикатор, защитный потолок). Это и был отвергнутый вариант.
**Решение — отдать всё одним запросом на сервере.** У бэкенда уже есть готовые
кирпичи для рекурсивной выборки поддерева с учётом прав (используются в
`movePageToSpace`):
- `pageRepo.getPageAndDescendants(parentPageId, { includeContent: false })`
([page.repo.ts:557](apps/server/src/database/repos/page/page.repo.ts#L557)) —
рекурсивный CTE: страница + все потомки одним запросом.
- `pageRepo.getPageAndDescendantsExcludingRestricted(parentPageId, opts)`
([page.repo.ts:612](apps/server/src/database/repos/page/page.repo.ts#L612)) —
то же, но **обрезает закрытые (restricted) поддеревья прямо в SQL** (один
запрос, не тянет лишнее).
- `pageService.filterAccessibleTreePages(allPages, rootId, userId, spaceId)`
([page.service.ts:1136](apps/server/src/core/page/services/page.service.ts#L1136))
— точечная фильтрация дерева по правам с сохранением целостности (для
per-page permissions сверх restricted-спейсов).
- `pageRepo.withHasChildren(eb)`
([page.repo.ts:539](apps/server/src/database/repos/page/page.repo.ts#L539)) —
вычисление `hasChildren` в SQL (при отдаче всего дерева `hasChildren` можно и
вывести на клиенте — у узла есть дети, если в ответе есть страница с
`parentPageId === id`).
Плюсы серверного пути: один-два запроса вместо сотен; предсказуемо даже на
тысячах страниц; права считаются на сервере (единый источник правды); на клиенте
нет BFS/ограничителя параллелизма/защитного потолка. Минус — нужна работа на
бэкенде (новый рекурсивный режим эндпоинта) и контроль размера ответа.
## Где сейчас живёт код (точные места)
### Клиент — фича `apps/client/src/features/page/tree/`
- **Состояние раскрытия** —
[open-tree-nodes-atom.ts](apps/client/src/features/page/tree/atoms/open-tree-nodes-atom.ts):
`openTreeNodesAtom`, тип `OpenMap = Record<string, boolean>` (id → раскрыт ли),
**персист в localStorage**, ключ `openTreeNodes:{workspaceId}:{userId}`.
**Карта общая для всех спейсов воркспейса.**
- **Данные дерева** —
[tree-data-atom.ts](apps/client/src/features/page/tree/atoms/tree-data-atom.ts):
`treeDataAtom: SpaceTreeNode[]`, накопительно по спейсам; на рендере
фильтруется по `spaceId`.
- **Модель узла** —
[types.ts](apps/client/src/features/page/tree/types.ts): `SpaceTreeNode`
(`id`, `spaceId`, `hasChildren`, `children`, `name`, `icon`, `position`,
`parentPageId`, `canEdit`, `slugId`).
- **Обёртка/тоггл/загрузка** —
[space-tree.tsx](apps/client/src/features/page/tree/components/space-tree.tsx):
`filteredData` (стр. 184-187, узлы текущего спейса), `handleToggle` (стр.
164-182, ленивая загрузка уровня), `spaceIdRef` (стр. 46-47, защита от гонок).
- **Модель-операции** —
[tree-model.ts](apps/client/src/features/page/tree/model/tree-model.ts):
`find`, `appendChildren`, `visible`, `siblingsOf`.
- **HTTP-загрузка** —
[page-query.ts](apps/client/src/features/page/queries/page-query.ts) +
[page-service.ts](apps/client/src/features/page/services/page-service.ts):
`getSidebarPages` / `getAllSidebarPages` (паджинируют **один уровень**),
`fetchAllAncestorChildren`, утилиты `buildTree` / `buildTreeWithChildren` /
`mergeRootTrees` ([utils.ts](apps/client/src/features/page/tree/utils/utils.ts)).
- **Шапка дерева (куда вешать команды)** —
[space-sidebar.tsx:117-149](apps/client/src/features/space/components/sidebar/space-sidebar.tsx#L117):
`SpaceMenu` (дропдаун на `IconDots`, стр. 172-281, уже с `Menu.Item`/
`Menu.Divider`) + кнопка «+» (Create page).
### Сервер — фича `apps/server/src/core/page/`
- **Эндпоинт сайдбара** —
[page.controller.ts:540](apps/server/src/core/page/page.controller.ts#L540)
`POST /pages/sidebar-pages` (`SidebarPageDto`: `spaceId | pageId`),
CASL-скоуп на спейс, отдаёт **один уровень**.
- **Сервис** —
[page.service.ts:304](apps/server/src/core/page/services/page.service.ts#L304)
`getSidebarPages(spaceId, pagination, pageId?, userId?, spaceCanEdit?)`:
выборка одного уровня + `withHasChildren` + **двухветочная фильтрация прав**
если в спейсе нет ограничений (`pagePermissionRepo.hasRestrictedPagesInSpace`)
`canEdit = spaceCanEdit`; иначе per-page фильтр через
`filterAccessiblePageIdsWithPermissions` + корректировка `hasChildren` по
`getParentIdsWithAccessibleChildren`. **Эту же логику прав надо повторить в
рекурсивном режиме.**
## Решение
### Серверная часть — «отдать всё поддерево» одним запросом
Добавить рекурсивный режим выдачи дерева. Варианты оформления (выбрать на ревью):
- флаг `recursive: true` (и опц. `depth`) к существующему `POST /pages/sidebar-pages`, **или**
- отдельный эндпоинт `POST /pages/tree` (`{ spaceId }` → всё дерево спейса;
`{ pageId }` → всё поддерево страницы).
Контракт ответа: **плоский список элементов в точно том же shape, что и текущий
`/pages/sidebar-pages`** (`id`, `slugId`, `title`, `icon`, `position`,
`parentPageId`, `spaceId`, `hasChildren`, `canEdit`), чтобы клиентские
`buildTree`/`buildTreeWithChildren` собрали дерево без изменений. Порядок — по
`position` (collate "C"), как сейчас.
Сервисный метод (эскиз), переиспользует существующие кирпичи:
```ts
// 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.
async getSidebarPagesTree(spaceId, userId, spaceCanEdit, pageId?) {
const hasRestrictions = await this.pagePermissionRepo.hasRestrictedPagesInSpace(spaceId);
// Seed: a single page subtree, or all root pages of the space.
// - restricted space -> *ExcludingRestricted (prunes closed subtrees in SQL)
// - open space -> plain recursive descendants
// For the whole-space case add a space-rooted recursive CTE (seed:
// parentPageId is null AND spaceId = ? AND deletedAt is null), mirroring
// getPageAndDescendants/...ExcludingRestricted.
let pages = hasRestrictions
? await this.pageRepo.getSpaceDescendantsExcludingRestricted(spaceId, pageId, { includeContent: false })
: await this.pageRepo.getSpaceDescendants(spaceId, pageId, { includeContent: false });
// Fine-grained per-page permissions on top of restricted pruning.
if (hasRestrictions) {
pages = await this.filterAccessibleTreePages(pages, pageId ?? null, userId, spaceId);
}
// Derive hasChildren from the returned set; stamp canEdit (per-page when
// restricted, else spaceCanEdit). Same two-branch logic as getSidebarPages().
return shapeAsSidebarItems(pages, { hasRestrictions, spaceCanEdit /*, permissionMap */ });
}
```
Где `getSpaceDescendants` / `getSpaceDescendantsExcludingRestricted` — новые
тонкие обёртки над существующими рекурсивными CTE (для случая «всё дерево спейса»
— CTE, засеянный корнями спейса вместо одного `parentPageId`).
**Важно про права:** обязательно сохранить **обе ветки** фильтрации из
`getSidebarPages` (restricted / не-restricted) и корректировку `hasChildren`,
иначе рекурсивный эндпоинт начнёт отдавать страницы, к которым у пользователя нет
доступа. Это критичная грань — на ревью проверить отдельно.
### Клиентская часть — упрощённый `expandAll`
Поскольку дерево приходит целиком, BFS/параллелизм/потолок не нужны.
`page-service.ts` — новый вызов:
```ts
// Fetch the whole space tree (all roots + descendants) in one shot.
export async function getSpaceTree(params: { spaceId: string; pageId?: string }): Promise<IPage[]> {
const req = await api.post("/pages/tree", params); // or /sidebar-pages { recursive: true }
return req.data.items;
}
```
`space-tree.tsx` — превратить `SpaceTree` в `forwardRef` и выставить
`useImperativeHandle`:
```ts
export type SpaceTreeApi = {
expandAll: () => Promise<void>;
collapseAll: () => void;
isExpanding: boolean;
};
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 });
if (spaceIdRef.current !== startSpaceId) return; // space switched — abort
const fullTree = buildTreeWithChildren(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, ...mergeRootTrees(prev.filter((n) => n?.spaceId === startSpaceId), fullTree)];
});
// Open every branch node of the current space.
const branchIds = collectBranchIds(fullTree); // nodes with children
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of branchIds) next[id] = true;
return next;
});
} catch (err) {
// Never swallow: log full error + show the real reason (project convention).
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 */]);
```
`collapseAll` — снимать раскрытие **только у узлов текущего спейса** (карта общая):
```ts
const collapseAll = useCallback(() => {
// The open-map is shared across spaces; clearing it wholesale would drop
// other spaces' expanded state. Collapse only current-space ids.
const ids = new Set<string>();
const walk = (nodes: SpaceTreeNode[]) => {
for (const n of nodes) { ids.add(n.id); if (n.children?.length) walk(n.children); }
};
walk(filteredData);
setOpenTreeNodes((prev) => {
const next = { ...prev };
for (const id of ids) next[id] = false;
return next;
});
}, [filteredData, setOpenTreeNodes]);
```
`space-sidebar.tsx``const treeRef = useRef<SpaceTreeApi | null>(null)`, передать
в `<SpaceTree ref={treeRef} ... />`, и подвесить команды в шапке. **Без
`canManage`-гейта** — это операция над видом, не над данными.
## UX-развилка по размещению
В шапке уже два значка (`IconDots` меню + `IconPlus` создать). Варианты:
- **(1) Две `ActionIcon`** «развернуть»/«свернуть» (`IconChevronsDown` /
`IconChevronsUp`) → 4 значка в узкой шапке, явно и в один клик.
- **(2) Одна `ActionIcon`-тоггл** развернуть↔свернуть → 3 значка, компактнее, но
состояние менее очевидно.
- **(3) Два `Menu.Item`** в `SpaceMenu` (`Развернуть всё` / `Свернуть всё` +
`Menu.Divider`) → шапка не растёт, но в два клика и менее заметно.
> **Рекомендация:** **(3)** как самый чистый по вёрстке (узкая колонка) либо
> **(1)**, если важна доступность в один клик. Тултипы/`aria-label`:
> `t("Expand all")` / `t("Collapse all")`; во время загрузки — `loading`/
> `disabled` (`isExpanding`).
## Тонкие моменты / edge cases
- **Права в рекурсивном эндпоинте.** Самый важный пункт: повторить **обе** ветки
фильтрации (restricted / открытый спейс) и корректировку `hasChildren` из
`getSidebarPages`. Предпочесть `*ExcludingRestricted` (обрезает закрытые
поддеревья в SQL) + `filterAccessibleTreePages` для per-page прав. На ревью —
тест: пользователь без доступа к ветке не должен видеть её через «развернуть
всё».
- **Размер ответа.** Всё дерево спейса может быть большим. `content` **не**
тянуть (`includeContent: false`). Прикинуть потолок (число узлов) и поведение
при очень больших спейсах — отдавать всё или ограничить + честно сообщить
(конвенция: не молчать про усечение).
- **Скоуп карты раскрытия.** `openTreeNodesAtom` общая для спейсов — и
`expandAll`, и `collapseAll` работают **только по узлам текущего спейса**.
- **Гонки при смене спейса.** Запрос асинхронный; сверяться с
`spaceIdRef.current` и прерывать мёрдж/раскрытие, если спейс сменился (паттерн
уже есть в эффектах `space-tree.tsx`).
- **Мёрдж с уже загруженным.** Полное дерево вмёрджить в `treeDataAtom`, заместив
узлы текущего спейса (`mergeRootTrees`/замена ветки), **не трогая** узлы
других спейсов.
- **Ошибки не глотать.** Любой сбой — `console.error` с полным объектом **и**
уведомление с реальной причиной (`err.response?.data?.message`/`err.message`),
не «что-то пошло не так» (CLAUDE.md «Errors must never be swallowed»).
- **Индикатор.** На крупном спейсе запрос заметный — кнопку в `loading`, чтобы не
было повторных кликов/ощущения зависания.
- **Рост localStorage-карты.** `expandAll` пишет много ключей; для удалённых
страниц ключи «висят». Не критично; уборка карты — отдельная задача.
- **Пустой спейс / одни листья.** Кнопки — no-op; «развернуть» можно `disabled`.
- **Шорткат `*`** (развернуть сиблингов,
[doc-tree.tsx](apps/client/src/features/page/tree/components/doc-tree.tsx)) не
трогаем — дополняем его.
- **Виртуализация.** Дерево на `@tanstack/react-virtual` — раскрытие тысяч строк
рендер не убьёт (рисуются видимые), но резко меняет высоту скролла; проверить,
что позиция/скролл не прыгают.
## Тесты / проверка
- **Сервер:** `pnpm --filter server test` (unit на новый сервисный метод).
Кейсы: открытый спейс (видно всё), restricted-спейс (закрытые ветки и их
поддеревья **не** попадают в ответ), per-page права (`canEdit`), корректный
`hasChildren`, порядок по `position`, `content` не тянется.
- **Клиент:** `pnpm --filter client lint`, `pnpm --filter client test`.
- **Ручная:** глубокий спейс → «развернуть всё» раскрывает все уровни одним
запросом, индикатор работает; «свернуть всё» схлопывает до корней и **не**
теряет состояние другого спейса (переключиться туда-обратно); перезагрузка —
состояние сохраняется (localStorage); смена спейса в середине загрузки —
корректно прерывается; пустой спейс — без поломок; имитация ошибки сети — видно
конкретное уведомление, ошибка залогирована.
## Открытые вопросы
1. **Оформление эндпоинта:** флаг `recursive` к `/pages/sidebar-pages` против
отдельного `/pages/tree`. (Контракт ответа в обоих — плоский список в shape
текущего сайдбара.)
2. **Размещение команд:** две иконки (1) / одна-тоггл (2) / пункты меню (3).
Рекомендация — (3) или (1).
3. **Потолок размера ответа:** отдавать дерево любого размера или ограничить
(число узлов) и как сообщать про усечение.