From 001ebe2e536bdbae110cc7cf3a4607265a899e3f Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 26 Jun 2026 06:20:53 +0300 Subject: [PATCH 1/3] feat(ai): generate page title from content (#199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an AI button in the page byline that generates a note's title from the live editor content (including unsaved edits) and applies it immediately. Server: one-shot, non-streaming POST /ai-chat/generate-page-title mirroring the chat generateTitle path — gated by settings.ai.generative, throttled via AI_CHAT_THROTTLER, resolves the workspace chat model and returns { title }. The endpoint never touches the page; the client applies the title through the existing /pages/update route (which enforces edit permission). Client: ai-chat-service.generatePageTitle, a useGeneratePageTitle hook that converts the editor HTML to markdown, calls the endpoint, applies the title via updateTitle + updatePageData, reflects it in the unfocused title editor, and broadcasts the UpdateEvent (mirroring TitleEditor.saveTitle). A sparkles button (GenerateTitleGroup) renders next to dictation, edit-mode + flag gated. Tests: pure cleanGeneratedTitle helper + controller gate/delegation/error-map. i18n: en-US + ru-RU strings. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/en-US/translation.json | 10 +- .../public/locales/ru-RU/translation.json | 10 +- .../ai-chat/services/ai-chat-service.ts | 13 ++ .../groups/generate-title-group.tsx | 39 ++++++ .../src/features/editor/full-editor.tsx | 18 +++ .../editor/hooks/use-generate-page-title.ts | 104 +++++++++++++++ .../src/core/ai-chat/ai-chat.controller.ts | 38 ++++++ .../ai-chat.generate-page-title.spec.ts | 122 ++++++++++++++++++ .../src/core/ai-chat/ai-chat.service.ts | 33 +++++ .../src/core/ai-chat/dto/ai-chat.dto.ts | 10 ++ 10 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/features/editor/components/fixed-toolbar/groups/generate-title-group.tsx create mode 100644 apps/client/src/features/editor/hooks/use-generate-page-title.ts create mode 100644 apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 57018246..bdb263e1 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1328,5 +1328,13 @@ "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?", "The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?", "Failed to set custom address": "Failed to set custom address", - "Failed to remove custom address": "Failed to remove custom address" + "Failed to remove custom address": "Failed to remove custom address", + "Generate title with AI": "Generate title with AI", + "Title generated": "Title generated", + "Failed to generate title": "Failed to generate title", + "The note is empty": "The note is empty", + "Could not generate a title": "Could not generate a title", + "AI title generation is disabled": "AI title generation is disabled", + "AI is not configured": "AI is not configured", + "Too many requests, please try again later": "Too many requests, please try again later" } diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 1ce29237..de994244 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1185,5 +1185,13 @@ "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?", "The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?", "Failed to set custom address": "Не удалось задать пользовательский адрес", - "Failed to remove custom address": "Не удалось удалить пользовательский адрес" + "Failed to remove custom address": "Не удалось удалить пользовательский адрес", + "Generate title with AI": "Сгенерировать название через AI", + "Title generated": "Название сгенерировано", + "Failed to generate title": "Не удалось сгенерировать название", + "The note is empty": "Заметка пустая", + "Could not generate a title": "Не удалось придумать название", + "AI title generation is disabled": "Генерация названий через AI отключена", + "AI is not configured": "AI не настроен", + "Too many requests, please try again later": "Слишком много запросов, попробуйте позже" } diff --git a/apps/client/src/features/ai-chat/services/ai-chat-service.ts b/apps/client/src/features/ai-chat/services/ai-chat-service.ts index cc8e6b5a..0d64bbe3 100644 --- a/apps/client/src/features/ai-chat/services/ai-chat-service.ts +++ b/apps/client/src/features/ai-chat/services/ai-chat-service.ts @@ -68,6 +68,19 @@ export async function exportAiChat( return req.data.markdown; } +/** + * Generate a page title from note content (markdown). One-shot, non-streaming + * (#199): the server only summarizes the supplied text and returns a suggestion; + * it never writes the page. The caller applies the title via /pages/update. + */ +export async function generatePageTitle(content: string): Promise { + const req = await api.post<{ title: string }>( + "/ai-chat/generate-page-title", + { content }, + ); + return req.data.title; +} + /** * Agent roles API (`/ai-chat/roles`). `list` is available to any workspace * member (for the chat-creation picker); create/update/delete are admin-only diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/generate-title-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/generate-title-group.tsx new file mode 100644 index 00000000..b191c426 --- /dev/null +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/generate-title-group.tsx @@ -0,0 +1,39 @@ +import { FC } from "react"; +import { ActionIcon, Tooltip } from "@mantine/core"; +import { IconSparkles } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { useGeneratePageTitle } from "@/features/editor/hooks/use-generate-page-title.ts"; + +interface Props { + pageId: string; + color?: string; + iconSize?: number; +} + +/** + * AI "generate title" button (#199). Reads the live editor content and applies a + * model-suggested title immediately. Rendered in the page byline, only in edit + * mode and when the workspace's generative AI flag is on. + */ +export const GenerateTitleGroup: FC = ({ + pageId, + color = "gray", + iconSize = 20, +}) => { + const { t } = useTranslation(); + const gen = useGeneratePageTitle(pageId); + + return ( + + gen.mutate()} + > + + + + ); +}; diff --git a/apps/client/src/features/editor/full-editor.tsx b/apps/client/src/features/editor/full-editor.tsx index e9dcff4b..93b3b8ed 100644 --- a/apps/client/src/features/editor/full-editor.tsx +++ b/apps/client/src/features/editor/full-editor.tsx @@ -32,6 +32,7 @@ import { pageEditorAtom, } from "@/features/editor/atoms/editor-atoms.ts"; import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group"; +import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group"; const MemoizedTitleEditor = React.memo(TitleEditor); const MemoizedPageEditor = React.memo(PageEditor); @@ -74,6 +75,9 @@ export function FullEditor({ const [user] = useAtom(userAtom); const workspace = useAtomValue(workspaceAtom); const isDictationEnabled = workspace?.settings?.ai?.dictation === true; + // AI title generation reuses the generative AI flag (same gate as the on-page + // generative menu); the server enforces it too (#199). + const isTitleGenEnabled = workspace?.settings?.ai?.generative === true; const fullPageWidth = user.settings?.preferences?.fullPageWidth; const editorToolbarEnabled = user.settings?.preferences?.editorToolbar ?? false; @@ -111,11 +115,13 @@ export function FullEditor({ editable={editable} /> c.id !== creator?.id, @@ -238,6 +251,11 @@ function PageByline({ {showDictation && editor && ( )} + {/* Shown only in edit mode when the workspace's generative AI flag is on, + so AI title generation stays reachable from the byline (#199). */} + {showTitleGen && ( + + )} ); diff --git a/apps/client/src/features/editor/hooks/use-generate-page-title.ts b/apps/client/src/features/editor/hooks/use-generate-page-title.ts new file mode 100644 index 00000000..e2ae88a3 --- /dev/null +++ b/apps/client/src/features/editor/hooks/use-generate-page-title.ts @@ -0,0 +1,104 @@ +import { useMutation } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; +import { htmlToMarkdown } from "@docmost/editor-ext"; +import { + pageEditorAtom, + titleEditorAtom, +} from "@/features/editor/atoms/editor-atoms.ts"; +import { + updatePageData, + useUpdateTitlePageMutation, +} from "@/features/page/queries/page-query.ts"; +import { generatePageTitle } from "@/features/ai-chat/services/ai-chat-service.ts"; +import { useQueryEmit } from "@/features/websocket/use-query-emit.ts"; +import { UpdateEvent } from "@/features/websocket/types"; +import localEmitter from "@/lib/local-emitter.ts"; + +// Maximum length we send to the model. The server truncates again; this is a +// cheap client-side bound so we never ship a huge body over the wire. +const MAX_CONTENT_CHARS = 20000; + +/** + * Generate a title for the given page from the LIVE editor content (#199), + * including unsaved edits, then apply it IMMEDIATELY (per product decision). The + * server endpoint only summarizes the supplied markdown — it never writes the + * page; the actual title write goes through the existing /pages/update mutation + * (which enforces edit permission), and is mirrored to the title field + other + * clients exactly like TitleEditor.saveTitle. Returns a mutation-like API so the + * button can show a loading state via `isPending`. + */ +export function useGeneratePageTitle(pageId: string) { + const { t } = useTranslation(); + const pageEditor = useAtomValue(pageEditorAtom); + const titleEditor = useAtomValue(titleEditorAtom); + const { mutateAsync: updateTitle } = useUpdateTitlePageMutation(); + const emit = useQueryEmit(); + + return useMutation({ + mutationFn: async () => { + if (!pageEditor || pageEditor.isDestroyed) return; + + const markdown = htmlToMarkdown(pageEditor.getHTML()).trim(); + if (!markdown) { + notifications.show({ message: t("The note is empty"), color: "yellow" }); + return; + } + + const title = ( + await generatePageTitle(markdown.slice(0, MAX_CONTENT_CHARS)) + ).trim(); + if (!title) { + // The model returned nothing usable — keep the existing title untouched. + notifications.show({ + message: t("Could not generate a title"), + color: "yellow", + }); + return; + } + + const page = await updateTitle({ pageId, title }); // POST /pages/update + updatePageData(page); // refresh the react-query cache + + // Reflect the new title in the field immediately. The button lives in the + // byline, so the title editor is not focused — setContent is safe and stays + // undoable through its History extension (Ctrl/Cmd+Z reverts the change). + if (titleEditor && !titleEditor.isDestroyed && !titleEditor.isFocused) { + titleEditor.commands.setContent(page.title); + } + + // Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape. + const event: UpdateEvent = { + operation: "updateOne", + spaceId: page.spaceId, + entity: ["pages"], + id: page.id, + payload: { + title: page.title, + slugId: page.slugId, + parentPageId: page.parentPageId, + icon: page.icon, + }, + }; + localEmitter.emit("message", event); + emit(event); + + notifications.show({ message: t("Title generated") }); + }, + onError: (err) => { + // Map known HTTP statuses to friendly messages, falling back to generic. + const status = (err as { response?: { status?: number } })?.response + ?.status; + const message = + status === 403 + ? t("AI title generation is disabled") + : status === 503 + ? t("AI is not configured") + : status === 429 + ? t("Too many requests, please try again later") + : t("Failed to generate title"); + notifications.show({ message, color: "red" }); + }, + }); +} diff --git a/apps/server/src/core/ai-chat/ai-chat.controller.ts b/apps/server/src/core/ai-chat/ai-chat.controller.ts index 0f243dec..da09c340 100644 --- a/apps/server/src/core/ai-chat/ai-chat.controller.ts +++ b/apps/server/src/core/ai-chat/ai-chat.controller.ts @@ -32,6 +32,7 @@ import { AiTranscriptionService } from './ai-transcription.service'; import { ChatIdDto, ExportChatDto, + GeneratePageTitleDto, GetChatMessagesDto, RenameChatDto, } from './dto/ai-chat.dto'; @@ -316,6 +317,43 @@ export class AiChatController { return { text }; } + /** + * Generate a page title from supplied note content (#199). One-shot, + * non-streaming. Gated by the workspace AI flag (reusing settings.ai.generative, + * the same flag that gates the on-page generative AI menu); returns { title }. + * The endpoint NEVER writes the page — the client applies the title via the + * existing /pages/update route (which enforces edit permission), so access + * checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER. + */ + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard, UserThrottlerGuard) + @Throttle({ [AI_CHAT_THROTTLER]: { limit: 20, ttl: 60000 } }) + @Post('generate-page-title') + async generatePageTitle( + @Body() dto: GeneratePageTitleDto, + @AuthWorkspace() workspace: Workspace, + ): Promise<{ title: string }> { + const settings = (workspace.settings ?? {}) as { + ai?: { generative?: boolean }; + }; + if (settings.ai?.generative !== true) { + throw new ForbiddenException('AI title generation is disabled'); + } + try { + const title = await this.aiChatService.generatePageTitle( + workspace.id, + dto.content, + ); + return { title }; + } catch (err) { + // Preserve meaningful HTTP errors (e.g. AiNotConfiguredException -> 503). + if (err instanceof HttpException) throw err; + // Surface the real provider/transport reason instead of an opaque 500. + this.logger.error('AI title generation failed', err as Error); + throw new ServiceUnavailableException(describeProviderError(err)); + } + } + /** * Ensure the chat exists, belongs to this workspace, AND was created by the * requesting user (per-user isolation). Throws ForbiddenException otherwise. diff --git a/apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts b/apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts new file mode 100644 index 00000000..08d5bbc9 --- /dev/null +++ b/apps/server/src/core/ai-chat/ai-chat.generate-page-title.spec.ts @@ -0,0 +1,122 @@ +import { + ForbiddenException, + HttpException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { AiChatController } from './ai-chat.controller'; +import { cleanGeneratedTitle } from './ai-chat.service'; +import type { Workspace } from '@docmost/db/types/entity.types'; + +/** + * Pure post-processing of a model-generated title (#199): trims, strips a single + * pair of surrounding quotes, drops a trailing period, and hard-caps the length. + */ +describe('cleanGeneratedTitle', () => { + it('trims surrounding whitespace', () => { + expect(cleanGeneratedTitle(' Hello world ')).toBe('Hello world'); + }); + + it('strips a single pair of surrounding double quotes', () => { + expect(cleanGeneratedTitle('"My note"')).toBe('My note'); + }); + + it('strips surrounding single quotes', () => { + expect(cleanGeneratedTitle("'My note'")).toBe('My note'); + }); + + it('drops a trailing period', () => { + expect(cleanGeneratedTitle('A complete sentence.')).toBe( + 'A complete sentence', + ); + }); + + it('caps the result at 255 characters (the page-title column bound)', () => { + expect(cleanGeneratedTitle('x'.repeat(400))).toHaveLength(255); + }); + + it('returns an empty string for blank/garbage input', () => { + expect(cleanGeneratedTitle(' ')).toBe(''); + expect(cleanGeneratedTitle('""')).toBe(''); + }); +}); + +/** + * Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must: + * gate on settings.ai.generative (403 when off), delegate to the service when on, + * rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map + * any other provider/transport fault to a 503. Exercised by instantiating the + * controller with hand-rolled mocks — no Nest graph, no DB. + */ +describe('AiChatController.generatePageTitle', () => { + const enabledWorkspace = { + id: 'ws1', + settings: { ai: { generative: true } }, + } as unknown as Workspace; + + function makeController(generate: jest.Mock) { + const aiChatService = { generatePageTitle: generate }; + const controller = new AiChatController( + aiChatService as never, + {} as never, + {} as never, + {} as never, + ); + return { controller, aiChatService }; + } + + it('forbids when the generative AI flag is off', async () => { + const generate = jest.fn(); + const { controller } = makeController(generate); + const disabled = { id: 'ws1', settings: {} } as unknown as Workspace; + await expect( + controller.generatePageTitle({ content: 'body' }, disabled), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(generate).not.toHaveBeenCalled(); + }); + + it('forbids when settings.ai.generative is anything but exactly true', async () => { + const generate = jest.fn(); + const { controller } = makeController(generate); + const ws = { + id: 'ws1', + settings: { ai: { generative: 'yes' } }, + } as unknown as Workspace; + await expect( + controller.generatePageTitle({ content: 'body' }, ws), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('returns { title } from the service when enabled', async () => { + const generate = jest.fn().mockResolvedValue('Generated Title'); + const { controller } = makeController(generate); + const res = await controller.generatePageTitle( + { content: 'some markdown body' }, + enabledWorkspace, + ); + expect(generate).toHaveBeenCalledWith('ws1', 'some markdown body'); + expect(res).toEqual({ title: 'Generated Title' }); + }); + + it('rethrows an HttpException from the service verbatim (e.g. 503 not configured)', async () => { + const notConfigured = new ServiceUnavailableException('AI not configured'); + const generate = jest.fn().mockRejectedValue(notConfigured); + const { controller } = makeController(generate); + await expect( + controller.generatePageTitle({ content: 'body' }, enabledWorkspace), + ).rejects.toBe(notConfigured); + }); + + it('maps a non-HTTP provider error to a 503', async () => { + const generate = jest.fn().mockRejectedValue(new Error('socket hang up')); + const { controller } = makeController(generate); + // Silence the expected error log. + jest + .spyOn((controller as unknown as { logger: { error: () => void } }).logger, 'error') + .mockImplementation(() => undefined); + const err = await controller + .generatePageTitle({ content: 'body' }, enabledWorkspace) + .catch((e) => e); + expect(err).toBeInstanceOf(ServiceUnavailableException); + expect(err).toBeInstanceOf(HttpException); + }); +}); diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts index 492ce9f6..de4b90db 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -75,6 +75,18 @@ export function prepareAgentStep( export { MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION }; +// Pure, unit-testable post-processing for a model-generated title (#199): trim +// whitespace, strip a single pair of surrounding quotes the model often adds, +// drop a trailing period, and hard-cap the length to the page-title column. +export function cleanGeneratedTitle(text: string): string { + return text + .trim() + .replace(/^["']|["']$/g, '') + .replace(/\.+$/, '') + .trim() + .slice(0, 255); +} + /** * Payload accepted from the client `useChat` POST body. We do NOT bind a strict * DTO (the global ValidationPipe whitelist would strip the useChat-specific @@ -793,6 +805,27 @@ export class AiChatService implements OnModuleInit { } } + /** + * One-shot page-title generation from a note's content (#199). No tools, no + * streaming — mirrors generateTitle() but for an arbitrary note body supplied + * by the client, and RETURNS the title instead of writing it (the client + * applies it via the existing /pages/update route, which enforces edit + * permission). The content is truncated to keep the prompt cheap and within + * context limits. Throws AiNotConfiguredException (503) if AI is unconfigured. + */ + async generatePageTitle(workspaceId: string, content: string): Promise { + const model = await this.ai.getChatModel(workspaceId); + const { text } = await generateText({ + model, + system: + 'You generate a single concise, descriptive title for a note based on ' + + 'its content. Reply with the title only — at most 8 words, no quotes, ' + + 'no trailing punctuation, written in the same language as the note.', + prompt: content.slice(0, 8000), + }); + return cleanGeneratedTitle(text); + } + /** * Cheap, non-blocking title generation from the first user message. Uses * generateText (async) and writes the result back onto the chat row. Any diff --git a/apps/server/src/core/ai-chat/dto/ai-chat.dto.ts b/apps/server/src/core/ai-chat/dto/ai-chat.dto.ts index a48f2b84..1fb13304 100644 --- a/apps/server/src/core/ai-chat/dto/ai-chat.dto.ts +++ b/apps/server/src/core/ai-chat/dto/ai-chat.dto.ts @@ -17,6 +17,16 @@ export class RenameChatDto { title: string; } +/** One-shot page-title generation from note content (#199). */ +export class GeneratePageTitleDto { + // Note body as markdown/plain text. Capped to bound the prompt cost and + // reject abusive payloads; the service truncates again before the model call. + @IsString() + @MinLength(1) + @MaxLength(20000) + content: string; +} + /** Optional chat id for listing messages of a specific chat. */ export class GetChatMessagesDto { @IsString() From 0314416bfab6189e070fed3e54a4804e01768091 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 26 Jun 2026 17:25:33 +0300 Subject: [PATCH 2/3] Address PR #210 review: changelog, navigation guard, hook tests (#199) - CHANGELOG: add an [Unreleased]/Added bullet documenting the "generate title from content" byline button (reads live editor content, generates via the workspace AI provider, applies through /pages/update, gated by settings.ai.generative, throttled per user). - use-generate-page-title: guard the visible title write against page navigation during generation. The mutation awaits the model for 1-3s; its closure captures the editors from the starting render, but the global page/title atoms re-point on navigation. We now keep a live ref to the current editors and skip setContent unless the live page editor still belongs to the page the title was generated for (editor.storage.pageId === pageId, mirroring TitleEditor's activePageId guard). The DB write stays correct (keyed by the captured pageId) and the websocket broadcast is unchanged, so only the wrong-page field write is suppressed. - Add a vitest suite for the hook: empty content, empty model response, happy path, the navigation guard, and 403/503/429/other onError mapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 + .../hooks/use-generate-page-title.test.tsx | 232 ++++++++++++++++++ .../editor/hooks/use-generate-page-title.ts | 34 ++- 3 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 77fe9718..061cf9af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,12 @@ per-workspace rolling-day token budget. - **Footnote multi-backlinks.** A footnote referenced more than once now shows a back-link per reference (↩ a b c …), each scrolling to its own occurrence, like Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168) +- **Generate a page title from its content.** A "sparkles" button in the page + byline reads the live editor content (including unsaved edits), generates a + title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and + applies it through the existing `/pages/update` route — reflecting it in the + title field and broadcasting to other clients. Gated by the `settings.ai.generative` + flag and throttled per user. (#199) ### Changed diff --git a/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx b/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx new file mode 100644 index 00000000..17800312 --- /dev/null +++ b/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Provider, createStore } from "jotai"; +import type { Editor } from "@tiptap/core"; +import { + pageEditorAtom, + titleEditorAtom, +} from "@/features/editor/atoms/editor-atoms.ts"; + +// --- Mocks for the hook's collaborators --------------------------------------- + +const generatePageTitleMock = vi.fn(); +vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({ + generatePageTitle: (content: string) => generatePageTitleMock(content), +})); + +const updateTitleMock = vi.fn(); +const updatePageDataMock = vi.fn(); +vi.mock("@/features/page/queries/page-query.ts", () => ({ + useUpdateTitlePageMutation: () => ({ mutateAsync: updateTitleMock }), + updatePageData: (page: unknown) => updatePageDataMock(page), +})); + +const emitMock = vi.fn(); +vi.mock("@/features/websocket/use-query-emit.ts", () => ({ + useQueryEmit: () => emitMock, +})); + +const localEmitMock = vi.fn(); +vi.mock("@/lib/local-emitter.ts", () => ({ + default: { emit: (...args: unknown[]) => localEmitMock(...args) }, +})); + +// htmlToMarkdown just echoes the editor HTML so each test controls the markdown +// purely via the fake page editor's getHTML(). +vi.mock("@docmost/editor-ext", () => ({ + htmlToMarkdown: (html: string) => html, +})); + +const notificationsShowMock = vi.fn(); +vi.mock("@mantine/notifications", () => ({ + notifications: { show: (opts: unknown) => notificationsShowMock(opts) }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Import after mocks are registered. +import { useGeneratePageTitle } from "./use-generate-page-title.ts"; + +// --- Test helpers ------------------------------------------------------------- + +function makePageEditor(pageId: string, html = "

content

"): Editor { + return { + isDestroyed: false, + getHTML: () => html, + storage: { pageId }, + } as unknown as Editor; +} + +function makeTitleEditor(): Editor & { + commands: { setContent: ReturnType }; +} { + return { + isDestroyed: false, + isFocused: false, + commands: { setContent: vi.fn() }, + } as unknown as Editor & { + commands: { setContent: ReturnType }; + }; +} + +function setup(pageId: string, store = createStore()) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useGeneratePageTitle(pageId), { + wrapper, + }); + return { result, store }; +} + +const PAGE_A = { + id: "pageA", + title: "Generated Title", + spaceId: "space1", + slugId: "slugA", + parentPageId: null, + icon: null, +} as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("useGeneratePageTitle", () => { + it("shows a notice and bails when the editor content is empty", async () => { + const store = createStore(); + store.set(pageEditorAtom as never, makePageEditor("pageA", " ")); + store.set(titleEditorAtom as never, makeTitleEditor()); + const { result } = setup("pageA", store); + + await act(async () => { + await result.current.mutateAsync(); + }); + + expect(notificationsShowMock).toHaveBeenCalledWith( + expect.objectContaining({ message: "The note is empty", color: "yellow" }), + ); + expect(generatePageTitleMock).not.toHaveBeenCalled(); + expect(updateTitleMock).not.toHaveBeenCalled(); + }); + + it("leaves the title untouched when the model returns nothing usable", async () => { + const store = createStore(); + store.set(pageEditorAtom as never, makePageEditor("pageA")); + store.set(titleEditorAtom as never, makeTitleEditor()); + generatePageTitleMock.mockResolvedValue(" "); + const { result } = setup("pageA", store); + + await act(async () => { + await result.current.mutateAsync(); + }); + + expect(updateTitleMock).not.toHaveBeenCalled(); + expect(notificationsShowMock).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Could not generate a title", + color: "yellow", + }), + ); + }); + + it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => { + const store = createStore(); + const titleEditor = makeTitleEditor(); + store.set(pageEditorAtom as never, makePageEditor("pageA")); + store.set(titleEditorAtom as never, titleEditor); + generatePageTitleMock.mockResolvedValue("Generated Title"); + updateTitleMock.mockResolvedValue(PAGE_A); + const { result } = setup("pageA", store); + + await act(async () => { + await result.current.mutateAsync(); + }); + + expect(updateTitleMock).toHaveBeenCalledWith({ + pageId: "pageA", + title: "Generated Title", + }); + expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A); + expect(titleEditor.commands.setContent).toHaveBeenCalledWith( + "Generated Title", + ); + expect(localEmitMock).toHaveBeenCalled(); + expect(emitMock).toHaveBeenCalled(); + expect(notificationsShowMock).toHaveBeenCalledWith( + expect.objectContaining({ message: "Title generated" }), + ); + }); + + it("does NOT write the visible title field when the user navigated away during generation", async () => { + const store = createStore(); + const titleEditor = makeTitleEditor(); // persistent across navigation + store.set(pageEditorAtom as never, makePageEditor("pageA")); + store.set(titleEditorAtom as never, titleEditor); + + // Control when generation resolves so we can navigate mid-flight. + let resolveTitle!: (t: string) => void; + generatePageTitleMock.mockReturnValue( + new Promise((res) => { + resolveTitle = res; + }), + ); + updateTitleMock.mockResolvedValue(PAGE_A); + const { result } = setup("pageA", store); + + let pending!: Promise; + act(() => { + pending = result.current.mutateAsync(); + }); + + // User navigates to page B: the live page editor now belongs to pageB. + act(() => { + store.set(pageEditorAtom as never, makePageEditor("pageB")); + }); + + await act(async () => { + resolveTitle("Generated Title"); + await pending; + }); + + // DB write is still correct (keyed by the captured pageId)... + expect(updateTitleMock).toHaveBeenCalledWith({ + pageId: "pageA", + title: "Generated Title", + }); + // ...but we must NOT stamp page A's title into page B's visible field. + expect(titleEditor.commands.setContent).not.toHaveBeenCalled(); + // The change is still broadcast to other clients. + expect(emitMock).toHaveBeenCalled(); + }); + + it.each([ + [403, "AI title generation is disabled"], + [503, "AI is not configured"], + [429, "Too many requests, please try again later"], + [500, "Failed to generate title"], + ])("maps HTTP %s onError to a friendly message", async (status, message) => { + const store = createStore(); + store.set(pageEditorAtom as never, makePageEditor("pageA")); + store.set(titleEditorAtom as never, makeTitleEditor()); + generatePageTitleMock.mockRejectedValue({ response: { status } }); + const { result } = setup("pageA", store); + + await act(async () => { + await expect(result.current.mutateAsync()).rejects.toBeTruthy(); + }); + + expect(notificationsShowMock).toHaveBeenCalledWith( + expect.objectContaining({ message, color: "red" }), + ); + }); +}); diff --git a/apps/client/src/features/editor/hooks/use-generate-page-title.ts b/apps/client/src/features/editor/hooks/use-generate-page-title.ts index e2ae88a3..e8d9e0e2 100644 --- a/apps/client/src/features/editor/hooks/use-generate-page-title.ts +++ b/apps/client/src/features/editor/hooks/use-generate-page-title.ts @@ -1,3 +1,4 @@ +import { useRef } from "react"; import { useMutation } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { notifications } from "@mantine/notifications"; @@ -36,6 +37,14 @@ export function useGeneratePageTitle(pageId: string) { const { mutateAsync: updateTitle } = useUpdateTitlePageMutation(); const emit = useQueryEmit(); + // The page/title editors come from GLOBAL atoms that re-point when the user + // navigates to another page. The mutation below awaits the model for 1-3s, and + // its closure captures the editors from the render that started it. Keep a live + // reference so the post-generation write targets whatever page is on screen + // *now*, not the page the generation was started from. + const editorsRef = useRef({ pageEditor, titleEditor }); + editorsRef.current = { pageEditor, titleEditor }; + return useMutation({ mutationFn: async () => { if (!pageEditor || pageEditor.isDestroyed) return; @@ -64,8 +73,29 @@ export function useGeneratePageTitle(pageId: string) { // Reflect the new title in the field immediately. The button lives in the // byline, so the title editor is not focused — setContent is safe and stays // undoable through its History extension (Ctrl/Cmd+Z reverts the change). - if (titleEditor && !titleEditor.isDestroyed && !titleEditor.isFocused) { - titleEditor.commands.setContent(page.title); + // + // Guard against navigation during generation: if the user switched pages + // while the model ran, the (persistent) title editor now shows ANOTHER + // page, so writing here would drop page A's title into page B's visible + // field. page-editor.tsx stamps the live page editor with its pageId + // (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !== + // pageId` guard — bail the visible write unless that live editor still + // belongs to the page this title was generated for. The DB write above is + // already correct (keyed by the captured `pageId`), and the broadcast below + // still propagates page A's change to other clients. + const livePageEditor = editorsRef.current.pageEditor; + const liveTitleEditor = editorsRef.current.titleEditor; + // `storage.pageId` is stamped untyped in page-editor.tsx's onCreate. + const livePageId = (livePageEditor?.storage as { pageId?: string }) + ?.pageId; + const stillOnPage = livePageId === pageId; + if ( + stillOnPage && + liveTitleEditor && + !liveTitleEditor.isDestroyed && + !liveTitleEditor.isFocused + ) { + liveTitleEditor.commands.setContent(page.title); } // Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape. From 9632146d23490e53bf1886b53e9ec4d3b9307ea1 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Fri, 26 Jun 2026 18:04:40 +0300 Subject: [PATCH 3/3] test(editor): cover focused-title guard and destroyed-editor early-out Add coverage for the two untested branches in useGeneratePageTitle's post-generation write: suppressing setContent when the live title editor is focused (DB write + broadcast still happen, only the visible field write is skipped), and the early return when the page editor is destroyed (model never called). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hooks/use-generate-page-title.test.tsx | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx b/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx index 17800312..880611ae 100644 --- a/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx +++ b/apps/client/src/features/editor/hooks/use-generate-page-title.test.tsx @@ -209,6 +209,68 @@ describe("useGeneratePageTitle", () => { expect(emitMock).toHaveBeenCalled(); }); + it("does NOT write the visible title field when the title editor is focused", async () => { + const store = createStore(); + const titleEditor = makeTitleEditor(); + store.set(pageEditorAtom as never, makePageEditor("pageA")); + store.set(titleEditorAtom as never, titleEditor); + + // Resolve generation under our control so we can mark the live title editor + // as focused before the post-generation write runs. + let resolveTitle!: (t: string) => void; + generatePageTitleMock.mockReturnValue( + new Promise((res) => { + resolveTitle = res; + }), + ); + updateTitleMock.mockResolvedValue(PAGE_A); + const { result } = setup("pageA", store); + + let pending!: Promise; + act(() => { + pending = result.current.mutateAsync(); + }); + + // The user clicked into the title field while the model ran — overwriting it + // now would clobber what they are actively typing. + act(() => { + (titleEditor as { isFocused: boolean }).isFocused = true; + }); + + await act(async () => { + resolveTitle("Generated Title"); + await pending; + }); + + // The DB write still persists the value... + expect(updateTitleMock).toHaveBeenCalledWith({ + pageId: "pageA", + title: "Generated Title", + }); + expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A); + // ...but the visible field is left alone while it is focused. + expect(titleEditor.commands.setContent).not.toHaveBeenCalled(); + // The change is still broadcast to other clients. + expect(localEmitMock).toHaveBeenCalled(); + expect(emitMock).toHaveBeenCalled(); + }); + + it("bails before calling the model when the page editor is destroyed", async () => { + const store = createStore(); + const pageEditor = makePageEditor("pageA"); + (pageEditor as { isDestroyed: boolean }).isDestroyed = true; + store.set(pageEditorAtom as never, pageEditor); + store.set(titleEditorAtom as never, makeTitleEditor()); + const { result } = setup("pageA", store); + + await act(async () => { + await result.current.mutateAsync(); + }); + + expect(generatePageTitleMock).not.toHaveBeenCalled(); + expect(updateTitleMock).not.toHaveBeenCalled(); + }); + it.each([ [403, "AI title generation is disabled"], [503, "AI is not configured"],