Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fdb6f39a8e | |||
| 6475cb81e0 | |||
| 51925e955f | |||
| 8978d69f3e | |||
| c192f2a2e1 | |||
| d78b985062 | |||
| 2ce672709a | |||
| a4fc6c7f64 | |||
| c252068672 | |||
| 68caf8157a | |||
| cb9c5dda59 | |||
| e431b33bb1 | |||
| 4369bbc53d | |||
| 8e5ad8070b | |||
| cfc105c7d6 | |||
| d7fa6738e5 | |||
| e6d8eda8e5 | |||
| 8d8ecaed82 | |||
| eacc1c4811 | |||
| 8e12aa8ebf | |||
| 348dcd0802 | |||
| 086bc1bf8b | |||
| 77b245461f | |||
| 77c64c4fd9 | |||
| 2bb71c1a45 | |||
| 20248b8c95 | |||
| 9274c51053 | |||
| 832c3cafdf | |||
| 94f60cf0ec | |||
| 40d42d61e6 | |||
| bcd194ee5d | |||
| f13105333a | |||
| 08222345ef | |||
| baa41d66ad | |||
| 1a7b817250 | |||
| 124f5a45a2 | |||
| b751852425 | |||
| 65d81f745a | |||
| bfbd927866 | |||
| 77f5224b55 | |||
| e2a3b5fc4d | |||
| d7d8db2102 | |||
| e814bca243 | |||
| f1ab76e879 | |||
| 6dcc19ce59 | |||
| d6d7dd82f6 |
@@ -202,6 +202,13 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Default 900000 (15 min).
|
# Default 900000 (15 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||||
|
|
||||||
|
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
||||||
|
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
||||||
|
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
|
||||||
|
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
|
||||||
|
# active" behavior.
|
||||||
|
# AI_CHAT_DEFERRED_TOOLS=true
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is the shared converter (#293/#326); its
|
||||||
|
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
|
||||||
|
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
|
||||||
|
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
|
||||||
|
# unless it is built first. Build it before the recursive test run.
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -4,12 +4,21 @@
|
|||||||
data
|
data
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
node_modules/
|
node_modules
|
||||||
|
|
||||||
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
||||||
# so src/ and prod can never silently diverge).
|
# so src/ and prod can never silently diverge).
|
||||||
packages/git-sync/build/
|
packages/git-sync/build/
|
||||||
|
|
||||||
|
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
|
||||||
|
# never committed, so src/ and prod can never silently diverge).
|
||||||
|
packages/prosemirror-markdown/build/
|
||||||
|
|
||||||
|
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
|
||||||
|
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
|
||||||
|
# markdown convention; the package is private and rebuilt at deploy.
|
||||||
|
packages/mcp/build/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
||||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||||
|
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp` and `git-sync`; there is exactly ONE copy of the converter now |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -282,7 +283,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by both `mcp` and `git-sync` — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
|
||||||
@@ -293,7 +294,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
||||||
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). Remember `packages/mcp/build/` is committed — rebuild after editing.
|
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
|||||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||||
|
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||||
|
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||||
|
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||||
|
# install resolves a broken workspace symlink and every ai-chat tool dies with
|
||||||
|
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
|
||||||
|
# revisit at step 6 when #119 lands.)
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ roles:
|
|||||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||||
@@ -169,7 +169,7 @@ roles:
|
|||||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||||
|
|
||||||
HOW TO WORK
|
HOW TO WORK
|
||||||
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important".
|
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ roles:
|
|||||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||||
@@ -170,7 +170,7 @@ roles:
|
|||||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||||
|
|
||||||
КАК РАБОТАТЬ
|
КАК РАБОТАТЬ
|
||||||
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное».
|
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ bundles:
|
|||||||
- slug: line-editor
|
- slug: line-editor
|
||||||
version: 4
|
version: 4
|
||||||
- slug: fact-checker
|
- slug: fact-checker
|
||||||
version: 5
|
version: 6
|
||||||
- slug: proofreader
|
- slug: proofreader
|
||||||
version: 7
|
version: 8
|
||||||
- slug: narrator
|
- slug: narrator
|
||||||
version: 2
|
version: 2
|
||||||
- id: research
|
- id: research
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"fact-checker": {
|
"fact-checker": {
|
||||||
"version": 5,
|
"version": 6,
|
||||||
"hash": "d7769872968109a1ccfb58d71bc3f3564a750b91766156f59031762848de4f24"
|
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||||
},
|
},
|
||||||
"line-editor": {
|
"line-editor": {
|
||||||
"version": 4,
|
"version": 4,
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||||
},
|
},
|
||||||
"proofreader": {
|
"proofreader": {
|
||||||
"version": 7,
|
"version": 8,
|
||||||
"hash": "fdf8e0a443fa3c4102095e024146401363629a3f9015fb938c7bac2642825e56"
|
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^3.0.208",
|
"@ai-sdk/react": "^3.0.208",
|
||||||
|
"@braintree/sanitize-url": "7.1.2",
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"blueimp-load-image": "5.16.0",
|
"blueimp-load-image": "5.16.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"diff": "8.0.3",
|
||||||
"dompurify": "3.4.1",
|
"dompurify": "3.4.1",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"highlightjs-sap-abap": "0.3.0",
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
@@ -81,6 +83,7 @@
|
|||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.1",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"eslint": "9.28.0",
|
"eslint": "9.28.0",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.0.1",
|
"eslint-plugin-react-hooks": "7.0.1",
|
||||||
|
|||||||
@@ -1382,5 +1382,8 @@
|
|||||||
"Applied": "Applied",
|
"Applied": "Applied",
|
||||||
"Suggestion applied": "Suggestion applied",
|
"Suggestion applied": "Suggestion applied",
|
||||||
"Failed to apply suggestion": "Failed to apply suggestion",
|
"Failed to apply suggestion": "Failed to apply suggestion",
|
||||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied."
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||||
|
"Dismiss": "Dismiss",
|
||||||
|
"Suggestion dismissed": "Suggestion dismissed",
|
||||||
|
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1245,5 +1245,8 @@
|
|||||||
"Applied": "Применено",
|
"Applied": "Применено",
|
||||||
"Suggestion applied": "Предложение применено",
|
"Suggestion applied": "Предложение применено",
|
||||||
"Failed to apply suggestion": "Не удалось применить предложение",
|
"Failed to apply suggestion": "Не удалось применить предложение",
|
||||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||||
|
"Dismiss": "Не применять",
|
||||||
|
"Suggestion dismissed": "Предложение отклонено",
|
||||||
|
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-24
@@ -1,38 +1,72 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
import Layout from "@/components/layouts/global/layout.tsx";
|
||||||
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
|
|
||||||
|
// ShareLayout is route-split: its ShareShell chrome pulls in the table of
|
||||||
|
// contents (and thus TipTap), so keeping it out of the eager graph removes the
|
||||||
|
// editor engine from startup for authenticated users too.
|
||||||
|
const ShareLayout = lazy(
|
||||||
|
() => import("@/features/share/components/share-layout.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth / entry pages stay eager: they are the first paint for an unauthenticated
|
||||||
|
// visitor (e.g. /login) and are already small, so code-splitting them would only
|
||||||
|
// add a cold-chunk round trip to the most common cold-start path.
|
||||||
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
||||||
import LoginPage from "@/pages/auth/login";
|
import LoginPage from "@/pages/auth/login";
|
||||||
import Home from "@/pages/dashboard/home";
|
|
||||||
import Page from "@/pages/page/page";
|
|
||||||
import AccountSettings from "@/pages/settings/account/account-settings";
|
|
||||||
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
|
|
||||||
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
|
|
||||||
import AiSettings from "@/pages/settings/workspace/ai-settings";
|
|
||||||
import Groups from "@/pages/settings/group/groups";
|
|
||||||
import GroupInfo from "./pages/settings/group/group-info";
|
|
||||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
|
||||||
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
|
||||||
import SpaceHome from "@/pages/space/space-home.tsx";
|
|
||||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
|
||||||
import Layout from "@/components/layouts/global/layout.tsx";
|
|
||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
import PasswordReset from "./pages/auth/password-reset";
|
||||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
|
||||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
|
||||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
|
||||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
// Heavy / leaf pages are route-split with React.lazy so their code (most
|
||||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
|
||||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
// the page editor and the readonly share editor pull in) is fetched only when
|
||||||
import LabelPage from "@/pages/label/label-page";
|
// the matching route is actually visited. The <Suspense> boundaries live inside
|
||||||
|
// each Layout (around its <Outlet/>), so the app shell stays mounted while a
|
||||||
|
// route chunk loads.
|
||||||
|
const Home = lazy(() => import("@/pages/dashboard/home"));
|
||||||
|
const Page = lazy(() => import("@/pages/page/page"));
|
||||||
|
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
|
||||||
|
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
|
||||||
|
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
|
||||||
|
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
|
||||||
|
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
||||||
|
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
|
||||||
|
|
||||||
|
const AccountSettings = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-settings"),
|
||||||
|
);
|
||||||
|
const AccountPreferences = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||||
|
);
|
||||||
|
const WorkspaceSettings = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||||
|
);
|
||||||
|
const AiSettings = lazy(() => import("@/pages/settings/workspace/ai-settings"));
|
||||||
|
const WorkspaceMembers = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-members"),
|
||||||
|
);
|
||||||
|
const Groups = lazy(() => import("@/pages/settings/group/groups"));
|
||||||
|
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
|
||||||
|
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
|
||||||
|
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useTrackOrigin();
|
useTrackOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="100vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
@@ -83,6 +117,6 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="*" element={<Error404 />} />
|
<Route path="*" element={<Error404 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||||
|
|
||||||
|
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||||
|
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||||
|
// recovery UI, no reload). A false negative on a real chunk failure re-blanks the
|
||||||
|
// app; a false positive would auto-reload on an ordinary error. Pin both sides.
|
||||||
|
describe("isChunkLoadError", () => {
|
||||||
|
it("detects the ChunkLoadError name", () => {
|
||||||
|
expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"Failed to fetch dynamically imported module: https://x/assets/index-abc.js",
|
||||||
|
"error loading dynamically imported module",
|
||||||
|
"Importing a module script failed.",
|
||||||
|
])("detects the dynamic-import failure message %#", (message) => {
|
||||||
|
expect(isChunkLoadError({ name: "TypeError", message })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive on the message", () => {
|
||||||
|
expect(
|
||||||
|
isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{ name: "TypeError", message: "Cannot read properties of undefined" },
|
||||||
|
{ message: "Network request failed" },
|
||||||
|
new Error("some ordinary render error"),
|
||||||
|
])("returns false for a non-chunk error %#", (err) => {
|
||||||
|
expect(isChunkLoadError(err)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||||
|
|
||||||
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
|
// replaces the hashed chunks, a tab left open on the old index.html requests a
|
||||||
|
// chunk URL that now 404s, and React.lazy rejects. Browsers / Vite surface these
|
||||||
|
// with a ChunkLoadError name or one of these messages.
|
||||||
|
export function isChunkLoadError(error: unknown): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
const name = (error as { name?: string }).name ?? "";
|
||||||
|
const message = (error as { message?: string }).message ?? "";
|
||||||
|
return (
|
||||||
|
name === "ChunkLoadError" ||
|
||||||
|
/Failed to fetch dynamically imported module/i.test(message) ||
|
||||||
|
/error loading dynamically imported module/i.test(message) ||
|
||||||
|
/Importing a module script failed/i.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(error: unknown) {
|
||||||
|
if (!isChunkLoadError(error)) return;
|
||||||
|
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||||
|
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
||||||
|
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
||||||
|
// flag is already set we fall through to the manual recovery UI below.
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
||||||
|
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||||
|
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root-level boundary that sits ABOVE every route-level Suspense boundary so a
|
||||||
|
// lazy route/component chunk failure is caught here instead of unmounting the
|
||||||
|
// whole tree into a blank white screen. Per-feature ErrorBoundaries (page.tsx,
|
||||||
|
// transclusion, page-embed) remain in place underneath for their local errors.
|
||||||
|
export function ChunkLoadErrorBoundary({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
onError={handleError}
|
||||||
|
fallbackRender={({ error }) => {
|
||||||
|
const chunk = isChunkLoadError(error);
|
||||||
|
return (
|
||||||
|
<Center h="100vh" p="md">
|
||||||
|
<Stack align="center" gap="sm" maw={420}>
|
||||||
|
<Text fw={600}>
|
||||||
|
{chunk ? "A new version is available" : "Something went wrong"}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{chunk
|
||||||
|
? "Please reload the page to load the latest version."
|
||||||
|
: "An unexpected error occurred. Reloading the page may help."}
|
||||||
|
</Text>
|
||||||
|
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
import { AppShell, Container } from "@mantine/core";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
import {
|
import {
|
||||||
APP_NAVBAR_ID,
|
APP_NAVBAR_ID,
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||||
@@ -14,8 +15,6 @@ import {
|
|||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
|
||||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
|
||||||
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
@@ -23,6 +22,21 @@ import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
|||||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||||
|
|
||||||
|
// Lazily load the AI chat window so the AI SDK runtime it pulls in is fetched
|
||||||
|
// only after the user first opens the chat, instead of for every authenticated
|
||||||
|
// user on load. The window itself renders null while closed, so there is no
|
||||||
|
// behavior difference — it simply is not mounted until first opened.
|
||||||
|
const AiChatWindow = React.lazy(
|
||||||
|
() => import("@/features/ai-chat/components/ai-chat-window.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The right aside hosts the comment panel and table of contents, both of which
|
||||||
|
// pull in TipTap. It only ever renders on page routes, so lazy-loading it keeps
|
||||||
|
// the whole editor engine out of the eager global-shell startup graph.
|
||||||
|
const Aside = React.lazy(
|
||||||
|
() => import("@/components/layouts/global/aside.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -37,6 +51,15 @@ export default function GlobalAppShell({
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef(null);
|
const sidebarRef = useRef(null);
|
||||||
|
|
||||||
|
// Latch: once the AI chat window has been opened, keep it mounted so an
|
||||||
|
// in-flight stream is never torn down. Before the first open the AI chat chunk
|
||||||
|
// is never fetched.
|
||||||
|
const aiChatOpen = useAtomValue(aiChatWindowOpenAtom);
|
||||||
|
const [aiChatEverOpened, setAiChatEverOpened] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiChatOpen) setAiChatEverOpened(true);
|
||||||
|
}, [aiChatOpen]);
|
||||||
|
|
||||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
mouseDownEvent.preventDefault();
|
mouseDownEvent.preventDefault();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
@@ -160,13 +183,21 @@ export default function GlobalAppShell({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Aside />
|
<Suspense fallback={null}>
|
||||||
|
<Aside />
|
||||||
|
</Suspense>
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
{/* Floating AI chat window. Mounted once globally on first open; it is
|
||||||
and self-hides when closed, so its place in the tree is not critical. */}
|
position: fixed and self-hides when closed, so its place in the tree is
|
||||||
<AiChatWindow />
|
not critical. Kept mounted after the first open so a live stream is not
|
||||||
|
aborted. */}
|
||||||
|
{aiChatEverOpened && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AiChatWindow />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||||
createPageWithRecording on window.gitmost so the native host can
|
createPageWithRecording on window.gitmost so the native host can
|
||||||
create a page with a recording even when no page editor is open. */}
|
create a page with a recording even when no page editor is open. */}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { Suspense, useEffect } from "react";
|
||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet, useParams } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -8,10 +10,39 @@ export default function Layout() {
|
|||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
|
// Warm the (now route-split) editor chunk during idle time on authenticated
|
||||||
|
// routes, so the first navigation to a page renders from cache instead of a
|
||||||
|
// cold chunk fetch. Best-effort: gated on requestIdleCallback and never blocks
|
||||||
|
// startup — the dynamic import mirrors the App.tsx route lazy loader so both
|
||||||
|
// resolve to the same chunk.
|
||||||
|
useEffect(() => {
|
||||||
|
const ric =
|
||||||
|
typeof window !== "undefined" && (window as any).requestIdleCallback;
|
||||||
|
const warm = () => {
|
||||||
|
// Best-effort prefetch: a failed warm-up (offline, stale 404) is harmless
|
||||||
|
// and must not surface as an unhandledrejection.
|
||||||
|
void import("@/pages/page/page").catch(() => {});
|
||||||
|
};
|
||||||
|
if (ric) {
|
||||||
|
const id = ric(warm);
|
||||||
|
return () => (window as any).cancelIdleCallback?.(id);
|
||||||
|
}
|
||||||
|
const timer = setTimeout(warm, 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
<SearchSpotlight spaceId={space?.id} />
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||||
@@ -140,3 +140,91 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The turn-end decision lives in the `onFinish` handler: given the terminal
|
||||||
|
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
|
||||||
|
// it decides whether to CONTINUE (flush the next queued message) or END (leave
|
||||||
|
// the queue intact for the user), and which stop notice — if any — to show.
|
||||||
|
// `sendNow` is exercised above; these tests pin down the plain outcomes.
|
||||||
|
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
h.state.status = "streaming";
|
||||||
|
h.state.onFinish = null;
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
h.state.stop.mockClear();
|
||||||
|
h.state.transport = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drive a fresh onFinish with the given terminal flags after queueing a
|
||||||
|
// message, and report both what the parent was told and whether the queue was
|
||||||
|
// flushed (a resend to the sendMessage spy).
|
||||||
|
function finishWith(flags: {
|
||||||
|
isAbort?: boolean;
|
||||||
|
isDisconnect?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
// Tear down any prior render so the loop-driven "every outcome" case does
|
||||||
|
// not leave duplicate queue buttons in the DOM.
|
||||||
|
cleanup();
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
const { onTurnFinished } = renderThread();
|
||||||
|
// Populate the queue while the turn is streaming.
|
||||||
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
|
act(() => {
|
||||||
|
h.state.onFinish?.({
|
||||||
|
message: { id: "a", role: "assistant", parts: [] },
|
||||||
|
isAbort: false,
|
||||||
|
isDisconnect: false,
|
||||||
|
isError: false,
|
||||||
|
...flags,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { onTurnFinished };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||||
|
finishWith({});
|
||||||
|
// Clean finish (no terminal flag): the queued message is auto-sent.
|
||||||
|
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||||
|
// A clean finish shows no stop notice.
|
||||||
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||||
|
finishWith({ isAbort: true });
|
||||||
|
// A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the
|
||||||
|
// queue is preserved for the user to decide.
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a disconnect and shows the connection-lost notice", () => {
|
||||||
|
finishWith({ isDisconnect: true });
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Connection lost — the answer was interrupted."),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||||
|
finishWith({ isError: true });
|
||||||
|
// Blindly retrying after a failure would be wrong; the queue is left alone.
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
// isError clears the neutral notice (the error banner covers this case).
|
||||||
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the parent on EVERY terminal outcome", () => {
|
||||||
|
// The chat-list refresh / new-chat id adoption must run on success and on
|
||||||
|
// every failure path alike.
|
||||||
|
for (const flags of [
|
||||||
|
{},
|
||||||
|
{ isAbort: true },
|
||||||
|
{ isDisconnect: true },
|
||||||
|
{ isError: true },
|
||||||
|
]) {
|
||||||
|
const { onTurnFinished } = finishWith(flags);
|
||||||
|
expect(onTurnFinished).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// The fallback path renders the full TipTap editor; stub it so we can assert the
|
||||||
|
// safety valve fired without pulling in the editor stack.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor-fallback" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mention rendering hits react-query; stub the page/share queries so the mention
|
||||||
|
// case renders in isolation.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CommentContentView } from "./comment-content-view";
|
||||||
|
|
||||||
|
function renderView(content: string | object) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommentContentView content={content} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = (content: any[]) => JSON.stringify({ type: "doc", content });
|
||||||
|
const para = (content: any[]) => ({ type: "paragraph", content });
|
||||||
|
const text = (t: string, marks?: any[]) => ({ type: "text", text: t, marks });
|
||||||
|
|
||||||
|
describe("CommentContentView", () => {
|
||||||
|
it("renders paragraphs as <p> with text", () => {
|
||||||
|
const { container } = renderView(doc([para([text("Hello world")])]));
|
||||||
|
expect(screen.getByText("Hello world")).toBeDefined();
|
||||||
|
expect(container.querySelector("p")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the read-only CommentEditor DOM nesting for CSS parity", () => {
|
||||||
|
const { container } = renderView(doc([para([text("x")])]));
|
||||||
|
// outer .commentEditor > .ProseMirror (module) > .ProseMirror (global) > p
|
||||||
|
const globalPm = container.querySelector("div.ProseMirror > p");
|
||||||
|
expect(globalPm).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the bold mark as <strong>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("bold", [{ type: "bold" }])])]),
|
||||||
|
);
|
||||||
|
const el = container.querySelector("strong");
|
||||||
|
expect(el?.textContent).toBe("bold");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the italic mark as <em>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("it", [{ type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("em")?.textContent).toBe("it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the strike mark as <s>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("st", [{ type: "strike" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("s")?.textContent).toBe("st");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the underline mark as <u> (not the editor fallback)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("un", [{ type: "underline" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("u")?.textContent).toBe("un");
|
||||||
|
// Underline is a supported mark, so no degrade to the editor fallback.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the code mark as <code>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("co", [{ type: "code" }])])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("code")?.textContent).toBe("co");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the link mark as an anchor with safe rel/target", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "https://example.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a?.getAttribute("href")).toBe("https://example.com");
|
||||||
|
expect(a?.getAttribute("target")).toBe("_blank");
|
||||||
|
expect(a?.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a javascript: link href (stored XSS) while keeping the text", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("click", [
|
||||||
|
{ type: "link", attrs: { href: "javascript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const a = container.querySelector("a");
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
// No navigable javascript: href — attribute is absent (or empty).
|
||||||
|
expect(a?.getAttribute("href")).toBeFalsy();
|
||||||
|
// The link text is still rendered.
|
||||||
|
expect(a?.textContent).toBe("click");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a control-char-obfuscated javascript: href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{ type: "link", attrs: { href: "java\tscript:alert(1)" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes a data: link href", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("x", [
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
attrs: { href: "data:text/html,<script>alert(1)</script>" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a mailto: link href (allowlisted scheme)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("mail", [
|
||||||
|
{ type: "link", attrs: { href: "mailto:a@b.com" } },
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"mailto:a@b.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a relative link href (no scheme, not a script vector)", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
text("rel", [{ type: "link", attrs: { href: "/some/path" } }]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||||
|
"/some/path",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nests multiple marks on one text node", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("x", [{ type: "bold" }, { type: "italic" }])])]),
|
||||||
|
);
|
||||||
|
// bold wraps italic (or vice versa) — both elements exist around the text.
|
||||||
|
expect(container.querySelector("strong")).not.toBeNull();
|
||||||
|
expect(container.querySelector("em")).not.toBeNull();
|
||||||
|
expect(screen.getByText("x")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hardBreak as <br/>", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([para([text("a"), { type: "hardBreak" }, text("b")])]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("br")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a user mention as a styled span", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: { label: "Alice", entityType: "user", entityId: "u1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("@Alice")).toBeDefined();
|
||||||
|
// No fallback to the editor.
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a page mention as a link", () => {
|
||||||
|
const { container } = renderView(
|
||||||
|
doc([
|
||||||
|
para([
|
||||||
|
{
|
||||||
|
type: "mention",
|
||||||
|
attrs: {
|
||||||
|
label: "Some Page",
|
||||||
|
entityType: "page",
|
||||||
|
slugId: "pg1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(container.querySelector("a")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Some Page")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a legacy plain-text (non-JSON) string as plain text", () => {
|
||||||
|
renderView("just a legacy string");
|
||||||
|
expect(screen.getByText("just a legacy string")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for an unknown node type", () => {
|
||||||
|
renderView(doc([{ type: "codeBlock", content: [text("x")] }]));
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to CommentEditor for malformed JSON", () => {
|
||||||
|
renderView('{"type":"doc","content":[');
|
||||||
|
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from "react";
|
||||||
|
import classes from "./comment.module.css";
|
||||||
|
import { MentionContent } from "@/features/editor/components/mention/mention-view";
|
||||||
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
|
||||||
|
// Static, editor-free renderer of a comment body (ProseMirror JSON). It walks the
|
||||||
|
// document and emits plain DOM, avoiding the cost of a full TipTap/ProseMirror
|
||||||
|
// instance per comment (the panel used to spin up 400+ editors on mount).
|
||||||
|
//
|
||||||
|
// The supported node/mark set MUST mirror what CommentEditor enables
|
||||||
|
// (StarterKit + Mention + LinkExtension). Anything outside that set makes the
|
||||||
|
// whole comment degrade to the read-only CommentEditor via the fallback below,
|
||||||
|
// so we never show a half-rendered comment.
|
||||||
|
|
||||||
|
// Sentinel thrown when we hit a node/mark we don't know how to render statically.
|
||||||
|
// Caught at the top level to trigger the CommentEditor fallback for the whole comment.
|
||||||
|
class UnknownNodeError extends Error {}
|
||||||
|
|
||||||
|
// Protocol allowlist mirroring @tiptap/extension-link's default (the read-only
|
||||||
|
// CommentEditor path relies on it to blank javascript:/data: hrefs). The static
|
||||||
|
// renderer must apply the SAME sanitization because the backend stores comment
|
||||||
|
// content verbatim and React does not neutralize javascript: in an href.
|
||||||
|
const ALLOWED_URI_SCHEMES = /^(?:https?|ftps?|mailto|tel|callto|sms|cid|xmpp):/i;
|
||||||
|
|
||||||
|
function safeHref(href: unknown): string | undefined {
|
||||||
|
if (typeof href !== "string") return undefined;
|
||||||
|
// Strip control chars/whitespace that could smuggle a scheme past the test
|
||||||
|
// (e.g. "java\tscript:").
|
||||||
|
const cleaned = href.replace(/[\u0000-\u0020]/g, "").trim();
|
||||||
|
// Allow relative/anchor/protocol-relative links (no scheme) — not script vectors.
|
||||||
|
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return href;
|
||||||
|
return ALLOWED_URI_SCHEMES.test(cleaned) ? href : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMMark {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PMNode {
|
||||||
|
type: string;
|
||||||
|
attrs?: Record<string, any>;
|
||||||
|
content?: PMNode[];
|
||||||
|
text?: string;
|
||||||
|
marks?: PMMark[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap a text node's string in its marks (marks nest, e.g. bold + italic).
|
||||||
|
function renderMarks(
|
||||||
|
text: React.ReactNode,
|
||||||
|
marks: PMMark[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!marks || marks.length === 0) return text;
|
||||||
|
|
||||||
|
return marks.reduce<React.ReactNode>((acc, mark, i) => {
|
||||||
|
const key = `${keyPrefix}-m${i}`;
|
||||||
|
switch (mark.type) {
|
||||||
|
case "bold":
|
||||||
|
return <strong key={key}>{acc}</strong>;
|
||||||
|
case "italic":
|
||||||
|
return <em key={key}>{acc}</em>;
|
||||||
|
case "strike":
|
||||||
|
return <s key={key}>{acc}</s>;
|
||||||
|
case "underline":
|
||||||
|
// StarterKit enables the Underline extension by default (Mod-u) and
|
||||||
|
// CommentEditor does not disable it, so real comments can carry this
|
||||||
|
// mark. Render it here rather than degrading the whole comment.
|
||||||
|
return <u key={key}>{acc}</u>;
|
||||||
|
case "code":
|
||||||
|
return <code key={key}>{acc}</code>;
|
||||||
|
case "link": {
|
||||||
|
// LinkExtension (TiptapLink) opens links in a new tab; keep the same
|
||||||
|
// safe rel semantics the editor produces. Sanitize the href against the
|
||||||
|
// extension's protocol allowlist — a disallowed scheme (javascript:,
|
||||||
|
// data:) yields undefined so the anchor is non-navigable but still shows
|
||||||
|
// its text, matching how extension-link blanks a bad href.
|
||||||
|
const href = safeHref(mark.attrs?.href);
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
>
|
||||||
|
{acc}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown mark type: ${mark.type}`);
|
||||||
|
}
|
||||||
|
}, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: PMNode, key: string): React.ReactNode {
|
||||||
|
switch (node.type) {
|
||||||
|
case "paragraph":
|
||||||
|
return <p key={key}>{renderChildren(node.content, key)}</p>;
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
{renderMarks(node.text ?? "", node.marks, key)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
case "hardBreak":
|
||||||
|
return <br key={key} />;
|
||||||
|
case "mention":
|
||||||
|
return (
|
||||||
|
<span key={key} style={{ display: "inline" }}>
|
||||||
|
<MentionContent attrs={node.attrs as any} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new UnknownNodeError(`Unknown node type: ${node.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChildren(
|
||||||
|
content: PMNode[] | undefined,
|
||||||
|
keyPrefix: string,
|
||||||
|
): React.ReactNode {
|
||||||
|
if (!content) return null;
|
||||||
|
return content.map((child, i) => renderNode(child, `${keyPrefix}-${i}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reproduce the exact DOM nesting the read-only CommentEditor renders so the
|
||||||
|
// scoped CSS in comment.module.css (which targets
|
||||||
|
// `.commentEditor .ProseMirror :global(.ProseMirror)` and `.ProseMirror p`)
|
||||||
|
// applies pixel-for-pixel. Read-only => no data-editable / data-surface attrs.
|
||||||
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={classes.commentEditor}>
|
||||||
|
<div className={classes.ProseMirror}>
|
||||||
|
<div className="ProseMirror">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentContentViewProps {
|
||||||
|
content: string | object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentContentView({ content }: CommentContentViewProps) {
|
||||||
|
// Degrade this single comment to the old editor-based render (safety valve).
|
||||||
|
const fallback = () => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn(
|
||||||
|
"CommentContentView: unsupported comment content, falling back to editor",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <CommentEditor defaultContent={content} editable={false} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
let doc: unknown = content;
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
try {
|
||||||
|
doc = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
const trimmed = content.trim();
|
||||||
|
// Looks like it was meant to be JSON but is malformed -> safety-valve fallback.
|
||||||
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
// Otherwise it's a legacy plain-text comment: render as a single paragraph.
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{content}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-stringified / legacy plain-text stored as a JSON string.
|
||||||
|
if (typeof doc === "string") {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<p>{doc}</p>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pmDoc = doc as PMNode;
|
||||||
|
if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
|
||||||
|
throw new UnknownNodeError("Not a ProseMirror doc");
|
||||||
|
}
|
||||||
|
return <Shell>{renderChildren(pmDoc.content, "n")}</Shell>;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnknownNodeError) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommentContentView;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
@@ -8,23 +8,74 @@ import { IComment } from "@/features/comment/types/comment.types";
|
|||||||
// The comment mutation hooks reach out to react-query/network — stub them so the
|
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
const applyMutateAsync = vi.fn();
|
const applyMutateAsync = vi.fn();
|
||||||
|
const dismissMutateAsync = vi.fn();
|
||||||
|
const updateMutateAsync = vi.fn();
|
||||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
|
||||||
useApplySuggestionMutation: () => ({
|
useApplySuggestionMutation: () => ({
|
||||||
mutateAsync: applyMutateAsync,
|
mutateAsync: applyMutateAsync,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
}),
|
}),
|
||||||
|
useDismissSuggestionMutation: () => ({
|
||||||
|
mutateAsync: dismissMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The document the mocked editor emits via onUpdate when the edit form is open.
|
||||||
|
// Duplicated inside the mock factory (below) to keep the factory self-contained.
|
||||||
|
const EDITED_DOC = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
|
||||||
default: () => <div data-testid="comment-editor" />,
|
// so the edit->save/cancel flow can be driven without a live editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
default: ({ onUpdate, onSave }: any) => (
|
||||||
|
<div data-testid="comment-editor">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-update"
|
||||||
|
onClick={() => onUpdate?.(doc)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="editor-emit-save"
|
||||||
|
onClick={() => onSave?.()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// CommentContentView (used for the read-only body) imports the mention view,
|
||||||
|
// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
|
||||||
|
// renders in isolation without the app entry side-effect.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import CommentListItem from "./comment-list-item";
|
import CommentListItem from "./comment-list-item";
|
||||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||||
({
|
({
|
||||||
@@ -38,14 +89,20 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
|||||||
...over,
|
...over,
|
||||||
}) as IComment;
|
}) as IComment;
|
||||||
|
|
||||||
function renderItem(comment: IComment, canEdit = true) {
|
function renderItem(
|
||||||
|
comment: IComment,
|
||||||
|
canEdit = true,
|
||||||
|
canComment = true,
|
||||||
|
userSpaceRole?: string,
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={comment}
|
comment={comment}
|
||||||
pageId="page-1"
|
pageId="page-1"
|
||||||
canComment={true}
|
canComment={canComment}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
@@ -108,10 +165,12 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||||
renderItem(suggestion(), true);
|
const { container } = renderItem(suggestion(), true);
|
||||||
// Old text appears both as the selection quote and as the struck diff row.
|
// Old text appears as the selection quote (a single unsplit Text node).
|
||||||
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
||||||
|
// so it is no longer a single text node — assert the concatenated content.
|
||||||
|
expect(container.textContent).toContain("new wording here");
|
||||||
// Apply button is present.
|
// Apply button is present.
|
||||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
// No Applied badge yet.
|
// No Applied badge yet.
|
||||||
@@ -119,9 +178,9 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hides the Apply button when canEdit is false", () => {
|
it("hides the Apply button when canEdit is false", () => {
|
||||||
renderItem(suggestion(), false);
|
const { container } = renderItem(suggestion(), false);
|
||||||
// Diff still renders...
|
// Diff still renders (as per-fragment spans, #331)...
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
expect(container.textContent).toContain("new wording here");
|
||||||
// ...but no Apply button.
|
// ...but no Apply button.
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -157,6 +216,65 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — dismiss suggestion (#329)", () => {
|
||||||
|
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||||
|
baseComment({
|
||||||
|
selection: "old wording here",
|
||||||
|
suggestedText: "new wording here",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
|
||||||
|
// regardless of who authored the comment; the tests below use it as the lever
|
||||||
|
// since the currentUser atom is unseeded (null) in this harness.
|
||||||
|
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
||||||
|
renderItem(suggestion(), false, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss when the viewer cannot comment", () => {
|
||||||
|
renderItem(suggestion(), false, false, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
|
||||||
|
// canComment=true but NOT a space admin and NOT the comment owner (the
|
||||||
|
// currentUser atom is null while the comment is authored by user-1), so the
|
||||||
|
// server would 403 a dismiss — the button must not be shown at all.
|
||||||
|
renderItem(suggestion(), false, true, "member");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss once the thread is resolved", () => {
|
||||||
|
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss (shows the Applied badge) once applied", () => {
|
||||||
|
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.getByText("Applied")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
|
||||||
|
dismissMutateAsync.mockClear();
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||||
|
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("canShowApply predicate", () => {
|
describe("canShowApply predicate", () => {
|
||||||
const c = (over?: Partial<IComment>): IComment =>
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
({ suggestedText: "x", ...over }) as IComment;
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
@@ -182,3 +300,161 @@ describe("canShowApply predicate", () => {
|
|||||||
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("canShowDismiss predicate", () => {
|
||||||
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
|
|
||||||
|
it("true when suggestion present, can comment, owner/admin, not applied/resolved, top-level", () => {
|
||||||
|
expect(canShowDismiss(c(), true, true)).toBe(true);
|
||||||
|
});
|
||||||
|
it("false without comment permission", () => {
|
||||||
|
expect(canShowDismiss(c(), false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when not owner and not admin (#338 F5)", () => {
|
||||||
|
expect(canShowDismiss(c(), true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when no suggestion", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestedText: null }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when already applied", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestionAppliedAt: new Date() }), true, true)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("false when resolved", () => {
|
||||||
|
expect(canShowDismiss(c({ resolvedAt: new Date() }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for a reply comment", () => {
|
||||||
|
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
|
||||||
|
const body = (t: string) =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The edit menu item is gated on the viewer owning the comment
|
||||||
|
// (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
|
||||||
|
// so seed localStorage to make the viewer the owner (creatorId "user-1").
|
||||||
|
beforeEach(() => {
|
||||||
|
updateMutateAsync.mockClear();
|
||||||
|
localStorage.setItem(
|
||||||
|
"currentUser",
|
||||||
|
JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openEditor() {
|
||||||
|
// Open the comment menu, then click "Edit comment" to toggle into edit mode.
|
||||||
|
fireEvent.click(screen.getByLabelText("Comment menu"));
|
||||||
|
fireEvent.click(await screen.findByText("Edit comment"));
|
||||||
|
// Edit form (mocked editor + actions) is now mounted.
|
||||||
|
await screen.findByTestId("comment-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("saves the edited content and, on cache update, shows the new body", async () => {
|
||||||
|
const { rerender } = renderItem(
|
||||||
|
baseComment({ content: body("original body") }),
|
||||||
|
);
|
||||||
|
// Static body first.
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Editor emits an update (populates editContentRef), then Save is clicked.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
// mutateAsync is called with the stringified edited doc.
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(EDITED_DOC),
|
||||||
|
});
|
||||||
|
|
||||||
|
// On success the form closes (isEditing -> false); the static body renders
|
||||||
|
// from the comment.content prop again.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate the cache invalidation swapping in a new comment object with the
|
||||||
|
// updated content — the static body reflects it.
|
||||||
|
rerender(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentListItem
|
||||||
|
comment={baseComment({ content: body("updated body after save") })}
|
||||||
|
pageId="page-1"
|
||||||
|
canComment={true}
|
||||||
|
canEdit={true}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("updated body after save")).toBeDefined();
|
||||||
|
expect(screen.queryByText("original body")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancel restores the static body and does not call the update mutation", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
await openEditor();
|
||||||
|
|
||||||
|
// Type something (editContentRef set), then cancel.
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
// Editor unmounts, static body restored, no save happened.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(screen.getByText("original body")).toBeDefined();
|
||||||
|
expect(updateMutateAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
|
||||||
|
renderItem(baseComment({ content: body("original body") }));
|
||||||
|
|
||||||
|
// Cancel path clears editContentRef...
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ...so re-opening and saving WITHOUT an update falls back to comment.content.
|
||||||
|
await openEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
content: JSON.stringify(body("original body")),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — read-only body renders statically", () => {
|
||||||
|
it("renders the comment body as static text without a TipTap editor", () => {
|
||||||
|
renderItem(
|
||||||
|
baseComment({
|
||||||
|
content: JSON.stringify({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Hello static world" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Body text is present...
|
||||||
|
expect(screen.getByText("Hello static world")).toBeDefined();
|
||||||
|
// ...and it did NOT go through the (mocked) CommentEditor instance.
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||||
|
import CommentContentView from "@/features/comment/components/comment-content-view";
|
||||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||||
@@ -13,11 +14,16 @@ import { useHover } from "@mantine/hooks";
|
|||||||
import {
|
import {
|
||||||
useApplySuggestionMutation,
|
useApplySuggestionMutation,
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
useResolveCommentMutation,
|
useResolveCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
computeSuggestionDiff,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -45,31 +51,43 @@ function CommentListItem({
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
const [content, setContent] = useState<string>(comment.content);
|
|
||||||
const editContentRef = useRef<any>(null);
|
const editContentRef = useRef<any>(null);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
const applySuggestionMutation = useApplySuggestionMutation();
|
const applySuggestionMutation = useApplySuggestionMutation();
|
||||||
|
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
useEffect(() => {
|
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
||||||
setContent(comment.content);
|
// fragments that actually changed get emphasised inside the red/green block,
|
||||||
}, [comment]);
|
// instead of striking through / greening the whole line. Memoised on the
|
||||||
|
// (selection, suggestedText) pair so it recomputes only when they change.
|
||||||
|
const suggestionDiff = useMemo(
|
||||||
|
() =>
|
||||||
|
comment.suggestedText != null
|
||||||
|
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
||||||
|
: null,
|
||||||
|
[comment.selection, comment.suggestedText],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
||||||
|
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
||||||
|
// render an action the server will 403.
|
||||||
|
const isOwnerOrAdmin =
|
||||||
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||||
|
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const commentToUpdate = {
|
const commentToUpdate = {
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
content: JSON.stringify(editContentRef.current ?? content),
|
content: JSON.stringify(editContentRef.current ?? comment.content),
|
||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
if (editContentRef.current) {
|
editContentRef.current = null;
|
||||||
setContent(editContentRef.current);
|
|
||||||
editContentRef.current = null;
|
|
||||||
}
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update comment:", error);
|
console.error("Failed to update comment:", error);
|
||||||
@@ -115,6 +133,19 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDismissSuggestion() {
|
||||||
|
try {
|
||||||
|
await dismissSuggestionMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Idempotent races are reconciled to success in the mutation's onError;
|
||||||
|
// anything else surfaces there as a notification.
|
||||||
|
console.error("Failed to dismiss suggestion:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
function handleCommentClick(comment: IComment) {
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||||
@@ -190,7 +221,7 @@ function CommentListItem({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
{isOwnerOrAdmin && (
|
||||||
<CommentMenu
|
<CommentMenu
|
||||||
onEditComment={handleEditToggle}
|
onEditComment={handleEditToggle}
|
||||||
onDeleteComment={handleDeleteComment}
|
onDeleteComment={handleDeleteComment}
|
||||||
@@ -236,12 +267,28 @@ function CommentListItem({
|
|||||||
{!comment.parentCommentId && comment.suggestedText && (
|
{!comment.parentCommentId && comment.suggestedText && (
|
||||||
<Box className={classes.suggestionBlock}>
|
<Box className={classes.suggestionBlock}>
|
||||||
{comment.selection && (
|
{comment.selection && (
|
||||||
|
// Old line: read as removed as a whole (line-through/red); only the
|
||||||
|
// changed fragments carry the extra intraline emphasis.
|
||||||
<Text size="xs" className={classes.suggestionOld}>
|
<Text size="xs" className={classes.suggestionOld}>
|
||||||
{comment.selection}
|
{suggestionDiff?.old.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" className={classes.suggestionNew}>
|
<Text size="xs" className={classes.suggestionNew}>
|
||||||
{comment.suggestedText}
|
{suggestionDiff?.new.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{comment.suggestionAppliedAt ? (
|
{comment.suggestionAppliedAt ? (
|
||||||
@@ -255,29 +302,53 @@ function CommentListItem({
|
|||||||
{t("Applied")}
|
{t("Applied")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
canShowApply(comment, canEdit) && (
|
(canShowApply(comment, canEdit) ||
|
||||||
<Button
|
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||||
size="compact-xs"
|
<Group gap="xs" mt={6}>
|
||||||
variant="light"
|
{canShowApply(comment, canEdit) && (
|
||||||
color="green"
|
<Button
|
||||||
mt={6}
|
size="compact-xs"
|
||||||
onClick={handleApplySuggestion}
|
variant="light"
|
||||||
loading={applySuggestionMutation.isPending}
|
color="green"
|
||||||
disabled={applySuggestionMutation.isPending}
|
onClick={handleApplySuggestion}
|
||||||
>
|
loading={applySuggestionMutation.isPending}
|
||||||
{t("Apply")}
|
disabled={
|
||||||
</Button>
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Apply")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Dismiss ("Не применять", #329): removes the suggestion
|
||||||
|
without changing the page text. Gated on canComment. */}
|
||||||
|
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleDismissSuggestion}
|
||||||
|
loading={dismissSuggestionMutation.isPending}
|
||||||
|
disabled={
|
||||||
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Dismiss")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<CommentEditor defaultContent={content} editable={false} />
|
<CommentContentView content={comment.content} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={content}
|
defaultContent={comment.content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||||
onSave={handleUpdateComment}
|
onSave={handleUpdateComment}
|
||||||
@@ -297,4 +368,6 @@ function CommentListItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CommentListItem;
|
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
|
||||||
|
// comment's object identity) re-renders that one thread, not all ~356 items.
|
||||||
|
export default React.memo(CommentListItem);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
|
||||||
|
// the lazy reply editor's mount transition can be observed without the editor.
|
||||||
|
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||||
|
default: () => <div data-testid="comment-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
|
||||||
|
// pulled in transitively so importing the module is side-effect free.
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
|
useSharePageQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
// space-query -> main.tsx (createRoot) is another module side effect; stub it.
|
||||||
|
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||||
|
useGetSpaceBySlugQuery: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildChildrenByParent,
|
||||||
|
CommentEditorWithActions,
|
||||||
|
} from "./comment-list-with-tabs";
|
||||||
|
|
||||||
|
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||||
|
({ id, parentCommentId }) as IComment;
|
||||||
|
|
||||||
|
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||||
|
it("returns an empty map for undefined or empty input", () => {
|
||||||
|
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||||
|
expect(buildChildrenByParent([]).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not index a top-level comment (parentCommentId null)", () => {
|
||||||
|
const map = buildChildrenByParent([c("p1", null)]);
|
||||||
|
expect(map.size).toBe(0);
|
||||||
|
expect(map.has("p1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups replies under the correct parent, including reply-to-reply nesting", () => {
|
||||||
|
const p1 = c("p1", null);
|
||||||
|
const r1 = c("r1", "p1");
|
||||||
|
const r2 = c("r2", "r1"); // a reply to a reply
|
||||||
|
const map = buildChildrenByParent([p1, r1, r2]);
|
||||||
|
expect(map.get("p1")).toEqual([r1]);
|
||||||
|
expect(map.get("r1")).toEqual([r2]);
|
||||||
|
// The top-level comment itself is never a key.
|
||||||
|
expect(map.has("p1") && map.get("p1")?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still groups a reply whose parent is not present in items", () => {
|
||||||
|
const orphan = c("o1", "missing-parent");
|
||||||
|
const map = buildChildrenByParent([orphan]);
|
||||||
|
expect(map.get("missing-parent")).toEqual([orphan]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves insertion order among sibling replies", () => {
|
||||||
|
const map = buildChildrenByParent([
|
||||||
|
c("a", "p1"),
|
||||||
|
c("b", "p1"),
|
||||||
|
c("d", "p1"),
|
||||||
|
]);
|
||||||
|
expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderReplyEditor() {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<CommentEditorWithActions commentId="c-1" onSave={vi.fn()} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CommentEditorWithActions — lazy reply editor activation", () => {
|
||||||
|
it("shows only the stub initially (no editor instance mounted)", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
expect(screen.getByRole("button")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
// The stub button is replaced by the editor subtree.
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor when the stub receives focus", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.focus(screen.getByRole("button"));
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts the editor on Enter keydown of the stub", () => {
|
||||||
|
renderReplyEditor();
|
||||||
|
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
|
||||||
|
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,6 @@ import CommentActions from "@/features/comment/components/comment-actions";
|
|||||||
import { useFocusWithin } from "@mantine/hooks";
|
import { useFocusWithin } from "@mantine/hooks";
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
|
|||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
|
||||||
|
// thread. Replies whose parent is not in `items` are still grouped under their
|
||||||
|
// parentCommentId (they simply won't be reached by the top-level walk).
|
||||||
|
// Exported for unit testing.
|
||||||
|
export function buildChildrenByParent(
|
||||||
|
items: IComment[] | undefined,
|
||||||
|
): Map<string, IComment[]> {
|
||||||
|
const m = new Map<string, IComment[]>();
|
||||||
|
for (const c of items ?? []) {
|
||||||
|
if (c.parentCommentId) {
|
||||||
|
const arr = m.get(c.parentCommentId);
|
||||||
|
if (arr) arr.push(c);
|
||||||
|
else m.set(c.parentCommentId, [c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -46,7 +63,9 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
isError,
|
isError,
|
||||||
} = useCommentsQuery({ pageId: page?.id });
|
} = useCommentsQuery({ pageId: page?.id });
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
const createCommentMutation = useCreateCommentMutation();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
// mutateAsync is a stable reference across renders; depend on it (not the
|
||||||
|
// mutation object) so the reply/comment callbacks stay stable.
|
||||||
|
const createCommentAsync = createCommentMutation.mutateAsync;
|
||||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canEdit = page?.permissions?.canEdit ?? false;
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
@@ -75,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
return { activeComments: active, resolvedComments: resolved };
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
}, [comments]);
|
}, [comments]);
|
||||||
|
|
||||||
|
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||||
|
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
||||||
|
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
||||||
|
const childrenByParent = useMemo(
|
||||||
|
() => buildChildrenByParent(comments?.items),
|
||||||
|
[comments?.items],
|
||||||
|
);
|
||||||
|
|
||||||
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||||
|
|
||||||
const handleAddPageComment = useCallback(
|
const handleAddPageComment = useCallback(
|
||||||
async (_commentId: string, content: string) => {
|
async (_commentId: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
setIsPageCommentLoading(true);
|
setIsPageCommentLoading(true);
|
||||||
const createdComment = await createCommentMutation.mutateAsync({
|
const createdComment = await createCommentAsync({
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
});
|
});
|
||||||
@@ -100,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
setIsPageCommentLoading(false);
|
setIsPageCommentLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
const handleAddReply = useCallback(
|
||||||
async (commentId: string, content: string) => {
|
async (commentId: string, content: string) => {
|
||||||
|
// Pending state lives inside CommentEditorWithActions so sending a reply
|
||||||
|
// does not churn renderComments and re-render the whole list.
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
|
||||||
const commentData = {
|
const commentData = {
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
parentCommentId: commentId,
|
parentCommentId: commentId,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
};
|
};
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
await createCommentAsync(commentData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to post comment:", error);
|
console.error("Failed to post comment:", error);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
const renderComments = useCallback(
|
||||||
@@ -143,7 +169,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={space?.membership?.role}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -158,16 +184,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<CommentEditorWithActions
|
<CommentEditorWithActions
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
onSave={handleAddReply}
|
onSave={handleAddReply}
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
comments,
|
childrenByParent,
|
||||||
handleAddReply,
|
handleAddReply,
|
||||||
isLoading,
|
page?.id,
|
||||||
space?.membership?.role,
|
space?.membership?.role,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -203,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="open"
|
defaultValue="open"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
// Default to not mounting an inactive tab (the heavy Resolved list stays
|
||||||
|
// unmounted while Open is shown). The Open panel overrides this with its
|
||||||
|
// own keepMounted (below) so an in-progress reply/edit draft survives an
|
||||||
|
// Open -> Resolved -> Open switch.
|
||||||
|
keepMounted={false}
|
||||||
style={{
|
style={{
|
||||||
flex: "1 1 auto",
|
flex: "1 1 auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -261,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<div style={{ paddingBottom: "8px" }}>
|
<div style={{ paddingBottom: "8px" }}>
|
||||||
<Tabs.Panel value="open" pt="xs">
|
{/* keepMounted keeps the Open panel alive even while Resolved is
|
||||||
|
active, so a lazily-mounted reply editor's draft (and an
|
||||||
|
in-progress edit) is not discarded on tab switch. */}
|
||||||
|
<Tabs.Panel value="open" pt="xs" keepMounted>
|
||||||
{activeComments.length === 0 ? (
|
{activeComments.length === 0 ? (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
@@ -307,7 +340,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ChildCommentsProps {
|
interface ChildCommentsProps {
|
||||||
comments: IPagination<IComment>;
|
childrenByParent: Map<string, IComment[]>;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
@@ -315,24 +348,18 @@ interface ChildCommentsProps {
|
|||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
const ChildComments = ({
|
const ChildComments = ({
|
||||||
comments,
|
childrenByParent,
|
||||||
parentId,
|
parentId,
|
||||||
pageId,
|
pageId,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
userSpaceRole,
|
userSpaceRole,
|
||||||
}: ChildCommentsProps) => {
|
}: ChildCommentsProps) => {
|
||||||
const getChildComments = useCallback(
|
const children = childrenByParent.get(parentId) ?? [];
|
||||||
(parentId: string) =>
|
|
||||||
comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === parentId,
|
|
||||||
),
|
|
||||||
[comments.items],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{getChildComments(parentId).map((childComment) => (
|
{children.map((childComment) => (
|
||||||
<div key={childComment.id}>
|
<div key={childComment.id}>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={childComment}
|
comment={childComment}
|
||||||
@@ -342,7 +369,7 @@ const ChildComments = ({
|
|||||||
userSpaceRole={userSpaceRole}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={childComment.id}
|
parentId={childComment.id}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -357,22 +384,61 @@ const ChildComments = ({
|
|||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
const MemoizedChildComments = memo(ChildComments);
|
||||||
|
|
||||||
const CommentEditorWithActions = ({
|
export const CommentEditorWithActions = ({
|
||||||
commentId,
|
commentId,
|
||||||
onSave,
|
onSave,
|
||||||
isLoading,
|
|
||||||
placeholder = undefined,
|
placeholder = undefined,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Lazily mount the TipTap reply editor: until the user interacts with the
|
||||||
|
// stub, no editor instance is created for this thread. Once mounted it stays
|
||||||
|
// mounted so the draft is preserved.
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
const { ref, focused } = useFocusWithin();
|
const { ref, focused } = useFocusWithin();
|
||||||
const commentEditorRef = useRef(null);
|
const commentEditorRef = useRef(null);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const activate = useCallback(() => setMounted(true), []);
|
||||||
onSave(commentId, content);
|
|
||||||
setContent("");
|
const handleSave = useCallback(async () => {
|
||||||
commentEditorRef.current?.clearContent();
|
try {
|
||||||
|
setIsSending(true);
|
||||||
|
await onSave(commentId, content);
|
||||||
|
setContent("");
|
||||||
|
commentEditorRef.current?.clearContent();
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
}, [commentId, content, onSave]);
|
}, [commentId, content, onSave]);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={activate}
|
||||||
|
onFocus={activate}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px",
|
||||||
|
fontSize: "var(--mantine-font-size-sm)",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: "var(--mantine-color-placeholder)",
|
||||||
|
cursor: "text",
|
||||||
|
borderRadius: "var(--mantine-radius-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{placeholder || t("Reply...")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -381,8 +447,9 @@ const CommentEditorWithActions = ({
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
editable={true}
|
editable={true}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,21 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Intraline diff (#331): the fragment that actually changed within the
|
||||||
|
red "before" / green "after" block. It inherits the surrounding red/green
|
||||||
|
framing and adds a stronger tint plus bold weight so the eye lands on the
|
||||||
|
changed letters/words (git/GitHub-style) rather than the whole line. The
|
||||||
|
container's line-through (old) / green (new) still marks the full line. */
|
||||||
|
.suggestionChanged {
|
||||||
|
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
||||||
|
within the block. `currentColor` follows the parent's red (old) or green
|
||||||
|
(new) text colour. No `text-decoration` here on purpose: the old block's
|
||||||
|
inherited line-through must survive on the changed letters too. */
|
||||||
|
background: color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
InfiniteData,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for the ephemeral-suggestion (#329) cache reconciliation in
|
||||||
|
* useApplySuggestionMutation / useDismissSuggestionMutation: the mutations act on
|
||||||
|
* the server `outcome` — 'deleted' drops the comment from the local list,
|
||||||
|
* 'resolved' relocates it (by stamping resolvedAt, which the tabs split on).
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
applySuggestion: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
updateComment: vi.fn(),
|
||||||
|
deleteComment: vi.fn(),
|
||||||
|
resolveComment: vi.fn(),
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import {
|
||||||
|
applySuggestion,
|
||||||
|
dismissSuggestion,
|
||||||
|
} from "@/features/comment/services/comment-service";
|
||||||
|
import {
|
||||||
|
useApplySuggestionMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
|
RQ_KEY,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
const PAGE_ID = "page-1";
|
||||||
|
|
||||||
|
function seededClient(comment: IComment) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
const seed: InfiniteData<any> = {
|
||||||
|
pageParams: [undefined],
|
||||||
|
pages: [{ items: [comment], meta: { hasNextPage: false, nextCursor: null } }],
|
||||||
|
};
|
||||||
|
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return { queryClient, wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(queryClient: QueryClient): IComment[] {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||||
|
| InfiniteData<any>
|
||||||
|
| undefined;
|
||||||
|
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
content: "{}",
|
||||||
|
creatorId: "u-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
suggestedText: "new",
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
describe("useApplySuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt/applied fields", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
suggestionAppliedAt: resolvedAt,
|
||||||
|
suggestionAppliedById: "u-1",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
expect(list[0].suggestionAppliedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDismissSuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
// mutateAsync rejects even though onError reconciles the cache; swallow it.
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast, not just
|
||||||
|
// silently drop the comment.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismiss 400 (thread still alive) → NOT a success, comment kept, no green toast (#338 F2)", async () => {
|
||||||
|
// 400 means the thread is alive (already resolved / a reply raced in).
|
||||||
|
// Narrowed onError: only 404 is a success-noop; 400 must surface a real error
|
||||||
|
// and keep the comment in the cache.
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 400 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Comment NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// A real (red) error, never the success message.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ color: "red" }),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
// After #329 an applied reply-less suggestion is hard-deleted, so a racing
|
||||||
|
// second apply hits 404 — must reconcile to success like dismiss, not a red
|
||||||
|
// error (restores the #315 apply idempotency).
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY 400 (thread resolved, not applied) → NOT a success, comment kept, red error (#338 F2)", async () => {
|
||||||
|
// apply's only 400 is "Cannot apply … on a resolved comment thread" — the
|
||||||
|
// thread was resolved (often with discussion) but NOT applied. It must be a
|
||||||
|
// real error surfacing the server message, and must NOT drop the live thread.
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// The live thread is NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// Surfaces the server's specific message as a red error, never a success.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
color: "red",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
applySuggestion,
|
applySuggestion,
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
dismissSuggestion,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
resolveComment,
|
resolveComment,
|
||||||
updateComment,
|
updateComment,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
@@ -51,7 +53,10 @@ export function useCommentsQuery(params: ICommentParams) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
isLoading: query.isLoading || query.hasNextPage,
|
// Paint the first page as soon as it arrives instead of blocking until every
|
||||||
|
// page has loaded; the background effect above keeps streaming the rest
|
||||||
|
// (tab counts grow as pages arrive).
|
||||||
|
isLoading: query.isLoading,
|
||||||
isError: query.isError,
|
isError: query.isError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -177,40 +182,121 @@ function updateCommentInCache(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeCommentFromCache(
|
||||||
|
cache: InfiniteData<IPagination<IComment>>,
|
||||||
|
commentId: string,
|
||||||
|
): InfiniteData<IPagination<IComment>> {
|
||||||
|
return {
|
||||||
|
...cache,
|
||||||
|
pages: cache.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.filter((comment) => comment.id !== commentId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the local comment cache with an ephemeral-suggestion outcome (#329)
|
||||||
|
// returned by apply/dismiss: 'deleted' → drop the comment (it disappeared);
|
||||||
|
// 'resolved' → the thread had replies and was resolved, so carry the resolved
|
||||||
|
// state through (which relocates it to the resolved tab).
|
||||||
|
function applySuggestionOutcomeToCache(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
pageId: string,
|
||||||
|
commentId: string,
|
||||||
|
data: ISuggestionOutcome,
|
||||||
|
) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (!cache) return;
|
||||||
|
|
||||||
|
if (data.outcome === "deleted") {
|
||||||
|
queryClient.setQueryData(RQ_KEY(pageId), removeCommentFromCache(cache, commentId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'resolved' (or an older server that omits outcome): reflect the resolved
|
||||||
|
// state and the applied stamps (apply sets them; dismiss leaves them null).
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(pageId),
|
||||||
|
updateCommentInCache(cache, commentId, (comment) => ({
|
||||||
|
...comment,
|
||||||
|
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||||
|
suggestionAppliedById: data.suggestionAppliedById,
|
||||||
|
resolvedAt: data.resolvedAt,
|
||||||
|
resolvedById: data.resolvedById,
|
||||||
|
resolvedBy: data.resolvedBy,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useApplySuggestionMutation() {
|
export function useApplySuggestionMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IComment, any, { commentId: string; pageId: string }>({
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
||||||
// so we only mutate the cache once the server confirms.
|
// so we only mutate the cache once the server confirms.
|
||||||
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
const cache = queryClient.getQueryData(
|
// Ephemeral (#329): the server hard-deletes the applied suggestion when the
|
||||||
RQ_KEY(variables.pageId),
|
// thread has no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
if (cache) {
|
variables.pageId,
|
||||||
queryClient.setQueryData(
|
variables.commentId,
|
||||||
RQ_KEY(variables.pageId),
|
data,
|
||||||
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
);
|
||||||
...comment,
|
|
||||||
suggestionAppliedAt: data.suggestionAppliedAt,
|
|
||||||
suggestionAppliedById: data.suggestionAppliedById,
|
|
||||||
// The server auto-resolves the thread on apply — carry that through.
|
|
||||||
resolvedAt: data.resolvedAt,
|
|
||||||
resolvedById: data.resolvedById,
|
|
||||||
resolvedBy: data.resolvedBy,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({ message: t("Suggestion applied") });
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any, variables) => {
|
||||||
|
const status = err?.response?.status;
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): after #329 an applied
|
||||||
|
// reply-less suggestion is hard-deleted, so a second/racing apply hits 404
|
||||||
|
// (already gone). ONLY 404 is a real success-noop — drop it from the cache
|
||||||
|
// and report success, the user's intent is already satisfied (restores the
|
||||||
|
// #315 apply idempotency the ephemeral delete would otherwise break).
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): apply's only 400 is "Cannot apply … on a
|
||||||
|
// resolved comment thread" — the thread was resolved (often WITH a live
|
||||||
|
// discussion) but the edit was NOT applied. Treating it as "Suggestion
|
||||||
|
// applied" is a false success that also drops a live thread from the cache.
|
||||||
|
// The #315 idempotent repeat does NOT produce 400 (childless → 404;
|
||||||
|
// with-replies → 200), so we never lose idempotency by excluding it here.
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 400 => the thread was resolved and the edit could not be applied. Show a
|
||||||
|
// real error and KEEP the comment in the cache (it is still alive). Prefer
|
||||||
|
// the server's specific message when it carries one.
|
||||||
|
if (status === 400) {
|
||||||
|
const serverMsg = err?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
typeof serverMsg === "string" && serverMsg.length > 0
|
||||||
|
? serverMsg
|
||||||
|
: t("Failed to apply suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 409 => the commented text changed since the suggestion was made. Surface
|
// 409 => the commented text changed since the suggestion was made. Surface
|
||||||
// a specific message (with the current text) rather than a generic error.
|
// a specific message (with the current text) rather than a generic error.
|
||||||
const status = err?.response?.status;
|
|
||||||
const currentText = err?.response?.data?.currentText;
|
const currentText = err?.response?.data?.currentText;
|
||||||
if (status === 409 && typeof currentText === "string") {
|
if (status === 409 && typeof currentText === "string") {
|
||||||
const shortText =
|
const shortText =
|
||||||
@@ -234,6 +320,58 @@ export function useApplySuggestionMutation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDismissSuggestionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ commentId }) => dismissSuggestion(commentId),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Ephemeral (#329): dismiss hard-deletes the suggestion when the thread has
|
||||||
|
// no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
|
variables.pageId,
|
||||||
|
variables.commentId,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
},
|
||||||
|
onError: (err: any, variables) => {
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): the comment is already
|
||||||
|
// gone (404). ONLY 404 is a real success-noop — drop it from the cache and
|
||||||
|
// report success, the user's intent (make it disappear) is satisfied.
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): it means the thread is still ALIVE (already
|
||||||
|
// resolved, or a reply raced in), so treating it as "dismissed" would drop
|
||||||
|
// a live thread from the cache. Show a real error and keep the comment.
|
||||||
|
const status = err?.response?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to dismiss suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
export function useResolveCommentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
@@ -18,13 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applySuggestion(commentId: string): Promise<IComment> {
|
export async function applySuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
||||||
// the 409 body (`{ message, currentText }`) off err.response.data.
|
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||||
const req = await api.post("/comments/apply-suggestion", { commentId });
|
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||||
return req.data.data ?? req.data;
|
return req.data.data ?? req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function dismissSuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
|
// Dismiss ("Не применять") a suggested edit (#329): the server hard-deletes
|
||||||
|
// the comment (or resolves it when it has replies) and returns the outcome.
|
||||||
|
const req = await api.post("/comments/dismiss-suggestion", { commentId });
|
||||||
|
return req.data.data ?? req.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateComment(
|
export async function updateComment(
|
||||||
data: Partial<IComment>,
|
data: Partial<IComment>,
|
||||||
): Promise<IComment> {
|
): Promise<IComment> {
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export interface IResolveComment {
|
|||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Result of applying or dismissing an ephemeral suggested edit (#329). The
|
||||||
|
// server hard-deletes the comment (`deleted`) unless the thread has replies, in
|
||||||
|
// which case it is resolved (`resolved`). The returned comment fields carry the
|
||||||
|
// resolved-branch state; `outcome` tells the client which optimistic action to
|
||||||
|
// take (drop the comment vs. move it to the resolved tab).
|
||||||
|
export type ISuggestionOutcome = IComment & {
|
||||||
|
outcome?: "deleted" | "resolved";
|
||||||
|
};
|
||||||
|
|
||||||
export interface ICommentParams extends QueryParams {
|
export interface ICommentParams extends QueryParams {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
|
// Reconstruct the plain string from a segment stream — the diff must be
|
||||||
|
// lossless (concatenating every fragment yields the original input).
|
||||||
|
const join = (segments: Segment[]): string =>
|
||||||
|
segments.map((s) => s.text).join("");
|
||||||
|
|
||||||
|
// The subset of segments (in order) that the UI would emphasise.
|
||||||
|
const changed = (segments: Segment[]): string[] =>
|
||||||
|
segments.filter((s) => s.changed).map((s) => s.text);
|
||||||
|
|
||||||
|
// Find the segment that contains a substring, to assert its `changed` flag.
|
||||||
|
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
|
||||||
|
segments.find((s) => s.text.includes(needle));
|
||||||
|
|
||||||
|
describe("computeSuggestionDiff", () => {
|
||||||
|
it("highlights only the single changed letter in a one-letter edit", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("заведем");
|
||||||
|
expect(join(neu)).toBe("заведём");
|
||||||
|
|
||||||
|
// Old side: exactly the `е` is changed, the rest is common.
|
||||||
|
expect(changed(old)).toEqual(["е"]);
|
||||||
|
expect(old).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "е", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// New side: exactly the `ё` is changed.
|
||||||
|
expect(changed(neu)).toEqual(["ё"]);
|
||||||
|
expect(neu).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "ё", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the differing words changed but keeps the shared word common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff(
|
||||||
|
"привет мир",
|
||||||
|
"здравствуй мир",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("привет мир");
|
||||||
|
expect(join(neu)).toBe("здравствуй мир");
|
||||||
|
|
||||||
|
// The shared trailing word stays common on both sides (no per-letter noise
|
||||||
|
// leaking across the differing words into `мир`).
|
||||||
|
expect(segmentWith(old, "мир")?.changed).toBe(false);
|
||||||
|
expect(segmentWith(neu, "мир")?.changed).toBe(false);
|
||||||
|
|
||||||
|
// The differing words are emphasised somewhere on each side.
|
||||||
|
expect(changed(old).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(neu).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(old).join("")).toContain("п"); // from `привет`
|
||||||
|
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
|
||||||
|
|
||||||
|
// No changed fragment on either side touches the word `мир`.
|
||||||
|
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole inserted word changed and leaves the old line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a c");
|
||||||
|
expect(join(neu)).toBe("a b c");
|
||||||
|
|
||||||
|
// Old line has no changed fragment (nothing was removed).
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
// The inserted word is the only changed fragment on the new side.
|
||||||
|
expect(neu).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(neu)).toEqual(["b "]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole deleted word changed and leaves the new line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a b c");
|
||||||
|
expect(join(neu)).toBe("a c");
|
||||||
|
|
||||||
|
// The deleted word is the only changed fragment on the old side.
|
||||||
|
expect(old).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(old)).toEqual(["b "]);
|
||||||
|
// New line has no changed fragment (nothing was added).
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks everything common for identical strings", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
|
||||||
|
|
||||||
|
expect(old).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(neu).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { diffWordsWithSpace, diffChars } from "diff";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||||
@@ -12,3 +13,127 @@ export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
|||||||
!comment.parentCommentId,
|
!comment.parentCommentId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One contiguous run of text within a suggestion's "before" or "after" line.
|
||||||
|
// `changed` marks the fragment that actually differs from the other side, so
|
||||||
|
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
|
||||||
|
// the whole line.
|
||||||
|
export interface Segment {
|
||||||
|
text: string;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pure "before -> after" intraline diff (#331): the old line split into
|
||||||
|
// common vs. removed-and-changed fragments, and the new line split into common
|
||||||
|
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
|
||||||
|
// the original strings.
|
||||||
|
export interface SuggestionDiff {
|
||||||
|
old: Segment[];
|
||||||
|
new: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a segment, coalescing runs of the same `changed` flag on the same side
|
||||||
|
// so the render emits as few spans as possible and tests stay predictable.
|
||||||
|
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
|
||||||
|
if (text === "") return;
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last && last.changed === changed) {
|
||||||
|
last.text += text;
|
||||||
|
} else {
|
||||||
|
segments.push({ text, changed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute an intraline diff between the old `selection` and the new
|
||||||
|
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
|
||||||
|
//
|
||||||
|
// Hybrid word + char algorithm (per #331):
|
||||||
|
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
|
||||||
|
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
|
||||||
|
// `diffChars`: shared characters stay common, differing characters are
|
||||||
|
// marked `changed` on their respective side. This is what keeps a
|
||||||
|
// one-letter edit (заведем -> заведём) from highlighting the whole word.
|
||||||
|
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
|
||||||
|
// fragment `changed`.
|
||||||
|
// 4. An unchanged part is `common` on both sides.
|
||||||
|
//
|
||||||
|
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
|
||||||
|
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
|
||||||
|
export function computeSuggestionDiff(
|
||||||
|
oldStr: string,
|
||||||
|
newStr: string,
|
||||||
|
): SuggestionDiff {
|
||||||
|
const oldSegments: Segment[] = [];
|
||||||
|
const newSegments: Segment[] = [];
|
||||||
|
|
||||||
|
const parts = diffWordsWithSpace(oldStr, newStr);
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const next = parts[i + 1];
|
||||||
|
|
||||||
|
// A word replacement: a removed part immediately followed by an added part
|
||||||
|
// (or the reverse). Refine it character-by-character so only the differing
|
||||||
|
// letters are highlighted while shared letters stay common.
|
||||||
|
const isReplacementPair =
|
||||||
|
next &&
|
||||||
|
((part.removed && next.added) || (part.added && next.removed));
|
||||||
|
|
||||||
|
if (isReplacementPair) {
|
||||||
|
const removedPart = part.removed ? part : next;
|
||||||
|
const addedPart = part.added ? part : next;
|
||||||
|
|
||||||
|
const charParts = diffChars(removedPart.value, addedPart.value);
|
||||||
|
for (const cp of charParts) {
|
||||||
|
if (cp.added) {
|
||||||
|
pushSegment(newSegments, cp.value, true);
|
||||||
|
} else if (cp.removed) {
|
||||||
|
pushSegment(oldSegments, cp.value, true);
|
||||||
|
} else {
|
||||||
|
// Shared character: common on both sides.
|
||||||
|
pushSegment(oldSegments, cp.value, false);
|
||||||
|
pushSegment(newSegments, cp.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i++; // consume the paired part as well
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.added) {
|
||||||
|
// Lone insertion: only present in the new line, wholly changed.
|
||||||
|
pushSegment(newSegments, part.value, true);
|
||||||
|
} else if (part.removed) {
|
||||||
|
// Lone deletion: only present in the old line, wholly changed.
|
||||||
|
pushSegment(oldSegments, part.value, true);
|
||||||
|
} else {
|
||||||
|
// Unchanged: common on both sides.
|
||||||
|
pushSegment(oldSegments, part.value, false);
|
||||||
|
pushSegment(newSegments, part.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { old: oldSegments, new: newSegments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the suggested-edit (#329) "Не применять" (Dismiss) button should be
|
||||||
|
// shown. Dismiss does NOT change the page text (so it needs only canComment, not
|
||||||
|
// canEdit), BUT a childless dismiss IRREVERSIBLY hard-deletes the comment, so the
|
||||||
|
// server gates it on comment-owner-OR-space-admin (#338 F5). The button must
|
||||||
|
// mirror that authz or a non-owner non-admin sees a live Dismiss that always
|
||||||
|
// 403s → red error. Hence isOwnerOrAdmin is required IN ADDITION to canComment.
|
||||||
|
// Same not-applied/not-resolved/top-level conditions as Apply.
|
||||||
|
export function canShowDismiss(
|
||||||
|
comment: IComment,
|
||||||
|
canComment?: boolean,
|
||||||
|
isOwnerOrAdmin?: boolean,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
canComment &&
|
||||||
|
isOwnerOrAdmin &&
|
||||||
|
comment.suggestedText &&
|
||||||
|
!comment.suggestionAppliedAt &&
|
||||||
|
!comment.resolvedAt &&
|
||||||
|
!comment.parentCommentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { Editor } from "@tiptap/core";
|
// Type-only: these atoms only hold an Editor reference for typing. A value
|
||||||
|
// import would drag the whole @tiptap/core engine into the eager graph of every
|
||||||
|
// shell component that reads one of these atoms.
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
|
|
||||||
|
// Lazily load the drawio bubble menu so it is split out of the editor chunk and
|
||||||
|
// fetched only when an editable editor is mounted (mirrors excalidraw-menu-lazy).
|
||||||
|
const DrawioMenu = lazy(
|
||||||
|
() => import("@/features/editor/components/drawio/drawio-menu.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DrawioMenuLazy(props: EditorMenuProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DrawioMenu {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the drawio node view so the heavy react-drawio embed runtime is
|
||||||
|
// split into its own chunk and fetched only when a drawio diagram is actually
|
||||||
|
// rendered (mirrors excalidraw-view-lazy).
|
||||||
|
const DrawioView = lazy(
|
||||||
|
() => import("@/features/editor/components/drawio/drawio-view.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DrawioViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DrawioView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the KaTeX-backed block math view so the katex chunk is fetched
|
||||||
|
// only when a document actually contains a math node (mirrors the mermaid/
|
||||||
|
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||||
|
// crashing or blocking the whole editor: while it loads we render the raw
|
||||||
|
// LaTeX source as a node-sized placeholder.
|
||||||
|
const MathBlockView = lazy(
|
||||||
|
() => import("@/features/editor/components/math/math-block.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MathBlockViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div data-katex="true">{props.node.attrs.text}</div>}>
|
||||||
|
<MathBlockView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the KaTeX-backed inline math view so the katex chunk is fetched
|
||||||
|
// only when a document actually contains a math node (mirrors the mermaid/
|
||||||
|
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||||
|
// crashing or blocking the whole editor: while it loads we render the raw
|
||||||
|
// LaTeX source as a node-sized placeholder.
|
||||||
|
const MathInlineView = lazy(
|
||||||
|
() => import("@/features/editor/components/math/math-inline.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MathInlineViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<span data-katex="true">{props.node.attrs.text}</span>}>
|
||||||
|
<MathInlineView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,9 +11,19 @@ import {
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
interface MentionAttrs {
|
||||||
const { node } = props;
|
label?: string;
|
||||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
slugId?: string;
|
||||||
|
anchorId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentational mention renderer (no NodeViewWrapper). Shared by the editor
|
||||||
|
// NodeView (MentionView) and the static comment renderer (CommentContentView)
|
||||||
|
// so mention click/nav/icon behavior stays identical outside of an editor.
|
||||||
|
export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||||
|
const { label, entityType, slugId, anchorId } = attrs;
|
||||||
const isPageMention = entityType === "page";
|
const isPageMention = entityType === "page";
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
@@ -56,7 +66,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
<>
|
||||||
{entityType === "user" && (
|
{entityType === "user" && (
|
||||||
<Text className={classes.userMention} component="span">
|
<Text className={classes.userMention} component="span">
|
||||||
@{label}
|
@{label}
|
||||||
@@ -139,6 +149,14 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentionView(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||||
|
<MentionContent attrs={props.node.attrs} />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ import {
|
|||||||
createResizeHandle,
|
createResizeHandle,
|
||||||
buildResizeClasses,
|
buildResizeClasses,
|
||||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block-lazy.tsx";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
||||||
@@ -90,7 +90,7 @@ import VideoView from "@/features/editor/components/video/video-view.tsx";
|
|||||||
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { getDefaultStore } from "jotai";
|
import { getDefaultStore } from "jotai";
|
||||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
|
||||||
import { Editor } from "@tiptap/core";
|
// Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
|
||||||
|
// so this always-mounted global bridge does not statically import
|
||||||
|
// @hocuspocus/provider — that import pulls Yjs (and, through a shared chunk, the
|
||||||
|
// whole TipTap engine) into the eager startup graph. yjsConnectionStatusAtom
|
||||||
|
// already stores these raw status strings.
|
||||||
|
const YJS_STATUS_CONNECTED = "connected";
|
||||||
|
// Type-only: importing Editor as a type keeps @tiptap/core (the whole editor
|
||||||
|
// engine) out of the eager global-shell graph — the bridge only uses it for
|
||||||
|
// annotations/casts, never as a runtime value.
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -16,15 +25,19 @@ import {
|
|||||||
getSidebarPages,
|
getSidebarPages,
|
||||||
} from "@/features/page/services/page-service.ts";
|
} from "@/features/page/services/page-service.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import {
|
// Types are erased at build time, so importing them does not pull the module's
|
||||||
|
// runtime (which drags in @tiptap + the editor-ext barrel). The actual recording
|
||||||
|
// helpers are dynamically imported at call time inside createPageWithRecording,
|
||||||
|
// keeping the editor engine out of the eager global-shell startup graph — the
|
||||||
|
// bridge is mounted for every authenticated user but recording is a rare,
|
||||||
|
// native-host-driven action.
|
||||||
|
import type {
|
||||||
GitmostBridge,
|
GitmostBridge,
|
||||||
GitmostCreatePagePayload,
|
GitmostCreatePagePayload,
|
||||||
GitmostCreatePageResult,
|
GitmostCreatePageResult,
|
||||||
GitmostListPagesPayload,
|
GitmostListPagesPayload,
|
||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
|
||||||
gitmostUploadFileToEditor,
|
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
// How long to wait for a freshly-navigated page's editor to mount, become
|
// How long to wait for a freshly-navigated page's editor to mount, become
|
||||||
@@ -57,7 +70,7 @@ function gitmostWaitForEditor(
|
|||||||
!editor.isDestroyed &&
|
!editor.isDestroyed &&
|
||||||
editor.isEditable &&
|
editor.isEditable &&
|
||||||
editorPageId === pageId &&
|
editorPageId === pageId &&
|
||||||
yjsStatus === WebSocketStatus.Connected;
|
yjsStatus === YJS_STATUS_CONNECTED;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
resolve(editor);
|
resolve(editor);
|
||||||
return;
|
return;
|
||||||
@@ -171,6 +184,12 @@ export default function GitmostGlobalBridge() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the recording helpers on demand (see the import note above). This
|
||||||
|
// is the only place they are needed, so the @tiptap/editor-ext code they
|
||||||
|
// pull in stays out of the eager startup graph.
|
||||||
|
const { gitmostDecodePayloadToFile, gitmostUploadFileToEditor } =
|
||||||
|
await import("@/features/editor/gitmost/gitmost-recording.ts");
|
||||||
|
|
||||||
// Validate/decode the recording BEFORE creating the page so a bad
|
// Validate/decode the recording BEFORE creating the page so a bad
|
||||||
// payload never leaves an empty junk page behind. Per the createPage
|
// payload never leaves an empty junk page behind. Per the createPage
|
||||||
// error contract, any decode failure collapses to "insert-failed" (the
|
// error contract, any decode failure collapses to "insert-failed" (the
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu-lazy";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
import ShareShell from "@/features/share/components/share-shell.tsx";
|
||||||
|
|
||||||
export default function ShareLayout() {
|
export default function ShareLayout() {
|
||||||
return (
|
return (
|
||||||
<ShareShell>
|
<ShareShell>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</ShareShell>
|
</ShareShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
||||||
// polyfilled to support execCommand fallback
|
// polyfilled to support execCommand fallback
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { execCommandCopy } from "@docmost/editor-ext";
|
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
|
||||||
|
|
||||||
export type UseClipboardOptions = {
|
export type UseClipboardOptions = {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
import { castToBoolean } from "@/lib/utils.tsx";
|
import { castToBoolean } from "@/lib/utils.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { sanitizeUrl } from "@docmost/editor-ext";
|
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Client-local execCommand copy fallback (previously imported from
|
||||||
|
// @docmost/editor-ext). It lives here so the ubiquitous useClipboard / CopyButton
|
||||||
|
// path does not pull in the editor-ext barrel — and with it the whole TipTap
|
||||||
|
// engine — through the eager startup graph. Behavior is identical to the
|
||||||
|
// editor-ext helper it replaces.
|
||||||
|
export function execCommandCopy(text: string): void {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
textarea.style.top = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sanitizeUrl } from "./sanitize-url";
|
||||||
|
|
||||||
|
// `sanitizeUrl` is a byte-identical client-local copy of editor-ext's wrapper
|
||||||
|
// around @braintree/sanitize-url: it maps the sanitizer's "about:blank" XSS
|
||||||
|
// sentinel to "". These assertions mirror editor-ext's own security-contract
|
||||||
|
// test so the extracted copy keeps the same guarantees.
|
||||||
|
describe("sanitizeUrl", () => {
|
||||||
|
it("blocks dangerous schemes (returns empty string)", () => {
|
||||||
|
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
|
||||||
|
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
|
||||||
|
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
|
||||||
|
// Case / whitespace obfuscation must not slip past the sanitizer.
|
||||||
|
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty / undefined input", () => {
|
||||||
|
expect(sanitizeUrl(undefined)).toBe("");
|
||||||
|
expect(sanitizeUrl("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows safe https, relative file and mailto URLs", () => {
|
||||||
|
expect(sanitizeUrl("https://example.com/page")).toMatch(
|
||||||
|
/^https:\/\/example\.com\/page/,
|
||||||
|
);
|
||||||
|
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
|
||||||
|
expect(sanitizeUrl("mailto:user@example.com")).toBe(
|
||||||
|
"mailto:user@example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
|
||||||
|
|
||||||
|
// Client-local copy of editor-ext's sanitizeUrl wrapper. Importing it from the
|
||||||
|
// editor-ext barrel dragged the whole TipTap engine into the eager startup graph
|
||||||
|
// via the app-wide config module (getFileUrl). This keeps the exact same
|
||||||
|
// behavior (braintree sanitize + normalize "about:blank" -> "") without that
|
||||||
|
// dependency.
|
||||||
|
export function sanitizeUrl(url: string | undefined): string {
|
||||||
|
if (!url) return "";
|
||||||
|
|
||||||
|
const sanitized = braintreeSanitizeUrl(url);
|
||||||
|
|
||||||
|
// Return an empty string instead of "about:blank".
|
||||||
|
return sanitized === "about:blank" ? "" : sanitized;
|
||||||
|
}
|
||||||
+60
-27
@@ -13,15 +13,14 @@ import { ModalsProvider } from "@mantine/modals";
|
|||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
|
||||||
import {
|
import {
|
||||||
getPostHogHost,
|
getPostHogHost,
|
||||||
getPostHogKey,
|
getPostHogKey,
|
||||||
isCloud,
|
isCloud,
|
||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
import posthog from "posthog-js";
|
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -34,31 +33,65 @@ export const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCloud() && isPostHogEnabled) {
|
|
||||||
posthog.init(getPostHogKey(), {
|
|
||||||
api_host: getPostHogHost(),
|
|
||||||
defaults: "2025-05-24",
|
|
||||||
disable_session_recording: true,
|
|
||||||
capture_pageleave: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
root.render(
|
function renderApp() {
|
||||||
<BrowserRouter>
|
root.render(
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<BrowserRouter>
|
||||||
<ModalsProvider>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<ModalsProvider>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<QueryClientProvider client={queryClient}>
|
||||||
<HelmetProvider>
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
<PostHogProvider client={posthog}>
|
<HelmetProvider>
|
||||||
<App />
|
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||||
</PostHogProvider>
|
404 after a deploy is caught and recovered here instead of
|
||||||
</HelmetProvider>
|
blanking the whole app. */}
|
||||||
</QueryClientProvider>
|
<ChunkLoadErrorBoundary>
|
||||||
</ModalsProvider>
|
<App />
|
||||||
</MantineProvider>
|
</ChunkLoadErrorBoundary>
|
||||||
</BrowserRouter>,
|
</HelmetProvider>
|
||||||
);
|
</QueryClientProvider>
|
||||||
|
</ModalsProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</BrowserRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initAnalytics() {
|
||||||
|
// posthog-js is only pulled in for cloud deployments with analytics enabled, so
|
||||||
|
// self-hosted builds never download it. The gate is kept identical to the
|
||||||
|
// previous eager code so cloud analytics behavior is unchanged; the import is
|
||||||
|
// simply deferred behind it.
|
||||||
|
//
|
||||||
|
// Crucially this runs AFTER the immediate first render below, so first paint is
|
||||||
|
// never gated on the analytics chunk. Any failure (network, stale 404, or an
|
||||||
|
// ad-blocker blocking a chunk named "posthog") is swallowed so the user keeps a
|
||||||
|
// working app without analytics instead of a permanently blank page.
|
||||||
|
//
|
||||||
|
// NOTE: we init the posthog SINGLETON only and do NOT wrap the tree in
|
||||||
|
// <PostHogProvider>. The app has zero consumers of the PostHog React context
|
||||||
|
// (no usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given
|
||||||
|
// an already-initialized `client` is a no-op — all capture goes through the
|
||||||
|
// singleton. Re-rendering to attach the provider would only REMOUNT the whole
|
||||||
|
// App (running every mount effect twice and dropping local state / focus /
|
||||||
|
// in-progress input on cloud cold-load) for no functional gain.
|
||||||
|
if (!(isCloud() && isPostHogEnabled)) return;
|
||||||
|
try {
|
||||||
|
const { default: posthog } = await import("posthog-js");
|
||||||
|
posthog.init(getPostHogKey(), {
|
||||||
|
api_host: getPostHogHost(),
|
||||||
|
defaults: "2025-05-24",
|
||||||
|
disable_session_recording: true,
|
||||||
|
capture_pageleave: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Analytics failed to load — degrade gracefully; the app already rendered.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint immediately for everyone (self-hosted stays exactly as instant as before,
|
||||||
|
// cloud no longer blocks on the analytics import). The posthog singleton is
|
||||||
|
// initialized after, without re-rendering the tree.
|
||||||
|
renderApp();
|
||||||
|
void initAnalytics();
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ export default defineConfig(({ mode }) => {
|
|||||||
name: "vendor-mantine",
|
name: "vendor-mantine",
|
||||||
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
||||||
},
|
},
|
||||||
|
// NOTE: TipTap/ProseMirror/Yjs are intentionally NOT force-grouped
|
||||||
|
// into a single vendor chunk. Doing so backfires: rolldown co-locates
|
||||||
|
// a small module shared with the (eager) react-i18next runtime into
|
||||||
|
// that group chunk, which then drags the whole ~590KB editor engine
|
||||||
|
// into the eager modulepreload graph. Left to the default splitting,
|
||||||
|
// the editor engine stays in lazily-loaded chunks pulled only by the
|
||||||
|
// route-split editor/share pages. KaTeX is safe to group (nothing
|
||||||
|
// eager references it).
|
||||||
|
// KaTeX in its own stable chunk; loaded on demand by the lazy math
|
||||||
|
// node views (never in the startup path).
|
||||||
|
{
|
||||||
|
name: "vendor-katex",
|
||||||
|
test: /[\\/]node_modules[\\/]katex[\\/]/,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,5 +13,22 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
// Coverage gate (issue #324). v8 provider (not istanbul) so ESM barrels
|
||||||
|
// like `@docmost/editor-ext` are not re-parsed/instrumented. Thresholds are
|
||||||
|
// set a few points below the level measured on develop, scoped to the files
|
||||||
|
// the suite exercises (`all: false`) rather than the whole app, so the gate
|
||||||
|
// passes today but fails on a genuine coverage regression.
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text-summary', 'text'],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 55,
|
||||||
|
branches: 53,
|
||||||
|
functions: 44,
|
||||||
|
lines: 55,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,3 +130,59 @@ describe('CollaborationHandler.applyCommentSuggestion', () => {
|
|||||||
expect(value).toBe(42);
|
expect(value).toBe(42);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CollaborationHandler.deleteCommentMark', () => {
|
||||||
|
it('strips the comment mark for the given commentId (ephemeral suggestion #329)', async () => {
|
||||||
|
const doc = buildDocWithComment('Hello world', 'c1');
|
||||||
|
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||||
|
|
||||||
|
// The mark is gone; the text itself stays (deleting the anchor, not the run).
|
||||||
|
const xmlText = (
|
||||||
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||||
|
).get(0) as Y.XmlText;
|
||||||
|
expect(xmlText.toDelta()).toEqual([{ insert: 'Hello world' }]);
|
||||||
|
expect(connection.transact).toHaveBeenCalledTimes(1);
|
||||||
|
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes the removal through removeYjsMarkByAttribute with the right args', async () => {
|
||||||
|
const doc = buildDocWithComment('abc', 'c9');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const spy = jest.spyOn(yjsUtil, 'removeYjsMarkByAttribute');
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c9', user });
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
doc.getXmlFragment('default'),
|
||||||
|
'comment',
|
||||||
|
'commentId',
|
||||||
|
'c9',
|
||||||
|
);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a different comment\'s mark intact', async () => {
|
||||||
|
const doc = buildDocWithComment('keep me', 'other');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||||
|
|
||||||
|
const xmlText = (
|
||||||
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||||
|
).get(0) as Y.XmlText;
|
||||||
|
expect(xmlText.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'keep me',
|
||||||
|
attributes: { comment: { commentId: 'other', resolved: false } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
import {
|
import {
|
||||||
|
removeYjsMarkByAttribute,
|
||||||
replaceYjsMarkedText,
|
replaceYjsMarkedText,
|
||||||
setYjsMark,
|
setYjsMark,
|
||||||
updateYjsMarkAttribute,
|
updateYjsMarkAttribute,
|
||||||
@@ -78,6 +79,40 @@ export class CollaborationHandler {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
deleteCommentMark: async (
|
||||||
|
documentName: string,
|
||||||
|
payload: {
|
||||||
|
commentId: string;
|
||||||
|
user: User;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { commentId, user } = payload;
|
||||||
|
// Ephemeral suggestions (#329): when a suggestion-edit is dismissed or an
|
||||||
|
// applied one has no replies, the comment is hard-deleted and its inline
|
||||||
|
// anchor must vanish too. Mirror resolveCommentMark exactly, but instead
|
||||||
|
// of flipping the mark's `resolved` attribute we STRIP the `comment` mark
|
||||||
|
// entirely via removeYjsMarkByAttribute so no orphan highlight remains in
|
||||||
|
// the collaborative document.
|
||||||
|
//
|
||||||
|
// Routing this through collaboration.gateway's handleYjsEvent means the
|
||||||
|
// COLLAB_DISABLE_REDIS path invokes this handler directly (never a silent
|
||||||
|
// no-op) and a missing live instance is a hard error — the same guarantee
|
||||||
|
// applyCommentSuggestion/resolveCommentMark rely on.
|
||||||
|
await this.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
documentName,
|
||||||
|
{ user },
|
||||||
|
(doc) => {
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
removeYjsMarkByAttribute(
|
||||||
|
fragment,
|
||||||
|
'comment',
|
||||||
|
'commentId',
|
||||||
|
commentId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
applyCommentSuggestion: async (
|
applyCommentSuggestion: async (
|
||||||
documentName: string,
|
documentName: string,
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const AuditEvent = {
|
|||||||
COMMENT_RESOLVED: 'comment.resolved',
|
COMMENT_RESOLVED: 'comment.resolved',
|
||||||
COMMENT_REOPENED: 'comment.reopened',
|
COMMENT_REOPENED: 'comment.reopened',
|
||||||
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
||||||
|
COMMENT_SUGGESTION_DISMISSED: 'comment.suggestion_dismissed',
|
||||||
|
|
||||||
// Page
|
// Page
|
||||||
PAGE_CREATED: 'page.created',
|
PAGE_CREATED: 'page.created',
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { buildSystemPrompt, buildMcpToolingBlock } from './ai-chat.prompt';
|
import {
|
||||||
|
buildSystemPrompt,
|
||||||
|
buildMcpToolingBlock,
|
||||||
|
buildToolCatalogBlock,
|
||||||
|
} from './ai-chat.prompt';
|
||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -396,3 +400,62 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
|||||||
expect(opens).toBe(1);
|
expect(opens).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading — the <tool_catalog> block builder and its
|
||||||
|
* gating inside buildSystemPrompt.
|
||||||
|
*/
|
||||||
|
describe('buildToolCatalogBlock (#332)', () => {
|
||||||
|
const catalog = [
|
||||||
|
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||||
|
{ name: 'transformPage', catalogLine: 'transformPage — run a JS transform.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('renders nothing when the feature is disabled', () => {
|
||||||
|
expect(buildToolCatalogBlock(catalog, false)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when the catalog is empty', () => {
|
||||||
|
expect(buildToolCatalogBlock([], true)).toBe('');
|
||||||
|
expect(buildToolCatalogBlock(undefined, true)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the verbatim header + each deferred catalogLine when enabled', () => {
|
||||||
|
const block = buildToolCatalogBlock(catalog, true);
|
||||||
|
expect(block).toContain('<tool_catalog note="deferred tools;');
|
||||||
|
expect(block).toContain('NEVER tell the user you lack a capability');
|
||||||
|
expect(block).toContain('Deferred tools (name — purpose):');
|
||||||
|
expect(block).toContain('- createPage — create a new page.');
|
||||||
|
expect(block).toContain('- transformPage — run a JS transform.');
|
||||||
|
expect(block).toContain('</tool_catalog>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSystemPrompt <tool_catalog> gating (#332)', () => {
|
||||||
|
const workspace = { name: 'Acme' } as unknown as Workspace;
|
||||||
|
const catalog = [
|
||||||
|
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('omits the catalog when the toggle is off (unchanged behavior)', () => {
|
||||||
|
const prompt = buildSystemPrompt({
|
||||||
|
workspace,
|
||||||
|
deferredToolsEnabled: false,
|
||||||
|
toolCatalog: catalog,
|
||||||
|
});
|
||||||
|
expect(prompt).not.toContain('<tool_catalog');
|
||||||
|
expect(prompt).not.toContain('createPage — create a new page.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the catalog (deferred lines only) when enabled', () => {
|
||||||
|
const prompt = buildSystemPrompt({
|
||||||
|
workspace,
|
||||||
|
deferredToolsEnabled: true,
|
||||||
|
toolCatalog: catalog,
|
||||||
|
});
|
||||||
|
expect(prompt).toContain('<tool_catalog');
|
||||||
|
expect(prompt).toContain('createPage — create a new page.');
|
||||||
|
// A core tool line is never in the catalog (the caller passes deferred only).
|
||||||
|
expect(prompt).not.toContain('searchPages —');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Workspace } from '@docmost/db/types/entity.types';
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
||||||
|
import type { ToolCatalogEntry } from './tools/tool-tiers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default agent persona used when the admin has not configured a custom system
|
* Default agent persona used when the admin has not configured a custom system
|
||||||
@@ -183,6 +184,55 @@ export interface BuildSystemPromptInput {
|
|||||||
* block (unchanged page, page not open, or first turn).
|
* block (unchanged page, page not open, or first turn).
|
||||||
*/
|
*/
|
||||||
pageChanged?: { title: string; diff: string } | null;
|
pageChanged?: { title: string; diff: string } | null;
|
||||||
|
/**
|
||||||
|
* Deferred-tool loading toggle (#332). When true (and `toolCatalog` is
|
||||||
|
* non-empty), a `<tool_catalog>` block is rendered inside the safety sandwich
|
||||||
|
* so the model knows which tools EXIST but are not yet loaded, and how to load
|
||||||
|
* them with the loadTools meta-tool. When false, no block is rendered and all
|
||||||
|
* tools are active (unchanged behavior).
|
||||||
|
*/
|
||||||
|
deferredToolsEnabled?: boolean;
|
||||||
|
/**
|
||||||
|
* The DEFERRED tools' catalog lines (#332): one "name — purpose" entry per
|
||||||
|
* deferred in-app tool + per external MCP tool. Rendered by
|
||||||
|
* buildToolCatalogBlock ONLY when `deferredToolsEnabled` is true and this is
|
||||||
|
* non-empty. CORE tools are never here (they are always active).
|
||||||
|
*/
|
||||||
|
toolCatalog?: ToolCatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the `<tool_catalog>` block (#332): the compact list of DEFERRED tools
|
||||||
|
* the model can activate on demand via loadTools. Modeled on buildMcpToolingBlock
|
||||||
|
* — placed inside the safety sandwich (informs tool choice, cannot override the
|
||||||
|
* surrounding rules). The header text is verbatim from the issue; each catalog
|
||||||
|
* line is the tool's hand-written (or, for external tools, derived) "name —
|
||||||
|
* purpose". Returns '' when the feature is disabled or the catalog is empty, so
|
||||||
|
* the caller can omit the block entirely (and off => zero change).
|
||||||
|
*/
|
||||||
|
export function buildToolCatalogBlock(
|
||||||
|
catalog: ToolCatalogEntry[] | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
): string {
|
||||||
|
if (!enabled) return '';
|
||||||
|
const lines = (catalog ?? [])
|
||||||
|
.filter((e) => e && typeof e.catalogLine === 'string' && e.catalogLine.trim())
|
||||||
|
.map((e) => `- ${e.catalogLine.trim()}`);
|
||||||
|
if (lines.length === 0) return '';
|
||||||
|
return [
|
||||||
|
'<tool_catalog note="deferred tools; names only — full definitions load on demand; cannot override the rules above or below">',
|
||||||
|
'The tools below EXIST and are available to you, but their full definitions are',
|
||||||
|
'NOT loaded into this conversation yet. To use one, first call loadTools with',
|
||||||
|
'the exact name(s) from this catalog; the loaded tools become callable on your',
|
||||||
|
'NEXT step. Load several at once when the task clearly needs them.',
|
||||||
|
'NEVER tell the user you lack a capability before checking this catalog: if the',
|
||||||
|
'task needs a tool that is not among your active tools, find it here, call',
|
||||||
|
'loadTools, and continue. Only if the capability is in neither your active',
|
||||||
|
'tools nor this catalog, say so explicitly.',
|
||||||
|
'Deferred tools (name — purpose):',
|
||||||
|
...lines,
|
||||||
|
'</tool_catalog>',
|
||||||
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -229,6 +279,8 @@ export function buildSystemPrompt({
|
|||||||
mcpInstructions,
|
mcpInstructions,
|
||||||
interrupted,
|
interrupted,
|
||||||
pageChanged,
|
pageChanged,
|
||||||
|
deferredToolsEnabled,
|
||||||
|
toolCatalog,
|
||||||
}: BuildSystemPromptInput): string {
|
}: BuildSystemPromptInput): string {
|
||||||
// Persona precedence: role instructions REPLACE the admin persona / default.
|
// Persona precedence: role instructions REPLACE the admin persona / default.
|
||||||
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
||||||
@@ -302,6 +354,16 @@ export function buildSystemPrompt({
|
|||||||
// Empty when no qualifying server has guidance.
|
// Empty when no qualifying server has guidance.
|
||||||
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
|
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
|
||||||
|
|
||||||
|
// Deferred-tool catalog (#332). Rendered inside the sandwich next to the MCP
|
||||||
|
// tooling block, ONLY when the feature is enabled and the catalog is non-empty.
|
||||||
|
// Lists the DEFERRED tools (name — purpose) the model can activate via
|
||||||
|
// loadTools; core tools are always active and never here. Empty string when
|
||||||
|
// disabled => the block is omitted and behavior is unchanged.
|
||||||
|
const toolCatalogBlock = buildToolCatalogBlock(
|
||||||
|
toolCatalog,
|
||||||
|
deferredToolsEnabled === true,
|
||||||
|
);
|
||||||
|
|
||||||
// Sandwich the lower-trust persona/role text between two copies of the
|
// Sandwich the lower-trust persona/role text between two copies of the
|
||||||
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
||||||
// and followed by the safety rules. The persona is delimited with explicit
|
// and followed by the safety rules. The persona is delimited with explicit
|
||||||
@@ -316,6 +378,7 @@ export function buildSystemPrompt({
|
|||||||
'</role_persona>',
|
'</role_persona>',
|
||||||
context,
|
context,
|
||||||
mcpTooling,
|
mcpTooling,
|
||||||
|
toolCatalogBlock,
|
||||||
SAFETY_FRAMEWORK,
|
SAFETY_FRAMEWORK,
|
||||||
]
|
]
|
||||||
.filter((part) => part !== '')
|
.filter((part) => part !== '')
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe('AiChatService.resolveRoleForRequest', () => {
|
|||||||
aiAgentRoleRepo as never,
|
aiAgentRoleRepo as never,
|
||||||
{} as never, // pageRepo
|
{} as never, // pageRepo
|
||||||
{} as never, // pageAccess
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
);
|
);
|
||||||
return { service, aiChatRepo, aiAgentRoleRepo };
|
return { service, aiChatRepo, aiAgentRoleRepo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
|||||||
{} as never, // aiAgentRoleRepo
|
{} as never, // aiAgentRoleRepo
|
||||||
{} as never, // pageRepo
|
{} as never, // pageRepo
|
||||||
{} as never, // pageAccess
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
);
|
);
|
||||||
return { service, aiChatMessageRepo };
|
return { service, aiChatMessageRepo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,23 +217,78 @@ describe('rowToUiMessage', () => {
|
|||||||
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
||||||
* appended onto — not replacing — the original system prompt.
|
* appended onto — not replacing — the original system prompt.
|
||||||
*/
|
*/
|
||||||
|
// Narrowing helpers for the prepareAgentStep union return type.
|
||||||
|
const asLockdown = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||||
|
r as { toolChoice: 'none'; system: string };
|
||||||
|
const asActive = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||||
|
r as { activeTools: string[] };
|
||||||
|
|
||||||
describe('prepareAgentStep', () => {
|
describe('prepareAgentStep', () => {
|
||||||
it('returns undefined for the first step', () => {
|
// --- toggle OFF (default): unchanged behavior ---
|
||||||
|
it('returns undefined for the first step (toggle off)', () => {
|
||||||
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
|
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for a non-final step (just before the last)', () => {
|
it('returns undefined for a non-final step (toggle off)', () => {
|
||||||
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forces a text-only synthesis on the final allowed step', () => {
|
it('forces a text-only synthesis on the final allowed step (toggle off)', () => {
|
||||||
const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
|
const result = asLockdown(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result?.toolChoice).toBe('none');
|
expect(result.toolChoice).toBe('none');
|
||||||
// The original persona is preserved (prefix), not replaced.
|
// The original persona is preserved (prefix), not replaced.
|
||||||
expect(result?.system.startsWith('SYS')).toBe(true);
|
expect(result.system.startsWith('SYS')).toBe(true);
|
||||||
// The synthesis instruction is appended.
|
// The synthesis instruction is appended.
|
||||||
expect(result?.system).toContain(FINAL_STEP_INSTRUCTION);
|
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT narrow activeTools when the toggle is off', () => {
|
||||||
|
const result = prepareAgentStep(0, 'SYS', new Set(['createPage']), false);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- toggle ON (#332): deferred tool visibility ---
|
||||||
|
it('a non-final step exposes CORE + loadTools + activatedTools', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
const result = asActive(prepareAgentStep(0, 'SYS', activated, true));
|
||||||
|
expect(result.activeTools).toContain('searchPages'); // core
|
||||||
|
expect(result.activeTools).toContain('searchInPage'); // #330, core
|
||||||
|
expect(result.activeTools).toContain('editPageText'); // core
|
||||||
|
expect(result.activeTools).toContain('loadTools'); // meta-tool
|
||||||
|
// No deferred tool is active before it is loaded.
|
||||||
|
expect(result.activeTools).not.toContain('createPage');
|
||||||
|
expect(result.activeTools).not.toContain('transformPage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adding a name to activatedTools makes it appear on the next step', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
// Before loading: createPage is not active.
|
||||||
|
expect(
|
||||||
|
asActive(prepareAgentStep(1, 'SYS', activated, true)).activeTools,
|
||||||
|
).not.toContain('createPage');
|
||||||
|
// loadTools grows the SAME set…
|
||||||
|
activated.add('createPage');
|
||||||
|
// …so the next step sees it.
|
||||||
|
const next = asActive(prepareAgentStep(2, 'SYS', activated, true));
|
||||||
|
expect(next.activeTools).toContain('createPage');
|
||||||
|
expect(next.activeTools).toContain('loadTools');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an array for activatedTools too', () => {
|
||||||
|
const result = asActive(prepareAgentStep(0, 'SYS', ['transformPage'], true));
|
||||||
|
expect(result.activeTools).toContain('transformPage');
|
||||||
|
expect(result.activeTools).toContain('loadTools');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('final-step lockdown WINS even when the toggle is on', () => {
|
||||||
|
const result = asLockdown(
|
||||||
|
prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS', new Set(['createPage']), true),
|
||||||
|
);
|
||||||
|
// The lockdown shape (toolChoice none + synthesis) — not the activeTools shape.
|
||||||
|
expect(result.toolChoice).toBe('none');
|
||||||
|
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||||
|
expect((result as unknown as { activeTools?: string[] }).activeTools).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||||
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||||
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||||
|
import {
|
||||||
|
CORE_TOOL_KEYS,
|
||||||
|
CORE_TOOL_SET,
|
||||||
|
LOAD_TOOLS_NAME,
|
||||||
|
makeLoadToolsTool,
|
||||||
|
buildExternalToolCatalog,
|
||||||
|
} from './tools/tool-tiers';
|
||||||
import { computePageChange } from './page-change/page-change.util';
|
import { computePageChange } from './page-change/page-change.util';
|
||||||
import { roleModelOverride } from './roles/role-model-config';
|
import { roleModelOverride } from './roles/role-model-config';
|
||||||
import {
|
import {
|
||||||
@@ -54,24 +62,52 @@ const FINAL_STEP_INSTRUCTION =
|
|||||||
'language. If the information is incomplete, say so explicitly: summarize ' +
|
'language. If the information is incomplete, say so explicitly: summarize ' +
|
||||||
'what you found, what is still missing, and give your best partial conclusion.';
|
'what you found, what is still missing, and give your best partial conclusion.';
|
||||||
|
|
||||||
// Pure, unit-testable: decide per-step overrides. Returns undefined for normal
|
// Pure, unit-testable: decide per-step overrides. Two responsibilities:
|
||||||
// steps; on the final allowed step forces a text-only synthesis answer.
|
// 1. Final-step lockdown (always): on the final allowed step force a text-only
|
||||||
|
// synthesis answer (toolChoice 'none' + FINAL_STEP_INSTRUCTION). This WINS —
|
||||||
|
// it takes precedence over the deferred-tool narrowing below.
|
||||||
|
// 2. Deferred tool visibility (#332): when `deferredEnabled` and NOT the final
|
||||||
|
// step, expose only the CORE tools + loadTools + whatever loadTools has
|
||||||
|
// activated so far this turn (`activatedTools`), via `activeTools`. Deferred
|
||||||
|
// tools stay in the <tool_catalog> until the model loads them.
|
||||||
|
// When `deferredEnabled` is false the behavior is unchanged: undefined on normal
|
||||||
|
// steps (all tools active), lockdown on the final step.
|
||||||
|
//
|
||||||
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
// `system` is the in-scope system prompt; we CONCATENATE so the original
|
||||||
// persona/context is preserved — a bare `system` override would REPLACE the
|
// persona/context is preserved — a bare `system` override would REPLACE the
|
||||||
// whole system prompt for the step.
|
// whole system prompt for the step. `activatedTools` is PER-TURN mutable state
|
||||||
|
// owned by the streaming loop (a closure Set grown by loadTools); it is passed
|
||||||
|
// in (not module-global, not persisted) so this stays a pure function of its
|
||||||
|
// arguments.
|
||||||
//
|
//
|
||||||
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
// NOTE: at AI SDK v7 the per-step `system` field is renamed to `instructions`.
|
||||||
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
// On v6 (`^6.0.134`) `system` is the correct field — adjust when bumping.
|
||||||
export function prepareAgentStep(
|
export function prepareAgentStep(
|
||||||
stepNumber: number,
|
stepNumber: number,
|
||||||
system: string,
|
system: string,
|
||||||
): { toolChoice: 'none'; system: string } | undefined {
|
activatedTools: ReadonlySet<string> | readonly string[] = [],
|
||||||
|
deferredEnabled = false,
|
||||||
|
):
|
||||||
|
| { toolChoice: 'none'; system: string }
|
||||||
|
| { activeTools: string[] }
|
||||||
|
| undefined {
|
||||||
|
// Final-step lockdown WINS (applies regardless of the deferred toggle).
|
||||||
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
if (stepNumber >= MAX_AGENT_STEPS - 1) {
|
||||||
return {
|
return {
|
||||||
toolChoice: 'none',
|
toolChoice: 'none',
|
||||||
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
system: `${system}\n\n${FINAL_STEP_INSTRUCTION}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Deferred tool loading: narrow this step's visible tools to CORE + loadTools
|
||||||
|
// + the tools already activated this turn.
|
||||||
|
if (deferredEnabled) {
|
||||||
|
const activated = Array.isArray(activatedTools)
|
||||||
|
? activatedTools
|
||||||
|
: [...activatedTools];
|
||||||
|
return {
|
||||||
|
activeTools: [...CORE_TOOL_KEYS, LOAD_TOOLS_NAME, ...activated],
|
||||||
|
};
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +242,9 @@ export class AiChatService implements OnModuleInit {
|
|||||||
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
private readonly aiAgentRoleRepo: AiAgentRoleRepo,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
private readonly pageAccess: PageAccessService,
|
private readonly pageAccess: PageAccessService,
|
||||||
|
// Reads the AI_CHAT_DEFERRED_TOOLS toggle (#332). Injected last so existing
|
||||||
|
// positional constructor callers (tests) only append one stub.
|
||||||
|
private readonly environment: EnvironmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -625,9 +664,25 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// Build the system prompt + Docmost toolset. If either throws after the
|
// Build the system prompt + Docmost toolset. If either throws after the
|
||||||
// external MCP lease was taken above, release the lease before rethrowing so
|
// external MCP lease was taken above, release the lease before rethrowing so
|
||||||
// the leased transports are not leaked (#185 review).
|
// the leased transports are not leaked (#185 review).
|
||||||
|
// Deferred tool loading toggle (#332). When ON, the model sees a compact
|
||||||
|
// <tool_catalog> and only CORE tools + loadTools are active each step; other
|
||||||
|
// tools (fat/rare in-app tools + ALL external MCP tools) load on demand. When
|
||||||
|
// OFF, every tool is active and nothing below changes.
|
||||||
|
const deferredEnabled = this.environment.isAiChatDeferredToolsEnabled();
|
||||||
|
|
||||||
let system: string;
|
let system: string;
|
||||||
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
|
let docmostTools: Awaited<ReturnType<AiChatToolsService['forUser']>>;
|
||||||
try {
|
try {
|
||||||
|
// Assemble the deferred catalog for the system prompt: hand-written lines
|
||||||
|
// for the in-app deferred tools + a derived line for each external MCP tool
|
||||||
|
// (also deferred by default). Only built when the feature is enabled.
|
||||||
|
const toolCatalog = deferredEnabled
|
||||||
|
? [
|
||||||
|
...(await this.tools.getInAppDeferredCatalog()),
|
||||||
|
...buildExternalToolCatalog(external.tools),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
system = buildSystemPrompt({
|
system = buildSystemPrompt({
|
||||||
workspace,
|
workspace,
|
||||||
adminPrompt: resolved?.systemPrompt,
|
adminPrompt: resolved?.systemPrompt,
|
||||||
@@ -644,6 +699,10 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// Detected between-turns human edit to the open page (#274): adds the
|
// Detected between-turns human edit to the open page (#274): adds the
|
||||||
// page_changed note + unified diff so the agent doesn't overwrite it.
|
// page_changed note + unified diff so the agent doesn't overwrite it.
|
||||||
pageChanged,
|
pageChanged,
|
||||||
|
// Deferred tool loading (#332): renders the <tool_catalog> block (only
|
||||||
|
// when enabled + non-empty) so the model can activate deferred tools.
|
||||||
|
deferredToolsEnabled: deferredEnabled,
|
||||||
|
toolCatalog,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pass the resolved chatId so the write tools can mint provenance tokens
|
// Pass the resolved chatId so the write tools can mint provenance tokens
|
||||||
@@ -664,7 +723,31 @@ export class AiChatService implements OnModuleInit {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = { ...external.tools, ...docmostTools };
|
// Base toolset: external MCP tools + Docmost in-app tools (Docmost wins on a
|
||||||
|
// name clash — external are namespaced, so no clash is expected).
|
||||||
|
const baseTools = { ...external.tools, ...docmostTools };
|
||||||
|
|
||||||
|
// Deferred tool loading state (#332), scoped to THIS streaming loop:
|
||||||
|
// - `activatedTools` is per-TURN mutable state — a fresh closure Set created
|
||||||
|
// per streamText call, NOT module-global and NOT persisted, so a new turn
|
||||||
|
// starts cold. loadTools.execute adds to it; prepareAgentStep reads it to
|
||||||
|
// widen `activeTools` on the NEXT step.
|
||||||
|
// - `validDeferredNames` = every tool that is NOT core (the in-app deferred
|
||||||
|
// tools + ALL external MCP tools), computed from the ACTUAL toolset so an
|
||||||
|
// external tool is loadable by its namespaced name. loadTools rejects any
|
||||||
|
// name outside this set.
|
||||||
|
const activatedTools = new Set<string>();
|
||||||
|
const validDeferredNames = new Set<string>(
|
||||||
|
Object.keys(baseTools).filter((k) => !CORE_TOOL_SET.has(k)),
|
||||||
|
);
|
||||||
|
// Add the loadTools meta-tool ONLY when the feature is enabled; when off the
|
||||||
|
// toolset and behavior are exactly as before.
|
||||||
|
const tools = deferredEnabled
|
||||||
|
? {
|
||||||
|
...baseTools,
|
||||||
|
[LOAD_TOOLS_NAME]: makeLoadToolsTool(activatedTools, validDeferredNames),
|
||||||
|
}
|
||||||
|
: baseTools;
|
||||||
|
|
||||||
// Accumulate the turn's streamed output so a provider error / disconnect can
|
// Accumulate the turn's streamed output so a provider error / disconnect can
|
||||||
// persist the PARTIAL answer the user already saw — the SDK's onError/onAbort
|
// persist the PARTIAL answer the user already saw — the SDK's onError/onAbort
|
||||||
@@ -799,7 +882,8 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// ends with no assistant text (an empty turn). prepareAgentStep forbids
|
// ends with no assistant text (an empty turn). prepareAgentStep forbids
|
||||||
// further tool calls and appends a synthesis instruction on that step,
|
// further tool calls and appends a synthesis instruction on that step,
|
||||||
// concatenated onto the original `system` so the persona is preserved.
|
// concatenated onto the original `system` so the persona is preserved.
|
||||||
prepareStep: ({ stepNumber }) => prepareAgentStep(stepNumber, system),
|
prepareStep: ({ stepNumber }) =>
|
||||||
|
prepareAgentStep(stepNumber, system, activatedTools, deferredEnabled),
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
onChunk: ({ chunk }) => {
|
onChunk: ({ chunk }) => {
|
||||||
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
|
// DIAGNOSTIC (Safari stream-drop investigation) — temporary. Any model
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import { resolveCurrentPageResult } from './current-page.util';
|
|||||||
import { parseNodeArg } from './parse-node-arg';
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
import { modelFriendlyInput } from './model-friendly-input';
|
import { modelFriendlyInput } from './model-friendly-input';
|
||||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
||||||
|
import {
|
||||||
|
buildInAppDeferredCatalog,
|
||||||
|
type ToolCatalogEntry,
|
||||||
|
} from './tool-tiers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
||||||
@@ -123,6 +127,18 @@ export class AiChatToolsService {
|
|||||||
return client.exportPageMarkdown(pageId);
|
return client.exportPageMarkdown(pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the IN-APP deferred <tool_catalog> entries (#332): one "name — purpose"
|
||||||
|
* line per DEFERRED tool, merging the per-layer INLINE_TOOL_TIERS with the
|
||||||
|
* shared registry's own catalogLine. Loads @docmost/mcp for the shared specs
|
||||||
|
* (memoized). Core tools are always active and are NOT listed here. External
|
||||||
|
* MCP tools are catalogued separately by the caller (they are runtime-scoped).
|
||||||
|
*/
|
||||||
|
async getInAppDeferredCatalog(): Promise<ToolCatalogEntry[]> {
|
||||||
|
const { sharedToolSpecs } = await loadDocmostMcp();
|
||||||
|
return buildInAppDeferredCatalog(sharedToolSpecs);
|
||||||
|
}
|
||||||
|
|
||||||
async forUser(
|
async forUser(
|
||||||
user: User,
|
user: User,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -630,6 +646,16 @@ export class AiChatToolsService {
|
|||||||
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
searchInPage: sharedTool(
|
||||||
|
sharedToolSpecs.searchInPage,
|
||||||
|
async ({ pageId, query, regex, caseSensitive, limit }) =>
|
||||||
|
await client.searchInPage(pageId, query, {
|
||||||
|
regex,
|
||||||
|
caseSensitive,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
getTable: tool({
|
getTable: tool({
|
||||||
description:
|
description:
|
||||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||||
@@ -649,13 +675,21 @@ export class AiChatToolsService {
|
|||||||
|
|
||||||
listComments: tool({
|
listComments: tool({
|
||||||
description:
|
description:
|
||||||
'List ALL comments on a page in one call, including RESOLVED ' +
|
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
||||||
'threads — filter by resolvedAt when you need only open ones. ' +
|
'threads are returned; resolved threads (a resolved top-level ' +
|
||||||
'Content is returned as Markdown.',
|
'comment and all its replies) are hidden and their count reported ' +
|
||||||
|
'as `resolvedThreadsHidden` so you can re-query with ' +
|
||||||
|
'`includeResolved: true` to see everything. Returns ' +
|
||||||
|
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
||||||
inputSchema: modelFriendlyInput({
|
inputSchema: modelFriendlyInput({
|
||||||
pageId: z.string().describe('The id of the page.'),
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
includeResolved: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('default only active threads; true — include resolved'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ pageId }) => await client.listComments(pageId),
|
execute: async ({ pageId, includeResolved }) =>
|
||||||
|
await client.listComments(pageId, includeResolved),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getComment: tool({
|
getComment: tool({
|
||||||
|
|||||||
@@ -55,8 +55,18 @@ export interface DocmostClientLike {
|
|||||||
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
||||||
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
||||||
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
||||||
|
searchInPage(
|
||||||
|
pageId: string,
|
||||||
|
query: string,
|
||||||
|
opts?: { regex?: boolean; caseSensitive?: boolean; limit?: number },
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
||||||
listComments(pageId: string): Promise<unknown[]>;
|
// Returns `{ items, resolvedThreadsHidden }`. DEFAULT (includeResolved unset/
|
||||||
|
// false) hides resolved threads wholesale; pass true for the full feed.
|
||||||
|
listComments(
|
||||||
|
pageId: string,
|
||||||
|
includeResolved?: boolean,
|
||||||
|
): Promise<{ items: unknown[]; resolvedThreadsHidden: number }>;
|
||||||
getComment(
|
getComment(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||||
@@ -231,6 +241,11 @@ export interface SharedToolSpec {
|
|||||||
mcpName: string;
|
mcpName: string;
|
||||||
inAppKey: string;
|
inAppKey: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
// Deferred-tool metadata (#332). Optional in this mirror so an older/stale
|
||||||
|
// @docmost/mcp build (pre-#332) still type-checks; the in-app catalog builder
|
||||||
|
// reads them defensively. The external /mcp server ignores both fields.
|
||||||
|
tier?: 'core' | 'deferred';
|
||||||
|
catalogLine?: string;
|
||||||
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
|
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
|
||||||
// its own zod (v4) and the MCP package its own (v3) into the same builder.
|
// its own zod (v4) and the MCP package its own (v3) into the same builder.
|
||||||
buildShape?: (z: any) => Record<string, unknown>;
|
buildShape?: (z: any) => Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import {
|
||||||
|
CORE_TOOL_KEYS,
|
||||||
|
CORE_TOOL_SET,
|
||||||
|
LOAD_TOOLS_NAME,
|
||||||
|
LOAD_TOOLS_DESCRIPTION,
|
||||||
|
INLINE_TOOL_TIERS,
|
||||||
|
buildInAppDeferredCatalog,
|
||||||
|
buildExternalToolCatalog,
|
||||||
|
shortenForCatalog,
|
||||||
|
applyLoadTools,
|
||||||
|
} from './tool-tiers';
|
||||||
|
// The real shared registry, imported from source (same approach as the
|
||||||
|
// SHARED_TOOL_SPECS contract spec) so the tier metadata is checked against
|
||||||
|
// exactly what @docmost/mcp ships.
|
||||||
|
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
|
||||||
|
// For the live-toolset partition test (F3): the REAL adapter, so the catalog is
|
||||||
|
// checked against the tools AiChatToolsService.forUser() actually builds — not a
|
||||||
|
// static list that could drift from it.
|
||||||
|
import { AiChatToolsService } from './ai-chat-tools.service';
|
||||||
|
import * as loader from './docmost-client.loader';
|
||||||
|
import type { DocmostClientLike } from './docmost-client.loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading — tier metadata, catalog assembly, and the
|
||||||
|
* loadTools meta-tool. Pure units; no Nest graph, no @docmost/mcp build (the
|
||||||
|
* registry is imported from TS source).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('tool tier metadata (#332)', () => {
|
||||||
|
it('core set is the documented 13 + searchInPage (14)', () => {
|
||||||
|
expect(CORE_TOOL_KEYS).toHaveLength(14);
|
||||||
|
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
||||||
|
// loadTools is a meta-tool, not a normal core key.
|
||||||
|
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARED_TOOL_SPECS tier agrees with CORE_TOOL_SET for every shared tool', () => {
|
||||||
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||||
|
const isCoreByTier = spec.tier === 'core';
|
||||||
|
const isCoreByList = CORE_TOOL_SET.has(key);
|
||||||
|
expect(isCoreByTier).toBe(isCoreByList);
|
||||||
|
// Every spec carries a non-empty catalogLine (core tools too).
|
||||||
|
expect(typeof spec.catalogLine).toBe('string');
|
||||||
|
expect(spec.catalogLine.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every INLINE tool tier agrees with CORE_TOOL_SET and has a catalogLine', () => {
|
||||||
|
for (const [key, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||||
|
expect(meta.tier === 'core').toBe(CORE_TOOL_SET.has(key));
|
||||||
|
expect(meta.catalogLine.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildInAppDeferredCatalog (#332)', () => {
|
||||||
|
const catalog = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never);
|
||||||
|
const names = catalog.map((e) => e.name);
|
||||||
|
|
||||||
|
it('includes deferred tools from BOTH the inline map and the shared registry', () => {
|
||||||
|
expect(names).toContain('transformPage'); // inline deferred
|
||||||
|
expect(names).toContain('getPageJson'); // shared deferred
|
||||||
|
expect(names).toContain('patchNode'); // shared deferred
|
||||||
|
expect(names).toContain('createPage'); // inline deferred
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NEVER lists a core tool', () => {
|
||||||
|
for (const core of CORE_TOOL_KEYS) {
|
||||||
|
expect(names).not.toContain(core);
|
||||||
|
}
|
||||||
|
// spot-check a couple that are core in each source.
|
||||||
|
expect(names).not.toContain('searchInPage'); // shared core
|
||||||
|
expect(names).not.toContain('searchPages'); // inline core
|
||||||
|
expect(names).not.toContain('editPageText'); // shared core
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders every entry as a "name — purpose" line', () => {
|
||||||
|
// Non-empty catalog (the length is pinned structurally by the live-toolset
|
||||||
|
// partition test below, not by a magic constant that rots on every new tool).
|
||||||
|
expect(catalog.length).toBeGreaterThan(0);
|
||||||
|
for (const entry of catalog) {
|
||||||
|
expect(entry.catalogLine).toMatch(/ — /);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F3 — the deferred <tool_catalog> is built from STATIC metadata (INLINE_TOOL_TIERS
|
||||||
|
* + SHARED_TOOL_SPECS), but the loadable-by-name set is derived at RUNTIME from the
|
||||||
|
* actual toolset (`Object.keys(baseTools)` in ai-chat.service.ts). Those two must
|
||||||
|
* agree or a tool becomes loadable-but-invisible (agent thinks it doesn't exist) or
|
||||||
|
* catalogued-but-phantom. INLINE_TOOL_TIERS is a plain hand-maintained Record with
|
||||||
|
* no compile-time link to the tools AiChatToolsService.forUser() builds, so nothing
|
||||||
|
* else catches that drift. This test uses forUser()'s LIVE keys as the source of
|
||||||
|
* truth (mirroring ai-chat-tools.service.spec.ts's loader mock) and asserts a
|
||||||
|
* two-way partition against buildInAppDeferredCatalog — replacing the old magic
|
||||||
|
* toHaveLength(28), so a tool added to forUser() without a catalog line (or a
|
||||||
|
* catalog line without a real tool) fails the suite instead of silently vanishing.
|
||||||
|
*/
|
||||||
|
describe('deferred catalog ↔ live forUser() toolset partition (#332, F3)', () => {
|
||||||
|
let toolKeys: string[];
|
||||||
|
const catalogNames = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never).map(
|
||||||
|
(e) => e.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Intercept the ESM loader so forUser() builds against the TS-source shared
|
||||||
|
// specs (no @docmost/mcp build) and never touches the network.
|
||||||
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||||
|
DocmostClient: function () {
|
||||||
|
return {} as DocmostClientLike;
|
||||||
|
} as unknown as loader.DocmostClientCtor,
|
||||||
|
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
|
||||||
|
});
|
||||||
|
const service = new AiChatToolsService(
|
||||||
|
{
|
||||||
|
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||||
|
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||||
|
} as never,
|
||||||
|
{} as never, // aiService — not exercised while merely BUILDING the tools
|
||||||
|
{} as never, // pageEmbeddingRepo
|
||||||
|
{} as never, // spaceMemberRepo
|
||||||
|
{} as never, // pagePermissionRepo
|
||||||
|
// sandboxStore: forUser() eagerly calls asSink() to wire the stash tool.
|
||||||
|
{
|
||||||
|
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
const tools = await service.forUser(
|
||||||
|
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||||
|
'session-1',
|
||||||
|
'ws-1',
|
||||||
|
'chat-1',
|
||||||
|
);
|
||||||
|
toolKeys = Object.keys(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a non-trivial toolset (sanity: the mock actually built tools)', () => {
|
||||||
|
expect(toolKeys.length).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every non-core live tool is present in the catalog (no capability silently hidden)', () => {
|
||||||
|
// forUser() does not itself add loadTools (ai-chat.service does), but guard
|
||||||
|
// anyway. Every remaining non-core key MUST have a catalog line.
|
||||||
|
const catalogSet = new Set(catalogNames);
|
||||||
|
const missing = toolKeys.filter(
|
||||||
|
(k) => !CORE_TOOL_SET.has(k) && k !== LOAD_TOOLS_NAME && !catalogSet.has(k),
|
||||||
|
);
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every catalog entry corresponds to a real, non-core live tool (no phantom)', () => {
|
||||||
|
const liveSet = new Set(toolKeys);
|
||||||
|
const phantom = catalogNames.filter(
|
||||||
|
(n) => !liveSet.has(n) || CORE_TOOL_SET.has(n),
|
||||||
|
);
|
||||||
|
expect(phantom).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildExternalToolCatalog + shortenForCatalog (#332)', () => {
|
||||||
|
it('derives a short "name — purpose" line from each external tool description', () => {
|
||||||
|
const catalog = buildExternalToolCatalog({
|
||||||
|
tavily_search: { description: 'Search the web for fresh results. More detail here.' },
|
||||||
|
tavily_extract: { description: '' },
|
||||||
|
});
|
||||||
|
expect(catalog).toEqual([
|
||||||
|
{ name: 'tavily_search', catalogLine: 'tavily_search — Search the web for fresh results.' },
|
||||||
|
{ name: 'tavily_extract', catalogLine: 'tavily_extract — external tool' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps a very long description', () => {
|
||||||
|
const long = 'x'.repeat(500);
|
||||||
|
expect(shortenForCatalog(long).length).toBeLessThanOrEqual(140);
|
||||||
|
expect(shortenForCatalog(long).endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyLoadTools (#332)', () => {
|
||||||
|
const valid = new Set(['createPage', 'transformPage', 'tavily_search']);
|
||||||
|
|
||||||
|
it('adds valid names to the activated set and returns { loaded }', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
const result = applyLoadTools(['createPage', 'tavily_search'], activated, valid);
|
||||||
|
expect(result).toEqual({ loaded: ['createPage', 'tavily_search'] });
|
||||||
|
expect(activated.has('createPage')).toBe(true);
|
||||||
|
expect(activated.has('tavily_search')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown name with an error listing the valid deferred names', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
expect(() => applyLoadTools(['nope'], activated, valid)).toThrow(/unknown tool name/i);
|
||||||
|
try {
|
||||||
|
applyLoadTools(['nope'], activated, valid);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as Error).message;
|
||||||
|
// Lists every valid name (sorted).
|
||||||
|
expect(msg).toContain('createPage');
|
||||||
|
expect(msg).toContain('transformPage');
|
||||||
|
expect(msg).toContain('tavily_search');
|
||||||
|
}
|
||||||
|
// Nothing is activated on a rejected call.
|
||||||
|
expect(activated.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a non-array / empty input (loads nothing)', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
expect(applyLoadTools(undefined, activated, valid)).toEqual({ loaded: [] });
|
||||||
|
expect(applyLoadTools([], activated, valid)).toEqual({ loaded: [] });
|
||||||
|
expect(activated.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadTools description is the verbatim issue text', () => {
|
||||||
|
expect(LOAD_TOOLS_DESCRIPTION).toContain('only ACTIVATES them');
|
||||||
|
expect(LOAD_TOOLS_DESCRIPTION).toContain('callable on your NEXT step');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editorial "Corrector" scenario is fully served by CORE (#332)', () => {
|
||||||
|
it('read + comment + edit + search need no loadTools', () => {
|
||||||
|
// A Corrector role reads a page, searches within it, edits text, and leaves
|
||||||
|
// inline comments — every tool it needs is core, so it never has to load a
|
||||||
|
// deferred tool.
|
||||||
|
const needed = [
|
||||||
|
'getCurrentPage',
|
||||||
|
'getPage',
|
||||||
|
'searchPages',
|
||||||
|
'searchInPage',
|
||||||
|
'editPageText',
|
||||||
|
'createComment',
|
||||||
|
'listComments',
|
||||||
|
'getComment',
|
||||||
|
'resolveComment',
|
||||||
|
];
|
||||||
|
for (const t of needed) {
|
||||||
|
expect(CORE_TOOL_SET.has(t)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { tool, type Tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { SharedToolSpec } from './docmost-client.loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferred tool loading for the in-app AI chat (#332).
|
||||||
|
*
|
||||||
|
* The agent otherwise sends ALL ~41 tool definitions on EVERY model call every
|
||||||
|
* step, bloating context. Instead we split the in-app tools into two tiers:
|
||||||
|
*
|
||||||
|
* - CORE (hot, always active): frequent OR tiny tools whose full schema is
|
||||||
|
* always visible, plus the `loadTools` meta-tool. Deferring a one-line tool is
|
||||||
|
* pure loss, so tiny tools stay core even if rare.
|
||||||
|
* - DEFERRED (loaded on demand): the fat/rare tools + ALL external MCP tools by
|
||||||
|
* default. The model sees only a compact <tool_catalog> (name — purpose) and
|
||||||
|
* calls `loadTools(names)` to ACTIVATE a tool's full schema for the NEXT step
|
||||||
|
* (one extra round-trip on first use).
|
||||||
|
*
|
||||||
|
* This module is the single source of truth for the IN-APP tiering:
|
||||||
|
* - CORE_TOOL_KEYS / CORE_TOOL_SET — the authoritative core list (used by
|
||||||
|
* prepareAgentStep to build per-step `activeTools`).
|
||||||
|
* - INLINE_TOOL_TIERS — tier + catalogLine for the per-layer INLINE tools (the
|
||||||
|
* ones NOT in @docmost/mcp's SHARED_TOOL_SPECS, which carry their own).
|
||||||
|
* - buildInAppDeferredCatalog / buildExternalToolCatalog — assemble the
|
||||||
|
* <tool_catalog> deferred lines.
|
||||||
|
* - applyLoadTools / makeLoadToolsTool — the loadTools meta-tool.
|
||||||
|
*
|
||||||
|
* The tier/catalogLine fields on SHARED_TOOL_SPECS are IN-APP metadata only; the
|
||||||
|
* external /mcp server ignores them and exposes every tool normally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A single rendered <tool_catalog> line: the tool name + its "name — purpose". */
|
||||||
|
export interface ToolCatalogEntry {
|
||||||
|
/** Exact tool name the model must pass to loadTools. */
|
||||||
|
name: string;
|
||||||
|
/** Hand-written (in-app) or derived (external) "name — purpose" line. */
|
||||||
|
catalogLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools. `searchInPage`
|
||||||
|
* (#330) is added to core on top of the issue's original tier list: it is
|
||||||
|
* frequent for the editorial roles this feature targets. `loadTools` is active
|
||||||
|
* too but is not a normal tool key (it is added to activeTools separately).
|
||||||
|
*/
|
||||||
|
export const CORE_TOOL_KEYS = [
|
||||||
|
'searchPages',
|
||||||
|
'listPages',
|
||||||
|
'listSpaces',
|
||||||
|
'getWorkspace',
|
||||||
|
'getCurrentPage',
|
||||||
|
'getPage',
|
||||||
|
'getOutline',
|
||||||
|
'getNode',
|
||||||
|
'createComment',
|
||||||
|
'getComment',
|
||||||
|
'listComments',
|
||||||
|
'resolveComment',
|
||||||
|
'editPageText',
|
||||||
|
// #330 search_in_page — frequent for editorial sweeps; core despite predating
|
||||||
|
// the issue's tier list.
|
||||||
|
'searchInPage',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** O(1) membership test for the core tier. */
|
||||||
|
export const CORE_TOOL_SET: ReadonlySet<string> = new Set(CORE_TOOL_KEYS);
|
||||||
|
|
||||||
|
/** The meta-tool name (always active alongside the core tools when enabled). */
|
||||||
|
export const LOAD_TOOLS_NAME = 'loadTools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loadTools description — VERBATIM from issue #332. Tells the model that the
|
||||||
|
* catalog names EXIST, that loadTools only ACTIVATES them (callable next step),
|
||||||
|
* and to load several at once.
|
||||||
|
*/
|
||||||
|
export const LOAD_TOOLS_DESCRIPTION =
|
||||||
|
'loadTools — Load the full definitions of deferred tools from the <tool_catalog>\n' +
|
||||||
|
'block in your instructions. Pass the EXACT tool names from the catalog; this\n' +
|
||||||
|
'call only ACTIVATES them and returns { loaded: [...] } — the tools become\n' +
|
||||||
|
'callable on your NEXT step. Load several names in one call when the task clearly\n' +
|
||||||
|
'needs them. Unknown names are rejected with the list of valid ones.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier + catalogLine for the INLINE ai-chat tools — those defined per-layer in
|
||||||
|
* ai-chat-tools.service.ts and NOT present in @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
|
* (which carries its own tier/catalogLine). Together with the shared registry
|
||||||
|
* this describes every in-app tool. catalogLine is present for core tools too
|
||||||
|
* (uniformity), but only DEFERRED tools are rendered into the catalog.
|
||||||
|
*/
|
||||||
|
export const INLINE_TOOL_TIERS: Record<
|
||||||
|
string,
|
||||||
|
{ tier: 'core' | 'deferred'; catalogLine: string }
|
||||||
|
> = {
|
||||||
|
// --- core inline ---
|
||||||
|
searchPages: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'searchPages — hybrid semantic + keyword search across the wiki.',
|
||||||
|
},
|
||||||
|
getCurrentPage: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||||
|
},
|
||||||
|
getPage: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||||
|
},
|
||||||
|
listPages: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||||
|
},
|
||||||
|
listComments: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'listComments — list all comments on a page (including resolved).',
|
||||||
|
},
|
||||||
|
getComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getComment — fetch a single comment by id.',
|
||||||
|
},
|
||||||
|
createComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'createComment — add an inline comment (optionally with a suggested edit).',
|
||||||
|
},
|
||||||
|
resolveComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- deferred inline ---
|
||||||
|
createPage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
||||||
|
},
|
||||||
|
updatePageContent: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
||||||
|
},
|
||||||
|
renamePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "renamePage — change a page's title only (body untouched).",
|
||||||
|
},
|
||||||
|
movePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
||||||
|
},
|
||||||
|
deletePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
||||||
|
},
|
||||||
|
listSidebarPages: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"listSidebarPages — list a space's root pages or a page's direct children.",
|
||||||
|
},
|
||||||
|
getTable: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||||
|
},
|
||||||
|
checkNewComments: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'checkNewComments — find comments in a space created after a timestamp.',
|
||||||
|
},
|
||||||
|
getPageHistory: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||||
|
},
|
||||||
|
exportPageMarkdown: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||||
|
},
|
||||||
|
updatePageJson: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
||||||
|
},
|
||||||
|
tableInsertRow: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
||||||
|
},
|
||||||
|
tableDeleteRow: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
||||||
|
},
|
||||||
|
tableUpdateCell: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
||||||
|
},
|
||||||
|
sharePage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
||||||
|
},
|
||||||
|
transformPage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the <tool_catalog> deferred lines for the IN-APP tools by merging the
|
||||||
|
* two metadata sources: the per-layer INLINE_TOOL_TIERS and the shared registry
|
||||||
|
* (SHARED_TOOL_SPECS, loaded at runtime). Only DEFERRED tools are included; core
|
||||||
|
* tools are always active and never appear in the catalog. Pure — the caller
|
||||||
|
* passes the loaded specs so this stays unit-testable.
|
||||||
|
*/
|
||||||
|
export function buildInAppDeferredCatalog(
|
||||||
|
sharedToolSpecs: Record<string, SharedToolSpec>,
|
||||||
|
): ToolCatalogEntry[] {
|
||||||
|
const entries: ToolCatalogEntry[] = [];
|
||||||
|
// Inline deferred tools (hand-written lines).
|
||||||
|
for (const [name, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||||
|
if (meta.tier === 'deferred') {
|
||||||
|
entries.push({ name, catalogLine: meta.catalogLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shared deferred tools (line comes from the registry's own catalogLine).
|
||||||
|
for (const [name, spec] of Object.entries(sharedToolSpecs)) {
|
||||||
|
if (spec.tier === 'deferred' && spec.catalogLine) {
|
||||||
|
entries.push({ name, catalogLine: spec.catalogLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap an external tool's (untrusted) description into a short catalog purpose.
|
||||||
|
* External MCP tools have no hand-written catalogLine, so we derive one from the
|
||||||
|
* first sentence of the description, hard-capped. Whitespace is collapsed.
|
||||||
|
*/
|
||||||
|
export function shortenForCatalog(description: string, max = 140): string {
|
||||||
|
const flat = description.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!flat) return 'external tool';
|
||||||
|
// Prefer the first sentence if it is reasonably short.
|
||||||
|
const firstSentence = flat.split(/(?<=[.!?])\s/)[0];
|
||||||
|
const base =
|
||||||
|
firstSentence.length > 0 && firstSentence.length <= max
|
||||||
|
? firstSentence
|
||||||
|
: flat;
|
||||||
|
return base.length > max ? `${base.slice(0, max - 1).trimEnd()}…` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build catalog lines for the EXTERNAL MCP tools (all deferred by default,
|
||||||
|
* #332). Their names are the namespaced tool keys; the purpose is derived from
|
||||||
|
* each tool's own description (no hand-written line exists). Pure.
|
||||||
|
*/
|
||||||
|
export function buildExternalToolCatalog(
|
||||||
|
externalTools: Record<string, { description?: string } | undefined>,
|
||||||
|
): ToolCatalogEntry[] {
|
||||||
|
return Object.entries(externalTools).map(([name, t]) => ({
|
||||||
|
name,
|
||||||
|
catalogLine: `${name} — ${shortenForCatalog(t?.description ?? '')}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure core of the loadTools meta-tool. Validates the requested names against
|
||||||
|
* the per-turn set of valid deferred names, ADDS the valid ones to the caller's
|
||||||
|
* mutable `activatedTools` set (so they become callable next step), and returns
|
||||||
|
* `{ loaded }`. An unknown name throws a clear error listing the valid deferred
|
||||||
|
* names — surfaced to the model as a tool error so it can retry.
|
||||||
|
*/
|
||||||
|
export function applyLoadTools(
|
||||||
|
names: unknown,
|
||||||
|
activatedTools: Set<string>,
|
||||||
|
validDeferredNames: ReadonlySet<string>,
|
||||||
|
): { loaded: string[] } {
|
||||||
|
const requested = Array.isArray(names)
|
||||||
|
? names.filter((n): n is string => typeof n === 'string')
|
||||||
|
: [];
|
||||||
|
const unknown = requested.filter((n) => !validDeferredNames.has(n));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
const valid = [...validDeferredNames].sort().join(', ');
|
||||||
|
throw new Error(
|
||||||
|
`loadTools: unknown tool name(s): ${unknown.join(', ')}. ` +
|
||||||
|
`Valid deferred tools are: ${valid || '(none)'}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const n of requested) activatedTools.add(n);
|
||||||
|
return { loaded: requested };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the loadTools AI-SDK tool bound to THIS turn's mutable state: the
|
||||||
|
* `activatedTools` set (grown by execute, read by prepareAgentStep next step)
|
||||||
|
* and the `validDeferredNames` set (every non-core tool in this turn's toolset,
|
||||||
|
* incl. external MCP). Created per streamText call — never module-global.
|
||||||
|
*/
|
||||||
|
export function makeLoadToolsTool(
|
||||||
|
activatedTools: Set<string>,
|
||||||
|
validDeferredNames: ReadonlySet<string>,
|
||||||
|
): Tool {
|
||||||
|
return tool({
|
||||||
|
description: LOAD_TOOLS_DESCRIPTION,
|
||||||
|
inputSchema: z.object({
|
||||||
|
names: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe(
|
||||||
|
'EXACT deferred tool names from the <tool_catalog> to activate for ' +
|
||||||
|
'your next step.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ names }) =>
|
||||||
|
applyLoadTools(names, activatedTools, validDeferredNames),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -117,3 +118,207 @@ describe('CommentController apply-suggestion authz', () => {
|
|||||||
expect(commentService.applySuggestion).not.toHaveBeenCalled();
|
expect(commentService.applySuggestion).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authz-gate tests for the dismiss-suggestion route (#329). Dismissing a
|
||||||
|
* suggestion does NOT change the page text, so it authorizes with
|
||||||
|
* validateCanComment (NOT validateCanEdit) — a viewer allowed to comment but not
|
||||||
|
* edit can still dismiss. The gate MUST run BEFORE the service (which performs
|
||||||
|
* the delete/resolve + mark removal). These tests pin that boundary.
|
||||||
|
*/
|
||||||
|
describe('CommentController dismiss-suggestion authz', () => {
|
||||||
|
// isAdmin=false → ability.cannot(Manage, Settings) returns true (i.e. the user
|
||||||
|
// is NOT a space admin). Flip to true to model a space admin.
|
||||||
|
function makeController(isAdmin = false) {
|
||||||
|
const commentService = {
|
||||||
|
dismissSuggestion: jest.fn(async () => ({
|
||||||
|
id: 'c-1',
|
||||||
|
outcome: 'deleted',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const commentRepo = { findById: jest.fn() };
|
||||||
|
const pageRepo = { findById: jest.fn() };
|
||||||
|
const spaceAbility = {
|
||||||
|
createForUser: jest.fn(async () => ({
|
||||||
|
cannot: jest.fn(() => !isAdmin),
|
||||||
|
})),
|
||||||
|
} as any;
|
||||||
|
const pageAccessService = {
|
||||||
|
validateCanComment: jest.fn(async () => undefined),
|
||||||
|
validateCanEdit: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
const wsService = {} as any;
|
||||||
|
const auditService = { log: jest.fn() };
|
||||||
|
|
||||||
|
const controller = new CommentController(
|
||||||
|
commentService as any,
|
||||||
|
commentRepo as any,
|
||||||
|
pageRepo as any,
|
||||||
|
spaceAbility,
|
||||||
|
pageAccessService as any,
|
||||||
|
wsService,
|
||||||
|
auditService as any,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
commentService,
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
pageAccessService,
|
||||||
|
spaceAbility,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user: any = { id: 'u-1' };
|
||||||
|
const workspace: any = { id: 'ws-1' };
|
||||||
|
const provenance: any = undefined;
|
||||||
|
const dto: any = { commentId: 'c-1' };
|
||||||
|
// Owned by the acting user (u-1) unless a test overrides creatorId.
|
||||||
|
const comment = {
|
||||||
|
id: 'c-1',
|
||||||
|
pageId: 'p-1',
|
||||||
|
spaceId: 'sp-1',
|
||||||
|
creatorId: 'u-1',
|
||||||
|
suggestedText: 'new text',
|
||||||
|
selection: 'old text',
|
||||||
|
};
|
||||||
|
const page = { id: 'p-1', spaceId: 'sp-1', deletedAt: null };
|
||||||
|
|
||||||
|
it('authorizes with validateCanComment (NOT validateCanEdit) then calls the service', async () => {
|
||||||
|
const {
|
||||||
|
controller,
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
pageAccessService,
|
||||||
|
commentService,
|
||||||
|
} = makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
const dismissed = { id: 'c-1', outcome: 'deleted' };
|
||||||
|
commentService.dismissSuggestion.mockResolvedValue(dismissed);
|
||||||
|
|
||||||
|
const result = await controller.dismissSuggestion(
|
||||||
|
dto,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pageAccessService.validateCanComment).toHaveBeenCalledWith(
|
||||||
|
page,
|
||||||
|
user,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
// Dismiss must NOT require edit access.
|
||||||
|
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||||
|
expect(commentService.dismissSuggestion).toHaveBeenCalledWith(
|
||||||
|
comment,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
expect(result).toBe(dismissed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateCanComment throwing Forbidden rejects AND dismissSuggestion is never called', async () => {
|
||||||
|
const {
|
||||||
|
controller,
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
pageAccessService,
|
||||||
|
commentService,
|
||||||
|
} = makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
pageAccessService.validateCanComment.mockRejectedValue(
|
||||||
|
new ForbiddenException('no comment access'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missing comment: NotFound without authorizing or dismissing', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
|
||||||
|
makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
|
||||||
|
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||||
|
expect(pageAccessService.validateCanComment).not.toHaveBeenCalled();
|
||||||
|
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a service BadRequest (e.g. already applied/resolved) unchanged', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService } =
|
||||||
|
makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
commentService.dismissSuggestion.mockRejectedValue(
|
||||||
|
new BadRequestException('already applied'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- #338 owner-or-space-admin gate (mirrors POST /comments/delete) --------
|
||||||
|
// A childless dismiss irreversibly hard-deletes the comment, so canComment is
|
||||||
|
// not enough: only the comment owner or a space admin may dismiss.
|
||||||
|
|
||||||
|
it('owner dismisses their own suggestion → allowed, no admin check needed', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||||
|
makeController(false);
|
||||||
|
// comment.creatorId === user.id (owner).
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await controller.dismissSuggestion(dto, user, workspace, provenance);
|
||||||
|
|
||||||
|
// Owner short-circuits the admin lookup.
|
||||||
|
expect(spaceAbility.createForUser).not.toHaveBeenCalled();
|
||||||
|
expect(commentService.dismissSuggestion).toHaveBeenCalledWith(
|
||||||
|
comment,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-owner non-admin → Forbidden AND the service is never called', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||||
|
makeController(false); // NOT a space admin
|
||||||
|
commentRepo.findById.mockResolvedValue({
|
||||||
|
...comment,
|
||||||
|
creatorId: 'someone-else',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
expect(spaceAbility.createForUser).toHaveBeenCalledWith(user, comment.spaceId);
|
||||||
|
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-owner space admin → allowed to dismiss another user’s suggestion', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||||
|
makeController(true); // space admin
|
||||||
|
commentRepo.findById.mockResolvedValue({
|
||||||
|
...comment,
|
||||||
|
creatorId: 'someone-else',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await controller.dismissSuggestion(dto, user, workspace, provenance);
|
||||||
|
|
||||||
|
expect(spaceAbility.createForUser).toHaveBeenCalledWith(user, comment.spaceId);
|
||||||
|
expect(commentService.dismissSuggestion).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CreateCommentDto } from './dto/create-comment.dto';
|
|||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
||||||
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
|
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
|
||||||
|
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
|
||||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
@@ -234,6 +235,59 @@ export class CommentController {
|
|||||||
return this.commentService.applySuggestion(comment, user, provenance);
|
return this.commentService.applySuggestion(comment, user, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('dismiss-suggestion')
|
||||||
|
async dismissSuggestion(
|
||||||
|
@Body() dto: DismissSuggestionDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@AuthProvenance() provenance: AuthProvenanceData,
|
||||||
|
) {
|
||||||
|
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||||
|
includeCreator: true,
|
||||||
|
includeResolvedBy: true,
|
||||||
|
});
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await this.pageRepo.findById(comment.pageId);
|
||||||
|
if (!page || page.deletedAt) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize BEFORE revealing any structural detail (metadata-disclosure
|
||||||
|
// hygiene, mirroring apply-suggestion). Dismissing a suggestion does NOT
|
||||||
|
// change the page text — it only removes/resolves the comment — so the
|
||||||
|
// page-level gate is comment access (canComment), NOT edit access. A viewer
|
||||||
|
// allowed to comment but not edit can still dismiss their own suggestion.
|
||||||
|
// The structural 400s (top-level / has-a-suggested-edit / not applied /
|
||||||
|
// not resolved) are re-checked by the service below.
|
||||||
|
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||||
|
|
||||||
|
// AUTHZ (#338): a childless dismiss IRREVERSIBLY hard-deletes the comment,
|
||||||
|
// so — beyond canComment — restrict it to the comment owner OR a space
|
||||||
|
// admin, exactly like POST /comments/delete. canComment alone is not enough:
|
||||||
|
// it would let any bystander commenter erase another user's suggestion for
|
||||||
|
// good. (apply-suggestion deliberately stays on canEdit: accepting an edit
|
||||||
|
// is the editor's semantics, not the suggestion author's.)
|
||||||
|
const isOwner = comment.creatorId === user.id;
|
||||||
|
if (!isOwner) {
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
comment.spaceId,
|
||||||
|
);
|
||||||
|
// Space admin can dismiss any suggestion.
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You can only dismiss your own suggestions',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commentService.dismissSuggestion(comment, user, provenance);
|
||||||
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
||||||
|
|||||||
@@ -13,17 +13,27 @@ import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
|||||||
*
|
*
|
||||||
* The collaboration gateway verdict is the pivot of the whole flow, so each test
|
* The collaboration gateway verdict is the pivot of the whole flow, so each test
|
||||||
* pins a specific { applied, currentText } and asserts the DB persistence,
|
* pins a specific { applied, currentText } and asserts the DB persistence,
|
||||||
* auto-resolve, audit, ws broadcast, and error mapping that follow from it.
|
* settle (ephemeral delete vs. resolve), audit, ws broadcast, and error mapping
|
||||||
|
* that follow from it.
|
||||||
|
*
|
||||||
|
* Ephemeral rule (#329): once applied a suggestion DISAPPEARS (hard-delete +
|
||||||
|
* strip the inline anchor mark) UNLESS the thread has replies, in which case it
|
||||||
|
* is resolved to preserve the discussion. `hasChildren` selects the branch.
|
||||||
*/
|
*/
|
||||||
describe('CommentService — applySuggestion', () => {
|
describe('CommentService — applySuggestion', () => {
|
||||||
const UPDATED = { id: 'c-1', __updated: true } as any;
|
const UPDATED = { id: 'c-1', __updated: true } as any;
|
||||||
|
|
||||||
function makeService(verdict: unknown) {
|
function makeService(verdict: unknown, hasChildren = false, deletedRows = 1) {
|
||||||
const commentRepo: any = {
|
const commentRepo: any = {
|
||||||
// Both the applied-stamp re-read and resolveComment's re-read go through
|
// Both the applied-stamp re-read and resolveComment's re-read go through
|
||||||
// findById; return a recognizable enriched row.
|
// findById; return a recognizable enriched row.
|
||||||
findById: jest.fn(async () => UPDATED),
|
findById: jest.fn(async () => UPDATED),
|
||||||
updateComment: jest.fn(async () => undefined),
|
updateComment: jest.fn(async () => undefined),
|
||||||
|
hasChildren: jest.fn(async () => hasChildren),
|
||||||
|
deleteComment: jest.fn(async () => undefined),
|
||||||
|
// #338 F1: the childless ephemeral delete is atomic-conditional and
|
||||||
|
// returns the number of rows removed (1 = deleted, 0 = a reply raced in).
|
||||||
|
deleteCommentIfChildless: jest.fn(async () => deletedRows),
|
||||||
};
|
};
|
||||||
const pageRepo: any = {};
|
const pageRepo: any = {};
|
||||||
const wsService: any = { emitCommentEvent: jest.fn() };
|
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||||
@@ -74,7 +84,9 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
.map((c: any[]) => c[0])
|
.map((c: any[]) => c[0])
|
||||||
.find((patch: any) => 'suggestionAppliedAt' in patch);
|
.find((patch: any) => 'suggestionAppliedAt' in patch);
|
||||||
|
|
||||||
it('applied=true → replaces text, persists applied stamps, auto-resolves, audits, returns updated', async () => {
|
// --- no replies → ephemeral delete branch -------------------------------
|
||||||
|
|
||||||
|
it('applied=true, no replies → replaces text, hard-deletes, strips the anchor mark, audits APPLIED, outcome=deleted', async () => {
|
||||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
makeService({ applied: true, currentText: 'new text' });
|
makeService({ applied: true, currentText: 'new text' });
|
||||||
|
|
||||||
@@ -92,37 +104,34 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Applied stamps persisted.
|
// Ephemeral: the redundant comment is hard-deleted (atomic-conditional) and
|
||||||
const patch = appliedPatch(commentRepo);
|
// its inline anchor mark removed via the deleteCommentMark collab event.
|
||||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
expect(patch.suggestionAppliedById).toBe('user-1');
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'deleteCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
// No applied stamps are written for a row about to be deleted.
|
||||||
|
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||||
|
|
||||||
// Auto-resolved: resolveComment writes a resolvedAt/resolvedById patch too.
|
// Broadcast a deletion, audit the (still-applied) suggestion, report outcome.
|
||||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||||
.map((c: any[]) => c[0])
|
'space-1',
|
||||||
.find((p: any) => 'resolvedAt' in p);
|
'page-1',
|
||||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
);
|
||||||
|
|
||||||
// Audit + broadcast + return.
|
|
||||||
expect(auditService.log).toHaveBeenCalledWith(
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
resourceType: AuditResource.COMMENT,
|
resourceType: AuditResource.COMMENT,
|
||||||
resourceId: 'c-1',
|
resourceId: 'c-1',
|
||||||
spaceId: 'space-1',
|
|
||||||
metadata: { pageId: 'page-1' },
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
expect(result.outcome).toBe('deleted');
|
||||||
'space-1',
|
|
||||||
'page-1',
|
|
||||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
|
||||||
);
|
|
||||||
expect(result).toBe(UPDATED);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applied=false but currentText === suggestedText → idempotent success (no 409)', async () => {
|
it('applied=false but currentText === suggestedText, no replies → idempotent delete (no 409)', async () => {
|
||||||
const { service, commentRepo, auditService } = makeService({
|
const { service, commentRepo, auditService } = makeService({
|
||||||
applied: false,
|
applied: false,
|
||||||
currentText: 'new text',
|
currentText: 'new text',
|
||||||
@@ -130,15 +139,55 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
|
|
||||||
const result = await service.applySuggestion(suggestionComment(), user());
|
const result = await service.applySuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
// The stamps are still persisted (reconciling a crash between the doc
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
// mutation and the DB write) and the call succeeds.
|
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.outcome).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- has replies → resolve branch (discussion preserved) ----------------
|
||||||
|
|
||||||
|
it('applied=true, WITH replies → resolves (not delete), persists applied stamps, audits, outcome=resolved', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
|
makeService({ applied: true, currentText: 'new text' }, true);
|
||||||
|
|
||||||
|
const result = await service.applySuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// Applied stamps persisted.
|
||||||
const patch = appliedPatch(commentRepo);
|
const patch = appliedPatch(commentRepo);
|
||||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
||||||
expect(patch.suggestionAppliedById).toBe('user-1');
|
expect(patch.suggestionAppliedById).toBe('user-1');
|
||||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toBe(UPDATED);
|
// Auto-resolved (resolveComment writes the resolve patch + resolve mark).
|
||||||
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
|
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||||
|
|
||||||
|
// NOT deleted; broadcast an update, not a deletion.
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
|
'deleteCommentMark',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||||
|
'space-1',
|
||||||
|
'page-1',
|
||||||
|
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.id).toBe('c-1');
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- error / rejection branches -----------------------------------------
|
||||||
|
|
||||||
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
|
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
|
||||||
const { service, commentRepo, auditService } = makeService({
|
const { service, commentRepo, auditService } = makeService({
|
||||||
applied: false,
|
applied: false,
|
||||||
@@ -153,14 +202,14 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
expect(err.getResponse()).toMatchObject({
|
expect(err.getResponse()).toMatchObject({
|
||||||
currentText: 'someone else edited this',
|
currentText: 'someone else edited this',
|
||||||
});
|
});
|
||||||
// No persistence and no audit on a conflict.
|
// No delete and no audit on a conflict.
|
||||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
expect(auditService.log).not.toHaveBeenCalled();
|
expect(auditService.log).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('already-applied AND already-resolved → idempotent success, no collab call, no re-resolve (#315 double-click)', async () => {
|
it('already-applied WITH replies → idempotent success, no re-apply, resolve branch', async () => {
|
||||||
const { service, collaborationGateway, commentRepo, auditService } =
|
const { service, collaborationGateway, commentRepo, auditService } =
|
||||||
makeService({ applied: true, currentText: 'new text' });
|
makeService({ applied: true, currentText: 'new text' }, true);
|
||||||
|
|
||||||
const result = await service.applySuggestion(
|
const result = await service.applySuggestion(
|
||||||
suggestionComment({
|
suggestionComment({
|
||||||
@@ -171,17 +220,20 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
user(),
|
user(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Idempotent SUCCESS, not a 409. The suggestion is already applied, so the
|
// Idempotent SUCCESS. The suggestion is already applied, so the document is
|
||||||
// collaborative document is never touched again and nothing is re-stamped
|
// never re-mutated (no applyCommentSuggestion) and nothing is re-stamped.
|
||||||
// or re-resolved.
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
expect(result).toBe(UPDATED);
|
'applyCommentSuggestion',
|
||||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
expect.anything(),
|
||||||
expect(commentRepo.updateComment).not.toHaveBeenCalled();
|
expect.anything(),
|
||||||
// Same success shape as the applied path (broadcast + audit).
|
);
|
||||||
|
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('already-applied but NOT resolved (crash window) → idempotent success, self-heals resolve, no re-apply', async () => {
|
it('already-applied, no replies (double-click after a delete) → deletes idempotently', async () => {
|
||||||
const { service, collaborationGateway, commentRepo } = makeService({
|
const { service, collaborationGateway, commentRepo } = makeService({
|
||||||
applied: true,
|
applied: true,
|
||||||
currentText: 'new text',
|
currentText: 'new text',
|
||||||
@@ -192,28 +244,43 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
user(),
|
user(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(UPDATED);
|
// No re-apply to the document; the childless applied comment is removed.
|
||||||
|
|
||||||
// The suggestion is NOT re-applied to the document…
|
|
||||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
'applyCommentSuggestion',
|
'applyCommentSuggestion',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
// …but the open thread is self-healed to resolved via resolveComment, which
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
// writes the resolve patch and updates the resolve mark.
|
expect(result.outcome).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applied=true, no replies at read time but a reply races in (conditional delete → 0 rows) → resolves instead, no hard-delete, outcome=resolved (#338 F1)', async () => {
|
||||||
|
// The suggested text is already applied to the document, but between the
|
||||||
|
// hasChildren read and the atomic delete a reply landed. The parent must NOT
|
||||||
|
// be hard-deleted (cascade would destroy the reply); resolve the thread.
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway } =
|
||||||
|
makeService({ applied: true, currentText: 'new text' }, false, 0);
|
||||||
|
|
||||||
|
const result = await service.applySuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
|
// No deletion broadcast — the row + the racing reply survive.
|
||||||
|
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||||
|
);
|
||||||
|
// Fell back to resolving.
|
||||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
.map((c: any[]) => c[0])
|
.map((c: any[]) => c[0])
|
||||||
.find((p: any) => 'resolvedAt' in p);
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
|
||||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
'resolveCommentMark',
|
'resolveCommentMark',
|
||||||
'page.page-1',
|
'page.page-1',
|
||||||
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||||
);
|
);
|
||||||
// The applied stamps are NOT re-written (already stamped).
|
expect(result.outcome).toBe('resolved');
|
||||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a comment with no suggestedText', async () => {
|
it('rejects a comment with no suggestedText', async () => {
|
||||||
@@ -238,8 +305,8 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
service.applySuggestion(suggestionComment(), user()),
|
service.applySuggestion(suggestionComment(), user()),
|
||||||
).rejects.toThrow(InternalServerErrorException);
|
).rejects.toThrow(InternalServerErrorException);
|
||||||
|
|
||||||
// Nothing persisted, nothing audited.
|
// Nothing deleted, nothing audited.
|
||||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
expect(auditService.log).not.toHaveBeenCalled();
|
expect(auditService.log).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for CommentService.dismissSuggestion (#329). Dismiss ("Не применять")
|
||||||
|
* removes a suggested edit WITHOUT changing the page text: the comment
|
||||||
|
* disappears (hard-delete + strip the inline anchor mark) unless the thread has
|
||||||
|
* replies, in which case it is resolved to preserve the discussion.
|
||||||
|
*
|
||||||
|
* The permission gate (canComment, NOT canEdit) lives in the controller and is
|
||||||
|
* covered in comment.controller.spec.ts; here we pin the service's own state
|
||||||
|
* guards and the delete-vs-resolve fork.
|
||||||
|
*/
|
||||||
|
describe('CommentService — dismissSuggestion', () => {
|
||||||
|
const UPDATED = { id: 'c-1', __updated: true } as any;
|
||||||
|
|
||||||
|
function makeService(hasChildren = false, deletedRows = 1) {
|
||||||
|
const commentRepo: any = {
|
||||||
|
findById: jest.fn(async () => UPDATED),
|
||||||
|
updateComment: jest.fn(async () => undefined),
|
||||||
|
hasChildren: jest.fn(async () => hasChildren),
|
||||||
|
deleteComment: jest.fn(async () => undefined),
|
||||||
|
// #338 F1: the childless ephemeral delete is now atomic-conditional and
|
||||||
|
// returns the number of rows removed (1 = deleted, 0 = a reply raced in).
|
||||||
|
deleteCommentIfChildless: jest.fn(async () => deletedRows),
|
||||||
|
};
|
||||||
|
const pageRepo: any = {};
|
||||||
|
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||||
|
const collaborationGateway: any = {
|
||||||
|
handleYjsEvent: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
|
||||||
|
const notificationQueue: any = { add: jest.fn(async () => undefined) };
|
||||||
|
const auditService: any = { log: jest.fn() };
|
||||||
|
|
||||||
|
const service = new CommentService(
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
wsService,
|
||||||
|
collaborationGateway,
|
||||||
|
generalQueue,
|
||||||
|
notificationQueue,
|
||||||
|
auditService,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, commentRepo, wsService, collaborationGateway, auditService };
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestionComment = (over?: Partial<any>): any => ({
|
||||||
|
id: 'c-1',
|
||||||
|
pageId: 'page-1',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
creatorId: 'user-1',
|
||||||
|
parentCommentId: null,
|
||||||
|
selection: 'old text',
|
||||||
|
suggestedText: 'new text',
|
||||||
|
suggestionAppliedAt: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||||
|
|
||||||
|
it('no replies → hard-deletes, strips the anchor mark, does NOT touch page text, audits DISMISSED, outcome=deleted', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
|
makeService(false);
|
||||||
|
|
||||||
|
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// Never applies the suggestion to the document.
|
||||||
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
|
'applyCommentSuggestion',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
// Hard-delete (atomic-conditional) + strip mark.
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'deleteCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||||
|
'space-1',
|
||||||
|
'page-1',
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||||
|
);
|
||||||
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: 'c-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no replies → if the anchor-mark removal FAILS, the row is NOT deleted and the error propagates (#329: no orphan anchor)', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway } =
|
||||||
|
makeService(false);
|
||||||
|
// Mark removal is FATAL and runs BEFORE the irreversible row delete: a collab
|
||||||
|
// failure (e.g. COLLAB_DISABLE_REDIS "no live instance") must abort the whole
|
||||||
|
// operation, leaving row + mark consistent — never a deleted row with an
|
||||||
|
// orphan anchor left in the document reporting success.
|
||||||
|
collaborationGateway.handleYjsEvent = jest.fn(async () => {
|
||||||
|
throw new Error('requires a live collaboration instance');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(suggestionComment(), user()),
|
||||||
|
).rejects.toThrow(/live collaboration/);
|
||||||
|
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).not.toHaveBeenCalled();
|
||||||
|
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WITH replies → resolves (not delete), does NOT apply, audits DISMISSED, outcome=resolved', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
|
makeService(true);
|
||||||
|
|
||||||
|
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// Resolved via resolveComment (resolve patch + resolve mark), NOT deleted.
|
||||||
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
|
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'resolveCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||||
|
);
|
||||||
|
// No applied stamp — dismiss does not apply the edit.
|
||||||
|
const appliedPatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'suggestionAppliedAt' in p);
|
||||||
|
expect(appliedPatch).toBeUndefined();
|
||||||
|
|
||||||
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reply races in after the childless read (conditional delete → 0 rows) → resolves instead, does NOT hard-delete, reply survives, outcome=resolved (#338 F1)', async () => {
|
||||||
|
// hasChildren=false selects the ephemeral branch (the read saw no replies),
|
||||||
|
// but the atomic delete matches 0 rows because a reply landed in the window
|
||||||
|
// between that read and the delete. The parent must NOT be hard-deleted
|
||||||
|
// (a cascade would destroy the just-added reply); the thread is resolved.
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway } =
|
||||||
|
makeService(false, 0);
|
||||||
|
|
||||||
|
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// The conditional delete was attempted (and matched nothing).
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
|
// No commentDeleted broadcast — the row (and the racing reply) survive.
|
||||||
|
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||||
|
);
|
||||||
|
// Fell back to resolving the thread.
|
||||||
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
|
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||||
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'resolveCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a reply (non-top-level) comment', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ parentCommentId: 'parent-1' }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a comment without a suggested edit', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ suggestedText: null }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already-applied suggestion', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ suggestionAppliedAt: new Date() }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already-resolved thread', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ resolvedAt: new Date() }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,12 @@ import {
|
|||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../integrations/audit/audit.service';
|
} from '../../integrations/audit/audit.service';
|
||||||
|
|
||||||
|
// Ephemeral-suggestion settle result (#329): 'deleted' → the comment vanished
|
||||||
|
// (hard-delete + anchor mark stripped); 'resolved' → the thread had replies and
|
||||||
|
// was resolved instead. Returned to the client so it can pick the optimistic
|
||||||
|
// cache action.
|
||||||
|
export type SuggestionOutcome = 'deleted' | 'resolved';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
private readonly logger = new Logger(CommentService.name);
|
private readonly logger = new Logger(CommentService.name);
|
||||||
@@ -362,7 +368,7 @@ export class CommentService {
|
|||||||
comment: Comment,
|
comment: Comment,
|
||||||
user: User,
|
user: User,
|
||||||
provenance?: AuthProvenanceData,
|
provenance?: AuthProvenanceData,
|
||||||
): Promise<Comment> {
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
// Structural guards.
|
// Structural guards.
|
||||||
if (comment.parentCommentId) {
|
if (comment.parentCommentId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -449,42 +455,148 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist the applied stamps (idempotently), auto-resolve the thread and
|
* Dismiss ("Не применять") a suggested edit without touching the page text:
|
||||||
* broadcast + audit the applied suggestion. Shared by the applied and the
|
* the suggestion disappears. Ephemeral rule (#329) — a top-level suggestion
|
||||||
|
* comment is transient UI, so dismissing it hard-deletes the comment AND strips
|
||||||
|
* its inline anchor mark UNLESS the thread has replies, in which case the
|
||||||
|
* discussion is preserved by resolving it instead.
|
||||||
|
*
|
||||||
|
* Dismiss does NOT change the document text, so the controller authorizes it
|
||||||
|
* with canComment (NOT canEdit). This re-checks the comment's own state so the
|
||||||
|
* invariant holds regardless of caller.
|
||||||
|
*/
|
||||||
|
async dismissSuggestion(
|
||||||
|
comment: Comment,
|
||||||
|
user: User,
|
||||||
|
provenance?: AuthProvenanceData,
|
||||||
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
|
// Structural guards (mirror applySuggestion).
|
||||||
|
if (comment.parentCommentId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Only a top-level comment can carry a suggested edit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!comment.suggestedText) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This comment has no suggested edit to dismiss',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// State guards: dismissing an already-applied or already-resolved thread is
|
||||||
|
// meaningless. On an apply↔dismiss race the loser sees the comment already
|
||||||
|
// gone (404 at the controller) or already resolved (this 400); the client
|
||||||
|
// treats both as "already resolved".
|
||||||
|
if (comment.suggestionAppliedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot dismiss a suggested edit that was already applied',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (comment.resolvedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot dismiss a suggested edit on a resolved comment thread',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChildren = await this.commentRepo.hasChildren(comment.id);
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
// Preserve the discussion: resolve (never delete) a thread with replies.
|
||||||
|
const updatedComment = await this.resolveComment(
|
||||||
|
comment,
|
||||||
|
true,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
this.auditService.log({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: comment.id,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
metadata: { pageId: comment.pageId },
|
||||||
|
});
|
||||||
|
return { ...updatedComment, outcome: 'resolved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ephemeral: no replies → the suggestion vanishes entirely. The atomic
|
||||||
|
// conditional delete may still fall back to a resolve if a reply raced in
|
||||||
|
// (see deleteEphemeralSuggestion), so the outcome is whatever it settled on.
|
||||||
|
const settled = await this.deleteEphemeralSuggestion(comment, user, provenance);
|
||||||
|
this.auditService.log({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: comment.id,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
metadata: { pageId: comment.pageId },
|
||||||
|
});
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the applied stamps (idempotently), then settle the suggestion under
|
||||||
|
* the ephemeral rule (#329): a suggestion whose thread has NO replies
|
||||||
|
* DISAPPEARS after apply (hard-delete + strip the inline anchor mark), since
|
||||||
|
* the suggested text is now in the document and a stand-alone resolved thread
|
||||||
|
* would only pile up an orphan anchor. A thread WITH replies is preserved by
|
||||||
|
* auto-resolving it (the historical behaviour). Shared by the applied and the
|
||||||
* idempotent "already-applied" branches of applySuggestion.
|
* idempotent "already-applied" branches of applySuggestion.
|
||||||
|
*
|
||||||
|
* Returns the comment augmented with `outcome` so the client can pick the
|
||||||
|
* optimistic action ('deleted' → drop it, 'resolved' → move to the resolved
|
||||||
|
* tab).
|
||||||
*/
|
*/
|
||||||
private async finalizeAppliedSuggestion(
|
private async finalizeAppliedSuggestion(
|
||||||
comment: Comment,
|
comment: Comment,
|
||||||
user: User,
|
user: User,
|
||||||
provenance?: AuthProvenanceData,
|
provenance?: AuthProvenanceData,
|
||||||
): Promise<Comment> {
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
if (!comment.suggestionAppliedAt) {
|
const hasChildren = await this.commentRepo.hasChildren(comment.id);
|
||||||
await this.commentRepo.updateComment(
|
|
||||||
{
|
if (hasChildren) {
|
||||||
suggestionAppliedAt: new Date(),
|
// Thread has replies → preserve the discussion: stamp applied + resolve.
|
||||||
suggestionAppliedById: user.id,
|
if (!comment.suggestionAppliedAt) {
|
||||||
},
|
await this.commentRepo.updateComment(
|
||||||
comment.id,
|
{
|
||||||
);
|
suggestionAppliedAt: new Date(),
|
||||||
|
suggestionAppliedById: user.id,
|
||||||
|
},
|
||||||
|
comment.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
||||||
|
// broadcast and the resolve notification. Stay defensive on re-entry.
|
||||||
|
if (!comment.resolvedAt) {
|
||||||
|
await this.resolveComment(comment, true, user, provenance);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||||
|
includeCreator: true,
|
||||||
|
includeResolvedBy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||||
|
operation: 'commentUpdated',
|
||||||
|
pageId: comment.pageId,
|
||||||
|
comment: updatedComment,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.auditService.log({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: comment.id,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
metadata: { pageId: comment.pageId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...updatedComment, outcome: 'resolved' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
// No replies → ephemeral: the suggested text is already in the document, so
|
||||||
// broadcast and the resolve notification. The guard above guarantees the
|
// the comment is redundant. Hard-delete it and strip its inline anchor. We
|
||||||
// thread was open when we entered, but stay defensive on re-entry.
|
// deliberately do NOT write the applied stamps first (the row is about to be
|
||||||
if (!comment.resolvedAt) {
|
// deleted); the audit event still records that the suggestion was applied.
|
||||||
await this.resolveComment(comment, true, user, provenance);
|
// The delete is atomic-conditional: if a reply raced in after the
|
||||||
}
|
// hasChildren read, it falls back to resolving instead (outcome 'resolved').
|
||||||
|
const settled = await this.deleteEphemeralSuggestion(comment, user, provenance);
|
||||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
|
||||||
includeCreator: true,
|
|
||||||
includeResolvedBy: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
|
||||||
operation: 'commentUpdated',
|
|
||||||
pageId: comment.pageId,
|
|
||||||
comment: updatedComment,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
@@ -494,7 +606,86 @@ export class CommentService {
|
|||||||
metadata: { pageId: comment.pageId },
|
metadata: { pageId: comment.pageId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedComment;
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle an ephemeral suggestion whose thread looked childless: remove its
|
||||||
|
* inline `comment` anchor mark, then ATOMICALLY hard-delete the row only if it
|
||||||
|
* is still childless. Shared by the apply/dismiss no-replies branches (#329).
|
||||||
|
*
|
||||||
|
* ORDER MATTERS: the anchor mark is removed FIRST and FATALLY (mirrors
|
||||||
|
* applySuggestion, which mutates the doc before writing the DB). The row
|
||||||
|
* delete is irreversible, so if the mark removal fails — including the
|
||||||
|
* COLLAB_DISABLE_REDIS "no live instance" hard-error — we must NOT delete the
|
||||||
|
* row and report success, or the document is left with a permanent orphan
|
||||||
|
* anchor pointing at a comment that no longer exists (the exact data-integrity
|
||||||
|
* bug #329 targets). Let the exception propagate (→ 5xx); the operation is
|
||||||
|
* then repeatable with row + mark still consistent.
|
||||||
|
*
|
||||||
|
* RACE (#338 F4): the caller read `hasChildren` BEFORE the (slow) mark
|
||||||
|
* removal, so a reply can land in that window. `comments.parent_comment_id` is
|
||||||
|
* ON DELETE CASCADE, so an unconditional delete here would cascade-destroy the
|
||||||
|
* just-added reply forever. Instead we use `deleteCommentIfChildless`, which
|
||||||
|
* re-checks childlessness under a FOR UPDATE lock inside a transaction (a plain
|
||||||
|
* anti-join DELETE is NOT race-safe under READ COMMITTED — see the repo method
|
||||||
|
* docstring). If it removes the row (outcome 'deleted') we broadcast the
|
||||||
|
* deletion as before. If it removes 0 rows (a reply interleaved) we do NOT
|
||||||
|
* hard-delete — we resolve the thread instead (outcome 'resolved'), preserving
|
||||||
|
* the discussion and the new reply. The anchor mark is already gone by then, an
|
||||||
|
* accepted degradation: the thread lands in the resolved tab without its inline
|
||||||
|
* highlight — far better than losing a reply.
|
||||||
|
*/
|
||||||
|
private async deleteEphemeralSuggestion(
|
||||||
|
comment: Comment,
|
||||||
|
user: User,
|
||||||
|
provenance?: AuthProvenanceData,
|
||||||
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
|
await this.deleteCommentMark(comment, user);
|
||||||
|
|
||||||
|
const deletedRows = await this.commentRepo.deleteCommentIfChildless(
|
||||||
|
comment.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deletedRows > 0) {
|
||||||
|
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||||
|
operation: 'commentDeleted',
|
||||||
|
pageId: comment.pageId,
|
||||||
|
commentId: comment.id,
|
||||||
|
});
|
||||||
|
return { ...comment, outcome: 'deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reply interleaved between the hasChildren read and this delete, so the
|
||||||
|
// conditional delete matched nothing. Preserve the discussion + the new
|
||||||
|
// reply by resolving the thread instead of hard-deleting it. resolveComment
|
||||||
|
// handles the resolve patch, its ws broadcast and the resolve notification;
|
||||||
|
// its collab call is best-effort, so the already-stripped mark is fine.
|
||||||
|
const resolvedComment = await this.resolveComment(
|
||||||
|
comment,
|
||||||
|
true,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
return { ...resolvedComment, outcome: 'resolved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inline `comment` mark for a comment from the collaborative
|
||||||
|
* document. FATAL, NOT best-effort: unlike resolveComment (which keeps the row,
|
||||||
|
* so a failed mark update is recoverable), this is used before an irreversible
|
||||||
|
* hard-delete, so the mark removal MUST succeed or throw. Under
|
||||||
|
* COLLAB_DISABLE_REDIS the gateway invokes the deleteCommentMark handler
|
||||||
|
* directly (never a silent no-op) and a missing live instance surfaces as a
|
||||||
|
* thrown error, which we let propagate so the caller aborts before deleting.
|
||||||
|
*/
|
||||||
|
private async deleteCommentMark(comment: Comment, user: User): Promise<void> {
|
||||||
|
const documentName = `page.${comment.pageId}`;
|
||||||
|
await this.collaborationGateway.handleYjsEvent(
|
||||||
|
'deleteCommentMark',
|
||||||
|
documentName,
|
||||||
|
{ commentId: comment.id, user },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async queueCommentNotification(
|
private async queueCommentNotification(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class DismissSuggestionDto {
|
||||||
|
@IsUUID()
|
||||||
|
commentId: string;
|
||||||
|
}
|
||||||
@@ -139,6 +139,65 @@ export class CommentRepo {
|
|||||||
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an ephemeral suggestion row ONLY if it is still childless, returning
|
||||||
|
* the number of rows removed (0 or 1). Closes the data-loss race in
|
||||||
|
* dismiss/apply (#338 F4): the service reads `hasChildren`, then removes the
|
||||||
|
* anchor mark (a collab round-trip of tens-to-hundreds of ms), then calls this.
|
||||||
|
* `comments.parent_comment_id` is ON DELETE CASCADE, so a reply landing in that
|
||||||
|
* window would be cascade-destroyed by a blind delete.
|
||||||
|
*
|
||||||
|
* A single anti-join `DELETE … WHERE NOT EXISTS(child)` is NOT sufficient under
|
||||||
|
* READ COMMITTED: if a reply INSERT (holding FOR KEY SHARE on the parent, not
|
||||||
|
* yet committed) interleaves, the DELETE's snapshot does not see the
|
||||||
|
* uncommitted child, so `NOT EXISTS` is true and the parent qualifies; the
|
||||||
|
* DELETE then blocks on the child's key-share lock, and when it wakes the row
|
||||||
|
* was only LOCKED (not modified), so EvalPlanQual does NOT re-evaluate the
|
||||||
|
* predicate → the parent is deleted and the just-committed reply cascades away.
|
||||||
|
*
|
||||||
|
* So we do a lock-then-recheck in ONE transaction:
|
||||||
|
* 1. `SELECT id … FOR UPDATE` on the parent. FOR UPDATE conflicts with the
|
||||||
|
* FOR KEY SHARE a concurrent reply INSERT takes on its parent (FK), so a
|
||||||
|
* reply in the window serializes against us: it either commits before we
|
||||||
|
* acquire the lock, or it must wait until this tx ends.
|
||||||
|
* 2. Re-read childlessness with a FRESH statement in the SAME tx. Under RC a
|
||||||
|
* new statement gets a new snapshot, so a reply that committed while we
|
||||||
|
* waited on the lock is now visible.
|
||||||
|
* 3. Delete only if still childless (return 1); otherwise return 0 so the
|
||||||
|
* caller resolves the thread instead. The FOR UPDATE lock is held to
|
||||||
|
* end-of-tx, so no new reply can insert between the re-check and the delete.
|
||||||
|
*/
|
||||||
|
async deleteCommentIfChildless(commentId: string): Promise<number> {
|
||||||
|
return this.db.transaction().execute(async (trx) => {
|
||||||
|
const parent = await trx
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', commentId)
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// Already gone (e.g. a racing delete won) → nothing to remove.
|
||||||
|
if (!parent) return 0;
|
||||||
|
|
||||||
|
const child = await trx
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('id')
|
||||||
|
.where('parentCommentId', '=', commentId)
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// A reply exists (possibly one that just committed) → do NOT hard-delete;
|
||||||
|
// the cascade would destroy it. Caller falls back to resolving the thread.
|
||||||
|
if (child) return 0;
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.deleteFrom('comments')
|
||||||
|
.where('id', '=', commentId)
|
||||||
|
.execute();
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async hasChildren(commentId: string): Promise<boolean> {
|
async hasChildren(commentId: string): Promise<boolean> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.selectFrom('comments')
|
.selectFrom('comments')
|
||||||
|
|||||||
@@ -261,6 +261,21 @@ export class EnvironmentService {
|
|||||||
return disable === 'true';
|
return disable === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferred tool loading for the in-app AI chat (#332). When enabled, the agent
|
||||||
|
* sees a compact <tool_catalog> and only CORE tools + the loadTools meta-tool
|
||||||
|
* are active each step; deferred tools (the fat/rare ones + all external MCP
|
||||||
|
* tools) load on demand. Defaults to ENABLED — the issue treats deferred
|
||||||
|
* loading as the new behavior; set AI_CHAT_DEFERRED_TOOLS=false to restore the
|
||||||
|
* old "all tools always active" behavior.
|
||||||
|
*/
|
||||||
|
isAiChatDeferredToolsEnabled(): boolean {
|
||||||
|
const enabled = this.configService
|
||||||
|
.get<string>('AI_CHAT_DEFERRED_TOOLS', 'true')
|
||||||
|
.toLowerCase();
|
||||||
|
return enabled === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
getPostHogHost(): string {
|
getPostHogHost(): string {
|
||||||
return this.configService.get<string>('POSTHOG_HOST');
|
return this.configService.get<string>('POSTHOG_HOST');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
|
import { tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||||
@@ -146,6 +148,9 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
{} as any, // aiAgentRoleRepo (role is pre-resolved + passed in)
|
{} as any, // aiAgentRoleRepo (role is pre-resolved + passed in)
|
||||||
{} as any, // pageRepo (only used when body.openPage is set)
|
{} as any, // pageRepo (only used when body.openPage is set)
|
||||||
{} as any, // pageAccess (idem)
|
{} as any, // pageAccess (idem)
|
||||||
|
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
||||||
|
// harness so the toolset/behavior is exactly as before.
|
||||||
|
{ isAiChatDeferredToolsEnabled: () => false } as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,4 +320,174 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading, the ON path. The riskiest property is that the
|
||||||
|
* per-turn `activatedTools` Set is created FRESH inside each stream() call, so a
|
||||||
|
* tool a previous turn activated via loadTools is NOT still active when the next
|
||||||
|
* turn starts — the new turn begins "cold" (CORE + loadTools only). The unit
|
||||||
|
* tests only exercise pure prepareAgentStep with hand-fed Sets; this pins the
|
||||||
|
* real wiring end-to-end (loadTools.execute -> activatedTools -> prepareStep ->
|
||||||
|
* per-step activeTools) against the real streamText loop, and proves there is no
|
||||||
|
* cross-turn leak. We drive a MockLanguageModelV3 whose step 1 calls
|
||||||
|
* loadTools(['createPage']) and assert, via the model's recorded per-step
|
||||||
|
* CallOptions.tools (the AI SDK filters the provider tool list by activeTools),
|
||||||
|
* that the deferred tool becomes active on the SAME turn's next step but NOT on a
|
||||||
|
* fresh turn's first step.
|
||||||
|
*/
|
||||||
|
describe('deferred tool loading ON — per-turn activation, no leak (#332)', () => {
|
||||||
|
// A stub deferred (non-core) tool the agent can activate. Its execute is never
|
||||||
|
// called — the model only needs to SEE it become active — but it must be a
|
||||||
|
// valid AI-SDK tool so the SDK includes it in a step's tool list once active.
|
||||||
|
const createPageStub = tool({
|
||||||
|
description: 'create a new page',
|
||||||
|
inputSchema: z.object({ title: z.string() }),
|
||||||
|
execute: async () => ({ id: 'p-stub' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A CORE tool in the toolset, so a cold step shows CORE tools ARE active while
|
||||||
|
// the deferred createPage is not. `searchPages` is in CORE_TOOL_SET.
|
||||||
|
const searchPagesStub = tool({
|
||||||
|
description: 'search the wiki',
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same lifecycle harness as buildService() above, but with deferred loading ON
|
||||||
|
// and a toolset that exposes exactly one deferred tool (createPage) so it is
|
||||||
|
// catalogued + loadable-by-name. Kept separate so the OFF scenarios are
|
||||||
|
// untouched.
|
||||||
|
function buildDeferredService(): AiChatService {
|
||||||
|
return new AiChatService(
|
||||||
|
{ getChatModel: async () => null } as any,
|
||||||
|
aiChatRepo,
|
||||||
|
msgRepo,
|
||||||
|
{} as any,
|
||||||
|
{ resolve: async () => null } as any,
|
||||||
|
{
|
||||||
|
forUser: async () => ({
|
||||||
|
searchPages: searchPagesStub,
|
||||||
|
createPage: createPageStub,
|
||||||
|
}),
|
||||||
|
getInAppDeferredCatalog: async () => [
|
||||||
|
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
mcpClients as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
// #332: deferred tool loading ON — the property under test.
|
||||||
|
{ isAiChatDeferredToolsEnabled: () => true } as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drive ONE stream() turn against `model` and wait for the assistant row to
|
||||||
|
// settle (mirrors runStream, but builds the deferred-ON service).
|
||||||
|
async function runDeferredTurn(
|
||||||
|
model: MockLanguageModelV3,
|
||||||
|
chatId: string,
|
||||||
|
body: any,
|
||||||
|
): Promise<void> {
|
||||||
|
closeCalls = 0;
|
||||||
|
const service = buildDeferredService();
|
||||||
|
const { res, cleanup } = await makeRealResponse();
|
||||||
|
try {
|
||||||
|
await service.stream({
|
||||||
|
user: { id: userId, workspaceId } as any,
|
||||||
|
workspace: { id: workspaceId, name: 'WS' } as any,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body,
|
||||||
|
res: { raw: res } as any,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: model as any,
|
||||||
|
role: null,
|
||||||
|
} as any);
|
||||||
|
await waitFor(async () => {
|
||||||
|
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
|
||||||
|
return rows.some(
|
||||||
|
(r) =>
|
||||||
|
r.role === 'assistant' &&
|
||||||
|
['completed', 'error', 'aborted'].includes(r.status as string),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitFor(() => closeCalls > 0, { timeoutMs: 5_000 });
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool names the provider actually received for a recorded step (activeTools
|
||||||
|
// filters this list, so it reflects what was active that step).
|
||||||
|
const toolNames = (call: any): string[] =>
|
||||||
|
((call?.tools ?? []) as any[]).map((t) => t?.name).filter(Boolean);
|
||||||
|
|
||||||
|
// A model that, on step 1, calls loadTools(['createPage']); on step 2, answers.
|
||||||
|
function loadThenAnswerModel(): MockLanguageModelV3 {
|
||||||
|
let step = 0;
|
||||||
|
return new MockLanguageModelV3({
|
||||||
|
doStream: async () => {
|
||||||
|
const n = step++;
|
||||||
|
if (n === 0) {
|
||||||
|
return {
|
||||||
|
stream: convertArrayToReadableStream([
|
||||||
|
{ type: 'stream-start', warnings: [] },
|
||||||
|
{
|
||||||
|
type: 'tool-call',
|
||||||
|
toolCallId: 'lt1',
|
||||||
|
toolName: 'loadTools',
|
||||||
|
input: JSON.stringify({ names: ['createPage'] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'finish',
|
||||||
|
finishReason: 'tool-calls',
|
||||||
|
usage: { inputTokens: 5, outputTokens: 3, totalTokens: 8 },
|
||||||
|
},
|
||||||
|
] as any),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { stream: successStream() };
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('activates a deferred tool for the SAME turn, and a NEW turn starts cold (no leak)', async () => {
|
||||||
|
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
||||||
|
|
||||||
|
// --- Turn 1: loadTools(createPage) on step 1, then answer on step 2. ---
|
||||||
|
const model1 = loadThenAnswerModel();
|
||||||
|
await runDeferredTurn(model1, chatId, {
|
||||||
|
chatId,
|
||||||
|
messages: [userUiMessage('Make me a page')],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The turn ran at least two steps (the load round-trip + the answer).
|
||||||
|
expect(model1.doStreamCalls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const step1Tools = toolNames(model1.doStreamCalls[0]);
|
||||||
|
const step2Tools = toolNames(model1.doStreamCalls[1]);
|
||||||
|
|
||||||
|
// Step 1 starts cold: CORE tools + the loadTools meta-tool are active, but
|
||||||
|
// the deferred createPage is NOT yet.
|
||||||
|
expect(step1Tools).toContain('loadTools');
|
||||||
|
expect(step1Tools).toContain('searchPages'); // a CORE tool, always active
|
||||||
|
expect(step1Tools).not.toContain('createPage');
|
||||||
|
// Step 2 of the SAME turn sees the just-activated deferred tool.
|
||||||
|
expect(step2Tools).toContain('createPage');
|
||||||
|
|
||||||
|
// --- Turn 2 on the SAME chat: must start cold again. ---
|
||||||
|
const model2 = new MockLanguageModelV3({
|
||||||
|
doStream: async () => ({ stream: successStream() }),
|
||||||
|
} as any);
|
||||||
|
await runDeferredTurn(model2, chatId, {
|
||||||
|
chatId,
|
||||||
|
messages: [userUiMessage('And another thing')],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextTurnFirstStep = toolNames(model2.doStreamCalls[0]);
|
||||||
|
expect(nextTurnFirstStep).toContain('loadTools');
|
||||||
|
// The activated set is per-turn: the prior turn's createPage did NOT leak,
|
||||||
|
// so the fresh turn's first step sees it deferred again.
|
||||||
|
expect(nextTurnFirstStep).not.toContain('createPage');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { CommentRepo } from '../../src/database/repos/comment/comment.repo';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
buildTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createSpace,
|
||||||
|
createPage,
|
||||||
|
createUser,
|
||||||
|
createComment,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-DB coverage for CommentRepo.deleteCommentIfChildless (#338 F4/F6).
|
||||||
|
*
|
||||||
|
* This is the guard that keeps an ephemeral-suggestion hard-delete from
|
||||||
|
* cascade-destroying a reply (`comments.parent_comment_id` is ON DELETE CASCADE).
|
||||||
|
* The unit tests MOCK this method to 0/1, so only an int-spec actually exercises
|
||||||
|
* the SQL — the FOR UPDATE lock-then-recheck transaction — against Postgres.
|
||||||
|
*
|
||||||
|
* The concurrency case is the whole point: a plain anti-join
|
||||||
|
* `DELETE … WHERE NOT EXISTS(child)` passes (a) and (b) but SILENTLY loses a
|
||||||
|
* reply that commits mid-operation under READ COMMITTED (EvalPlanQual does not
|
||||||
|
* re-check a merely-locked row). Test (c) reproduces exactly that interleaving
|
||||||
|
* and asserts the row + reply both survive.
|
||||||
|
*/
|
||||||
|
describe('CommentRepo.deleteCommentIfChildless [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let repo: CommentRepo;
|
||||||
|
let workspaceId: string;
|
||||||
|
let spaceId: string;
|
||||||
|
let pageId: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
repo = new CommentRepo(db as any);
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
spaceId = (await createSpace(db, workspaceId)).id;
|
||||||
|
pageId = (await createPage(db, { workspaceId, spaceId })).id;
|
||||||
|
userId = (await createUser(db, workspaceId)).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function rowExists(id: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTopLevel() {
|
||||||
|
return createComment(db, {
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
creatorId: userId,
|
||||||
|
selection: 'old text',
|
||||||
|
suggestedText: 'new text',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedReply(parentId: string) {
|
||||||
|
return createComment(db, {
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
creatorId: userId,
|
||||||
|
parentCommentId: parentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('(a) childless top-level → returns 1 and the row is gone', async () => {
|
||||||
|
const parent = await seedTopLevel();
|
||||||
|
expect(await rowExists(parent.id)).toBe(true);
|
||||||
|
|
||||||
|
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||||
|
|
||||||
|
expect(deleted).toBe(1);
|
||||||
|
expect(await rowExists(parent.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(b) top-level WITH a committed reply → returns 0, parent AND reply survive (gate blocks the cascade)', async () => {
|
||||||
|
const parent = await seedTopLevel();
|
||||||
|
const reply = await seedReply(parent.id);
|
||||||
|
|
||||||
|
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||||
|
|
||||||
|
expect(deleted).toBe(0);
|
||||||
|
expect(await rowExists(parent.id)).toBe(true);
|
||||||
|
expect(await rowExists(reply.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(c) reply COMMITS mid-operation (FOR UPDATE path) → returns 0, parent + reply survive; a blind anti-join would lose the reply', async () => {
|
||||||
|
const parent = await seedTopLevel();
|
||||||
|
|
||||||
|
// Second connection holds an open transaction that inserts a reply (taking
|
||||||
|
// FOR KEY SHARE on the parent via the FK) and does NOT commit until we open
|
||||||
|
// the gate — reproducing the "reply not yet committed" window.
|
||||||
|
const conn2 = buildTestDb();
|
||||||
|
let openGate!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
openGate = resolve;
|
||||||
|
});
|
||||||
|
let replyId: string | undefined;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const replyTx = conn2.transaction().execute(async (trx) => {
|
||||||
|
const row = await trx
|
||||||
|
.insertInto('comments')
|
||||||
|
.values({
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
creatorId: userId,
|
||||||
|
parentCommentId: parent.id,
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
replyId = row.id as string;
|
||||||
|
// Hold the FOR KEY SHARE lock on the parent until the gate opens.
|
||||||
|
await gate;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let the reply INSERT acquire its lock before the delete starts.
|
||||||
|
await sleep(250);
|
||||||
|
|
||||||
|
// deleteCommentIfChildless does SELECT ... FOR UPDATE on the parent, which
|
||||||
|
// conflicts with the reply's FOR KEY SHARE, so it BLOCKS here.
|
||||||
|
const deletePromise = repo.deleteCommentIfChildless(parent.id);
|
||||||
|
|
||||||
|
// Give the delete time to reach (and block on) its FOR UPDATE, then let the
|
||||||
|
// reply commit. The delete then wakes, re-checks under the lock, sees the
|
||||||
|
// now-committed reply, and returns 0.
|
||||||
|
await sleep(250);
|
||||||
|
openGate();
|
||||||
|
await replyTx;
|
||||||
|
|
||||||
|
const deleted = await deletePromise;
|
||||||
|
|
||||||
|
expect(deleted).toBe(0);
|
||||||
|
expect(await rowExists(parent.id)).toBe(true);
|
||||||
|
expect(replyId).toBeDefined();
|
||||||
|
expect(await rowExists(replyId!)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
// Always release the gate (in case an assertion threw before openGate) and
|
||||||
|
// close the extra connection so global-teardown can DROP the database.
|
||||||
|
openGate();
|
||||||
|
await conn2.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,6 +132,62 @@ export async function createUser(
|
|||||||
return { id: row.id as string };
|
return { id: row.id as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The default group every workspace has; `groupUserRepo.addUserToDefaultGroup`
|
||||||
|
// (invoked by acceptInvitation) looks it up by `isDefault = true`, so a
|
||||||
|
// workspace under test must have exactly one for the accept path to complete.
|
||||||
|
export async function createDefaultGroup(
|
||||||
|
db: Kysely<any>,
|
||||||
|
workspaceId: string,
|
||||||
|
overrides: { name?: string } = {},
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const suffix = shortId(id);
|
||||||
|
const row = await db
|
||||||
|
.insertInto('groups')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
// name is unique per workspace + NOT NULL.
|
||||||
|
name: overrides.name ?? `group-${suffix}`,
|
||||||
|
isDefault: true,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return { id: row.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pending workspace invitation. `role`/`token` are NOT NULL; `groupIds` is a
|
||||||
|
// nullable uuid[] and `invitedById` a nullable FK to users. Returns the fields a
|
||||||
|
// spec needs to drive acceptInvitation (id + token + the invited email).
|
||||||
|
export async function createInvitation(
|
||||||
|
db: Kysely<any>,
|
||||||
|
args: {
|
||||||
|
workspaceId: string;
|
||||||
|
email: string;
|
||||||
|
invitedById?: string | null;
|
||||||
|
role?: string;
|
||||||
|
token?: string;
|
||||||
|
groupIds?: string[] | null;
|
||||||
|
},
|
||||||
|
): Promise<{ id: string; token: string; email: string }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const token = args.token ?? `tok-${shortId(id)}`;
|
||||||
|
const row = await db
|
||||||
|
.insertInto('workspaceInvitations')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
email: args.email,
|
||||||
|
role: args.role ?? 'member',
|
||||||
|
token,
|
||||||
|
groupIds: (args.groupIds ?? null) as any,
|
||||||
|
invitedById: args.invitedById ?? null,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return { id: row.id as string, token, email: args.email };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSpace(
|
export async function createSpace(
|
||||||
db: Kysely<any>,
|
db: Kysely<any>,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -174,6 +230,40 @@ export async function createPage(
|
|||||||
return { id: row.id as string };
|
return { id: row.id as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createComment(
|
||||||
|
db: Kysely<any>,
|
||||||
|
args: {
|
||||||
|
workspaceId: string;
|
||||||
|
spaceId: string;
|
||||||
|
pageId: string;
|
||||||
|
creatorId?: string | null;
|
||||||
|
parentCommentId?: string | null;
|
||||||
|
content?: unknown;
|
||||||
|
selection?: string | null;
|
||||||
|
suggestedText?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const row = await db
|
||||||
|
.insertInto('comments')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
spaceId: args.spaceId,
|
||||||
|
pageId: args.pageId,
|
||||||
|
creatorId: args.creatorId ?? null,
|
||||||
|
parentCommentId: args.parentCommentId ?? null,
|
||||||
|
content: (args.content ?? null) as any,
|
||||||
|
selection: args.selection ?? null,
|
||||||
|
suggestedText: args.suggestedText ?? null,
|
||||||
|
type: args.type ?? 'page',
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return { id: row.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRole(
|
export async function createRole(
|
||||||
db: Kysely<any>,
|
db: Kysely<any>,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
|
import { WorkspaceInvitationService } from 'src/core/workspace/services/workspace-invitation.service';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createUser,
|
||||||
|
createDefaultGroup,
|
||||||
|
createInvitation,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* acceptInvitation atomicity (issue #324, tail of #244).
|
||||||
|
*
|
||||||
|
* acceptInvitation() reads the invitation OUTSIDE the transaction, then inside a
|
||||||
|
* single tx: inserts the invited user, adds them to the default group, and
|
||||||
|
* deletes the invitation. Two accepts of the SAME invitation therefore race to
|
||||||
|
* insert a user with the same (email, workspaceId) — which the
|
||||||
|
* `users_email_workspace_id_unique` constraint forbids. The service catches that
|
||||||
|
* violation and reports "Invitation already accepted".
|
||||||
|
*
|
||||||
|
* These specs pin the INVARIANT that path protects: no matter how many times the
|
||||||
|
* invitation is accepted (concurrently or repeatedly), the workspace ends up
|
||||||
|
* with exactly ONE membership for the invited email and the invitation is
|
||||||
|
* consumed exactly once — never a duplicate user and never a half-applied state.
|
||||||
|
*
|
||||||
|
* The service is wired with the REAL repos (UserRepo / GroupRepo / GroupUserRepo)
|
||||||
|
* against the test Kysely; only the peripheral collaborators that acceptInvitation
|
||||||
|
* touches AFTER the transaction (mail, session token, billing, audit, env) are
|
||||||
|
* stubbed, so the exercised DB write path is the production one.
|
||||||
|
*/
|
||||||
|
describe('WorkspaceInvitationService.acceptInvitation atomicity [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let service: WorkspaceInvitationService;
|
||||||
|
|
||||||
|
// Count the memberships (user rows) for an email within a workspace — the
|
||||||
|
// quantity the atomicity guarantee is about.
|
||||||
|
async function membershipCount(
|
||||||
|
workspaceId: string,
|
||||||
|
email: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select('id')
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('email', '=', email.toLowerCase())
|
||||||
|
.execute();
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invitationExists(invitationId: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('workspaceInvitations')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', invitationId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
db = getTestDb();
|
||||||
|
|
||||||
|
const userRepo = new UserRepo(db as any);
|
||||||
|
const groupRepo = new GroupRepo(db as any);
|
||||||
|
const groupUserRepo = new GroupUserRepo(db as any, groupRepo, userRepo);
|
||||||
|
|
||||||
|
// Collaborators used only on the post-commit success tail; safe to stub.
|
||||||
|
const mailService = { sendToQueue: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const domainService = {} as any;
|
||||||
|
const tokenService = {} as any;
|
||||||
|
const sessionService = {
|
||||||
|
createSessionAndToken: jest.fn().mockResolvedValue('test-auth-token'),
|
||||||
|
};
|
||||||
|
const billingQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const environmentService = { isCloud: () => false };
|
||||||
|
const auditService = { log: jest.fn() };
|
||||||
|
|
||||||
|
service = new WorkspaceInvitationService(
|
||||||
|
userRepo,
|
||||||
|
groupUserRepo,
|
||||||
|
mailService as any,
|
||||||
|
domainService,
|
||||||
|
tokenService,
|
||||||
|
sessionService as any,
|
||||||
|
db as any,
|
||||||
|
billingQueue as any,
|
||||||
|
environmentService as any,
|
||||||
|
auditService as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A workspace with its default group, an inviter, and a pending invitation.
|
||||||
|
async function seedInvite(): Promise<{
|
||||||
|
workspace: Workspace;
|
||||||
|
invitationId: string;
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
}> {
|
||||||
|
const { id: workspaceId } = await createWorkspace(db);
|
||||||
|
await createDefaultGroup(db, workspaceId);
|
||||||
|
const inviter = await createUser(db, workspaceId);
|
||||||
|
// Distinct address per invite so specs never collide across the suite.
|
||||||
|
const email = `invitee-${workspaceId.slice(0, 8)}@example.test`;
|
||||||
|
const invite = await createInvitation(db, {
|
||||||
|
workspaceId,
|
||||||
|
email,
|
||||||
|
invitedById: inviter.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// acceptInvitation only reads id/hostname/enforceSso/emailDomains/enforceMfa
|
||||||
|
// off the workspace; a minimal plain object is sufficient.
|
||||||
|
const workspace = {
|
||||||
|
id: workspaceId,
|
||||||
|
hostname: `host-${workspaceId.slice(0, 8)}`,
|
||||||
|
enforceSso: false,
|
||||||
|
enforceMfa: false,
|
||||||
|
emailDomains: [] as string[],
|
||||||
|
} as unknown as Workspace;
|
||||||
|
|
||||||
|
return { workspace, invitationId: invite.id, token: invite.token, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('concurrent accepts create a single membership and consume the invitation once', async () => {
|
||||||
|
const { workspace, invitationId, token, email } = await seedInvite();
|
||||||
|
|
||||||
|
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||||
|
|
||||||
|
// Fire two accepts of the SAME invitation at once. They race to insert the
|
||||||
|
// same (email, workspaceId); the unique constraint lets exactly one win.
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||||
|
const rejected = results.filter(
|
||||||
|
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exactly one accept succeeds; the other is rejected.
|
||||||
|
expect(fulfilled).toHaveLength(1);
|
||||||
|
expect(rejected).toHaveLength(1);
|
||||||
|
|
||||||
|
// The loser fails via the caught unique-constraint path with the specific
|
||||||
|
// "already accepted" message — not a half-state / generic failure.
|
||||||
|
expect(rejected[0].reason).toBeInstanceOf(BadRequestException);
|
||||||
|
expect(rejected[0].reason.message).toBe('Invitation already accepted');
|
||||||
|
|
||||||
|
// Invariant: exactly one membership, and the invitation is gone.
|
||||||
|
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||||
|
expect(await invitationExists(invitationId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeated (sequential) accept does not create a duplicate membership', async () => {
|
||||||
|
const { workspace, invitationId, token, email } = await seedInvite();
|
||||||
|
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||||
|
|
||||||
|
// First accept succeeds and returns an auth token.
|
||||||
|
const first = await service.acceptInvitation({ ...dto }, workspace);
|
||||||
|
expect(first?.authToken).toBe('test-auth-token');
|
||||||
|
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||||
|
expect(await invitationExists(invitationId)).toBe(false);
|
||||||
|
|
||||||
|
// Re-accepting the (now consumed) invitation must be rejected and must NOT
|
||||||
|
// add a second membership. The invitation row is gone, so this hits the
|
||||||
|
// "Invitation not found" guard rather than the unique-constraint path.
|
||||||
|
await expect(
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the single created membership is added to the default group (no partial state)', async () => {
|
||||||
|
const { workspace, invitationId, token, email } = await seedInvite();
|
||||||
|
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||||
|
|
||||||
|
await Promise.allSettled([
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resolve the one surviving user and assert the whole tx applied: they exist
|
||||||
|
// AND are in the workspace default group (the mid-transaction step), proving
|
||||||
|
// the winning accept committed as a whole rather than leaving a torn state.
|
||||||
|
const user = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id'])
|
||||||
|
.where('workspaceId', '=', workspace.id)
|
||||||
|
.where('email', '=', email.toLowerCase())
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const defaultGroup = await db
|
||||||
|
.selectFrom('groups')
|
||||||
|
.select(['id'])
|
||||||
|
.where('workspaceId', '=', workspace.id)
|
||||||
|
.where('isDefault', '=', true)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const membership = await db
|
||||||
|
.selectFrom('groupUsers')
|
||||||
|
.select(['userId'])
|
||||||
|
.where('groupId', '=', defaultGroup.id)
|
||||||
|
.where('userId', '=', user.id)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
expect(membership).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,5 +13,9 @@
|
|||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"marked": "17.0.5"
|
"marked": "17.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
|
"vitest": "4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,21 @@ export default defineConfig({
|
|||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ["src/**/*.{test,spec}.ts"],
|
include: ["src/**/*.{test,spec}.ts"],
|
||||||
|
// Coverage gate (issue #324). v8 provider avoids the istanbul AST-rewrite
|
||||||
|
// that broke on this package's ESM barrel. Thresholds sit a few points
|
||||||
|
// below the level measured on develop, over the files the suite exercises
|
||||||
|
// (`all: false`), so the gate passes today and catches a real regression.
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text-summary", "text"],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 54,
|
||||||
|
branches: 44,
|
||||||
|
functions: 60,
|
||||||
|
lines: 54,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@tiptap/core": "3.20.4",
|
"@tiptap/core": "3.20.4",
|
||||||
"@tiptap/extension-highlight": "3.20.4",
|
"@tiptap/extension-highlight": "3.20.4",
|
||||||
"@tiptap/extension-image": "3.20.4",
|
"@tiptap/extension-image": "3.20.4",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"fast-check": "^4.8.0",
|
"fast-check": "^4.8.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vitest": "4.1.6"
|
"vitest": "4.1.6"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
*/
|
*/
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { sep } from "node:path";
|
import { sep } from "node:path";
|
||||||
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
|
import { parsePageFile, serializePageFile } from "@docmost/prosemirror-markdown";
|
||||||
import type { GitSyncClient } from "./client.types.js";
|
import type { GitSyncClient } from "./client.types.js";
|
||||||
import { buildVaultLayout, type PageNode } from "./layout.js";
|
import { buildVaultLayout, type PageNode } from "./layout.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
* the gitmost server drives the engine in-process (there is no standalone CLI
|
* the gitmost server drives the engine in-process (there is no standalone CLI
|
||||||
* entry point).
|
* entry point).
|
||||||
*/
|
*/
|
||||||
import { type DocmostMdMeta } from "../lib/index.js";
|
import {
|
||||||
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
|
type DocmostMdMeta,
|
||||||
|
parsePageFile,
|
||||||
|
serializePageFile,
|
||||||
|
} from "@docmost/prosemirror-markdown";
|
||||||
import type { GitSyncClient } from "./client.types.js";
|
import type { GitSyncClient } from "./client.types.js";
|
||||||
import type { DiffEntry } from "./git.js";
|
import type { DiffEntry } from "./git.js";
|
||||||
import { VaultGit, DEFAULT_BRANCH } from "./git.js";
|
import { VaultGit, DEFAULT_BRANCH } from "./git.js";
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
markdownToProseMirror,
|
markdownToProseMirror,
|
||||||
serializeDocmostMarkdownBody,
|
serializeDocmostMarkdownBody,
|
||||||
type DocmostMdMeta,
|
type DocmostMdMeta,
|
||||||
} from "../lib/index.js";
|
} from "@docmost/prosemirror-markdown";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte
|
* Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Pure converter (markdown <-> ProseMirror, file envelope, canonicalization).
|
// Pure converter (markdown <-> ProseMirror, file envelope, canonicalization).
|
||||||
|
// Re-exported from the standalone `@docmost/prosemirror-markdown` package,
|
||||||
|
// which is the single source of truth for the converter core; git-sync keeps
|
||||||
|
// only the engine (vault/git/orchestrator) and re-surfaces the converter for
|
||||||
|
// in-process consumers of the git-sync barrel.
|
||||||
export {
|
export {
|
||||||
serializeDocmostMarkdown,
|
serializeDocmostMarkdown,
|
||||||
serializeDocmostMarkdownBody,
|
serializeDocmostMarkdownBody,
|
||||||
@@ -16,8 +20,8 @@ export {
|
|||||||
markdownToProseMirror,
|
markdownToProseMirror,
|
||||||
canonicalizeContent,
|
canonicalizeContent,
|
||||||
docsCanonicallyEqual,
|
docsCanonicallyEqual,
|
||||||
} from "./lib/index.js";
|
} from "@docmost/prosemirror-markdown";
|
||||||
export type { DocmostMdMeta } from "./lib/index.js";
|
export type { DocmostMdMeta } from "@docmost/prosemirror-markdown";
|
||||||
|
|
||||||
// Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize,
|
// Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize,
|
||||||
// loop-guard body hash.
|
// loop-guard body hash.
|
||||||
@@ -123,4 +127,4 @@ export {
|
|||||||
} from "./engine/path-guard.js";
|
} from "./engine/path-guard.js";
|
||||||
export type { PathGuardIo, VaultPathUnsafeReason } from "./engine/path-guard.js";
|
export type { PathGuardIo, VaultPathUnsafeReason } from "./engine/path-guard.js";
|
||||||
|
|
||||||
export { parsePageFile, serializePageFile } from "./lib/page-file.js";
|
export { parsePageFile, serializePageFile } from "@docmost/prosemirror-markdown";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,365 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure markdown -> ProseMirror conversion.
|
|
||||||
*
|
|
||||||
* The converter path is `markdownToProseMirror` (marked -> HTML ->
|
|
||||||
* generateJSON) plus the two pre/post processors it needs (`preprocessCallouts`,
|
|
||||||
* `bridgeTaskLists`). The gitmost server writes the resulting page bodies
|
|
||||||
* natively through the collab gateway, so no websocket/Yjs write-path lives
|
|
||||||
* here.
|
|
||||||
*/
|
|
||||||
import { generateJSON } from "@tiptap/html";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import { docmostExtensions } from "./docmost-schema.js";
|
|
||||||
|
|
||||||
// Setup DOM environment for Tiptap HTML parsing in Node.js
|
|
||||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
|
||||||
global.window = dom.window as any;
|
|
||||||
global.document = dom.window.document;
|
|
||||||
// @ts-ignore
|
|
||||||
global.Element = dom.window.Element;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hard ceiling above which we skip callout preprocessing entirely. The linear
|
|
||||||
* scanner below has no quadratic blow-up, but we still cap input defensively so
|
|
||||||
* a pathological multi-megabyte payload cannot tie up the event loop; in that
|
|
||||||
* case the markdown is passed through verbatim (callouts are simply not
|
|
||||||
* detected) rather than risking a slow scan.
|
|
||||||
*/
|
|
||||||
const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB
|
|
||||||
|
|
||||||
/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */
|
|
||||||
const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/;
|
|
||||||
/** Matches a bare closing callout fence: `:::`. */
|
|
||||||
const CALLOUT_CLOSE_RE = /^:::\s*$/;
|
|
||||||
/**
|
|
||||||
* Matches an Obsidian-native callout opener: `> [!type]` (type captured). An
|
|
||||||
* optional title after the type is allowed but ignored (the Docmost callout
|
|
||||||
* schema has no title). The body is the following contiguous blockquote lines.
|
|
||||||
*/
|
|
||||||
const CALLOUT_BQ_OPEN_RE = /^>\s*\[!(\w+)\]/;
|
|
||||||
/** Matches any blockquote continuation line (`>` … ). */
|
|
||||||
const BLOCKQUOTE_LINE_RE = /^>/;
|
|
||||||
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
|
|
||||||
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-process Docmost-flavoured markdown: convert `:::type ... :::`
|
|
||||||
* callout blocks (the syntax our markdown export produces) into HTML
|
|
||||||
* divs that the callout extension parses. The inner content is rendered
|
|
||||||
* through marked as regular markdown.
|
|
||||||
*
|
|
||||||
* Implemented as a single linear pass over the lines (no quadratic regex
|
|
||||||
* rescan). It:
|
|
||||||
* - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a
|
|
||||||
* `:::` line that lives inside a code fence as a callout delimiter, so a
|
|
||||||
* callout body that itself contains a fenced code block with a `:::` line is
|
|
||||||
* no longer corrupted;
|
|
||||||
* - matches an opening `:::type` line with the next CLOSING `:::` at the SAME
|
|
||||||
* nesting level, supporting NESTED callouts via a depth counter (an inner
|
|
||||||
* `:::type` opens a deeper level and consumes a matching `:::`);
|
|
||||||
* - emits the same `<div data-type="callout" data-callout-type="TYPE">` output
|
|
||||||
* (inner rendered through marked) as the previous regex implementation.
|
|
||||||
*/
|
|
||||||
async function preprocessCallouts(markdown: string): Promise<string> {
|
|
||||||
// Defensive cap: skip preprocessing for pathologically large inputs.
|
|
||||||
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
|
||||||
return markdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively transform a slice of lines, converting top-level callouts in
|
|
||||||
// that slice into <div> blocks and rendering their inner content (which may
|
|
||||||
// itself contain nested callouts) through this same function.
|
|
||||||
const transform = async (lines: string[]): Promise<string> => {
|
|
||||||
const out: string[] = [];
|
|
||||||
let inCodeFence = false;
|
|
||||||
let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < lines.length) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Inside a code fence, only its matching closing fence is significant;
|
|
||||||
// everything else (including `:::` lines) is copied through verbatim.
|
|
||||||
if (inCodeFence) {
|
|
||||||
out.push(line);
|
|
||||||
const fence = line.match(CODE_FENCE_RE);
|
|
||||||
if (fence && fence[2].startsWith(codeFenceMarker[0]) &&
|
|
||||||
fence[2].length >= codeFenceMarker.length) {
|
|
||||||
inCodeFence = false;
|
|
||||||
codeFenceMarker = "";
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A code fence opening outside any callout body: enter code-fence mode.
|
|
||||||
const fenceOpen = line.match(CODE_FENCE_RE);
|
|
||||||
if (fenceOpen) {
|
|
||||||
inCodeFence = true;
|
|
||||||
codeFenceMarker = fenceOpen[2];
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// An opening callout fence: scan forward (with code-fence and nested
|
|
||||||
// callout awareness) for its matching closing `:::` at the same level.
|
|
||||||
const open = line.match(CALLOUT_OPEN_RE);
|
|
||||||
if (open) {
|
|
||||||
const type = open[1].toLowerCase();
|
|
||||||
const bodyLines: string[] = [];
|
|
||||||
let depth = 1;
|
|
||||||
let innerInCodeFence = false;
|
|
||||||
let innerCodeFenceMarker = "";
|
|
||||||
let j = i + 1;
|
|
||||||
for (; j < lines.length; j++) {
|
|
||||||
const bl = lines[j];
|
|
||||||
if (innerInCodeFence) {
|
|
||||||
const f = bl.match(CODE_FENCE_RE);
|
|
||||||
if (f && f[2].startsWith(innerCodeFenceMarker[0]) &&
|
|
||||||
f[2].length >= innerCodeFenceMarker.length) {
|
|
||||||
innerInCodeFence = false;
|
|
||||||
innerCodeFenceMarker = "";
|
|
||||||
}
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const innerFence = bl.match(CODE_FENCE_RE);
|
|
||||||
if (innerFence) {
|
|
||||||
innerInCodeFence = true;
|
|
||||||
innerCodeFenceMarker = innerFence[2];
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (CALLOUT_OPEN_RE.test(bl)) {
|
|
||||||
depth++;
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (CALLOUT_CLOSE_RE.test(bl)) {
|
|
||||||
depth--;
|
|
||||||
if (depth === 0) break; // matching close for THIS callout
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
bodyLines.push(bl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (j < lines.length) {
|
|
||||||
// Found the matching closing fence: render the body (recursively, so
|
|
||||||
// nested callouts are handled) and emit the callout div.
|
|
||||||
const inner = await transform(bodyLines);
|
|
||||||
const renderedInner = await marked.parse(inner);
|
|
||||||
out.push(
|
|
||||||
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
|
|
||||||
);
|
|
||||||
i = j + 1; // skip past the closing `:::`
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// No matching close (unterminated callout): treat the opener as a
|
|
||||||
// literal line and continue, preserving the original text.
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// An Obsidian-native callout: `> [!type]` opener; the body is the following
|
|
||||||
// CONTIGUOUS blockquote (`>`-prefixed) lines. Strip ONE blockquote level and
|
|
||||||
// recurse so nested callouts (`> > [!type]`) are handled, then emit the same
|
|
||||||
// callout div the `:::` path produces. A normal blockquote (no `[!type]` on
|
|
||||||
// its first line) does not match and stays a blockquote.
|
|
||||||
const bqOpen = line.match(CALLOUT_BQ_OPEN_RE);
|
|
||||||
if (bqOpen) {
|
|
||||||
const type = bqOpen[1].toLowerCase();
|
|
||||||
const bodyLines: string[] = [];
|
|
||||||
let j = i + 1;
|
|
||||||
for (; j < lines.length; j++) {
|
|
||||||
if (!BLOCKQUOTE_LINE_RE.test(lines[j])) break;
|
|
||||||
bodyLines.push(lines[j].replace(/^>\s?/, ""));
|
|
||||||
}
|
|
||||||
const inner = await transform(bodyLines);
|
|
||||||
const renderedInner = await marked.parse(inner);
|
|
||||||
out.push(
|
|
||||||
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
|
|
||||||
);
|
|
||||||
i = j;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
return transform(markdown.split("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridge marked's checkbox lists to TipTap task lists.
|
|
||||||
*
|
|
||||||
* marked renders GitHub task list items (`- [x] done`) as a plain
|
|
||||||
* `<ul><li><p><input type="checkbox" checked> text</p></li></ul>` WITHOUT the
|
|
||||||
* markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists
|
|
||||||
* into the shape those extensions expect:
|
|
||||||
* TaskList parseHTML matches `ul[data-type="taskList"]`,
|
|
||||||
* TaskItem matches `li[data-type="taskItem"]`,
|
|
||||||
* the checked state is read from `data-checked === "true"`.
|
|
||||||
*
|
|
||||||
* A list is only converted when it has at least one `<li>` and EVERY direct
|
|
||||||
* `<li>` contains a checkbox input. Both `<ul>` and `<ol>` are considered: a
|
|
||||||
* numbered checklist (`1. [x] a`, which marked renders as an `<ol>` of checkbox
|
|
||||||
* `<li>`s) would otherwise lose its task state. TipTap task lists are unordered,
|
|
||||||
* so a matching `<ol>` is emitted as `data-type="taskList"` exactly like a
|
|
||||||
* `<ul>`. Mixed or ordinary lists (including ordinary `<ol>` lists) are left
|
|
||||||
* untouched so they keep rendering as bullet/numbered lists. The marked `<p>`
|
|
||||||
* wrapper is kept inside the `<li>` because TaskItem content allows paragraphs.
|
|
||||||
*/
|
|
||||||
function bridgeTaskLists(html: string): string {
|
|
||||||
// Cheap early-out: if the markup contains no checkbox input at all there is
|
|
||||||
// nothing to bridge, so skip the expensive JSDOM parse entirely. This is the
|
|
||||||
// common case (most pages have no task lists).
|
|
||||||
if (!/type=["']?checkbox/i.test(html)) {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
// Defensive cap (consistent with preprocessCallouts): skip the bridge for
|
|
||||||
// pathologically large inputs rather than running a second expensive JSDOM
|
|
||||||
// parse on a multi-megabyte payload. The markup is passed through verbatim.
|
|
||||||
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
const dom = new JSDOM(html);
|
|
||||||
const document = dom.window.document;
|
|
||||||
// Collect the checkbox(es) that belong to THIS <li> directly: either direct
|
|
||||||
// child <input type="checkbox"> elements or ones inside the <li>'s direct <p>
|
|
||||||
// child (the shape marked emits: `<li><p><input type="checkbox"> text</p></li>`).
|
|
||||||
// Checkboxes nested deeper (e.g. inside a child <ul>/<ol>) are excluded so a
|
|
||||||
// bullet <li> that merely contains a nested task sublist is not misdetected.
|
|
||||||
// Raw inline HTML can put more than one checkbox in a single <li>; we gather
|
|
||||||
// ALL of them so none survive into the converted item.
|
|
||||||
const directCheckboxes = (li: Element): Element[] => {
|
|
||||||
const found: Element[] = [];
|
|
||||||
for (const child of Array.from(li.children)) {
|
|
||||||
if (
|
|
||||||
child.tagName === "INPUT" &&
|
|
||||||
child.getAttribute("type") === "checkbox"
|
|
||||||
) {
|
|
||||||
found.push(child);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (child.tagName === "P") {
|
|
||||||
for (const inp of Array.from(
|
|
||||||
child.querySelectorAll(":scope > input[type='checkbox']"),
|
|
||||||
)) {
|
|
||||||
found.push(inp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
};
|
|
||||||
// Both <ul> and <ol> are candidates: an <ol> whose every direct <li> carries
|
|
||||||
// its own checkbox is a numbered checklist that must also become a taskList.
|
|
||||||
const lists = Array.from(document.querySelectorAll("ul, ol"));
|
|
||||||
for (const list of lists) {
|
|
||||||
// Only consider DIRECT child <li> elements; nested lists are handled by
|
|
||||||
// their own iteration of the outer loop.
|
|
||||||
const items = Array.from(list.children).filter(
|
|
||||||
(child) => child.tagName === "LI",
|
|
||||||
);
|
|
||||||
if (items.length === 0) continue;
|
|
||||||
const itemCheckboxes = items.map((li) => directCheckboxes(li));
|
|
||||||
// Convert only when every direct <li> carries at least one OWN checkbox.
|
|
||||||
if (!itemCheckboxes.every((boxes) => boxes.length > 0)) continue;
|
|
||||||
|
|
||||||
// A numbered checklist arrives as an <ol>. We must NOT leave the tag as
|
|
||||||
// <ol> while tagging it data-type="taskList": generateJSON would then match
|
|
||||||
// BOTH the orderedList rule (tag ol) and the taskList rule (data-type),
|
|
||||||
// emitting a phantom empty orderedList beside the real taskList. So rename a
|
|
||||||
// qualifying <ol> to a <ul> — move its <li> children over and replace it —
|
|
||||||
// leaving only the taskList rule to match. Already-<ul> lists are unchanged.
|
|
||||||
let target: Element = list;
|
|
||||||
if (list.tagName === "OL") {
|
|
||||||
const ul = document.createElement("ul");
|
|
||||||
// Carry over existing attributes (e.g. class) so nothing is silently lost.
|
|
||||||
for (const attr of Array.from(list.attributes)) {
|
|
||||||
ul.setAttribute(attr.name, attr.value);
|
|
||||||
}
|
|
||||||
// Move every child node (including the <li>s we collected) into the <ul>.
|
|
||||||
while (list.firstChild) {
|
|
||||||
ul.appendChild(list.firstChild);
|
|
||||||
}
|
|
||||||
list.replaceWith(ul);
|
|
||||||
target = ul;
|
|
||||||
}
|
|
||||||
|
|
||||||
target.setAttribute("data-type", "taskList");
|
|
||||||
items.forEach((li, index) => {
|
|
||||||
const boxes = itemCheckboxes[index];
|
|
||||||
// The first checkbox determines the checked state (matches the previous
|
|
||||||
// single-checkbox behaviour); any extras only need removing.
|
|
||||||
const input = boxes[0] ?? null;
|
|
||||||
li.setAttribute("data-type", "taskItem");
|
|
||||||
const checked =
|
|
||||||
input != null &&
|
|
||||||
(input.hasAttribute("checked") || (input as any).checked);
|
|
||||||
li.setAttribute("data-checked", checked ? "true" : "false");
|
|
||||||
// Remove ALL direct checkbox inputs so none survive into the content
|
|
||||||
// (a raw-inline-HTML <li> may carry more than one).
|
|
||||||
for (const box of boxes) {
|
|
||||||
box.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return document.body.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively strip content-less paragraph nodes from a generated doc.
|
|
||||||
*
|
|
||||||
* A block-level atom whose markdown form is INLINE (e.g. the block `image`'s
|
|
||||||
* ``, or a bare media element) is wrapped by marked in a <p>; the schema
|
|
||||||
* then HOISTS the block atom out of that paragraph, leaving an EMPTY paragraph
|
|
||||||
* sibling. On the next export that empty `<p>` renders to "" and the doc "\n\n"
|
|
||||||
* join injects a phantom blank gap, so the markdown is not byte-stable.
|
|
||||||
*
|
|
||||||
* Markdown blank lines are separators, never content, so generateJSON only ever
|
|
||||||
* produces an empty paragraph as such a hoist artifact — removing them is safe
|
|
||||||
* and general (it also subsumes the <div>-wrapper workaround the `video` case
|
|
||||||
* uses). We remove ONLY `type === 'paragraph'` nodes whose `content` is absent
|
|
||||||
* or an empty array; every other node (including atoms without `content`) is
|
|
||||||
* preserved, and we recurse into the content of any node that has children.
|
|
||||||
*/
|
|
||||||
function stripEmptyParagraphs(node: any): any {
|
|
||||||
if (!node || !Array.isArray(node.content)) {
|
|
||||||
// Atom / leaf node (no children to recurse into): keep as-is.
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
const mapped = node.content.map((child: any) => stripEmptyParagraphs(child));
|
|
||||||
const isEmptyParagraph = (child: any): boolean =>
|
|
||||||
!!child &&
|
|
||||||
child.type === "paragraph" &&
|
|
||||||
(!Array.isArray(child.content) || child.content.length === 0);
|
|
||||||
const filtered = mapped.filter((child: any) => !isEmptyParagraph(child));
|
|
||||||
// Schema-validity guard: several nodes require NON-empty block content
|
|
||||||
// (`content: "block+"` — tableCell, tableHeader, blockquote, column, callout,
|
|
||||||
// and the doc root). For an empty one of those, generateJSON materializes a
|
|
||||||
// single empty paragraph as its OBLIGATORY content — that is not a hoist
|
|
||||||
// artifact. If stripping would empty the container, keep ONE empty paragraph
|
|
||||||
// so the result stays schema-valid (an empty cell/quote must not become `[]`).
|
|
||||||
const cleaned =
|
|
||||||
filtered.length === 0 && mapped.length > 0 ? [mapped[0]] : filtered;
|
|
||||||
return { ...node, content: cleaned };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert markdown to a ProseMirror doc using the full Docmost schema. */
|
|
||||||
export async function markdownToProseMirror(
|
|
||||||
markdownContent: string,
|
|
||||||
): Promise<any> {
|
|
||||||
const withCallouts = await preprocessCallouts(markdownContent);
|
|
||||||
const html = await marked.parse(withCallouts);
|
|
||||||
const bridged = bridgeTaskLists(html);
|
|
||||||
const doc = generateJSON(bridged, docmostExtensions);
|
|
||||||
return stripEmptyParagraphs(doc);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import { applyPushActions, LAST_PUSHED_REF } from '../src/engine/push';
|
import { applyPushActions, LAST_PUSHED_REF } from '../src/engine/push';
|
||||||
import { bodyHash } from '../src/engine/loop-guard';
|
import { bodyHash } from '../src/engine/loop-guard';
|
||||||
import type { ApplyPushDeps, PushActions } from '../src/engine/push';
|
import type { ApplyPushDeps, PushActions } from '../src/engine/push';
|
||||||
import { parsePageFile, serializePageFile } from '../src/lib/page-file';
|
import { parsePageFile, serializePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// The Docmost space this vault mirrors (native files carry no spaceId; the run
|
// The Docmost space this vault mirrors (native files carry no spaceId; the run
|
||||||
// supplies it). A CREATE targets this space.
|
// supplies it). A CREATE targets this space.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
MetaSide,
|
MetaSide,
|
||||||
RenameMoveAction,
|
RenameMoveAction,
|
||||||
} from '../src/engine/push';
|
} from '../src/engine/push';
|
||||||
import type { DocmostMdMeta } from '../src/lib/index';
|
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// FS→Docmost push #3 (SPEC §5/§6/§16). `classifyRenameMoves` is the PURE half of
|
// FS→Docmost push #3 (SPEC §5/§6/§16). `classifyRenameMoves` is the PURE half of
|
||||||
// the move/rename apply: it resolves each `{pageId, oldPath, newPath}` into the
|
// the move/rename apply: it resolves each `{pageId, oldPath, newPath}` into the
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { computePushActions } from '../src/engine/push';
|
import { computePushActions } from '../src/engine/push';
|
||||||
import type { DiffEntry, MetaSide } from '../src/engine/push';
|
import type { DiffEntry, MetaSide } from '../src/engine/push';
|
||||||
import type { DocmostMdMeta } from '../src/lib/index';
|
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// FS→Docmost push, FIRST increment (SPEC §6). `computePushActions` is the PURE
|
// FS→Docmost push, FIRST increment (SPEC §6). `computePushActions` is the PURE
|
||||||
// half: it classifies each `git diff --name-status` row into a Docmost action by
|
// half: it classifies each `git diff --name-status` row into a Docmost action by
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { runCycle } from "../src/engine/cycle";
|
|||||||
import type { CycleFs } from "../src/engine/cycle";
|
import type { CycleFs } from "../src/engine/cycle";
|
||||||
import { VaultGit } from "../src/engine/git";
|
import { VaultGit } from "../src/engine/git";
|
||||||
import type { Settings } from "../src/engine/settings";
|
import type { Settings } from "../src/engine/settings";
|
||||||
import { serializeDocmostMarkdownBody } from "../src/lib/index";
|
import { serializeDocmostMarkdownBody } from "@docmost/prosemirror-markdown";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { firstDivergence } from './roundtrip-helpers';
|
|||||||
import { applyPullActions } from '../src/engine/pull';
|
import { applyPullActions } from '../src/engine/pull';
|
||||||
import type { PullActions, ApplyPullActionsDeps } from '../src/engine/pull';
|
import type { PullActions, ApplyPullActionsDeps } from '../src/engine/pull';
|
||||||
import type { DeletionDecision } from '../src/engine/reconcile';
|
import type { DeletionDecision } from '../src/engine/reconcile';
|
||||||
import { serializePageFile, parsePageFile } from '../src/lib/page-file';
|
import { serializePageFile, parsePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// Engine-layer coverage gaps flagged by the PR #119 reviewers (test-strategy
|
// Engine-layer coverage gaps flagged by the PR #119 reviewers (test-strategy
|
||||||
// report, Module 2 `src/engine`). Each block targets a specific under-covered
|
// report, Module 2 `src/engine`). Each block targets a specific under-covered
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { readExisting } from '../src/engine/pull';
|
import { readExisting } from '../src/engine/pull';
|
||||||
import { serializePageFile } from '../src/lib/page-file';
|
import { serializePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// R-Pull-1 (test-strategy report §5): `readExisting` now takes injectable IO
|
// R-Pull-1 (test-strategy report §5): `readExisting` now takes injectable IO
|
||||||
// (`listTracked` / `readFile`), so its parsing + skip rules are unit-testable
|
// (`listTracked` / `readFile`), so its parsing + skip rules are unit-testable
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
MetaSide,
|
MetaSide,
|
||||||
RenameMoveAction,
|
RenameMoveAction,
|
||||||
} from '../src/engine/push.js';
|
} from '../src/engine/push.js';
|
||||||
import type { DocmostMdMeta } from '../src/lib/index.js';
|
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// RED-TEAM finding #4 (two facets):
|
// RED-TEAM finding #4 (two facets):
|
||||||
// (a) buildVaultLayout disambiguation is ORDER-DEPENDENT: which of two
|
// (a) buildVaultLayout disambiguation is ORDER-DEPENDENT: which of two
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import type { PushDeps } from '../src/engine/push';
|
import type { PushDeps } from '../src/engine/push';
|
||||||
import type { Settings } from '../src/engine/settings';
|
import type { Settings } from '../src/engine/settings';
|
||||||
import { runCycle, type RunCycleDeps } from '../src/engine/cycle';
|
import { runCycle, type RunCycleDeps } from '../src/engine/cycle';
|
||||||
import { serializePageFile } from '../src/lib/page-file';
|
import { serializePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// Red-team confirmations for PR #119 (git-sync). Each test asserts the DESIRED
|
// Red-team confirmations for PR #119 (git-sync). Each test asserts the DESIRED
|
||||||
// behavior, so it FAILS today iff the bug is real.
|
// behavior, so it FAILS today iff the bug is real.
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { readdirSync } from 'node:fs';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
convertProseMirrorToMarkdown,
|
|
||||||
markdownToProseMirror,
|
|
||||||
docsCanonicallyEqual,
|
|
||||||
} from 'docmost-client';
|
|
||||||
|
|
||||||
// Resolve fixtures relative to this test file so the test is CWD-independent.
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const CORPUS_DIR = join(here, 'fixtures', 'corpus');
|
|
||||||
const KNOWN_LIMITATIONS_DIR = join(here, 'fixtures', 'known-limitations');
|
|
||||||
|
|
||||||
/** Run a single document through export -> import -> export. */
|
|
||||||
async function roundTrip(doc: any) {
|
|
||||||
const md1 = convertProseMirrorToMarkdown(doc);
|
|
||||||
const doc2 = await markdownToProseMirror(md1);
|
|
||||||
const md2 = convertProseMirrorToMarkdown(doc2);
|
|
||||||
return { md1, md2, doc2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('round-trip corpus (SPEC §11)', () => {
|
|
||||||
// Discover the corpus synchronously at collection time so each fixture gets
|
|
||||||
// its own `it` with the file name in the test title.
|
|
||||||
const files = readdirSync(CORPUS_DIR)
|
|
||||||
.filter((name) => name.endsWith('.json'))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
it('has a non-empty corpus', () => {
|
|
||||||
expect(files.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const name of files) {
|
|
||||||
it(`${name}: markdown byte-stable AND canonically stable`, async () => {
|
|
||||||
const doc = JSON.parse(await readFile(join(CORPUS_DIR, name), 'utf8'));
|
|
||||||
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
||||||
|
|
||||||
// 1) The byte-stable markdown property git actually needs.
|
|
||||||
expect(md2, `${name}: markdown not byte-stable`).toBe(md1);
|
|
||||||
// 2) Semantic stability (block ids stripped, default-null normalized).
|
|
||||||
expect(
|
|
||||||
docsCanonicallyEqual(doc, doc2),
|
|
||||||
`${name}: document not canonically stable`,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// KNOWN CONVERTER LIMITATIONS (isolated so they do NOT make CI red).
|
|
||||||
//
|
|
||||||
// SPEC §11 explicitly flags images and diagrams as high round-trip risk. These
|
|
||||||
// fixtures are kept OUT of the green corpus above and asserted with `it.fails`
|
|
||||||
// so the documented divergence is locked in (the test FAILS if the converter
|
|
||||||
// ever starts round-tripping them — at which point promote the fixture into
|
|
||||||
// the corpus). The precise divergences for `image-diagrams.json` are:
|
|
||||||
//
|
|
||||||
// * A BLOCK-LEVEL image preceded by a paragraph is NOT byte-stable on the
|
|
||||||
// FIRST re-export. The HTML re-parser hoists the block <img> out of its
|
|
||||||
// line and leaves an empty paragraph behind, so `paragraph` + ``
|
|
||||||
// re-imports as paragraph + empty-paragraph + image; the empty paragraph
|
|
||||||
// adds one blank line, so export #2 grows by a one-time "\n\n" (md1 !== md2).
|
|
||||||
// This is NOT non-convergence: the growth happens exactly ONCE. The doc
|
|
||||||
// CONVERGES to a fixpoint after one extra `export→import→export` pass — the
|
|
||||||
// empty paragraph is already present after the first import, so export #2
|
|
||||||
// and export #3 are byte-identical (md2 === md3, verified).
|
|
||||||
//
|
|
||||||
// * drawio / excalidraw diagrams gain `data-align="center"` on the second
|
|
||||||
// export: the schema's diagram `align` attribute has a NON-null default of
|
|
||||||
// "center", which materializes on import; the converter only emits
|
|
||||||
// data-align when set, so it appears on export #2 but not #1. Like the
|
|
||||||
// image case, this is one-time and converges after one extra pass.
|
|
||||||
//
|
|
||||||
// * A STANDALONE block image (no preceding paragraph) IS byte-stable from
|
|
||||||
// export #1 (md1 === md2) — but it is still NOT canonically stable: on
|
|
||||||
// import the bare <img> is wrapped, gaining a leading EMPTY paragraph, so
|
|
||||||
// the canonical doc differs by that spurious paragraph node even though the
|
|
||||||
// markdown bytes match.
|
|
||||||
//
|
|
||||||
// Resolution (SPEC §11, "normalize-on-write"): rather than deep-fixing the
|
|
||||||
// converter, the engine runs ONE `export→import→export` pass when writing into
|
|
||||||
// the vault; from that fixpoint onward the form is byte-stable, so git sees no
|
|
||||||
// phantom diff. The green corpus above avoids these one-time asymmetries by
|
|
||||||
// pre-authoring the materialized defaults (e.g. `align: "center"` on the
|
|
||||||
// diagrams in 06-diagrams.json) so a single pass is already at the fixpoint.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('round-trip KNOWN LIMITATIONS (SPEC §11 image/diagram risk)', () => {
|
|
||||||
it.fails(
|
|
||||||
'image-diagrams.json is NOT byte-stable on export #1 (block image hoist + diagram align default; converges after one extra pass — SPEC §11 normalize-on-write)',
|
|
||||||
async () => {
|
|
||||||
const doc = JSON.parse(
|
|
||||||
await readFile(join(KNOWN_LIMITATIONS_DIR, 'image-diagrams.json'), 'utf8'),
|
|
||||||
);
|
|
||||||
const { md1, md2 } = await roundTrip(doc);
|
|
||||||
// This assertion FAILS today (documented divergence). `it.fails` turns a
|
|
||||||
// failing body into a PASS; if the converter is fixed this flips and the
|
|
||||||
// test goes red, prompting promotion into the green corpus.
|
|
||||||
expect(md2).toBe(md1);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,7 @@ import { runPush, LAST_PUSHED_REF } from '../src/engine/push';
|
|||||||
import type { PushDeps } from '../src/engine/push';
|
import type { PushDeps } from '../src/engine/push';
|
||||||
import { VaultGit } from '../src/engine/git';
|
import { VaultGit } from '../src/engine/git';
|
||||||
import type { Settings } from '../src/engine/settings';
|
import type { Settings } from '../src/engine/settings';
|
||||||
import { serializeDocmostMarkdownBody } from '../src/lib/index';
|
import { serializeDocmostMarkdownBody } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/engine/push';
|
import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/engine/push';
|
||||||
import type { PushDeps } from '../src/engine/push';
|
import type { PushDeps } from '../src/engine/push';
|
||||||
import type { Settings } from '../src/engine/settings';
|
import type { Settings } from '../src/engine/settings';
|
||||||
import { serializePageFile } from '../src/lib/page-file';
|
import { serializePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
/** A native page file: `gitmost_id` frontmatter + clean body (title = filename). */
|
/** A native page file: `gitmost_id` frontmatter + clean body (title = filename). */
|
||||||
function fileFor(pageId: string, body = 'body'): string {
|
function fileFor(pageId: string, body = 'body'): string {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { stabilizePageFile, type PageMeta } from '../src/engine/stabilize.js';
|
import { stabilizePageFile, type PageMeta } from '../src/engine/stabilize.js';
|
||||||
// markdownToProseMirror lives in collaboration.ts; importing it mutates the
|
// markdownToProseMirror lives in collaboration.ts; importing it mutates the
|
||||||
// global DOM via jsdom at module load time (required for @tiptap/html under Node).
|
// global DOM via jsdom at module load time (required for @tiptap/html under Node).
|
||||||
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
import { parseDocmostMarkdown } from '../src/lib/markdown-document.js';
|
import { parseDocmostMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// stabilize.ts (SPEC §11 normalize-on-write) was 0% covered (only the gated e2e
|
// stabilize.ts (SPEC §11 normalize-on-write) was 0% covered (only the gated e2e
|
||||||
// touched it). stabilizePageFile is import-testable: build a small ProseMirror
|
// touched it). stabilizePageFile is import-testable: build a small ProseMirror
|
||||||
@@ -22,16 +22,27 @@ const meta: PageMeta = {
|
|||||||
|
|
||||||
describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () => {
|
describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () => {
|
||||||
it('reaches a byte-identical fixpoint after one extra export/import/export pass', async () => {
|
it('reaches a byte-identical fixpoint after one extra export/import/export pass', async () => {
|
||||||
// A diagram is the canonical one-pass asymmetry: drawio's `align` default of
|
// A diagram inside a column is the canonical one-pass asymmetry: on the
|
||||||
// "center" materializes on import, so a NAIVE export differs on the second
|
// raw-HTML/columns path a diagram's `align` default of "center" materializes
|
||||||
// export. stabilizePageFile runs the convergence pass at write time, so the
|
// on import, so a NAIVE export differs on the second export. (#293 canon #8
|
||||||
// written body must already be at the fixpoint: re-importing its body and
|
// made the TOP-LEVEL diagram form — `<!--drawio …-->` — byte-stable by
|
||||||
|
// omitting the default, so the asymmetry now lives only on the columns path
|
||||||
|
// where the schema `<div data-type="drawio">` form is retained.)
|
||||||
|
// stabilizePageFile runs the convergence pass at write time, so the written
|
||||||
|
// body must already be at the fixpoint: re-importing its body and
|
||||||
// re-stabilizing yields the exact same bytes.
|
// re-stabilizing yields the exact same bytes.
|
||||||
const content = {
|
const content = {
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
content: [
|
content: [
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] },
|
{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] },
|
||||||
{ type: 'drawio', attrs: { src: '/d.drawio' } },
|
{
|
||||||
|
type: 'columns',
|
||||||
|
attrs: { layout: 'two_equal' },
|
||||||
|
content: [
|
||||||
|
{ type: 'column', content: [{ type: 'drawio', attrs: { src: '/d.drawio' } }] },
|
||||||
|
{ type: 'column', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'side' }] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: 'outro' }] },
|
{ type: 'paragraph', content: [{ type: 'text', text: 'outro' }] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { getSchema } from "@tiptap/core";
|
import { getSchema } from "@tiptap/core";
|
||||||
|
|
||||||
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror";
|
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
|
||||||
import { docmostExtensions } from "../src/lib/docmost-schema";
|
import { docmostExtensions } from "@docmost/prosemirror-markdown";
|
||||||
|
|
||||||
// REGRESSION LOCK for the stripEmptyParagraphs schema-validity guard.
|
// REGRESSION LOCK for the stripEmptyParagraphs schema-validity guard.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
// Coverage gate (issue #324). The v8 provider is used deliberately: the
|
||||||
|
// istanbul provider instruments sources by rewriting their AST, which broke
|
||||||
|
// on the ESM `@docmost/editor-ext` barrel import; v8 collects native
|
||||||
|
// coverage from the runtime and never re-parses ESM, so it sidesteps that.
|
||||||
|
// Thresholds are calibrated a few points BELOW the level measured on
|
||||||
|
// develop so the gate passes today but fails on a real regression. Numbers
|
||||||
|
// reflect the files actually exercised by the suite (`all: false`).
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text-summary', 'text'],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 88,
|
||||||
|
branches: 75,
|
||||||
|
functions: 72,
|
||||||
|
lines: 88,
|
||||||
|
},
|
||||||
|
},
|
||||||
// Runtime suites. The `.test.ts` glob deliberately EXCLUDES the type-only
|
// Runtime suites. The `.test.ts` glob deliberately EXCLUDES the type-only
|
||||||
// contract file (`*.test-d.ts`), which is enforced by the typecheck pass
|
// contract file (`*.test-d.ts`), which is enforced by the typecheck pass
|
||||||
// below instead — so the 35 runtime suites are never typechecked.
|
// below instead — so the 35 runtime suites are never typechecked.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,133 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
||||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
import { createDocmostMcpServer } from "./index.js";
|
|
||||||
/**
|
|
||||||
* Build a stateful Streamable-HTTP handler for the Docmost MCP server. The
|
|
||||||
* embedding host (the gitmost NestJS server) bridges its raw Node req/res into
|
|
||||||
* `handleRequest`. One McpServer + transport is created per MCP session and
|
|
||||||
* kept alive between requests, keyed by the `mcp-session-id` header.
|
|
||||||
*
|
|
||||||
* `config` is EITHER a static `DocmostMcpConfig` (back-compat: stdio + the env
|
|
||||||
* service account, unchanged) OR a `McpConfigResolver` run once per session at
|
|
||||||
* `initialize` to bind that session to the request's identity.
|
|
||||||
*/
|
|
||||||
export function createMcpHttpHandler(config, options = {}) {
|
|
||||||
// One transport (and one McpServer) per MCP session, keyed by session id.
|
|
||||||
const transports = {};
|
|
||||||
// Last activity timestamp per session id, used for idle eviction.
|
|
||||||
const lastSeen = {};
|
|
||||||
// Anti-session-fixation: the opaque identity key bound to each session at
|
|
||||||
// initialize. A later request for that session whose key differs is rejected.
|
|
||||||
const sessionIdentity = {};
|
|
||||||
// Write a JSON-RPC error and end the response. Used for the 400/401 paths so
|
|
||||||
// every early rejection is a well-formed JSON-RPC error, not a torn response.
|
|
||||||
const sendJsonRpcError = (res, statusCode, code, message) => {
|
|
||||||
res.statusCode = statusCode;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
error: { code, message },
|
|
||||||
id: null,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
// Idle session TTL (ms): a session with no activity for this long is evicted.
|
|
||||||
// Defaults to 30 min; overridable via MCP_SESSION_IDLE_MS.
|
|
||||||
const idleTtlMs = (() => {
|
|
||||||
const parsed = parseInt(process.env.MCP_SESSION_IDLE_MS ?? "", 10);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30 * 60 * 1000;
|
|
||||||
})();
|
|
||||||
// Periodically close transports idle longer than the TTL. transport.close()
|
|
||||||
// triggers its onclose, which removes it from `transports`; we also drop the
|
|
||||||
// lastSeen entry. unref() so this timer never keeps the process alive.
|
|
||||||
const sweepIntervalMs = 5 * 60 * 1000;
|
|
||||||
const sweepTimer = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const sid of Object.keys(transports)) {
|
|
||||||
if (now - (lastSeen[sid] ?? 0) > idleTtlMs) {
|
|
||||||
void transports[sid].close();
|
|
||||||
delete lastSeen[sid];
|
|
||||||
delete sessionIdentity[sid];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, sweepIntervalMs);
|
|
||||||
sweepTimer.unref();
|
|
||||||
async function handleRequest(req, res, parsedBody) {
|
|
||||||
const sessionId = req.headers["mcp-session-id"];
|
|
||||||
const method = (req.method || "GET").toUpperCase();
|
|
||||||
let transport = sessionId ? transports[sessionId] : undefined;
|
|
||||||
if (method === "POST" && !transport) {
|
|
||||||
// A new session may only be created by an initialize request without a
|
|
||||||
// session id.
|
|
||||||
if (sessionId || !isInitializeRequest(parsedBody)) {
|
|
||||||
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Resolve the per-session config from the request (per-user identity) when
|
|
||||||
// a resolver was supplied; otherwise use the static config unchanged. The
|
|
||||||
// resolver may throw (e.g. bad credentials) — surface a clean 401, never
|
|
||||||
// a created session.
|
|
||||||
let sessionConfig;
|
|
||||||
let identity;
|
|
||||||
try {
|
|
||||||
sessionConfig =
|
|
||||||
typeof config === "function" ? await config(req) : config;
|
|
||||||
if (options.identify)
|
|
||||||
identity = await options.identify(req);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
transport = new StreamableHTTPServerTransport({
|
|
||||||
sessionIdGenerator: () => randomUUID(),
|
|
||||||
onsessioninitialized: (sid) => {
|
|
||||||
transports[sid] = transport;
|
|
||||||
lastSeen[sid] = Date.now();
|
|
||||||
// Bind the resolved identity to the new session id for anti-fixation.
|
|
||||||
if (identity !== undefined)
|
|
||||||
sessionIdentity[sid] = identity;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
transport.onclose = () => {
|
|
||||||
const sid = transport.sessionId;
|
|
||||||
if (sid && transports[sid])
|
|
||||||
delete transports[sid];
|
|
||||||
if (sid)
|
|
||||||
delete sessionIdentity[sid];
|
|
||||||
};
|
|
||||||
const server = createDocmostMcpServer(sessionConfig);
|
|
||||||
await server.connect(transport);
|
|
||||||
await transport.handleRequest(req, res, parsedBody);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!transport) {
|
|
||||||
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Anti-session-fixation: a request reusing an existing session id must
|
|
||||||
// present credentials/token that resolve to the SAME identity bound at
|
|
||||||
// initialize, otherwise reject with 401. This prevents hijacking another
|
|
||||||
// user's established session by replaying its session id with different
|
|
||||||
// credentials.
|
|
||||||
if (options.identify && sessionId && sessionId in sessionIdentity) {
|
|
||||||
let presented;
|
|
||||||
try {
|
|
||||||
presented = await options.identify(req);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (presented !== sessionIdentity[sessionId]) {
|
|
||||||
sendJsonRpcError(res, 401, -32001, "Credentials do not match the user that owns this MCP session.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Routing to an existing transport: refresh its idle timestamp.
|
|
||||||
if (sessionId)
|
|
||||||
lastSeen[sessionId] = Date.now();
|
|
||||||
await transport.handleRequest(req, res, parsedBody);
|
|
||||||
}
|
|
||||||
return { handleRequest };
|
|
||||||
}
|
|
||||||
@@ -1,801 +0,0 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import { dirname, join } from "path";
|
|
||||||
import { DocmostClient } from "./client.js";
|
|
||||||
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
|
||||||
import { SHARED_TOOL_SPECS } from "./tool-specs.js";
|
|
||||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
|
||||||
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
|
|
||||||
// directly — for the credentials variant OR the per-user getToken variant.
|
|
||||||
export { DocmostClient } from "./client.js";
|
|
||||||
// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK
|
|
||||||
// service can read it off the loaded module (it cannot import the ESM package's
|
|
||||||
// internals directly; it goes through loadDocmostMcp()).
|
|
||||||
export { SHARED_TOOL_SPECS } from "./tool-specs.js";
|
|
||||||
// Read version from package.json
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
||||||
const VERSION = packageJson.version;
|
|
||||||
// Configuration for an MCP server instance is the DocmostMcpConfig union
|
|
||||||
// (credentials OR getToken) defined and re-exported above. The factory below is
|
|
||||||
// fully side-effect-free on import: it reads no environment variables and opens
|
|
||||||
// no transport. The standalone stdio entrypoint (stdio.ts) and the HTTP handler
|
|
||||||
// (http.ts) supply this config and own the process/transport lifecycle.
|
|
||||||
// --- Modern McpServer Implementation ---
|
|
||||||
// Editing guide surfaced to MCP clients in the initialize result so they can
|
|
||||||
// pick the right tool by intent and avoid resending whole documents.
|
|
||||||
//
|
|
||||||
// MAINTENANCE RULE: when you ADD, RENAME, or REMOVE a tool (either an inline
|
|
||||||
// server.registerTool(...) here or a spec in tool-specs.ts), you MUST update
|
|
||||||
// this guide so the new tool is routed by intent. This is enforced by
|
|
||||||
// test/unit/server-instructions.test.mjs, which fails when a registered tool
|
|
||||||
// name is not mentioned below (see its EXCEPTIONS list for the rare opt-outs).
|
|
||||||
// Exported for that test.
|
|
||||||
export const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent.\n" +
|
|
||||||
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
|
|
||||||
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
|
||||||
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
|
||||||
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
|
|
||||||
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
|
||||||
// Helper to format JSON responses
|
|
||||||
const jsonContent = (data) => ({
|
|
||||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
||||||
});
|
|
||||||
/**
|
|
||||||
* Create a fully configured Docmost MCP server. Side-effect-free: it does not
|
|
||||||
* read environment variables and does not connect any transport — the caller
|
|
||||||
* decides how to expose it (stdio or HTTP). The client talks to Docmost over
|
|
||||||
* REST + the collaboration WebSocket using the provided service-account
|
|
||||||
* credentials and auto-re-authenticates.
|
|
||||||
*/
|
|
||||||
export function createDocmostMcpServer(config) {
|
|
||||||
// Pass the whole config union through: the client branches internally on
|
|
||||||
// credentials vs. getToken, so both the external /mcp (creds) and the
|
|
||||||
// internal per-user (getToken) paths are wired here unchanged.
|
|
||||||
const docmostClient = new DocmostClient(config);
|
|
||||||
const server = new McpServer({
|
|
||||||
name: "docmost-mcp",
|
|
||||||
version: VERSION,
|
|
||||||
}, { instructions: SERVER_INSTRUCTIONS });
|
|
||||||
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
|
|
||||||
// the canonical name + model-facing description + (optional) schema builder;
|
|
||||||
// only the execute body is supplied per call. buildShape is invoked with THIS
|
|
||||||
// package's zod (v3); the in-app layer passes its own zod (v4).
|
|
||||||
//
|
|
||||||
// The spec's schema builder returns a plain ZodRawShape (Record<string,
|
|
||||||
// unknown> in the shared module since it must stay zod-agnostic), so the
|
|
||||||
// McpServer.registerTool overloads cannot infer the execute arg's shape from
|
|
||||||
// it. We type `execute` loosely and cast the call through `any`; runtime
|
|
||||||
// behaviour is unchanged — each execute body destructures the same fields the
|
|
||||||
// builder declares.
|
|
||||||
const registerShared = (spec, execute) => server.registerTool(spec.mcpName, spec.buildShape
|
|
||||||
? { description: spec.description, inputSchema: spec.buildShape(z) }
|
|
||||||
: { description: spec.description }, execute);
|
|
||||||
// Tool: get_workspace
|
|
||||||
registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
|
|
||||||
const workspace = await docmostClient.getWorkspace();
|
|
||||||
return jsonContent(workspace);
|
|
||||||
});
|
|
||||||
// Tool: list_spaces
|
|
||||||
registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
|
|
||||||
const spaces = await docmostClient.getSpaces();
|
|
||||||
return jsonContent(spaces);
|
|
||||||
});
|
|
||||||
// Tool: list_pages
|
|
||||||
// INTENTIONAL per-transport divergence (not in the shared registry): this
|
|
||||||
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
|
|
||||||
// the in-app copy keeps the same tree option but is worded for the in-app agent.
|
|
||||||
// Kept per-layer so each side can tune its own guidance.
|
|
||||||
server.registerTool("list_pages", {
|
|
||||||
description: "List most recent pages in a space ordered by updatedAt (descending). " +
|
|
||||||
"Returns a bounded list (default 50, max 100) — use search for lookups " +
|
|
||||||
"in large spaces. Pass tree:true (with spaceId) to instead get the " +
|
|
||||||
"space's full page hierarchy as a nested tree.",
|
|
||||||
inputSchema: {
|
|
||||||
spaceId: z.string().optional(),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.optional()
|
|
||||||
.describe("Max pages to return (default 50, max 100)"),
|
|
||||||
tree: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe("When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit."),
|
|
||||||
},
|
|
||||||
}, async ({ spaceId, limit, tree }) => {
|
|
||||||
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: get_page
|
|
||||||
server.registerTool("get_page", {
|
|
||||||
description: "Get page details with content converted to Markdown. The conversion is " +
|
|
||||||
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
|
||||||
"lossless representation use get_page_json. Inline <span data-comment-id> " +
|
|
||||||
"tags in the markdown are comment highlight anchors (also present for " +
|
|
||||||
"RESOLVED threads) — treat them as markup, not page text.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const page = await docmostClient.getPage(pageId);
|
|
||||||
return jsonContent(page);
|
|
||||||
});
|
|
||||||
// Tool: get_page_json
|
|
||||||
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
|
||||||
const page = await docmostClient.getPageJson(pageId);
|
|
||||||
return jsonContent(page);
|
|
||||||
});
|
|
||||||
// Tool: get_outline
|
|
||||||
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.getOutline(pageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: get_node
|
|
||||||
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
|
|
||||||
const result = await docmostClient.getNode(pageId, nodeId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: table_get
|
|
||||||
server.registerTool("table_get", {
|
|
||||||
description: "Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
|
|
||||||
"cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
|
|
||||||
"get_outline, or any block id inside the table. Use cellIds with " +
|
|
||||||
"patch_node for rich-formatted cell edits. `cols` is the FIRST row's " +
|
|
||||||
"width; ragged tables may vary per row, so use the per-row length of " +
|
|
||||||
"`cells` for each row.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, table }) => {
|
|
||||||
const result = await docmostClient.getTable(pageId, table);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: table_insert_row
|
|
||||||
// NOT in the shared registry: this transport names the table argument `table`,
|
|
||||||
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
|
|
||||||
// one buildShape would rename a public MCP parameter, so the table row/cell
|
|
||||||
// tools stay per-transport by design.
|
|
||||||
server.registerTool("table_insert_row", {
|
|
||||||
description: "Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
|
|
||||||
"a block id inside it. `cells` = text per column (padded to the table's " +
|
|
||||||
"column count; error if more cells than columns). `index` = 0-based " +
|
|
||||||
"insert position (0 inserts before the header); omit to append at the end.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
cells: z.array(z.string()),
|
|
||||||
index: z.number().int().optional(),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, table, cells, index }) => {
|
|
||||||
const result = await docmostClient.tableInsertRow(pageId, table, cells, index);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: table_delete_row
|
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
|
||||||
// divergence as table_insert_row.
|
|
||||||
server.registerTool("table_delete_row", {
|
|
||||||
description: "Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
|
|
||||||
"a block id inside it). Refuses to delete the table's only row. An " +
|
|
||||||
"out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
|
|
||||||
"and the next row becomes the new header.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
index: z.number().int(),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, table, index }) => {
|
|
||||||
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: table_update_cell
|
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
|
||||||
// divergence as table_insert_row.
|
|
||||||
server.registerTool("table_update_cell", {
|
|
||||||
description: "Set the plain-text content of cell [row,col] (0-based) in a table " +
|
|
||||||
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
|
|
||||||
"content with a single text paragraph; for rich formatting use patch_node " +
|
|
||||||
"on the cell's paragraph id from table_get.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
row: z.number().int(),
|
|
||||||
col: z.number().int(),
|
|
||||||
text: z.string(),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, table, row, col, text }) => {
|
|
||||||
const result = await docmostClient.tableUpdateCell(pageId, table, row, col, text);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: create_page
|
|
||||||
server.registerTool("create_page", {
|
|
||||||
description: "Create a new page from Markdown in a space. Pass parentPageId to nest " +
|
|
||||||
"it under a parent; omit it to create at the space root.",
|
|
||||||
inputSchema: {
|
|
||||||
title: z.string().min(1).describe("Title of the page"),
|
|
||||||
content: z.string().min(1).describe("Markdown content"),
|
|
||||||
spaceId: z.string().min(1),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional parent page ID to nest under"),
|
|
||||||
},
|
|
||||||
}, async ({ title, content, spaceId, parentPageId }) => {
|
|
||||||
const result = await docmostClient.createPage(title, content, spaceId, parentPageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: update_page_json
|
|
||||||
server.registerTool("update_page_json", {
|
|
||||||
description: "Replace a page's content with a raw ProseMirror JSON document " +
|
|
||||||
"(lossless write: preserves the block ids, callouts, tables and " +
|
|
||||||
"attributes you pass in). Typical flow: get_page_json -> modify the " +
|
|
||||||
"JSON -> update_page_json. Keep existing node ids intact so heading " +
|
|
||||||
"anchors and history stay stable. Minimal full-doc example: " +
|
|
||||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
|
||||||
'[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
|
|
||||||
"JSON string (both accepted), and is OPTIONAL: omit it to update only " +
|
|
||||||
"the title (though prefer rename_page for a title-only change). " +
|
|
||||||
"Supplying neither content nor title is an error.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to update"),
|
|
||||||
content: z
|
|
||||||
.any()
|
|
||||||
.optional()
|
|
||||||
.describe('ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
|
|
||||||
"JSON string). Omit to rename only."),
|
|
||||||
title: z.string().optional().describe("Optional new title"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, content, title }) => {
|
|
||||||
// Only parse/validate the document when it was actually supplied; when it
|
|
||||||
// is omitted, pass it straight through so the client performs a title-only
|
|
||||||
// (or no-op) update.
|
|
||||||
let doc;
|
|
||||||
if (content === undefined || content === null) {
|
|
||||||
doc = undefined;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// String -> JSON.parse (throwing on invalid); object passes through.
|
|
||||||
doc = parseNodeArg(content, "content was a string but not valid JSON");
|
|
||||||
}
|
|
||||||
const result = await docmostClient.updatePageJson(pageId, doc, title);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: export_page_markdown
|
|
||||||
server.registerTool("export_page_markdown", {
|
|
||||||
description: "Export a page to a single self-contained, lossless Docmost-flavoured " +
|
|
||||||
"Markdown file (custom extensions): YAML-free meta header, body with " +
|
|
||||||
"inline comment anchors and diagrams, and a trailing comments-thread " +
|
|
||||||
"block. Designed for a download -> edit body -> import_page_markdown " +
|
|
||||||
"round-trip that preserves everything, including comment highlights. " +
|
|
||||||
"Comment THREADS are preserved in the file but are not re-pushed to the " +
|
|
||||||
"server on import.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const md = await docmostClient.exportPageMarkdown(pageId);
|
|
||||||
return { content: [{ type: "text", text: md }] };
|
|
||||||
});
|
|
||||||
// Tool: import_page_markdown
|
|
||||||
registerShared(SHARED_TOOL_SPECS.importPageMarkdown, async ({ pageId, markdown }) => {
|
|
||||||
const res = await docmostClient.importPageMarkdown(pageId, markdown);
|
|
||||||
return jsonContent(res);
|
|
||||||
});
|
|
||||||
// Tool: copy_page_content
|
|
||||||
registerShared(SHARED_TOOL_SPECS.copyPageContent, async ({ sourcePageId, targetPageId }) => {
|
|
||||||
const result = await docmostClient.copyPageContent(sourcePageId, targetPageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: rename_page
|
|
||||||
server.registerTool("rename_page", {
|
|
||||||
description: "Rename a page (change its title only) without touching or resending " +
|
|
||||||
"its content.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to rename"),
|
|
||||||
title: z.string().min(1).describe("New title"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, title }) => {
|
|
||||||
const result = await docmostClient.renamePage(pageId, title);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: edit_page_text
|
|
||||||
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
|
||||||
const result = await docmostClient.editPageText(pageId, edits);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: stash_page — returns a resource_link (NOT embedded text) so the doc
|
|
||||||
// body never enters the model context. Registered directly (not via
|
|
||||||
// registerShared) because that helper only emits text content. Also returns
|
|
||||||
// `structuredContent` carrying the full documented `{uri, sha256, size, images}`
|
|
||||||
// shape alongside the resource_link, so MCP clients receive the blob's sha256
|
|
||||||
// (its ETag, for integrity) and mirror counts, not just the link.
|
|
||||||
server.registerTool(SHARED_TOOL_SPECS.stashPage.mcpName, {
|
|
||||||
description: SHARED_TOOL_SPECS.stashPage.description,
|
|
||||||
inputSchema: SHARED_TOOL_SPECS.stashPage.buildShape(z),
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.stashPage(pageId);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "resource_link",
|
|
||||||
uri: result.uri,
|
|
||||||
name: "page.json",
|
|
||||||
mimeType: "application/json",
|
|
||||||
size: result.size,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// Mirror the full documented result shape ({ uri, size, sha256, images })
|
|
||||||
// as structuredContent so MCP clients get the blob's sha256 (its ETag, for
|
|
||||||
// integrity) and the mirror counts, not just the resource_link.
|
|
||||||
structuredContent: {
|
|
||||||
uri: result.uri,
|
|
||||||
sha256: result.sha256,
|
|
||||||
size: result.size,
|
|
||||||
images: result.images,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// Tool: patch_node — schema + description from the shared registry (identical
|
|
||||||
// across both transports). The execute body keeps its own parseNodeArg
|
|
||||||
// normalization (the model sometimes serializes `node` as a JSON string).
|
|
||||||
registerShared(SHARED_TOOL_SPECS.patchNode, async ({ pageId, nodeId, node }) => {
|
|
||||||
const parsedNode = parseNodeArg(node);
|
|
||||||
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: insert_node — schema + description from the shared registry. As with
|
|
||||||
// patch_node, the execute body retains parseNodeArg on the incoming node.
|
|
||||||
registerShared(SHARED_TOOL_SPECS.insertNode, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
|
||||||
const parsedNode = parseNodeArg(node);
|
|
||||||
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
|
||||||
position,
|
|
||||||
anchorNodeId,
|
|
||||||
anchorText,
|
|
||||||
});
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: delete_node
|
|
||||||
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
|
||||||
const result = await docmostClient.deleteNode(pageId, nodeId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: insert_image
|
|
||||||
server.registerTool("insert_image", {
|
|
||||||
description: "Download an image from a web (http/https) URL and insert it into " +
|
|
||||||
"a page in one step. By default " +
|
|
||||||
"appends the image at the end of the page. With replaceText, replaces the " +
|
|
||||||
"first top-level block whose text contains that string (handy for " +
|
|
||||||
'swapping a text placeholder like "[image: foo.png]" for the real image). ' +
|
|
||||||
"With afterText, inserts the image right after the first block containing " +
|
|
||||||
"that string. Preserves all other block ids.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
imageUrl: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("http(s) URL of the image to download and upload"),
|
|
||||||
align: z.enum(["left", "center", "right"]).optional(),
|
|
||||||
alt: z.string().optional(),
|
|
||||||
replaceText: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Replace the first top-level block whose text contains this string with the image"),
|
|
||||||
afterText: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Insert the image right after the first top-level block whose text contains this string"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
|
|
||||||
const result = await docmostClient.insertImage(pageId, imageUrl, {
|
|
||||||
align,
|
|
||||||
alt,
|
|
||||||
replaceText,
|
|
||||||
afterText,
|
|
||||||
});
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: replace_image
|
|
||||||
server.registerTool("replace_image", {
|
|
||||||
description: "Replace an existing image on a page with a new image fetched from a web " +
|
|
||||||
"(http/https) URL: uploads the new file as a NEW " +
|
|
||||||
"attachment (fresh clean URL that renders and busts browser caches), then " +
|
|
||||||
"repoints every image node referencing the old attachmentId (recursively, " +
|
|
||||||
"incl. callouts/tables) via the live document, preserving comments, " +
|
|
||||||
"alignment and alt. The old attachment is left as an unreferenced orphan " +
|
|
||||||
"(Docmost has no API to delete a single attachment; it is removed only when " +
|
|
||||||
"the page/space is deleted). In-place byte overwrite is avoided because some " +
|
|
||||||
"Docmost versions corrupt the attachment (HTTP 500) on overwrite.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
attachmentId: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("attachmentId of the image currently in the page to replace"),
|
|
||||||
imageUrl: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("http(s) URL of the new image to download"),
|
|
||||||
align: z.enum(["left", "center", "right"]).optional(),
|
|
||||||
alt: z.string().optional(),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, attachmentId, imageUrl, align, alt }) => {
|
|
||||||
const result = await docmostClient.replaceImage(pageId, attachmentId, imageUrl, {
|
|
||||||
align,
|
|
||||||
alt,
|
|
||||||
});
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: share_page
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
|
|
||||||
// security-confirmation framing ("only share when the user explicitly asked,
|
|
||||||
// since this exposes the page to anyone with the link") tuned for the in-app
|
|
||||||
// agent; this transport keeps the plain public-URL wording.
|
|
||||||
server.registerTool("share_page", {
|
|
||||||
description: "Make a page publicly accessible (idempotent) and return its public " +
|
|
||||||
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
|
|
||||||
"page content to ANYONE with the URL — do it only when explicitly asked.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to share"),
|
|
||||||
searchIndexing: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe("Allow search engines to index the page (default true)"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, searchIndexing }) => {
|
|
||||||
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: unshare_page
|
|
||||||
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.unsharePage(pageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: list_shares
|
|
||||||
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
|
||||||
const result = await docmostClient.listShares();
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: move_page
|
|
||||||
server.registerTool("move_page", {
|
|
||||||
description: "Move a page under a new parent (nesting) or to the space root.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe("Target parent page ID. Pass 'null' or empty string to move to root."),
|
|
||||||
position: z
|
|
||||||
.string()
|
|
||||||
.min(5)
|
|
||||||
.optional()
|
|
||||||
.describe("fractional-index position key; min 5 chars; omit to append at the end."),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, parentPageId, position }) => {
|
|
||||||
const finalParentId = parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
|
||||||
// Cheap cycle guard: a page cannot be moved directly under itself.
|
|
||||||
// (Deeper descendant-cycle detection is intentionally out of scope.)
|
|
||||||
if (finalParentId !== null && finalParentId === pageId) {
|
|
||||||
throw new Error("cannot move a page under itself");
|
|
||||||
}
|
|
||||||
const result = await docmostClient.movePage(pageId, finalParentId || null, position);
|
|
||||||
// Require POSITIVE confirmation: the live /pages/move success shape is
|
|
||||||
// exactly { success: true, status: 200 }. An empty body, a 204, or any odd
|
|
||||||
// shape lacking success === true must NOT be reported as a successful move,
|
|
||||||
// so we surface the raw API result instead of declaring success.
|
|
||||||
if (!(result && typeof result === "object" && result.success === true)) {
|
|
||||||
throw new Error(`Failed to move page ${pageId}: ${JSON.stringify(result)}`);
|
|
||||||
}
|
|
||||||
return jsonContent({
|
|
||||||
message: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Tool: delete_page
|
|
||||||
server.registerTool("delete_page", {
|
|
||||||
description: "Delete a single page by ID. SOFT delete only: the page is moved to " +
|
|
||||||
"trash and can be restored; nothing is permanently deleted.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
await docmostClient.deletePage(pageId);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: `Successfully deleted page ${pageId}` },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
|
||||||
// Tool: list_comments
|
|
||||||
server.registerTool("list_comments", {
|
|
||||||
description: "List ALL comments on a page in one call (pagination is handled " +
|
|
||||||
"internally), including RESOLVED threads — filter by resolvedAt when you " +
|
|
||||||
"need only open ones. Content is returned as Markdown.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const comments = await docmostClient.listComments(pageId);
|
|
||||||
return jsonContent(comments);
|
|
||||||
});
|
|
||||||
// Tool: create_comment
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
|
|
||||||
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
|
||||||
// and "Reversible via the comment UI"); this transport keeps its own wording.
|
|
||||||
server.registerTool("create_comment", {
|
|
||||||
description: "Create a new comment on a page. The comment is ALWAYS inline and is " +
|
|
||||||
"anchored to (highlights) its `selection` text — there are no page-level " +
|
|
||||||
"comments. Content is provided as Markdown and automatically converted. " +
|
|
||||||
"A top-level comment REQUIRES an exact `selection`; if the selection " +
|
|
||||||
"cannot be found in the page the call fails (no orphan comment is left). " +
|
|
||||||
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
|
|
||||||
"selection. You may also attach a `suggestedText` proposing a replacement " +
|
|
||||||
"for the `selection`; a human applies (or rejects) it from the UI. When " +
|
|
||||||
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
|
|
||||||
"page — expand it with surrounding context if it is ambiguous.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page to comment on"),
|
|
||||||
content: z.string().min(1).describe("Comment content in Markdown format"),
|
|
||||||
selection: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
// Enforce the documented 250-char cap to match the description above.
|
|
||||||
.max(250)
|
|
||||||
.optional()
|
|
||||||
.describe("EXACT contiguous text from a single paragraph/block to anchor the " +
|
|
||||||
"comment on (<=250 chars). Required for a top-level comment; omit " +
|
|
||||||
"only when replying via parentCommentId."),
|
|
||||||
parentCommentId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
|
|
||||||
suggestedText: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(2000)
|
|
||||||
.optional()
|
|
||||||
.describe("Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
|
|
||||||
"applied by a human via the UI (never auto-applied). REQUIRES a " +
|
|
||||||
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
|
|
||||||
"be UNIQUE in the page — expand it with surrounding context (still " +
|
|
||||||
"<=250 chars) if it occurs more than once, or the call is refused."),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
|
||||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
|
||||||
throw new Error("create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.");
|
|
||||||
}
|
|
||||||
if (suggestedText !== undefined) {
|
|
||||||
if (parentCommentId) {
|
|
||||||
throw new Error("create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.");
|
|
||||||
}
|
|
||||||
if (!selection || !selection.trim()) {
|
|
||||||
throw new Error("create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const result = await docmostClient.createComment(pageId, content, "inline", selection, parentCommentId, suggestedText);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: update_comment
|
|
||||||
server.registerTool("update_comment", {
|
|
||||||
description: "Update an existing comment's content. Only the comment creator can " +
|
|
||||||
"update it. Content is provided as Markdown.",
|
|
||||||
inputSchema: {
|
|
||||||
commentId: z.string().min(1).describe("ID of the comment to update"),
|
|
||||||
content: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("New comment content in Markdown format"),
|
|
||||||
},
|
|
||||||
}, async ({ commentId, content }) => {
|
|
||||||
const result = await docmostClient.updateComment(commentId, content);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: delete_comment
|
|
||||||
server.registerTool("delete_comment", {
|
|
||||||
description: "Delete a comment. Only the comment creator or space admin can delete it.",
|
|
||||||
inputSchema: {
|
|
||||||
commentId: z.string().min(1).describe("ID of the comment to delete"),
|
|
||||||
},
|
|
||||||
}, async ({ commentId }) => {
|
|
||||||
await docmostClient.deleteComment(commentId);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Successfully deleted comment ${commentId}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// Tool: resolve_comment
|
|
||||||
server.registerTool("resolve_comment", {
|
|
||||||
description: "Resolve (close) or reopen a comment thread. Only top-level comments can " +
|
|
||||||
"be resolved — the server rejects resolving a reply. Reversible: pass " +
|
|
||||||
"resolved=false to reopen. Resolving keeps the thread and its replies " +
|
|
||||||
"(unlike delete_comment, which permanently removes them).",
|
|
||||||
inputSchema: {
|
|
||||||
commentId: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("ID of the top-level comment thread to resolve or reopen"),
|
|
||||||
resolved: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(true)
|
|
||||||
.describe("true (default) marks the thread resolved/closed; false reopens it"),
|
|
||||||
},
|
|
||||||
}, async ({ commentId, resolved }) => {
|
|
||||||
const result = await docmostClient.resolveComment(commentId, resolved);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: check_new_comments
|
|
||||||
server.registerTool("check_new_comments", {
|
|
||||||
description: "Check for new comments across pages in a space since a given timestamp. " +
|
|
||||||
"Optionally scope to a page subtree (folder). Returns only comments " +
|
|
||||||
"created after the specified time.",
|
|
||||||
inputSchema: {
|
|
||||||
spaceId: z.string().describe("Space ID to check for new comments"),
|
|
||||||
since: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')"),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional root page ID to scope the check to a subtree (folder). " +
|
|
||||||
"Only pages under this parent will be checked."),
|
|
||||||
},
|
|
||||||
}, async ({ spaceId, since, parentPageId }) => {
|
|
||||||
// Reject an unparseable timestamp up front: otherwise the comparison
|
|
||||||
// against NaN silently treats every comment as "not new" and the tool
|
|
||||||
// returns zero results without signalling the bad input.
|
|
||||||
if (Number.isNaN(Date.parse(since))) {
|
|
||||||
throw new Error(`Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`);
|
|
||||||
}
|
|
||||||
const result = await docmostClient.checkNewComments(spaceId, since, parentPageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: search
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
|
||||||
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
|
||||||
// different schema (limit 1-20); this transport is a plain REST full-text search
|
|
||||||
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
|
|
||||||
server.registerTool("search", {
|
|
||||||
description: "Full-text search for pages and content across the whole workspace. " +
|
|
||||||
"Results are bounded by `limit` (1-100; when omitted the server applies " +
|
|
||||||
"its own default).",
|
|
||||||
inputSchema: {
|
|
||||||
query: z.string().min(1).describe("Search query"),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.optional()
|
|
||||||
.describe("Max results to return (max 100)"),
|
|
||||||
},
|
|
||||||
}, async ({ query, limit }) => {
|
|
||||||
// The tool exposes no spaceId filter, so pass undefined for the client's
|
|
||||||
// optional spaceId parameter and forward limit into its correct slot.
|
|
||||||
const result = await docmostClient.search(query, undefined, limit);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: docmost_transform
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
|
|
||||||
// deliberately omits the `deleteComments` schema field (comment-deletion
|
|
||||||
// guardrail) and carries a much shorter description; this transport exposes the
|
|
||||||
// full helper catalogue. Different schema, so kept per-layer.
|
|
||||||
server.registerTool("docmost_transform", {
|
|
||||||
description: "Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
|
|
||||||
"against its LIVE ProseMirror document, with a diff preview and page " +
|
|
||||||
"history as the safety net. By default dryRun=true: returns a diff " +
|
|
||||||
"preview WITHOUT writing. Set dryRun=false to apply (atomic, won't " +
|
|
||||||
"clobber concurrent edits). `doc` is the lossless ProseMirror document " +
|
|
||||||
"({type:'doc',content:[...]}); return a new doc of the same shape. " +
|
|
||||||
"`ctx` gives you: comments (the page's comments, each {id, content " +
|
|
||||||
"(markdown), selection, type}); log (array; console.log pushes to it); " +
|
|
||||||
"consume(id) (mark a comment id as consumed — those are deleted when " +
|
|
||||||
"deleteComments=true after a successful apply); and helpers: " +
|
|
||||||
"blockText(node) (plain text), walk(node, fn) (depth-first over all " +
|
|
||||||
"nodes incl. callouts/tables/lists), getList(doc, predicate) (find a " +
|
|
||||||
"node even without attrs.id), insertMarkerAfter(doc, anchor, marker, " +
|
|
||||||
"{beforeBlock}) (insert a plain unmarked text run after anchor, " +
|
|
||||||
"mark-safe), setCalloutRange(doc, n) (sync a [1]…[K] callout range to " +
|
|
||||||
"[1]…[n]), noteItem(inlineNodes) (wrap inline nodes in a listItem with a " +
|
|
||||||
"fresh id), mdToInlineNodes(markdown) (comment markdown -> inline nodes), " +
|
|
||||||
"commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " +
|
|
||||||
"comments into numbered footnotes), canonicalizeFootnotes(doc) (derive " +
|
|
||||||
"footnote numbering + the single bottom list from reference order, drop " +
|
|
||||||
"orphans/duplicates — runs AUTOMATICALLY on the transform RESULT, so the " +
|
|
||||||
"applied (and dryRun-previewed) doc is always footnote-canonical; a dryRun " +
|
|
||||||
"diff may therefore show footnote tidy-ups your script did not make, and " +
|
|
||||||
"it is idempotent after the first run), and " +
|
|
||||||
"insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " +
|
|
||||||
"marker + dedup'd definition, list derived). Footnote convention: markers are " +
|
|
||||||
"plain '[N]' text in the body; the notes are an orderedList under a " +
|
|
||||||
"heading whose text is 'Примечания переводчика' (that is only the DEFAULT " +
|
|
||||||
"notesHeading — pass the notesHeading option to the helpers to use a " +
|
|
||||||
"heading matching the page's language). The transform runs " +
|
|
||||||
"sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
|
|
||||||
"{type:'doc'} node.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
transformJs: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("A JS function `(doc, ctx) => doc` (expression-arrow or " +
|
|
||||||
"parenthesized function). It receives a clone of the live doc and " +
|
|
||||||
"ctx (comments, log, consume(id), helpers: blockText/walk/getList/" +
|
|
||||||
"insertMarkerAfter/setCalloutRange/noteItem/mdToInlineNodes/" +
|
|
||||||
"commentsToFootnotes/canonicalizeFootnotes/insertInlineFootnote) " +
|
|
||||||
"and must return a {type:'doc'} node."),
|
|
||||||
dryRun: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(true)
|
|
||||||
.describe("Preview only (no write) when true (default)."),
|
|
||||||
deleteComments: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(false)
|
|
||||||
.describe("After a successful apply, delete every comment id passed to " +
|
|
||||||
"ctx.consume(id)."),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, transformJs, dryRun, deleteComments }) => {
|
|
||||||
const result = await docmostClient.transformPage(pageId, transformJs, {
|
|
||||||
dryRun,
|
|
||||||
deleteComments,
|
|
||||||
});
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: insert_footnote
|
|
||||||
server.registerTool("insert_footnote", {
|
|
||||||
description: "Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) " +
|
|
||||||
"and WHAT (text). The footnote marker is placed right after anchorText in " +
|
|
||||||
"the body, and the bottom footnotes list + the numbering are derived " +
|
|
||||||
"deterministically server-side. You do NOT assign a number, and you " +
|
|
||||||
"never see or edit the footnotes list — so footnotes cannot end up out " +
|
|
||||||
"of order, orphaned, or as a raw '[^id]' block. If a footnote with the " +
|
|
||||||
"SAME text already exists, its number is REUSED (one definition, several " +
|
|
||||||
"references). The write is atomic and won't clobber concurrent edits; if " +
|
|
||||||
"anchorText is not found, nothing is written and an error is returned.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
anchorText: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("A snippet of existing body text; the footnote marker is inserted " +
|
|
||||||
"immediately after its first occurrence (mark-safe)."),
|
|
||||||
text: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("The footnote content as markdown (becomes the definition)."),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, anchorText, text }) => {
|
|
||||||
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: diff_page_versions
|
|
||||||
registerShared(SHARED_TOOL_SPECS.diffPageVersions, async ({ pageId, from, to }) => {
|
|
||||||
const result = await docmostClient.diffPageVersions(pageId, from, to);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: list_page_history
|
|
||||||
registerShared(SHARED_TOOL_SPECS.listPageHistory, async ({ pageId, cursor }) => {
|
|
||||||
const result = await docmostClient.listPageHistory(pageId, cursor);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: restore_page_version
|
|
||||||
registerShared(SHARED_TOOL_SPECS.restorePageVersion, async ({ historyId }) => {
|
|
||||||
const result = await docmostClient.restorePageVersion(historyId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user