diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 21f7c5f7..567df2d4 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -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.", diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index a53e326a..8429e754 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -116,8 +116,8 @@ function CommentListItem({ } return ( - - + + - + {comment.creator.name} @@ -177,7 +177,7 @@ function CommentListItem({ tabIndex={0} aria-label={t("Jump to comment selection")} > - {comment?.selection} + {comment?.selection} )} diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index a3f348b8..cb674442 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -121,8 +121,8 @@ function CommentListWithTabs() { - + { + const req = await api.post<{ items: IPage[] }>("/pages/tree", params); + return req.data.items; +} + export async function getPageBreadcrumbs( pageId: string, ): Promise> { diff --git a/apps/client/src/features/page/tree/components/doc-tree.tsx b/apps/client/src/features/page/tree/components/doc-tree.tsx index 69d88fe2..d93b9d15 100644 --- a/apps/client/src/features/page/tree/components/doc-tree.tsx +++ b/apps/client/src/features/page/tree/components/doc-tree.tsx @@ -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 = { node: TreeNode; level: number; @@ -122,11 +127,11 @@ function DocTreeInner( 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, diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 1c3aab8e..7a8e0535 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -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; + collapseAll: () => void; + isExpanding: boolean; +}; + +const SpaceTree = forwardRef(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) { )} ); -} +}); + +export default SpaceTree; diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index 0c42f9b9..5d5c0bad 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -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; +} diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx index 370c59e7..59915a57 100644 --- a/apps/client/src/features/share/components/shared-tree.tsx +++ b/apps/client/src/features/share/components/shared-tree.tsx @@ -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(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")} /> diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx index 1786d84e..b5b9a9c8 100644 --- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx +++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx @@ -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(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() {
void; + treeRef: React.RefObject; } 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({ + } + > + {t("Expand all")} + + + } + > + {t("Collapse all")} + + + + { + 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'); + }); +}); diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index 78727bda..7be52a46 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -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; -// 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 ( - + + + ); } @@ -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() { - + {t("Chat / LLM")} {t("root")} @@ -430,19 +472,34 @@ export default function AiProviderSettings() { disabled={isLoading} {...form.getInputProps("chatModel")} /> - - - {hasApiKey && ( - - {t("Clear")} - - )} - + {/* 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. */} + + + + + + ) : undefined + } + rightSectionPointerEvents="all" + {...form.getInputProps("apiKey")} + /> - + {t("Embeddings")} - - - {hasEmbeddingApiKey && ( - - {t("Clear")} - - )} - + {/* 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. */} + + + + + + ) : undefined + } + rightSectionPointerEvents="all" + {...form.getInputProps("embeddingApiKey")} + /> - + {t("Voice / STT")} - - - {hasSttApiKey && ( - - {t("Clear")} - - )} - + {/* 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. */} + + + + + + ) : undefined + } + rightSectionPointerEvents="all" + {...form.getInputProps("sttApiKey")} + />