Merge remote-tracking branch 'gitea/develop' into HEAD

# Conflicts:
#	CHANGELOG.md
#	packages/mcp/build/index.js
#	packages/mcp/build/lib/auth-utils.js
#	packages/mcp/build/lib/docmost-schema.js
#	packages/mcp/build/lib/markdown-converter.js
This commit is contained in:
claude code agent 227
2026-06-30 02:43:04 +03:00
81 changed files with 5839 additions and 399 deletions
@@ -9,6 +9,7 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
IconEyeOff,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
@@ -74,6 +75,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
isStrike: ctx.editor.isActive("strike"),
isCode: ctx.editor.isActive("code"),
isComment: ctx.editor.isActive("comment"),
isSpoiler: ctx.editor.isActive("spoiler"),
};
},
});
@@ -109,6 +111,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
command: () => props.editor.chain().focus().toggleCode().run(),
icon: IconCode,
},
{
name: "Spoiler",
isActive: () => editorState?.isSpoiler,
command: () => props.editor.chain().focus().toggleSpoiler().run(),
icon: IconEyeOff,
},
];
const commentItem: BubbleMenuItem = {
@@ -1,16 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/react";
import {
ActionIcon,
Button,
Group,
Paper,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { IconAlt } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
const ALT_MAX_LENGTH = 300;
@@ -27,113 +18,25 @@ type UseAltTextControlArgs = {
currentAlt: string;
};
// Thin wrapper over the shared image text-field popover; see
// useImageTextFieldControl. The t("...") literals stay here so they remain
// statically extractable for i18n.
export function useAltTextControl({
editor,
nodeName,
currentAlt,
}: UseAltTextControlArgs) {
const { t } = useTranslation();
const [showInput, setShowInput] = useState(false);
const [draft, setDraft] = useState("");
const open = useCallback(() => {
setDraft(currentAlt || "");
setShowInput(true);
}, [currentAlt]);
useEffect(() => {
const handler = () => {
if (!editor.isActive(nodeName)) {
setShowInput(false);
}
};
editor.on("selectionUpdate", handler);
return () => {
editor.off("selectionUpdate", handler);
};
}, [editor, nodeName]);
const cancel = useCallback(() => {
setShowInput(false);
}, []);
const save = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateAttributes(nodeName, { alt: sanitizeAlt(draft) || undefined })
.run();
setShowInput(false);
}, [editor, nodeName, draft]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
},
[save, cancel],
);
const button = (
<Tooltip position="top" label={t("Alt text")} withinPortal={false}>
<ActionIcon
onClick={open}
size="lg"
aria-label={t("Alt text")}
variant="subtle"
>
<IconAlt size={18} />
</ActionIcon>
</Tooltip>
);
const panel = showInput ? (
<Paper
withBorder
shadow="md"
radius={6}
p="sm"
w={320}
style={{ position: "relative", zIndex: 100 }}
>
<Text size="sm" fw={600} mb={2}>
{t("Alt text")}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t("Describe this for accessibility.")}
</Text>
<Textarea
size="xs"
placeholder={t("Add a description")}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={onKeyDown}
autoFocus
autosize
minRows={2}
maxRows={5}
maxLength={ALT_MAX_LENGTH}
/>
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{draft.length}/{ALT_MAX_LENGTH}
</Text>
<Group gap="xs">
<Button size="compact-xs" variant="default" onClick={cancel}>
{t("Cancel")}
</Button>
<Button size="compact-xs" onClick={save}>
{t("Save")}
</Button>
</Group>
</Group>
</Paper>
) : null;
return { button, panel, isEditing: showInput };
return useImageTextFieldControl({
editor,
nodeName,
currentValue: currentAlt,
attrName: "alt",
sanitize: sanitizeAlt,
maxLength: ALT_MAX_LENGTH,
icon: <IconAlt size={18} />,
label: t("Alt text"),
description: t("Describe this for accessibility."),
placeholder: t("Add a description"),
});
}
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { sanitizeCaption } from "@/features/editor/components/common/use-caption-control.tsx";
/**
* `sanitizeCaption` = collapse every whitespace run to a single space + trim +
* cap at 500 chars. Captions are plain visible text, so this is a softer
* normalization than alt-text sanitization.
*/
describe("sanitizeCaption", () => {
it("trims leading and trailing whitespace", () => {
expect(sanitizeCaption(" hello ")).toBe("hello");
});
it("collapses internal whitespace runs to a single space", () => {
expect(sanitizeCaption("a b c")).toBe("a b c");
});
it("treats tab, newline and CRLF as whitespace", () => {
expect(sanitizeCaption("a\tb")).toBe("a b");
expect(sanitizeCaption("a\nb")).toBe("a b");
expect(sanitizeCaption("a\r\nb")).toBe("a b");
expect(sanitizeCaption("line1\n\n\nline2")).toBe("line1 line2");
});
it("treats unicode whitespace (no-break space) as a separator", () => {
// U+00A0 NO-BREAK SPACE is matched by the \s class.
expect(sanitizeCaption("a b")).toBe("a b");
});
it("returns empty string for whitespace-only input", () => {
expect(sanitizeCaption(" ")).toBe("");
expect(sanitizeCaption("")).toBe("");
});
it("keeps a caption at the 500-char limit unchanged", () => {
const exact = "x".repeat(500);
expect(sanitizeCaption(exact)).toHaveLength(500);
expect(sanitizeCaption(exact)).toBe(exact);
});
it("slices a caption longer than 500 chars down to 500", () => {
const tooLong = "y".repeat(600);
const result = sanitizeCaption(tooLong);
expect(result).toHaveLength(500);
expect(result).toBe("y".repeat(500));
});
it("collapses whitespace before applying the 500-char cap", () => {
// 120 "a b " groups (600 raw chars) collapse to "a b a b ..." = 479 chars
// after trimming the trailing space, which stays under the 500 cap — so only
// the collapse is exercised here, no slice. (See the dedicated >500 test
// above for the slice boundary.)
const input = "a b ".repeat(120); // lots of double spaces
const result = sanitizeCaption(input);
expect(result).toHaveLength(479);
expect(result.length).toBeLessThanOrEqual(500);
expect(result).not.toMatch(/\s{2,}/);
});
});
@@ -0,0 +1,42 @@
import { Editor } from "@tiptap/react";
import { IconTextCaption } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useImageTextFieldControl } from "@/features/editor/components/common/use-image-text-field-control.tsx";
const CAPTION_MAX_LENGTH = 500;
// Caption is plain visible text (not a markdown link target like alt), so it is
// sanitized more softly than alt: collapse runs of whitespace/newlines into a
// single space and trim, keeping the limit generous.
export function sanitizeCaption(value: string): string {
return value.replace(/\s+/g, " ").trim().slice(0, CAPTION_MAX_LENGTH);
}
type UseCaptionControlArgs = {
editor: Editor;
nodeName: string;
currentCaption: string;
};
// Thin wrapper over the shared image text-field popover; see
// useImageTextFieldControl. The t("...") literals stay here so they remain
// statically extractable for i18n.
export function useCaptionControl({
editor,
nodeName,
currentCaption,
}: UseCaptionControlArgs) {
const { t } = useTranslation();
return useImageTextFieldControl({
editor,
nodeName,
currentValue: currentCaption,
attrName: "caption",
sanitize: sanitizeCaption,
maxLength: CAPTION_MAX_LENGTH,
icon: <IconTextCaption size={18} />,
label: t("Caption"),
description: t("Shown below the image."),
placeholder: t("Add a caption"),
});
}
@@ -0,0 +1,145 @@
import React, { useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/react";
import {
ActionIcon,
Button,
Group,
Paper,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
// Shared logic+UI for the image bubble-menu text-field popovers (alt text,
// caption, ...). Each field is the same popover — an ActionIcon that opens a
// titled Paper with a counted Textarea and Cancel/Save — differing only in the
// node attribute it writes, its sanitizer, length cap, icon and labels. The
// label/description/placeholder are passed already translated so the literal
// t("...") calls stay in the thin wrappers and remain extractable; the shared
// Cancel/Save strings are translated here.
type UseImageTextFieldControlArgs = {
editor: Editor;
nodeName: string;
currentValue: string;
attrName: string;
sanitize: (value: string) => string;
maxLength: number;
icon: React.ReactNode;
label: string;
description: string;
placeholder: string;
};
export function useImageTextFieldControl({
editor,
nodeName,
currentValue,
attrName,
sanitize,
maxLength,
icon,
label,
description,
placeholder,
}: UseImageTextFieldControlArgs) {
const { t } = useTranslation();
const [showInput, setShowInput] = useState(false);
const [draft, setDraft] = useState("");
const open = useCallback(() => {
setDraft(currentValue || "");
setShowInput(true);
}, [currentValue]);
useEffect(() => {
const handler = () => {
if (!editor.isActive(nodeName)) {
setShowInput(false);
}
};
editor.on("selectionUpdate", handler);
return () => {
editor.off("selectionUpdate", handler);
};
}, [editor, nodeName]);
const cancel = useCallback(() => {
setShowInput(false);
}, []);
const save = useCallback(() => {
editor
.chain()
.focus(undefined, { scrollIntoView: false })
.updateAttributes(nodeName, { [attrName]: sanitize(draft) || undefined })
.run();
setShowInput(false);
}, [editor, nodeName, attrName, sanitize, draft]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
},
[save, cancel],
);
const button = (
<Tooltip position="top" label={label} withinPortal={false}>
<ActionIcon onClick={open} size="lg" aria-label={label} variant="subtle">
{icon}
</ActionIcon>
</Tooltip>
);
const panel = showInput ? (
<Paper
withBorder
shadow="md"
radius={6}
p="sm"
w={320}
style={{ position: "relative", zIndex: 100 }}
>
<Text size="sm" fw={600} mb={2}>
{label}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{description}
</Text>
<Textarea
size="xs"
placeholder={placeholder}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={onKeyDown}
autoFocus
autosize
minRows={2}
maxRows={5}
maxLength={maxLength}
/>
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{draft.length}/{maxLength}
</Text>
<Group gap="xs">
<Button size="compact-xs" variant="default" onClick={cancel}>
{t("Cancel")}
</Button>
<Button size="compact-xs" onClick={save}>
{t("Save")}
</Button>
</Group>
</Group>
</Paper>
) : null;
return { button, panel, isEditing: showInput };
}
@@ -23,6 +23,7 @@ import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
import { useCaptionControl } from "@/features/editor/components/common/use-caption-control.tsx";
import classes from "../common/toolbar-menu.module.css";
export function ImageMenu({ editor }: EditorMenuProps) {
@@ -47,6 +48,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
caption: imageAttrs?.caption || "",
};
},
});
@@ -168,6 +170,16 @@ export function ImageMenu({ editor }: EditorMenuProps) {
currentAlt: editorState?.alt || "",
});
const {
button: captionButton,
panel: captionPanel,
isEditing: isEditingCaption,
} = useCaptionControl({
editor,
nodeName: "image",
currentCaption: editorState?.caption || "",
});
return (
<BaseBubbleMenu
editor={editor}
@@ -183,6 +195,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
>
{isEditingAlt ? (
altTextPanel
) : isEditingCaption ? (
captionPanel
) : (
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
@@ -249,6 +263,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
{altTextButton}
{captionButton}
<div className={classes.divider} />
<Tooltip position="top" label={t("Download")} withinPortal={false}>
@@ -9,7 +9,9 @@ import { useTranslation } from "react-i18next";
export default function ImageView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, selected } = props;
const { src, width, align, alt, aspectRatio, placeholder } = node.attrs;
const { src, width, align, alt, caption, aspectRatio, placeholder } =
node.attrs;
const captionText = (caption || "").trim();
const alignClass = useMemo(() => {
if (align === "left") return "alignLeft";
if (align === "right") return "alignRight";
@@ -29,6 +31,7 @@ export default function ImageView(props: NodeViewProps) {
return (
<NodeViewWrapper data-drag-handle>
<figure style={{ margin: 0 }}>
<div
className={clsx(
selected && "ProseMirror-selectednode",
@@ -66,6 +69,15 @@ export default function ImageView(props: NodeViewProps) {
</Group>
)}
</div>
{captionText && (
<Text
component="figcaption"
className="image-caption"
>
{captionText}
</Text>
)}
</figure>
</NodeViewWrapper>
);
}
@@ -0,0 +1,20 @@
import { MarkViewContent, MarkViewProps } from "@tiptap/react";
import { useState } from "react";
// Click-to-reveal spoiler. The revealed state is UI-only and is never written to
// the document: toggling only adds/removes the `is-revealed` class (CSS removes
// the blur). renderHTML never emits `is-revealed`, so it can't leak into the
// doc/clipboard. Works the same in editor, read-only and public-share views.
export default function SpoilerView(_props: MarkViewProps) {
const [revealed, setRevealed] = useState(false);
return (
<span
className={revealed ? "spoiler is-revealed" : "spoiler"}
data-spoiler="true"
onClick={() => setRevealed((v) => !v)}
>
<MarkViewContent />
</span>
);
}
@@ -53,6 +53,7 @@ import {
Subpages,
Heading,
Highlight,
Spoiler,
Indent,
UniqueID,
SharedStorage,
@@ -116,6 +117,7 @@ import mentionRenderItems from "@/features/editor/components/mention/mention-sug
import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react";
import MentionView from "@/features/editor/components/mention/mention-view.tsx";
import LinkView from "@/features/editor/components/link/link-view.tsx";
import SpoilerView from "@/features/editor/components/spoiler/spoiler-view.tsx";
import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
@@ -123,6 +125,7 @@ import { countWords } from "alfaaz";
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
import GlobalDragHandle from "@/features/editor/extensions/drag-handle.ts";
import { CleanStyles } from "@/features/editor/extensions/clean-styles.ts";
import { IntentionalClear } from "@/features/editor/extensions/intentional-clear.ts";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@@ -237,6 +240,11 @@ export const mainExtensions = [
Highlight.configure({
multicolor: true,
}),
Spoiler.configure({}).extend({
addMarkView() {
return ReactMarkViewRenderer(SpoilerView);
},
}),
Typography,
TrailingNode,
GlobalDragHandle.configure({
@@ -486,4 +494,10 @@ export const collabExtensions: CollabExtensions = (provider, user) => [
color: randomElement(userColors),
},
}),
// #251 — emit an intentional-clear signal to the server when the user
// deliberately empties the page, so the #248 store-side empty-guard lets that
// one clear through while still blocking accidental empties.
IntentionalClear.configure({
provider,
}),
];
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { ySyncPluginKey } from "@tiptap/y-tiptap";
import {
IntentionalClear,
INTENTIONAL_CLEAR_MESSAGE_TYPE,
} from "./intentional-clear";
/**
* #251 — the intentional-clear signal is driven through the REAL editor path:
* a fresh Editor with the IntentionalClear extension, a fake provider that
* records sendStateless, and the actual select-all + delete command the user's
* keystroke runs. No hand-poke of any flag.
*/
describe("IntentionalClear extension", () => {
let sendStateless: ReturnType<typeof vi.fn>;
const makeEditor = (content: unknown) =>
new Editor({
extensions: [
Document,
Paragraph,
Text,
IntentionalClear.configure({
// Minimal provider stand-in: only sendStateless is exercised.
provider: { sendStateless } as any,
}),
],
content: content as any,
});
beforeEach(() => {
sendStateless = vi.fn();
});
it("emits the clear signal when a user empties a non-empty doc (select-all + delete)", () => {
const editor = makeEditor({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "hello world" }] },
],
});
// The exact command path a select-all + Delete keystroke dispatches.
editor.chain().selectAll().deleteSelection().run();
expect(sendStateless).toHaveBeenCalledTimes(1);
const payload = JSON.parse(sendStateless.mock.calls[0][0]);
expect(payload).toEqual({ type: INTENTIONAL_CLEAR_MESSAGE_TYPE });
editor.destroy();
});
it("does NOT emit when typing into an empty doc (no non-empty → empty transition)", () => {
const editor = makeEditor({ type: "doc", content: [{ type: "paragraph" }] });
editor.chain().insertContent("typed text").run();
expect(sendStateless).not.toHaveBeenCalled();
editor.destroy();
});
it("does NOT emit on an edit that leaves the doc non-empty", () => {
const editor = makeEditor({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "keep me" }] },
],
});
editor.chain().insertContent(" more").run();
expect(sendStateless).not.toHaveBeenCalled();
editor.destroy();
});
it("does NOT emit when a REMOTE/merge (change-origin) transaction empties the doc", () => {
// This pins the CENTRAL #248 protection: only a LOCAL user edit may emit the
// intentional-clear signal. An emptiness arriving from another client, a bad
// merge, or an emptied transclusion is applied as a y-sync transaction tagged
// with the ySyncPluginKey meta, which `isChangeOrigin` detects. The extension
// must early-return on it and NOT punch the empty write through the server
// guard.
const editor = makeEditor({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "remote content" }] },
],
});
// Build a transaction that empties the non-empty doc and tag it exactly the
// way y-tiptap tags a remote y-sync update: `tr.setMeta(ySyncPluginKey,
// { isChangeOrigin: true })` (see @tiptap/y-tiptap sync-plugin). This makes
// the real `isChangeOrigin(tr)` predicate return true — not a stand-in.
const { state } = editor;
const tr = state.tr
.delete(0, state.doc.content.size)
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
editor.view.dispatch(tr);
// The transaction really emptied the doc (became the single empty paragraph)…
expect(editor.state.doc.textContent).toBe("");
// …yet because it is change-origin, no signal is emitted.
expect(sendStateless).not.toHaveBeenCalled();
editor.destroy();
});
it("does NOT emit when the doc was already empty", () => {
const editor = makeEditor({ type: "doc", content: [{ type: "paragraph" }] });
// Selecting all + delete on an already-empty doc is a no-op transition.
editor.chain().selectAll().deleteSelection().run();
expect(sendStateless).not.toHaveBeenCalled();
editor.destroy();
});
});
@@ -0,0 +1,94 @@
import { Extension } from "@tiptap/core";
import { isChangeOrigin } from "@tiptap/extension-collaboration";
import type { Node as PMNode } from "@tiptap/pm/model";
import type { HocuspocusProvider } from "@hocuspocus/provider";
/**
* Stateless message type sent to the server when a user deliberately clears a
* page to empty. Kept in one place so the client emitter and the server
* consumer (PersistenceExtension.onStateless) agree on the wire format.
*/
export const INTENTIONAL_CLEAR_MESSAGE_TYPE = "intentional-clear";
export interface IntentionalClearOptions {
/** The collab provider used to send the stateless clear signal. */
provider: HocuspocusProvider | null;
}
/**
* A "document is empty" check that mirrors the server's `isEmptyParagraphDoc`
* (collaboration.util.ts): exactly one top-level paragraph with no inline
* content. After a select-all + delete TipTap leaves precisely this shape, so
* matching it here keeps the client signal aligned with the server guard that
* consumes it.
*/
function isEmptyParagraphDoc(doc: PMNode): boolean {
if (doc.childCount !== 1) return false;
const child = doc.firstChild;
return (
child !== null &&
child !== undefined &&
child.type.name === "paragraph" &&
child.content.size === 0
);
}
/**
* #251 — intentional-clear signal.
*
* The server's #248 store-side empty-guard unconditionally refuses to overwrite
* non-empty persisted content with an empty document, because a momentarily
* empty live Y.Doc (a glitch, a bad merge, an emptying transclusion) is
* indistinguishable from a real clear *at the store layer*. That protection is
* correct, but it also blocks a user who genuinely wants to empty the page.
*
* This extension supplies the missing distinction. It watches LOCAL, user-driven
* transactions and, the moment one reduces a non-empty document to the empty
* single-paragraph shape, it sends a hocuspocus stateless message to the server.
* The server records a short-lived, single-use "intentional clear pending" flag
* for this document that the next (debounced) onStoreDocument consumes to let
* that one empty write through the guard.
*
* What counts as an intentional clear (precise definition):
* - the transaction actually changed the document (`docChanged`), AND
* - it is a LOCAL user edit, not a remote collab application — remote y-sync
* transactions are tagged and filtered out via `isChangeOrigin`, so an
* emptiness that arrives from another client / a merge never emits a signal,
* AND
* - the document was non-empty before the transaction and is the empty
* single-paragraph doc after it.
*
* This is exactly the select-all + Delete / Backspace (or any local command that
* empties the doc, e.g. clearContent) keystroke path. A transient/programmatic
* empty serialization that the server might see on the wire does NOT come with
* this signal, so the guard still blocks it.
*/
export const IntentionalClear = Extension.create<IntentionalClearOptions>({
name: "intentionalClear",
addOptions() {
return {
provider: null,
};
},
onTransaction({ transaction }) {
if (!transaction.docChanged) return;
// Only react to local user edits. Remote collaboration steps (and other
// y-sync-applied changes) carry the change origin and must never be treated
// as an intentional clear, otherwise a remote/merge-induced emptiness would
// punch through the server guard.
if (isChangeOrigin(transaction)) return;
const becameEmpty =
!isEmptyParagraphDoc(transaction.before) &&
isEmptyParagraphDoc(transaction.doc);
if (!becameEmpty) return;
// The server reads the originating document from the connection, so the
// payload only needs to declare intent — it cannot target another document.
this.options.provider?.sendStateless(
JSON.stringify({ type: INTENTIONAL_CLEAR_MESSAGE_TYPE }),
);
},
});
@@ -14,6 +14,7 @@
@import "./mention.css";
@import "./ordered-list.css";
@import "./highlight.css";
@import "./spoiler.css";
@import "./indent.css";
@import "./columns.css";
@import "./status.css";
@@ -33,6 +33,15 @@
}
}
.image-caption {
text-align: center;
font-size: 0.875em;
color: var(--mantine-color-dimmed);
margin-top: 0.4em;
line-height: 1.35;
word-break: break-word;
}
.uploading-text {
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
@@ -0,0 +1,21 @@
.spoiler {
background: rgba(0, 0, 0, 0.85);
border-radius: 0.25em;
cursor: pointer;
filter: blur(0.3em);
transition: filter 0.15s ease;
user-select: none;
}
.spoiler.is-revealed {
filter: none;
background: rgba(125, 125, 125, 0.18);
user-select: auto;
}
@media print {
.spoiler {
filter: none;
background: rgba(125, 125, 125, 0.18);
}
}
@@ -1,5 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import i18n from "@/i18n.ts";
import {
formatRelativeTime,
getTimeGroup,
groupNotificationsByTime,
} from "@/features/notification/notification.utils.ts";
@@ -132,3 +134,59 @@ describe("groupNotificationsByTime", () => {
expect(groupNotificationsByTime([], labels)).toEqual([]);
});
});
describe("formatRelativeTime — relative buckets and absolute-date fallback", () => {
// Distinct fixed clock for the relative formatter (uses Date.now via `new
// Date()`), so the bucket boundaries are deterministic under fake timers.
const NOW = new Date("2026-06-15T12:00:00.000Z");
const MIN = 60_000;
beforeEach(() => {
vi.setSystemTime(NOW);
});
// ISO string `ms` milliseconds before NOW.
function ago(ms: number): string {
return new Date(NOW.getTime() - ms).toISOString();
}
it("returns the i18n 'now' label for anything under a minute", () => {
expect(formatRelativeTime(ago(0))).toBe(i18n.t("now"));
expect(formatRelativeTime(ago(59_000))).toBe(i18n.t("now"));
});
it("crosses into the minutes bucket exactly at 1 minute", () => {
expect(formatRelativeTime(ago(MIN - 1000))).toBe(i18n.t("now"));
expect(formatRelativeTime(ago(MIN))).toBe("1m");
expect(formatRelativeTime(ago(5 * MIN))).toBe("5m");
expect(formatRelativeTime(ago(59 * MIN))).toBe("59m");
});
it("crosses into the hours bucket exactly at 60 minutes", () => {
expect(formatRelativeTime(ago(60 * MIN - 1000))).toBe("59m");
expect(formatRelativeTime(ago(HOUR))).toBe("1h");
expect(formatRelativeTime(ago(23 * HOUR))).toBe("23h");
});
it("crosses into the days bucket exactly at 24 hours", () => {
expect(formatRelativeTime(ago(24 * HOUR - 1000))).toBe("23h");
expect(formatRelativeTime(ago(DAY))).toBe("1d");
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d");
});
it("falls back to an absolute short date once >= 7 days old", () => {
// 6d -> still relative; 7d -> absolute date (no longer N[mhd], and equal to
// the localized short-date of the source timestamp).
expect(formatRelativeTime(ago(6 * DAY))).toBe("6d");
const sevenDaysAgo = ago(7 * DAY);
const result = formatRelativeTime(sevenDaysAgo);
expect(result).not.toMatch(/^\d+[mhd]$/);
expect(result).not.toBe(i18n.t("now"));
const expected = new Intl.DateTimeFormat(i18n.language, {
month: "short",
day: "numeric",
}).format(new Date(sevenDaysAgo));
expect(result).toBe(expected);
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { findBreadcrumbPath } from "./utils";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
// findBreadcrumbPath walks the live, SHARED sidebar tree. The high-value
// invariant: when a node has no usable name it must surface "Untitled" ONLY on
// the returned breadcrumb chain via a shallow copy — never by mutating the input
// node (which would silently rename the node in the sidebar). Also covers normal
// ancestor-chain resolution, the not-found case, and nested children.
function node(id: string, over: Partial<SpaceTreeNode> = {}): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: id.toUpperCase(),
icon: undefined,
position: "a0",
spaceId: "space-1",
parentPageId: null as unknown as string,
hasChildren: false,
children: [],
...over,
};
}
describe("findBreadcrumbPath", () => {
it("does NOT mutate the input tree when a node has an empty/whitespace name", () => {
// A whitespace-only-named node nested under a blank-named root.
const target = node("target", { name: " " });
const root = node("root", { name: "", hasChildren: true, children: [target] });
const tree = [root];
const result = findBreadcrumbPath(tree, "target");
expect(result).not.toBeNull();
// The RETURNED chain shows "Untitled" for both blank nodes.
expect(result!.map((n) => n.name)).toEqual(["Untitled", "Untitled"]);
// The original input nodes are untouched (still blank).
expect(root.name).toBe("");
expect(target.name).toBe(" ");
// The renamed breadcrumb entries are fresh copies, not the input objects.
expect(result![0]).not.toBe(root);
expect(result![1]).not.toBe(target);
});
it("returns the SAME node reference (no copy) when the name is non-empty", () => {
// No rename needed -> the node is passed through by reference (cheap path).
const target = node("target", { name: "Real Title" });
const result = findBreadcrumbPath([target], "target");
expect(result![0]).toBe(target);
expect(result![0].name).toBe("Real Title");
});
it("resolves the full ancestor chain ending at the target", () => {
const target = node("c");
const mid = node("b", { hasChildren: true, children: [target] });
const root = node("a", { hasChildren: true, children: [mid] });
const result = findBreadcrumbPath([root], "c");
expect(result!.map((n) => n.id)).toEqual(["a", "b", "c"]);
});
it("finds a target nested under a deeper sibling branch", () => {
// Two root branches; the target lives inside the second branch's child.
const target = node("deep");
const branch2 = node("r2", {
hasChildren: true,
children: [node("x"), node("y", { hasChildren: true, children: [target] })],
});
const branch1 = node("r1", { hasChildren: true, children: [node("z")] });
const result = findBreadcrumbPath([branch1, branch2], "deep");
expect(result!.map((n) => n.id)).toEqual(["r2", "y", "deep"]);
});
it("returns null when the page id is not present in the tree", () => {
const root = node("root", { hasChildren: true, children: [node("child")] });
expect(findBreadcrumbPath([root], "missing")).toBeNull();
expect(findBreadcrumbPath([], "anything")).toBeNull();
});
});
@@ -8,6 +8,8 @@ import {
closeIds,
mergeRootTrees,
loadedOpenBranchIds,
sortPositionKeys,
pageToTreeNode,
} from "./utils";
import type { IPage } from "@/features/page/types/page.types.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -60,6 +62,82 @@ function treeNode(id: string, children: SpaceTreeNode[] = []): SpaceTreeNode {
};
}
describe("sortPositionKeys", () => {
it("orders items ascending by their fractional `position` string", () => {
const items = [
{ id: "c", position: "a5" },
{ id: "a", position: "a1" },
{ id: "b", position: "a3" },
];
expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["a", "b", "c"]);
});
it("is a stable sort: equal positions keep their input order", () => {
const items = [
{ id: "x", position: "a1" },
{ id: "y", position: "a1" },
{ id: "z", position: "a1" },
];
expect(sortPositionKeys(items).map((i) => i.id)).toEqual(["x", "y", "z"]);
});
});
describe("pageToTreeNode", () => {
function pageRow(over: Partial<IPage> = {}): IPage {
return {
id: "p1",
slugId: "slug-p1",
title: "My Page",
icon: "📄",
position: "a1",
hasChildren: true,
spaceId: "space-1",
parentPageId: null as unknown as string,
...over,
} as IPage;
}
it("maps page.title -> node.name and copies the core fields", () => {
const node = pageToTreeNode(pageRow());
// The non-trivial transform: a page's `title` becomes the tree node's `name`.
expect(node.name).toBe("My Page");
expect(node.id).toBe("p1");
expect(node.slugId).toBe("slug-p1");
expect(node.icon).toBe("📄");
expect(node.position).toBe("a1");
expect(node.spaceId).toBe("space-1");
expect(node.hasChildren).toBe(true);
// Always materialized with an empty children array.
expect(node.children).toEqual([]);
});
it("derives canEdit from page.permissions.canEdit when the flat field is absent", () => {
const node = pageToTreeNode(
pageRow({ canEdit: undefined, permissions: { canEdit: true } } as Partial<IPage>),
);
expect(node.canEdit).toBe(true);
});
it("prefers the flat page.canEdit over permissions.canEdit", () => {
const node = pageToTreeNode(
pageRow({ canEdit: false, permissions: { canEdit: true } } as Partial<IPage>),
);
expect(node.canEdit).toBe(false);
});
it("carries temporaryExpiresAt straight off the page", () => {
const expiresAt = "2026-06-27T21:00:00.000Z";
expect(pageToTreeNode(pageRow({ temporaryExpiresAt: expiresAt })).temporaryExpiresAt).toBe(
expiresAt,
);
});
it("applies overrides on top of the mapped fields (e.g. optimistic blank name)", () => {
const node = pageToTreeNode(pageRow(), { name: "" });
expect(node.name).toBe("");
});
});
describe("buildTree", () => {
it("builds one node per unique page", () => {
const tree = buildTree([page("a", "a1"), page("b", "a2")]);
@@ -70,18 +70,22 @@ export function findBreadcrumbPath(
path: SpaceTreeNode[] = [],
): SpaceTreeNode[] | null {
for (const node of tree) {
if (!node.name || node.name.trim() === "") {
node.name = "Untitled";
}
// Never mutate the input tree (it is the live, shared sidebar tree state).
// When a node has no usable name, surface "Untitled" via a shallow copy that
// only the returned breadcrumb chain sees — the source node stays untouched.
const displayNode: SpaceTreeNode =
!node.name || node.name.trim() === ""
? { ...node, name: "Untitled" }
: node;
if (node.id === pageId) {
return [...path, node];
return [...path, displayNode];
}
if (node.children) {
const newPath = findBreadcrumbPath(node.children, pageId, [
...path,
node,
displayNode,
]);
if (newPath) {
return newPath;
@@ -3,6 +3,7 @@ import {
applyAddTreeNode,
applyMoveTreeNode,
applyDeleteTreeNode,
applyUpdateOne,
} from "./tree-socket-reducers";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -338,3 +339,76 @@ describe("applyAddTreeNode", () => {
expect(treeModel.find(next, "temp")?.temporaryExpiresAt).toBe(expiresAt);
});
});
describe("applyUpdateOne", () => {
// A loaded two-level tree so we can patch both a root and a nested node.
const buildTree = (): SpaceTreeNode[] => [
node("root", {
position: "a0",
name: "Root",
icon: "📁",
hasChildren: true,
children: [node("child", { position: "a1", parentPageId: "root", name: "Child", icon: "📄" })],
}),
];
// Build the UpdateEvent envelope; only `id`/`payload` matter to the reducer.
const ev = (id: string, payload: Record<string, unknown>) =>
({
operation: "updateOne",
spaceId: "space-1",
entity: ["pages"],
id,
payload,
}) as unknown as Parameters<typeof applyUpdateOne>[1];
it("applies a title-only update to the node's name (icon untouched)", () => {
const tree = buildTree();
const next = applyUpdateOne(tree, ev("child", { title: "Renamed" }));
const child = treeModel.find(next, "child");
expect(child?.name).toBe("Renamed");
// Icon is left as it was.
expect(child?.icon).toBe("📄");
});
it("applies an icon-only update to the node's icon (name untouched)", () => {
const tree = buildTree();
const next = applyUpdateOne(tree, ev("root", { icon: "🔥" }));
const root = treeModel.find(next, "root");
expect(root?.icon).toBe("🔥");
expect(root?.name).toBe("Root");
});
it("applies a combined title + icon update", () => {
const tree = buildTree();
const next = applyUpdateOne(tree, ev("child", { title: "Both", icon: "⭐" }));
const child = treeModel.find(next, "child");
expect(child?.name).toBe("Both");
expect(child?.icon).toBe("⭐");
});
it("returns prev UNCHANGED (same reference) when the id is not loaded", () => {
const tree = buildTree();
const next = applyUpdateOne(tree, ev("ghost", { title: "Nope" }));
expect(next).toBe(tree);
});
it("returns prev UNCHANGED (same reference) for a no-op payload (no title/icon)", () => {
// The node exists, but the payload carries neither title nor icon -> nothing
// to patch, so the reducer must hand back the same array reference.
const tree = buildTree();
const next = applyUpdateOne(tree, ev("child", {}));
expect(next).toBe(tree);
});
it("treats an explicit null icon/title as a value to apply (undefined check, not truthiness)", () => {
// The reducer guards on `!== undefined`, so a clearing null IS applied.
const tree = buildTree();
const next = applyUpdateOne(tree, ev("child", { title: "", icon: null }));
const child = treeModel.find(next, "child");
expect(child?.name).toBe("");
expect(child?.icon).toBeNull();
// And it did change something -> a fresh reference, not prev.
expect(next).not.toBe(tree);
});
});
@@ -3,6 +3,9 @@ import {
resolveCardStatus,
isEndpointConfigured,
resolveKeyField,
nextReindexPollInterval,
isReindexComplete,
isReindexButtonLoading,
} from './ai-provider-settings';
describe('resolveCardStatus', () => {
@@ -71,3 +74,152 @@ describe('resolveKeyField (write-only key payload)', () => {
expect(resolveKeyField('', false)).toEqual({ set: false });
});
});
describe('nextReindexPollInterval', () => {
const INTERVAL = 5000;
const base = { now: 1_000, intervalMs: INTERVAL };
it('does not poll when no reindex deadline is set', () => {
expect(
nextReindexPollInterval({
...base,
deadline: null,
status: { reindexing: true, indexedPages: 0, totalPages: 478 },
}),
).toBe(false);
});
it('keeps polling while the server reports an active run', () => {
expect(
nextReindexPollInterval({
...base,
deadline: 10_000,
status: { reindexing: true, indexedPages: 120, totalPages: 478 },
}),
).toBe(INTERVAL);
});
it('keeps polling during an active run even if counts momentarily look full', () => {
// The run clears its progress record only at the very end, so a transient
// indexed==total while reindexing is still true must NOT stop polling.
expect(
nextReindexPollInterval({
...base,
deadline: 10_000,
status: { reindexing: true, indexedPages: 478, totalPages: 478 },
}),
).toBe(INTERVAL);
});
it('stops once the run is finished AND fully indexed', () => {
expect(
nextReindexPollInterval({
...base,
deadline: 10_000,
status: { reindexing: false, indexedPages: 478, totalPages: 478 },
}),
).toBe(false);
});
it('keeps polling within the deadline when not yet done and no active flag', () => {
// First poll right after enqueue, before the worker publishes progress.
expect(
nextReindexPollInterval({
...base,
deadline: 10_000,
status: { reindexing: false, indexedPages: 0, totalPages: 478 },
}),
).toBe(INTERVAL);
});
it('cap always wins: stops once past the deadline even if still reindexing', () => {
expect(
nextReindexPollInterval({
deadline: 1_000,
now: 2_000, // past the deadline
intervalMs: INTERVAL,
status: { reindexing: true, indexedPages: 200, totalPages: 478 },
}),
).toBe(false);
});
it('stops on an empty workspace (0 of 0) once the run is finished', () => {
expect(
nextReindexPollInterval({
...base,
deadline: 10_000,
status: { reindexing: false, indexedPages: 0, totalPages: 0 },
}),
).toBe(false);
});
});
describe('isReindexComplete', () => {
it('false when no status yet', () => {
expect(isReindexComplete(undefined)).toBe(false);
});
it('false while a run is still active (even at indexed==total)', () => {
expect(
isReindexComplete({ reindexing: true, indexedPages: 478, totalPages: 478 }),
).toBe(false);
});
it('false when finished but not yet fully indexed', () => {
expect(
isReindexComplete({ reindexing: false, indexedPages: 120, totalPages: 478 }),
).toBe(false);
});
it('true once finished and fully indexed', () => {
expect(
isReindexComplete({ reindexing: false, indexedPages: 478, totalPages: 478 }),
).toBe(true);
});
});
describe('isReindexButtonLoading', () => {
it('loads while the POST mutation is pending', () => {
expect(
isReindexButtonLoading({
mutationPending: true,
deadline: null,
status: false,
}),
).toBe(true);
});
it('does NOT load post-cap: deadline nulled but reindexing left stale-true', () => {
// The key case: after the poll cap fires `reindexDeadline` is null while
// `settings.reindexing` can be a stale `true` from the last poll. Gating on
// the deadline keeps the spinner from sticking forever so the admin can
// restart.
expect(
isReindexButtonLoading({
mutationPending: false,
deadline: null,
status: true,
}),
).toBe(false);
});
it('loads during an active run within the poll window', () => {
expect(
isReindexButtonLoading({
mutationPending: false,
deadline: 10_000,
status: true,
}),
).toBe(true);
});
it('does not load once the run finished while still polling', () => {
expect(
isReindexButtonLoading({
mutationPending: false,
deadline: 10_000,
status: false,
}),
).toBe(false);
});
});
@@ -37,6 +37,7 @@ import {
} from "@/features/workspace/queries/ai-settings-query.ts";
import {
AiTestCapability,
IAiSettings,
IAiSettingsUpdate,
SttApiStyle,
ChatApiStyle,
@@ -169,6 +170,73 @@ export function resolveKeyField(
return { set: false };
}
// Subset of the status payload that drives the reindex poll decisions.
type ReindexStatus = Pick<
IAiSettings,
"reindexing" | "indexedPages" | "totalPages"
>;
/**
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
* Returns the poll interval (ms) to keep polling, or `false` to stop.
*
* Polls while the server reports an ACTIVE run (`reindexing === true`) OR we are
* still within the deadline window and not yet fully indexed. Stops once the run
* has finished AND everything is indexed (server cleared its progress record and
* fell back to the DB coverage count), or the deadline cap is hit the cap
* always wins so a stuck/never-clearing progress record can't poll forever.
*/
export function nextReindexPollInterval(args: {
deadline: number | null;
now: number;
intervalMs: number;
status?: ReindexStatus;
}): number | false {
const { deadline, now, intervalMs, status } = args;
if (deadline === null) return false;
// Cap always wins.
if (now > deadline) return false;
// Active run → keep polling even if the momentary counts already look full.
if (status?.reindexing) return intervalMs;
// Finished and fully indexed (incl. an empty workspace, 0 >= 0) → stop. Reuse
// isReindexComplete so the completeness check lives in exactly one place.
if (isReindexComplete(status)) return false;
// Within the deadline and not yet done → keep polling.
return intervalMs;
}
/**
* Whether the reindex poll deadline should be cleared: the server reports no
* active run AND the count is complete. The single source of truth for the
* "reindex finished" check `nextReindexPollInterval` reuses it for its stop
* condition (sans the cap, which the effect handles via time).
*/
export function isReindexComplete(status?: ReindexStatus): boolean {
return (
!!status && !status.reindexing && status.indexedPages >= status.totalPages
);
}
/**
* Whether the reindex button should show its spinner (and stay disabled).
*
* Spins while the POST is in flight, and for the WHOLE background run while the
* server reports `reindexing === true`. The `deadline !== null` gate is the
* load-bearing part: once the 120s poll cap fires it nulls `reindexDeadline`
* and stops refetching, so `status` (settings?.reindexing) can be a stale
* `true` from the last poll. Without the gate the spinner would stick forever
* for a run that outlives the cap and block a restart; gating on the active
* poll window clears it so the admin can re-trigger.
*/
export function isReindexButtonLoading(args: {
mutationPending: boolean;
deadline: number | null;
status?: boolean;
}): boolean {
const { mutationPending, deadline, status } = args;
return mutationPending || (deadline !== null && status === true);
}
// 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 {
@@ -215,31 +283,34 @@ export default function AiProviderSettings() {
// PRE-job counts immediately, so the only way the "Indexed X of Y" counter
// visibly climbs is to keep polling the settings query while the job runs.
// `reindexDeadline` is the timestamp until which we poll (set on reindex
// success); polling stops early once indexed === total. Bounded so a stuck
// job can never poll forever.
const REINDEX_POLL_INTERVAL = 3000; // ms between refetches while indexing
// success). Polling tracks the server's `reindexing` flag: it keeps going for
// the whole active run and stops promptly once the server reports the run is
// finished. Bounded by the cap so a stuck/never-clearing progress record can
// never poll forever.
const REINDEX_POLL_INTERVAL = 5000; // ms between refetches while indexing
const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap
const [reindexDeadline, setReindexDeadline] = useState<number | null>(null);
// Only admins may read the (masked) AI settings; the server enforces this too.
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) => {
if (reindexDeadline === null) return false;
// Past the cap → stop polling (cleared via the effect below too).
if (Date.now() > reindexDeadline) return false;
const data = query.state.data;
// Stop once everything is indexed; otherwise keep polling.
if (data && data.indexedPages >= data.totalPages) return false;
return REINDEX_POLL_INTERVAL;
});
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
nextReindexPollInterval({
deadline: reindexDeadline,
now: Date.now(),
intervalMs: REINDEX_POLL_INTERVAL,
status: query.state.data,
}),
);
// Stop polling once the work is done or the cap is reached. Also clears on
// Stop polling once the run is finished or the cap is reached. Also clears on
// unmount because the deadline state goes away with the component.
useEffect(() => {
if (reindexDeadline === null) return;
// "Done" matches the refetchInterval stop condition (indexed >= total),
// including an empty workspace (0 >= 0), so the deadline clears promptly
// instead of waiting out the cap.
if (settings && settings.indexedPages >= settings.totalPages) {
// "Done" matches the refetchInterval stop condition: the server reports no
// active run AND the count is complete (indexed >= total, incl. an empty
// workspace 0 >= 0), so the deadline clears promptly instead of waiting out
// the cap. While `reindexing` is still true we keep the deadline so polling
// continues for the whole run.
if (isReindexComplete(settings)) {
setReindexDeadline(null);
return;
}
@@ -1031,7 +1102,17 @@ export default function AiProviderSettings() {
<Button
variant="subtle"
size="compact-sm"
loading={reindexMutation.isPending}
// Spin for the WHOLE run: the POST resolves immediately, but the
// background job keeps running, so also stay loading while the
// server reports `reindexing` (this also blocks a redundant
// re-trigger mid-run; the server de-dupes regardless). The
// deadline gate (and why it matters post-cap) lives in
// `isReindexButtonLoading`, which is unit-tested.
loading={isReindexButtonLoading({
mutationPending: reindexMutation.isPending,
deadline: reindexDeadline,
status: settings?.reindexing,
})}
onClick={() =>
reindexMutation.mutate(undefined, {
// Begin bounded polling so the counter climbs as the async
@@ -23,8 +23,12 @@ export function useAiSettingsQuery(
enabled: boolean = true,
// While reindexing runs as an async background job, the counter only climbs
// if the client keeps refetching. The component passes a refetchInterval
// function that polls until indexed === total or a bounded deadline, then
// returns false to stop. See AiProviderSettings.
// function (`nextReindexPollInterval`) that keeps polling while the server
// reports an active run (reindexing === true) OR we are still within the
// bounded deadline and not yet fully indexed; it returns false to stop only
// once the run has finished AND indexed >= total, or the deadline cap is hit
// (the cap always wins). Note: a transient indexed === total during an active
// run does NOT stop polling. See AiProviderSettings.
refetchInterval?:
| number
| false
@@ -48,6 +48,9 @@ export interface IAiSettings {
// RAG indexing coverage (pages indexed for semantic search).
indexedPages: number;
totalPages: number;
// True while a full workspace reindex is actively running; the counts above
// then reflect the live run progress (done climbs 0 -> total).
reindexing?: boolean;
}
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):