Compare commits

..

94 Commits

Author SHA1 Message Date
vvzvlad c252068672 Merge pull request 'feat(ai-chat): отложенная загрузка инструментов (deferred tools + loadTools) (#332)' (#341) from fix/332-deferred-tools into develop
Reviewed-on: #341
2026-07-04 20:47:45 +03:00
agent_coder 68caf8157a test(ai-chat): document AI_CHAT_DEFERRED_TOOLS + pin ON-path & catalog completeness (#341 review F1-F3)
- F1: document AI_CHAT_DEFERRED_TOOLS in .env.example (AI_* section) — default
  ON = deferred loading (compact catalog + loadTools), =false restores the old
  "all tools always active" behavior.
- F2: integration test of the ON path in ai-chat-stream.int-spec.ts — a deferred
  tool activated via loadTools is active on the SAME turn's next step but a fresh
  turn starts cold (CORE + loadTools only), proving the per-turn activatedTools
  Set does not leak across turns/chats. Drives the real streamText loop with a
  MockLanguageModelV3 and inspects recorded per-step activeTools-filtered tools.
- F3: replace the magic toHaveLength(28) in tool-tiers.spec.ts with a two-way
  partition against the LIVE in-app toolset (AiChatToolsService.forUser keys):
  every non-core tool must appear in buildInAppDeferredCatalog and every catalog
  entry must map to a real non-core tool — so a future tool forgotten in
  INLINE_TOOL_TIERS fails the suite instead of silently vanishing from the agent.

No production logic change (mechanism was already reviewed correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:34:42 +03:00
claude code agent 227 e431b33bb1 feat(ai-chat): deferred tool loading (tiers + loadTools meta-tool) (#332)
The in-app AI agent shipped all ~41 tool schemas on every model step. This
adds a two-tier catalog: core tools (frequent or one-line) stay always-active;
the rest are advertised as a compact catalog and their full schema is fetched
on demand via the loadTools meta-tool, wired through ai@6 prepareStep's
per-step activeTools.

- tools/tool-tiers.ts: CORE_TOOL_KEYS, INLINE_TOOL_TIERS, applyLoadTools,
  catalog builders (+ tool-tiers.spec.ts, 13 cases).
- ai-chat.service.ts prepareAgentStep: returns activeTools =
  [...CORE_TOOL_KEYS, loadTools, ...activatedTools]; per-turn activated Set.
- ai-chat.prompt.ts: buildToolCatalogBlock renders the deferred catalog.
- mcp/tool-specs.ts: tier + catalogLine metadata (external snake_case /mcp
  transport unchanged).
- EnvironmentService.isAiChatDeferredToolsEnabled(): AI_CHAT_DEFERRED_TOOLS,
  default ON per issue intent (kill-switch =false restores old behavior).

Gate: server ai-chat 631/631, tool-tiers 13/13, mcp 472/472, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:57:11 +03:00
vvzvlad 4369bbc53d Merge pull request 'refactor(converter): единый пакет @docmost/prosemirror-markdown + канон форматов, git-sync и mcp переключены (#293, шаги 2–5)' (#333) from feat/293-B-prosemirror-markdown-pkg into develop
Reviewed-on: #333
2026-07-04 19:35:53 +03:00
claude code agent 227 8e5ad8070b fix(lock): repair pnpm-lock.yaml broken by develop merge (#333 F-lock)
The develop merge (eacc1c48) left an importer pointing at a vitest@4.1.6
peer-variant WITHOUT @vitest/coverage-v8 that has no snapshot entry, so
`pnpm install --frozen-lockfile` failed with ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY
(CI + Docker red at install). Regenerated with `pnpm install --lockfile-only
--fix-lockfile` (pnpm 10.4.0, matches packageManager pin): the importer now
resolves to the existing coverage-v8 variant; two transitive pointers realigned.
No package.json / source change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:27:09 +03:00
vvzvlad cfc105c7d6 Merge pull request 'feat(comment): эфемерные предложения-правки — Apply/Dismiss убирают комментарий (#329)' (#338) from fix/329-ephemeral-suggestions into develop
Reviewed-on: #338
2026-07-04 19:22:14 +03:00
claude code agent 227 d7fa6738e5 fix(comment): transactional childless-delete race fix + client dismiss gate + DB int-spec (#329 review round 2)
F4 [critical] — the anti-join `DELETE … WHERE NOT EXISTS(child)` was still racy
under Postgres READ COMMITTED: a reply INSERT holds FOR KEY SHARE on the parent;
the DELETE's start snapshot doesn't see the uncommitted child (NOT EXISTS true),
blocks on the reply's lock, and when the reply commits the parent was only LOCKED
(not modified) so EvalPlanQual does NOT re-check → the DELETE proceeds and CASCADE
destroys the just-committed reply. Replaced with a transaction: SELECT the parent
FOR UPDATE (conflicts with the reply's FOR KEY SHARE → serializes the concurrent
reply), re-check for a child with a FRESH statement in the same tx (a new RC
snapshot sees a just-committed reply), delete only if still childless (return 1)
else return 0 (caller resolves). The FOR UPDATE lock is held to end-of-tx so no
reply can insert between the re-check and the delete. Signature unchanged, so the
service + its mocked unit tests are untouched; docstrings updated.

F5 [warning] — the client Dismiss button was gated only on canComment, but the
server now gates dismiss on owner-or-space-admin, so a non-owner non-admin saw a
button the server 403s. `canShowDismiss` now also requires
`isOwnerOrAdmin = currentUser?.user?.id === comment.creatorId || userSpaceRole ===
"admin"` (the same gate the comment delete-menu already uses); threaded into both
call sites.

F6 [warning] — added a REAL-DB int-spec
(apps/server/test/integration/comment-delete-if-childless.int-spec.ts, + a
createComment seeder): (a) childless → returns 1, row gone; (b) committed reply →
returns 0, parent+reply survive; (c) CONCURRENCY — a second connection inserts a
reply (FOR KEY SHARE) and commits mid-operation while deleteCommentIfChildless
blocks on FOR UPDATE → asserts it returns 0 and both rows survive (a blind
anti-join would lose the reply here). Ran against live Postgres — 3/3 pass.

server tsc clean; comment jest 53 + int-spec 3 (live Postgres) pass. client tsc
clean; comment vitest 56 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:17:52 +03:00
claude code agent 227 e6d8eda8e5 fix(comment): dismiss owner/admin authz + atomic conditional delete + 404-only onError (#329 review)
Maintainer escalation decision (B) + reviewer findings on the ephemeral-
suggestion PR.

Authz (decision B): POST /comments/dismiss-suggestion now gates the destructive
branch on owner-OR-space-admin, mirroring POST /comments/delete exactly (same
SpaceCaslAction.Manage / SpaceCaslSubject.Settings, same owner short-circuit,
same ForbiddenException). A non-owner non-admin who tries to dismiss another's
childless suggestion gets Forbidden before the service runs. Apply stays on
canEdit (accepting an edit is the editor's semantics), unchanged.

F1 [blocking] — atomic conditional delete closes the hasChildren→delete race.
New repo `deleteCommentIfChildless(id)` runs a single
`DELETE FROM comments WHERE id=:id AND NOT EXISTS (SELECT 1 FROM comments child
WHERE child.parent_comment_id = comments.id)` (verified by compiling the Kysely
expression to SQL — the correlated subquery references the OUTER comments.id).
deleteEphemeralSuggestion strips the mark first, then the conditional delete: if
it removed the row → commentDeleted + outcome 'deleted'; if a reply raced in
(0 rows) → fall back to resolveComment (outcome 'resolved') so the discussion and
the new reply survive. No reply can be cascade-deleted anymore.

F2 [warning] — the apply/dismiss onError success-noop is narrowed from 404||400
to 404 ONLY. A 400 means the comment is ALIVE (apply's 400 = the thread was
resolved-not-applied), so it now shows a real error (surfacing the server
message) and KEEPS the comment in cache instead of a false "applied" + dropping a
live thread.

F3 [suggestion] — the 404-race client tests assert the success toast fired.

Tests: server — dismiss authz (owner ok / non-owner-non-admin Forbidden /
space-admin ok), the delete→resolve race (hasChildren=false but conditional
delete returns 0 → resolve, no commentDeleted), delete-path asserts switched to
deleteCommentIfChildless; client — apply-400 and dismiss-400 (kept in cache, red,
not success) + the toast assertions.

server tsc clean, comment+collaboration jest green; client tsc clean, comment
vitest 54 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:17:19 +03:00
claude code agent 227 8d8ecaed82 feat(comment): ephemeral suggestion-edits — Apply/Dismiss remove the comment (#329)
Agent suggestion-edits (comments with suggestedText, #315) piled up: Apply
auto-resolved the thread, cluttering the resolved tab, and the anchors stayed in
the document. Make them ephemeral: resolving (Apply OR the new Dismiss) makes the
comment DISAPPEAR — hard-delete + remove the Yjs `comment` mark — UNLESS the
thread has replies, in which case resolve it (preserve the discussion). Manual
Resolve is unchanged. Scope: only comments with `suggestedText`.

Server:
- New collab event `deleteCommentMark` (collaboration.handler) mirroring
  resolveCommentMark, wiring the existing removeYjsMarkByAttribute to strip the
  anchor from the doc.
- `finalizeAppliedSuggestion` forks on `hasChildren`: replies → apply + resolve
  (outcome 'resolved'); none → apply + hard-delete + mark removal (outcome
  'deleted').
- New `dismissSuggestion` (validates top-level + suggestedText + not applied/not
  resolved) with the same fork; permission `canComment` (NOT canEdit — dismiss
  doesn't change page text); audit COMMENT_SUGGESTION_DISMISSED. New
  POST /comments/dismiss-suggestion; apply stays canEdit.
- Both return `{ outcome: 'deleted' | 'resolved' }` so the client picks the
  optimistic action.

Data-integrity (review F1): the shared `deleteEphemeralSuggestion` removes the
anchor mark FIRST and FATALLY, then deletes the DB row only on success. The row
delete is irreversible, so a mark-removal failure — including the
COLLAB_DISABLE_REDIS "no live instance" hard-error — must abort the whole
operation (→ 5xx, repeatable) rather than swallow the error and leave a permanent
orphan anchor pointing at a deleted comment. `deleteCommentMark` is no longer
best-effort (unlike resolve, where the row is kept and a failed mark is
recoverable).

Client:
- `canShowDismiss` (canComment) alongside `canShowApply` (canEdit); a "Dismiss"
  button next to Apply in the suggestion block.
- `useApplySuggestionMutation`/`useDismissSuggestionMutation` reconcile the cache
  on `outcome` ('deleted' → remove; 'resolved' → relocate to the resolved tab).
- Idempotent races (review F2): BOTH apply and dismiss onError reduce 404/400 to
  success (comment already gone/resolved), dropping it from the cache instead of
  a red error — restores the #315 apply idempotency the ephemeral delete would
  otherwise break.
- i18n Dismiss / "Не применять" (ru/en).

Not done (flagged): deleteCommentMark on the normal /comments/delete path — left
out (would change every non-suggestion delete + needs gateway injection; the
interactive client already strips the mark via unsetComment). Out of scope per
the issue.

Tests: server — apply/dismiss delete-vs-resolve fork, all four dismiss state
guards, the deleteCommentMark handler, controller authz (dismiss=canComment,
apply=canEdit), AND a mark-removal-failure test proving the row is NOT deleted +
the error propagates (F1). client — Dismiss show-conditions, outcome cache
reconciliation, and 404 idempotent race for BOTH dismiss and apply (F2).

Verified: server tsc clean; comment+collaboration jest 144 passed. client tsc
clean; vitest 905 passed | 1 expected-fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:17:19 +03:00
claude code agent 227 eacc1c4811 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into feat/293-B-prosemirror-markdown-pkg
# Conflicts:
#	packages/mcp/build/client.js
#	packages/mcp/build/index.js
#	packages/mcp/build/tool-specs.js
2026-07-04 19:02:52 +03:00
claude code agent 227 8e12aa8ebf fix(build): ship + CI-build @docmost/prosemirror-markdown; refresh AGENTS.md (#333 conformance)
Two infra blockers from the #326-steps-2-5 conformance check — the converter/canon
are correct, but the new shared package wasn't wired into Docker/CI.

BLOCKER 1 (prod): the Docker installer stage copied mcp/build + editor-ext but NOT
packages/prosemirror-markdown. mcp now depends on it (workspace:*) and EAGER-imports
it at runtime — the in-app ai-chat DocmostClient loads build/index.js ->
lib/markdown-converter.js — so the shipped image would resolve a broken workspace
symlink and every ai-chat tool would die with ERR_MODULE_NOT_FOUND. Now the
installer COPYs packages/prosemirror-markdown/build + package.json before the prod
install. (git-sync has no runtime consumer yet — revisit at step 6 with #119.)

BLOCKER 2 (CI red): test.yml/develop.yml build only @docmost/editor-ext before
`pnpm -r test`. That is plain pnpm, which does NOT honour nx `dependsOn: ^build`,
so the package's (gitignored) build/ never appears and its consumers fail:
mcp `pretest: tsc` -> TS2307 Cannot find module '@docmost/prosemirror-markdown',
git-sync vitest typecheck the same. The green local runs only happened because the
coder+reviewer had a full install+build. Added a `pnpm --filter
@docmost/prosemirror-markdown build` step before `pnpm -r test` (mirrors the
editor-ext step); verified the build is clean (tsc exit 0).

Docs (remark 3): AGENTS.md:203 and :285 still told contributors to keep mcp's own
vendored schema mirror "in sync manually" — that copy was deleted by this PR.
Updated both: the converter + schema mirror now live in the SINGLE package
@docmost/prosemirror-markdown (consumed by mcp + git-sync, do NOT reintroduce a
per-package copy); editor-ext is the upstream schema source; the serializer-contract
test guards the boundary. Added the package to the workspace table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:46:33 +03:00
vvzvlad 348dcd0802 Merge pull request 'feat(mcp): search_in_page — внутристраничный поиск для агента (#330)' (#339) from fix/330-search-in-page into develop
Reviewed-on: #339
2026-07-04 18:43:40 +03:00
claude code agent 227 086bc1bf8b docs(mcp): search_in_page regex desc names RE2, not JS regex (#330 review F5)
The RE2 swap narrowed the contract: regex:true rejects lookaround ((?=…)/(?<=…))
and backreferences (\1). The internal JSDoc was updated, but the AGENT-VISIBLE
tool-spec (the only text the agent reads at call time, single-sourced to both
transports) still said 'a JS regular expression' — so an agent would write a
lookahead/backref and hit an error. Updated the .description and the regex flag
.describe() to name RE2 (linear-time, ReDoS-safe), list that char classes / word
boundaries / anchors / quantifiers work while lookaround and backreferences do
NOT, and keep the 'invalid/unsupported regex -> clear error' note.

mcp: tsc clean; tool-specs / server-instructions / contract tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:08:27 +03:00
claude code agent 227 77b245461f fix(mcp): search_in_page regex via re2 (ReDoS-safe) + review DO F1-F4 (#330 review)
Maintainer decision on the escalated ReDoS fork: use re2. The regex path
compiled agent-supplied patterns with `new RegExp` and ran them synchronously in
the shared event-loop; a catastrophic-backtracking pattern (e.g. `(a+)+$`) hung
the whole Node backend for all users (the tool is in both transports incl. the
in-app apps/server agent), and size caps do NOT bound backtracking.

Switch the regex engine to re2 (Google RE2, linear-time, no backtracking):
- `new RE2(query, caseSensitive?'g':'gi')`. RE2 extends RegExp, so eachMatch and
  the zero-length-match lastIndex guard are unchanged.
- Unsupported patterns are now a CLEAN error, not a hang: RE2 throws on invalid
  syntax AND on the backtracking-only features it can't do (lookaround
  (?=…)/(?<=…), backreferences \1) — caught at compile and returned as a clear
  tool error telling the agent to rewrite without them.
- Removed MAX_CONTAINER_TEXT + the per-container slice (re2 is linear, so it's no
  longer a ReDoS defense, and truncating risked silently dropping real matches in
  a long container); kept MAX_PATTERN_LENGTH as a cheap query sanity cap.
- Verified: `(a+)+$` over 50k `a` completes in ~4ms; lookaround/backref throw.
- Added re2 (^1.21.0) to packages/mcp; lockfile updated.

Reviewer DO items:
- F1 [doc]: removed the false "pass nodeId as a comment anchor" claim
  (create_comment has no nodeId param — it needs a text `selection`). Fixed in
  tool-specs.ts + page-search.ts (module + SearchMatch JSDoc) + client.ts; the ref
  is for get_node/patch_node, and for a comment you build a unique text selection
  from before+match+after.
- F2 [doc]: clarified `#<index>` refs (id-less table/cell) are accepted by get_node
  but NOT patch_node (id-only).
- F3 [test]: round-trip — each match's nodeId fed to the real getNodeByRef
  (attrs.id node + `#<index>` table-cell) to prove the ref format is consumable.
- F4 [test]: before/after edge-pinning (match in first 40 chars of a long
  container; index 0 → before==""; container end → after=="").
- New re2 tests: catastrophic patterns complete fast; lookaround/backref → error.

mcp: tsc clean; node --test 472 passed (+5). apps/server: tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:49 +03:00
vvzvlad 77c64c4fd9 Merge pull request 'test(infra): coverage-gate + acceptInvitation atomicity + turn-end unit (#324)' (#335) from fix/324-coverage-gate into develop
Reviewed-on: #335
2026-07-04 17:45:46 +03:00
vvzvlad 2bb71c1a45 Merge pull request 'fix(client): мобильный адаптив 390px — Create page открывает редактор; ревизия хвоста #291 (#325)' (#334) from fix/325-mobile-390 into develop
Reviewed-on: #334
2026-07-04 17:45:35 +03:00
vvzvlad 20248b8c95 Merge pull request 'feat(client): intraline diff в блоке предложения-правки (#331)' (#336) from fix/331-intraline-diff into develop
Reviewed-on: #336
2026-07-04 17:45:10 +03:00
vvzvlad 9274c51053 Merge pull request 'feat(mcp): скрыть resolved-комментарии (якоря + list_comments) от агента (#328)' (#337) from fix/328-resolved-anchor-spam into feat/293-B-prosemirror-markdown-pkg
Reviewed-on: #337
2026-07-04 17:44:55 +03:00
claude code agent 227 832c3cafdf test(mcp): update test-e2e.mjs listComments calls to the {items} shape (#328 review F1)
The listComments Comment[] -> { items, resolvedThreadsHidden } shape change
reached every src/host consumer but not the live-server e2e harness (run via
`node test-e2e.mjs`, not the node --test gate — so the green suite missed it).
The 4 calls now read .items; the post-resolve check passes includeResolved:true
so it still sees the now-resolved root c1 (the default feed hides it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 16:22:34 +03:00
claude code agent 227 94f60cf0ec docs(client): fix .suggestionChanged comment — bold weight, not underline (#331 review F1)
The header comment claimed the rule adds 'an underline'; it does not — it adds a
color-mix tint + font-weight:700, and the inner comment already notes text-
decoration is omitted on purpose. Aligned the header comment with the rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 16:21:25 +03:00
claude code agent 227 40d42d61e6 feat(mcp): search_in_page tool — in-page substring/regex search for the agent (#330)
Editorial roles (Corrector/Factchecker) brute-forced `get_node` block-by-block to
find occurrences (unquoted «ё», straight quotes, «т.е.»), burning tokens. New
`search_in_page(pageId, query, {regex?, caseSensitive?, limit?})` reads the page's
ProseMirror JSON via the existing getPageRaw and searches it IN MEMORY — no server
endpoint, no DB/schema change, no touch to the packages/mcp/src/lib schema mirror.

New pure `searchInDoc(doc, query, opts)` (packages/mcp/src/lib/page-search.ts):
recursive descent to each TEXT CONTAINER (paragraph/heading/table-cell paragraph),
glues its inline text via `blockPlainText` (a match survives inline-mark
boundaries — e.g. «т.е.» split across bold/italic), searches literal (indexOf) or
regex, and returns `{ total, truncated, matches:[{ nodeId, blockIndex, type,
before, match, after }] }`. `nodeId` is the container's attrs.id or the
`#<topLevelIndex>` of the enclosing top-level block — the SAME ref format
get_node/patch_node/comment-anchoring accept (verified identical to getNodeByRef),
so the agent goes straight from a hit to a targeted comment; `before`/`after` are
~40-char windows for a unique selection. `total`/`truncated` always reported (never
silent truncation). Lives in the SHARED_TOOL_SPECS registry → exposed in BOTH
transports (external /mcp + in-app AI-chat), with a SERVER_INSTRUCTIONS line and a
DocmostClientLike signature + contract-test entry. Corrector/Factchecker prompts
get a one-line "use search_in_page first" hint (versions bumped, catalog hash lock
refreshed).

Guards: empty/whitespace query → clear error; invalid regex → clear error (not a
generic 500); zero-length regex matches (`\b`, `a*`) skipped with lastIndex
advanced (no loop/flood); MAX_PATTERN_LENGTH=1000, MAX_CONTAINER_TEXT=100k bound
each exec; limit clamped [1,200] (default 50).

Tests: new page-search.test.mjs (17) — literal+regex, case-sensitivity,
mark-boundary glue, nodeId for paragraph/heading (attrs.id) and table-cell
(#<index> fallback), context bounds, limit/total/truncated + clamp, invalid
regex/empty/over-long errors, zero-length skip, empty-doc null-safety.

mcp: tsc clean; node --test 467 passed (+17). apps/server: tsc --noEmit clean
(DocmostClientLike + wiring). catalog check.mjs OK.

Known limitations (from internal review, non-blocking):
- Residual ReDoS: a crafted catastrophic-backtracking pattern (e.g. `(a+)+$`)
  against a large single container can hang the event loop — JS regex is not
  interruptible, so the length caps bound the base but not the backtracking.
  Realistic exposure is low (containers are small; the pattern is supplied by the
  authenticated model). Candidate for a follow-up hardening (safe-regex validation
  or a worker+timeout) if it matters.
- Case-insensitive LITERAL search folds via toLowerCase; a char whose lowercase
  differs in length (e.g. Turkish İ) BEFORE a match could shift the context
  window — negligible for the RU/EN editorial scenario.
- On a `#<index>` table-cell fallback, `type` is the inline container ("paragraph")
  while nodeId addresses the top-level block — addressing is correct; the field is
  documented as the container's type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:51:34 +03:00
claude code agent 227 bcd194ee5d feat(mcp): hide resolved-comment anchors + feed from the agent (#328)
The AI agent (MCP + in-app chat) saw ALL comments incl. resolved via two
channels, cluttering its context and breaking fragment search. Default now:
the agent sees only ACTIVE discussions; resolved is opt-in. Active anchors and
threads are always kept.

Channel 1 — resolved comment anchors on agent reads (converter option):
`convertProseMirrorToMarkdown(content, options?)` gains
`options.dropResolvedCommentAnchors` (default false — zero change for every
existing caller incl. git-sync). Both `case "comment"` emitters (top-level and
the raw-HTML inlineToHtml path) emit BARE text (no `<span data-comment-id>`) when
`resolved && the flag`; active anchors keep their wrapper. mcp `getPage` passes
the flag; `export_page_markdown` does NOT (lossless export must preserve resolved
anchors — that is why it is an opt-in option, not unconditional); `get_page_json`
is untouched (lossless PM JSON). Built on the #293 package converter.

Channel 2 — `list_comments` default active-only: `listComments(pageId,
includeResolved=false)` now returns `{ items, resolvedThreadsHidden }` (was a
bare array). By default a RESOLVED top-level thread is hidden wholesale — the
root AND every reply anchored to it (a thread is gated only by its root's
resolvedAt; a resolved reply under an ACTIVE root stays). `resolvedThreadsHidden`
counts hidden threads so the agent knows to re-query. `includeResolved:true`
returns everything. The `includeResolved` param is added to both tool
registrations (MCP index.ts + in-app ai-chat-tools.service.ts); `DocmostClientLike`
signature updated. Server `findPageComments` is NOT touched — the web UI's tabs
depend on the full feed; filtering is only at the mcp-client level. All internal
call sites (export_page_markdown / checkNewComments / transformPage) updated to
`.items` with `includeResolved:true` to keep their full-feed behavior.

The comment model is assumed FLAT (a reply's parentCommentId points at the
thread root) — documented in the filter; a future reply-of-reply model would
need a root-walk there.

Tests: resolved-comment-anchors.test.ts (6 — anchor dropped with flag / kept
without, for BOTH emitters; active always kept); list-comments-resolved.test.mjs
(4 — resolved thread+reply hidden + counter; includeResolved:true returns all;
an ACTIVE thread with a RESOLVED reply is NOT hidden).

package vitest: 664 passed; tsc clean. mcp: node --test 458 passed; tsc clean.
apps/server + git-sync: tsc clean (converter option default-off).

NOTE: based on feat/293-B (#293/#326 STEP 5) — the converter lives in the
package; this PR is stacked on #333 and its base retargets to develop once #333
merges. mcp/build is gitignored (not committed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:26:43 +03:00
claude code agent 227 f13105333a feat(client): intraline diff highlighting in the suggestion before→after block (#331)
The suggestion block (#315) struck the whole `selection` red and showed the whole
`suggestedText` green, so a one-letter edit (заведем→заведём) highlighted the
entire line. Now only the CHANGED fragments are emphasized intraline, git-style.

Pure, render-only — nothing changes in the DB/backend/MCP/IComment/mutations/
Apply/Badge. New pure `computeSuggestionDiff(old, new) => { old: Segment[], new:
Segment[] }` (Segment = {text, changed}) in suggestion.ts: hybrid word+char —
`diffWordsWithSpace` for the word skeleton, then `diffChars` inside an adjacent
removed+added pair so only the differing letters (not the whole word) are
flagged; a lone insertion/deletion is wholly changed; equal parts are common on
both sides. Concatenating each side reproduces the input (lossless). Wrapped in
`useMemo` on [selection, suggestedText].

comment-list-item.tsx renders per-segment spans instead of two whole <Text>;
changed segments get `.suggestionChanged` (a stronger currentColor tint + bold,
NO text-decoration so the old block's inherited line-through survives on the
changed letters — the whole old line still reads removed, new as added).

`diff@8.0.3` (jsdiff, already in the root package.json) added to
apps/client/package.json (+ lockfile, additive) so the workspace resolves it;
it bundles its own types.

Tests: new suggestion.test.ts (one-letter ё/е; word replacement keeping the
shared word common with no per-letter noise; word insertion/deletion; identical)
— asserts segment text + changed flags, non-vacuous. Two pre-existing
comment-list-item.test assertions switched from getByText (a single text node)
to container.textContent (the new line is now multiple spans) — adapts to the
intended DOM change, not a weakening.

Verified: tsc --noEmit clean; client vitest 892 passed | 1 expected-fail.
Visual/pixel check of the tint at the 390px comment panel needs a human (no
screenshot tooling in-repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:17:07 +03:00
claude code agent 227 08222345ef fix(prosemirror-markdown): escape canon inline-extension triggers = $ ^ in link/alt text (#333 review F5)
F1 (round 1) wrapped the image alt in escapeLinkText, and that helper also guards
the link-form media captions (attachment/pdf/embed). But its character class
covered only stock CommonMark — NOT the Docmost inline EXTENSIONS this same PR
registers on the marked instance: highlight `==x==` (canon #7), math `$x$`
(canon #6), footnote `^[x]` (canon #2). Their triggers `= $ ^` are not CommonMark
punctuation, so an alt or media filename like `x $A$ y`, `use ==bold==`, `^[fn]`,
or `data $A$.csv` was silently turned into a math/highlight/footnote node on
import — the same class of round-trip data loss F1 closed, reintroduced by this
PR's own canon.

Fix: add `= $ ^` to the escapeLinkText class (`/[\\`*_~[\]<&!()=$^]/g`). `\= \$ \^`
decode back to literals (all ASCII punctuation) AND, being escape tokens, stop
the extension tokenizer from matching — verified lossless byte-stable round-trip.
Updated the helper comment to name the two trigger sets (CommonMark + Docmost
inline extensions). Extended the adversarial round-trip tests: image alt gains
`x $A$ y` / `5$ and 10$` / `use ==bold==` / `^[fn]` / `cost $5 == price`; pdf name
gains `data $A$.csv` / `q3 ==final==.pdf` / `5$ and 10$.pdf` / `note ^[x].pdf` —
all byte-stable with the node intact, so the hole can't reopen.

package vitest: 658 passed; tsc clean. git-sync: 268. mcp: 454.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:46:30 +03:00
claude code agent 227 baa41d66ad test(infra): coverage-gate + acceptInvitation atomicity int-spec + turn-end unit (#324)
Tail of #244. Three items:

1. Coverage-gate (main). develop had no coverage tooling at all. Added
   @vitest/coverage-v8@4.1.6 (pinned to the vitest already in use) to the three
   vitest packages — git-sync, editor-ext (which also gains its missing direct
   `vitest` devDep), apps/client — and enabled v8 coverage with per-package
   thresholds (no root vitest config exists, so per-package is the only
   meaningful scope). v8 provider is chosen deliberately: istanbul broke on the
   ESM `@docmost/editor-ext` barrel; v8 collects native runtime coverage and
   never re-parses ESM. `enabled: true` wires the gate into the plain `test`
   script, so `pnpm -r test` (the CI entrypoint) enforces it without a manual
   `--coverage`. Thresholds set ~4-5 pts below measured current coverage so the
   gate PASSES today and FAILS on regression (verified: forcing lines=95 on
   editor-ext exits 1). `all: false` — coverage counts test-touched files;
   documented in the configs (with `all: true` the many untested type/barrel
   files would sink the % and make the gate meaningless).
   Measured→threshold (S/B/F/L): git-sync 91.78/79.16/76.76/92.46 → 88/75/72/88;
   editor-ext 58.58/48.1/64.96/58.91 → 54/44/60/54; client 59.93/58/48.47/59.39
   → 55/53/44/55. All exit 0.

2. acceptInvitation atomicity int-spec. New
   apps/server/test/integration/workspace-accept-invitation-atomicity.int-spec.ts
   (+ createDefaultGroup/createInvitation seeders in test/integration/db.ts per
   its convention). Wires the real WorkspaceInvitationService with real
   User/Group/GroupUser repos against the test Kysely, stubbing only the
   post-commit collaborators. Asserts the invariant protected by
   users_email_workspace_id_unique: (a) two CONCURRENT accepts → exactly one
   fulfilled, one BadRequestException('Invitation already accepted'), membership
   count == 1, invitation consumed; (b) repeated sequential accept → still one
   membership; (c) the survivor is in the workspace default group (whole-tx, no
   torn state). Ran against real Postgres+Redis: 3/3 pass.

3. turn-end decision unit test. `decideTurnEnd` does not exist as a symbol; the
   turn-end logic lives in chat-thread.tsx's onFinish handler. Added a focused
   block to the existing chat-thread.test.tsx (matching its hoisted-mock style):
   clean finish → flush queued (continue); abort/disconnect/error → queue
   preserved (end) with the correct notice; parent notified on every terminal
   outcome. 8 passed (3 existing + 5 new).

Verified: git-sync 712, editor-ext 247, client 888 (all with the gate, exit 0);
int-spec 3/3 (real Postgres); tsc --noEmit clean for client + server;
pnpm install --frozen-lockfile consistent (lockfile additive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:37:28 +03:00
claude code agent 227 1a7b817250 fix(prosemirror-markdown): escape image alt + consolidate schema sanitizers + tidy (#333 review F1-F4)
F1 [critical, data-loss] — escape the image alt in `![alt](src)`. Canon #4 moved
the top-level image off the lossless <img> form onto markdown `![alt](src)`, but
the alt was inserted raw; the importer re-parses the `![alt]` label as CommonMark
inline, so a markdown-active char in a realistic description ("Figure [1]", "the
*new* logo", "a]b[c") broke the round-trip — the image node vanished or emphasis
collapsed. Now `escapeLinkText(imgAttrs.alt ?? "")`, exactly as the link-form
media (attachment/pdf/embed) already escape their visible text. Regression test
added: six active-punctuation alts round-trip byte-stable with the node intact.

F2 [drift] — re-export `clampCalloutType` / `sanitizeCssColor` from the package
barrel and drop the verbatim copies in the mcp schema shim. The copies had
already drifted (the mcp `clampCalloutType` lost the callout-type alias mapping
the package applies), which is exactly the schema drift #293 exists to kill. The
sanitizers now live only in the package; mcp `schema.test.mjs` exercises the
single alias-aware implementation.

F3 [docs] — AGENTS.md:296 said `packages/mcp/build/` is committed; this branch
gitignored it (git-sync/prosemirror-markdown convention). Updated the line to say
it is gitignored and rebuilt in CI/Docker via `pnpm build`.

F4 [cleanup] — removed the dead `test.typecheck` block from the package
vitest.config.ts and deleted tsconfig.vitest.json. Both were copied verbatim from
git-sync; this package has zero `*.test-d.ts` files, and the ported comments
referenced git-sync-only entities. Kept the `docmost-client` resolve alias
(22 tests use it) and the runtime include/environment.

package vitest: 658 passed (+1 F1 regression); tsc clean. git-sync: 268 passed.
mcp: node --test 454 passed; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:17:46 +03:00
claude code agent 227 52beae85b3 fix(client): close mobile sidebar drawer after creating a page (#325)
On mobile the "create page" action is triggered from inside the off-canvas
sidebar drawer (the space sidebar "+" and temporary-note buttons, and the
tree-row "add subpage"). handleCreate navigated to the new page's editor route
but never closed that drawer, so it stayed open on top of the freshly created
page — the editor was hidden behind the page tree ("as if the page didn't
open", #325 item 5).

Close the mobile sidebar (`setMobileSidebar(false)`) right after navigating,
mirroring the existing drawer-close on a tree-row tap (space-tree-row). Placing
it in handleCreate covers all three create entry points in one spot. It is a
no-op on desktop, where the mobile-sidebar atom is already false and only
governs the sub-992px collapsed state — desktop behavior is unchanged.

Verified: `tsc --noEmit` clean; client vitest 887 passed | 1 expected-fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:06:45 +03:00
claude code agent 227 124f5a45a2 refactor(mcp): consume @docmost/prosemirror-markdown, drop the drifted converter copy (#293/#326 step 5)
mcp had its OWN drifted copy of the converter (markdown-converter.ts ~900 lines,
docmost-schema.ts ~1270 lines, markdown-document.ts) — older than the shared
package, missing the git-sync fixes AND the #293 canon. This switches mcp's
converter CORE to @docmost/prosemirror-markdown, so mcp jumps straight to the
canonical format and the drift-generating second copy is gone.

- markdown-converter.ts / markdown-document.ts / docmost-schema.ts become thin
  re-export shims of the package (convertProseMirrorToMarkdown, the docmost:meta
  envelope, docmostExtensions + docmostSchema=getSchema(docmostExtensions)). The
  mcp-only helpers clampCalloutType/sanitizeCssColor are preserved verbatim in
  the schema shim (the package doesn't expose them via its barrel). ~2170 lines
  of the drifted converter/schema bodies deleted.
- collaboration.ts drops its own ~360-line marked pipeline (preprocessCallouts,
  bridgeTaskLists, extractFootnotes, the footnoteRef extension) and re-points to
  the package's markdownToProseMirror, keeping markdownToProseMirrorCanonical and
  all the yjs/collab write glue. footnote-lex/analyze doc comments updated (they
  now describe advisory legacy-syntax diagnostics, not an importer).

Schema parity verified: the package schema is a strict SUPERSET of mcp's old
schema — every node and attr mcp declared is present (the package only adds
status/pageEmbed/transclusion*/subpages.recursive/etc.), so nothing is silently
dropped on the switch. The switch actually FIXES two pre-existing mcp data-loss
bugs its own tests documented: htmlEmbed and pageBreak now round-trip (were
dropped by the old mcp converter).

Footnotes: the package assembles inline ^[body] footnotes on import (sequential
fn-N ids, identical bodies merged), so mcp's canonicalizeFootnotes is now an
idempotent no-op after it (verified). Legacy reference footnotes [^id]/[^id]:
are inert literal text (canon #2 no-backward-compat) — lossless, the text
survives verbatim.

Build hygiene: packages/mcp/build/ is now gitignored and untracked, matching the
git-sync/prosemirror-markdown convention (private package, rebuilt in CI/Docker,
so src and prod can never silently diverge). This also removes a dead untracked
build/_vendored_editor_ext/ artifact that a broad `git add` would otherwise
commit.

Dependency: packages/mcp/package.json gains @docmost/prosemirror-markdown
(workspace:*); pnpm-lock.yaml gets the matching link importer (mirrors git-sync).

mcp tests updated deliberately to the canonical forms (highlight ==, math $…$,
image ![](src)<!--img-->, drawio/media discriminators, subpages/pageBreak
comments, textAlign, inline ^[…] footnotes) with strict assertions; 4 structural
safety-net round-trip tests added.

mcp: node --test 454 passed; tsc clean. package: 657 passed. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:16:09 +03:00
claude code agent 227 b751852425 fix(prosemirror-markdown): converter inventory bugs — spoiler/link-title in raw-HTML, contract test, codeCombined dead code (#293)
The four bugs found during the #293 HTML-emission inventory, fixed in the package:

1. Spoiler mark was silently lost in the raw-HTML path: inlineToHtml (columns /
   spanned cells) had no `case "spoiler"`, so spoilered text there dropped the
   mark on round-trip. Now emits `<span data-spoiler="true">` — the same form the
   top-level serializer uses and exactly what the schema's Spoiler mark parses.

2. Link `title` was dropped in the raw-HTML path: inlineToHtml's link case
   emitted `<a href>` without the title. The schema's link mark carries a
   `title` global attr (DocmostAttributes), so a titled link inside a column now
   round-trips via `<a href … title=…>`.

3. Serializer contract test: emoji/date/toc were flagged as possibly caseless
   inline atoms. Verified they exist in NEITHER the package schema NOR
   editor-ext, so no node handling is needed today. Added
   serializer-contract.test.ts, which derives every node type from the live
   schema (getSchema(docmostExtensions)) and asserts each has an explicit
   serializer `case` — all 45 current node types are covered and present, and a
   future node added without a case will fail this test loudly.

4. codeCombined dead code: `const codeCombined = false` was hardcoded, so every
   `codeCombined ? <html> : <markdown>` ternary always took the markdown branch.
   Removed the variable and the dead HTML-alternative branches (bold/italic/code/
   link/strike). Pure cleanup — output is byte-identical (goldens + full suite
   pass unchanged). The `hasCode` early-return (code excludes other marks) stays.

Tests: spoiler-inside-column and link-title-inside-column round-trips, the
serializer contract test + inline-atom non-empty behavioral checks.

package vitest: 657 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:37:35 +03:00
claude code agent 227 65d81f745a feat(prosemirror-markdown): inline footnotes ^[text] (#293 canon #2)
Footnotes now use the single canonical Pandoc/Obsidian inline form: the note
body is written AT the reference as `^[body]`, and the separate
`<section data-footnotes>` list is NOT emitted in markdown — it is reassembled
on import. New shared module src/lib/footnote.ts.

Serialize (markdown-converter.ts): a top-of-convert pre-scan builds
Map<id, definition> from the footnotesList; a footnoteReference emits
`^[<rendered body>]` (body paragraphs joined by a literal `\n`, real
backslash-n written `\\n`, stray unbalanced `[`/`]` escaped via balanceBrackets
while a balanced `[link](url)` stays intact); footnotesList/footnoteDefinition
emit nothing; an ORPHAN definition (no ref) is appended at doc end as its own
`^[body]` line so bodies are never lost (intentional, documented). The raw-HTML
path (inlineToHtml, columns) emits `<sup data-footnote-ref data-fn-text="…">`,
carrying the text at the ref there too; blockToHtml keeps the schema
`<section>`/`<div>` form for a list nested in a column.

Parse (markdown-to-prosemirror.ts): a `^[…]` inline extension on the dedicated
marked instance BALANCES brackets with a depth counter (respecting `\`-escapes),
so `^[note [a] b]` captures the full content, unbalanced `^[` fails open to
literal text. A post-marked assembleFootnotes pass collects every
`<sup data-fn-text>`, dedups by the EXACT body string, assigns sequential ids
(fn-1, fn-2, … first-seen), builds one `<div data-footnote-def>` per unique body
in a single `<section data-footnotes>`, and strips data-fn-text. No hash is used
(F1): dedup keying on the exact text makes an id collision between DIFFERENT
bodies impossible, while identical bodies still merge; ids are never written to
markdown, so round-trips stay byte-stable, and all id assignment is local to the
one call (race-free).

Correctness hardening from internal review:
- F2: raw user backslashes in a footnote body are doubled (`\`->`\\`) at text
  emission (via a per-conversion inFootnoteBody closure flag) BEFORE the
  serializer's own escapes (`\[ \] \= \$`) are layered on, so a body ending in
  `\` (Windows path, LaTeX, regex) no longer breaks the `^[…]` envelope and
  round-trips exactly; parseInline decodes `\\`->`\`. The old `\n`->`\\n` step is
  subsumed by this and removed.
- N1: assembleFootnotes runs to a FIXED POINT — parseInline of a def body can
  spawn a nested `<sup data-fn-text>` (a legal nested footnote `^[a ^[b] c]`),
  so the section is attached before the loop (querySelectorAll only sees
  attached nodes) and the scan repeats until no pending sup remains; the dedup
  map persists across rounds. Nested and 3+-level footnotes now round-trip
  byte-stably instead of silently dropping the inner body. Bounded by
  MAX_FOOTNOTE_ROUNDS as a fail-open safety net.
- N2: the id counter is seeded past the highest existing fn-<N> so a reused
  section's ids can never collide with generated ones.
- A literal `^[` in prose text is escaped `^\[` so it does not become a phantom
  footnote on re-import (codeBlock/inline-code excluded).

No backward compat: reference form `[^id]`/`[^id]: def` is not parsed (stays
literal). No existing golden asserted the old footnote HTML output.

Tests: new footnote.test.ts (22 cases: basic byte-stable round-trip, bracket
balancing, multi-paragraph `\n`, real backslash-n, dedup both directions,
NESTED + 3-level nest, F1 hash-collision pair surviving as distinct defs, F2
backslash bodies byte-stable, N2 id-seed, column data-fn-text form, orphan def,
no-backward-compat, literal-`^[` prose, fail-open, empty `^[]`).

package vitest: 607 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:31:00 +03:00
claude code agent 227 bfbd927866 feat(prosemirror-markdown): math as $…$ / $$…$$ (#293 canon #6)
mathInline serializes as `$LaTeX$` and mathBlock as an own-line `$$\n<latex>\n$$`
fence (multi-line safe), closing hand-authoring gap A18. The LaTeX still lives in
node.attrs.text; a literal `$` inside it is escaped `\$`. On the raw-HTML path
(columns/cells) math keeps the schema-HTML `<span data-type="mathInline">` /
`<div data-type="mathBlock">` form (markdown is not re-parsed inside raw HTML) —
blockToHtml gets an explicit mathBlock case and inlineToHtml a mathInline case,
sharing the mathInlineHtml/mathBlockHtml helpers with the fallbacks so the two
forms cannot drift.

Parse: mathInlineExtension (inline) + mathBlockExtension (block) are added to the
SAME dedicated marked instance introduced for canon #7 (global singleton
untouched). The inline extension uses a currency-safe PANDOC rule: an opening `$`
must not be followed by whitespace, and the closing `$` must not be preceded by
whitespace nor followed by a digit — so `$5`, `$5 and $10`, `a $5 b $6 c`, `100$`
stay literal text while `$x^2$` is math. The block extension matches a `$$` fence
line and captures multi-line LaTeX non-greedily up to the next `$$` line.

The pandoc boundary rule lives ONCE in the new math-inline.ts
(INLINE_MATH_SOURCE) and is shared by the import tokenizer (^-anchored) and the
export prose escaper (global), so parse and serialize cannot disagree about what
is math. escapeProseMath (case "text", non-code runs only) escapes ONLY the two
delimiting `$` of a span the rule WOULD match, so a would-be-math prose span like
`the set $A$` re-imports as literal text while currency `$5 and $10` is emitted
CLEAN (zero backslash churn). marked decodes `\$`→`$` on re-parse, byte-stable.

Fallbacks to the lossless schema-HTML form (all documented + tested):
mathInline → <span> when empty / whitespace-edged / multi-line / pre-existing
`\$` / trailing `\` / immediately before a digit-text sibling (renderInlineChildren
guard, so `$…$5` can't lose the node); mathBlock → <div> when the LaTeX contains
`$$`. Each fallback round-trips losslessly and byte-stably.

Code safety (guards the canon #7 regression class): codeBlock reads raw child
text and inline `code` runs are excluded from escapeProseMath, so `$5`/`$x$` in
code stay literal with no math and no backslash corruption. ReDoS-checked on
adversarial 40k-char inputs (0–1 ms).

Tests: new math.test.ts (26 cases: serialize exactness, multi-line block, `\$`
escaping, currency ×5 asserting no `\$`, prose escape, columns schema-HTML,
inline-code/codeBlock safety, fail-open). Goldens in roundtrip / markdown-converter
flipped top-level math to `$…$`/`$$…$$`; the escapeAttr-idempotence golden wraps
math in a column (still exercises escapeAttr); columns/raw-HTML math assertions
unchanged.

package vitest: 585 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:37:37 +03:00
claude code agent 227 77f5224b55 feat(prosemirror-markdown): highlight without color as ==text== (#293 canon #7)
A `highlight` mark WITHOUT a color now serializes as the Obsidian/GFM `==text==`
syntax (closing hand-authoring gap A19); a highlight WITH a color keeps the
`<mark style="background-color: …">` HTML form (condition is deterministic on
the color attr). On the raw-HTML path (columns/spanned cells) BOTH forms stay
`<mark>` via inlineToHtml — markdown is not re-parsed inside a raw-HTML block.

Parse: `==` is not standard markdown, so the importer uses a DEDICATED marked
instance (`new Marked().use({extensions:[highlightMark]})`) rather than the
global singleton — registered once, never leaks `==` behavior to other callers.
The inline extension tokenizes `==text==` (non-empty, non-space-leading inner,
lazy so `==a== ==b==` is two marks; inner re-tokenized so nested marks survive;
`====`/`==x` fail-open to literal) into `<mark>` with no color, which the schema
parses as a color-less highlight. Inline code (`` `a == b` ``) stays code via
marked token precedence. marked 17 defaults (gfm:true, breaks:false) are
identical for the fresh instance, so tables/strike/autolinks are unaffected.

Losslessness: a LITERAL `==` in a text run would otherwise be misparsed as a
highlight on the next import, so `case "text"` backslash-escapes each `=` of a
`==` pair (marked decodes `\=` back to `=`), and this round-trips byte-stably.
The escape does NOT run for inline-code runs, and — CRITICALLY — codeBlock now
reads its child text RAW (schema `content: "text*"`) instead of routing through
`case "text"`: marked does not decode `\=` inside a fence, so escaping there
would permanently stamp backslashes into any `==` comparison (ubiquitous in
source code) and corrupt the block on the git-sync data path.

Tests: new highlight.test.ts (19 cases incl. serialize forms, colored vs plain,
column `<mark>` path, nested marks, inline-code exclusion, literal-`==` escape,
fail-open, AND a codeBlock-with-`==` regression proving no backslash corruption
+ byte-stable round-trip). Golden inline-mark matrix flipped top-level no-color
highlight to `==m==`; the kept `<mark style=…>` assertions are the colored/
raw-HTML cases.

package vitest: 559 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:12:18 +03:00
claude code agent 227 e2a3b5fc4d feat(prosemirror-markdown): media family as md-form + discriminator comment (#293 canon #8)
Ten media/embed node types move their TOP-LEVEL serialization off raw schema
HTML onto a readable markdown target plus an always-emitted discriminator
comment whose NAME selects the node type. The schema-HTML form is retained on
the raw-HTML/columns path (comments are dropped by the DOM parse stage there).

  image-form  ![](src)<!--name …-->   youtube, video, audio, drawio, excalidraw
  link-form   [text](src)<!--name …--> pdf, attachment, embed (text=filename/provider)
  standalone  <!--pageembed …--> / <!--transclusion …-->  pageEmbed, transclusionReference

The comment NAME is the node-type discriminator and is ALWAYS emitted, even when
the attr JSON is empty (`![](u)<!--youtube-->`), so a bare `![](u)` is never
mistaken for an `image` and a bare `[t](u)` stays a plain link — no URL-sniffing.
src rides in the markdown target; every other non-default attr (incl. the id
links attachmentId/sourcePageId/transclusionId) rides in the comment JSON
(stable key order, numerics stringified, align="center" omitted).

New src/lib/media-html.ts: byte-exact builders reproducing the schema HTML each
old processNode case returned. Both the serializer's raw-HTML path (blockToHtml,
now de-delegated from `return processNode(block)` to explicit per-type cases)
and the importer call these, so serialize and parse cannot drift.

Import (applyCommentDirectives): image-form binds the preceding <img> (src from
it), link-form the preceding <a> (src=href, text=filename/provider), standalone
replaces the comment (same leading-doc-level handling as #5). Each rebuilds the
schema element via the media-html builder, then swaps it in; the empty-<p> hoist
is absorbed by stripEmptyParagraphs. Fail-open: wrong element/position/name or
malformed JSON -> inert, no throw.

Link-form visible text is escaped (escapeLinkText) for the FULL set of
CommonMark inline-active punctuation (\ ` * _ ~ [ ] < & ! ( )), not just [ ] \:
the label is parsed as inline content, so a filename/provider like
`report *v2*.pdf` or `![shot](x).pdf` would otherwise lose the markup (or
fragment the parse) when the importer reads a.textContent back — a data-loss
regression vs the old data-attachment-name form. Adversarial round-trip fixtures
lock byte- and value-stability for emphasis/code/strike/autolink/entity/image
markers and nested-link names.

Tests: new media-comments.test.ts (40 cases: per-type exact md + lossless
byte-stable round-trip incl. id links, minimal-node discriminator-still-emitted,
in-column schema-HTML form, discriminator integrity, fail-open, active-punct
filenames). Goldens in media-roundtrip / markdown-converter-golden /
markdown-converter / diagram-roundtrip updated to the md+comment form (columns
stay schema-HTML). The former known-limitation image-diagrams fixture is now
byte- AND canonically-stable (canon #8 omits the diagram align="center" default)
and was promoted from an it.fails into the green corpus (11-image-diagrams.json).
git-sync stabilize.test.ts: the "diagram materializes data-align=center" fixpoint
moved into a column (where the raw-HTML asymmetry still holds), since top level
is now byte-stable.

package vitest: 540 passed; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:52:02 +03:00
claude code agent 227 d7d8db2102 feat(prosemirror-markdown): images as ![alt](src) + attached img-comment (#293 canon #4)
Every image now serializes as `![alt](src)`; non-default layout/identity attrs
that markdown cannot express ride along in an attached `<!--img {…}-->` comment
on the same line, replacing the prior "image-with-attrs -> raw <img>" split for
the top-level path:
  ![схема](/s.png) <!--img {"width":"420","align":"left","attachmentId":"…"}-->

Keys (emitted only when non-default, stable order): width, height, align, size,
aspectRatio, attachmentId, caption, title. Numeric sizing attrs are stringified
in the payload (the import side reads DOM attributes back as strings), so a
numeric `width:420` round-trips byte-stably instead of churning `420 -> "420"`.
attachedCommentFor defuses any `--` in a value (e.g. a caption containing the
comment-closing `-->`) so the payload can never close the comment early.

Align default unified to "center" (#293 canon #4): editor-ext declares
image.align default "center" while this package's schema declared null — keeping
null would make the clean `![](src)` form dead code (every editor image is
"center"). Now the schema default is "center" (docmost-schema image align, with
explicit parseHTML/renderHTML), canonicalize KNOWN_DEFAULTS drops align=="center"
for image, and the serializer omits align when it is null OR "center". A null
align collapses to "center" on re-import (a null align is not a distinct editor
state) — stable, no ping-pong. Only left/right emit a comment.

Import: applyCommentDirectives gains an `img` handler that targets the comment's
previousElementSibling <img> and writes each decoded key to the DOM attribute
the schema reads (align, width, height, data-size, data-aspect-ratio,
data-attachment-id, data-caption, title), then removes the comment. Attached
only: a standalone `<!--img-->` with no adjacent image is inert. Fail-open on
malformed JSON / unknown keys.

Raw-HTML path unchanged in spirit: images inside columns/cells keep the
`<img …>` form (comments are dropped by the DOM parse stage); imageToHtml now
omits a redundant align="center" to match the unified default.

Tests: new image-comment.test.ts (21 cases incl. caption == `-->`, numeric-size
byte-stability, image-in-column <img> form, fail-open). Goldens updated
deliberately: markdown-roundtrip-spoiler-caption (captioned image -> comment
form), markdown-converter-gaps spec 14/15 (title now round-trips via comment;
column image drops redundant align), canonicalize-extra (center+null dropped,
left kept).

package vitest: 498 passed | 1 expected-fail; tsc clean. git-sync (rebuilt
build): 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:16:28 +03:00
claude code agent 227 e814bca243 feat(prosemirror-markdown): subpages/pageBreak as standalone comments (#293 canon #5)
Move the two "invisible machinery" atoms off the <div data-type="..."> HTML
form onto standalone HTML comments on their own line, keeping the markdown
human-readable while still round-tripping:
  subpages  -> <!--subpages-->  /  <!--subpages {"recursive":true}-->
  pageBreak -> <!--pagebreak-->

Adds standaloneCommentFor(name, attrs?) to attached-comment.ts (emits
`<!--name-->` when attrs are empty/absent, else `<!--name {compact-json}-->`).
The `--`-escaping + compact-JSON logic is factored into a shared internal
escapeCommentJson() so standaloneCommentFor and attachedCommentFor cannot drift
(verified byte-identical output for attachedCommentFor — no #9 regression).

Position determines legality (canon #5): subpages/pagebreak are honored ONLY
standalone; the same comment attached after visible text is inert. The parser
pass (applyAttachedComments renamed applyCommentDirectives) now also
materializes these standalone comments into the schema `<div data-type=...>`
element before generateJSON drops the comment node. A LEADING standalone
comment is parsed at document level (outside <body>); the pass walks the whole
document and re-inserts leading comments into <body> in document order, so
block order is preserved.

Raw-HTML path: blockToHtml gains explicit subpages/pageBreak cases emitting the
`<div data-type=...>` form. Comments are dropped by the DOM parse stage inside
columns/cells, so the div-form must stay there — this also fixes a latent
default-fallthrough (`<div></div>`) that silently dropped these atoms inside a
column.

Tests: new machinery-comments.test.ts (primitive, subpages default/recursive
exact strings + round-trip, pageBreak, subpages-inside-column div-form,
fail-open for attached-position/malformed, and multi-node document-order
regression locking the leading/mid/trailing comment ordering). Top-level
goldens in markdown-converter-golden/gaps updated deliberately to the comment
form; the columns/raw-HTML goldens keep the div-form.

package vitest: 477 passed | 1 expected-fail; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:56:40 +03:00
claude code agent 227 f1ab76e879 feat(prosemirror-markdown): serialize textAlign as attached comment (#293 canon #9)
Move paragraph/heading textAlign off the HTML-wrapper form
(<p style="text-align:…"> / <hN style=…>) onto a trailing attached HTML
comment on the block line: `text <!--attrs {"textAlign":"center"}-->`. This
keeps the readable markdown block form (plain `text` / `## Title`) while
preserving alignment losslessly. "left"/null stay bare (no churn).

Adds a reusable attached-comment primitive (attached-comment.ts) that #4
(image) and #8 (media) will reuse:
- attachedCommentFor(name, json) -> `<!--name {compact-json}-->`, escaping any
  `--` pair inside the JSON as -- so the payload can never close the
  comment early;
- parseAttachedComment(data) with grammar `^\s*([A-Za-z][\w-]*)(?:\s+({…}))?\s*$`
  whose name excludes `:`, so envelope comments (docmost:meta / docmost:comments)
  never match — fail-open on anything malformed.

On import, applyAttachedComments runs AFTER marked.parse but BEFORE generateJSON
(parse5 drops comments), re-expressing the attrs comment as an inline
text-align style on the parent block, then removing the comment node.

Guards: emit only when there is a visible element to attach to — paragraph
requires non-empty text, heading requires non-empty headingText (symmetry:
an empty aligned heading stays bare `##`, no orphan comment).

Goldens in markdown-converter-golden/gaps updated deliberately to the
attached-comment form (assertions stay strict: exact output + lossless
round-trip). New textalign.test.ts (19 tests) covers center/right/justify on
paragraph and heading, byte-stable re-export, and fail-open branches.

Raw-HTML containers (columns/cells/callout via blockToHtml) keep the inline
text-align form intentionally — comments are dropped inside raw HTML.

package vitest: 462 passed | 1 expected-fail; tsc clean. git-sync: 268 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:39:46 +03:00
claude code agent 227 6dcc19ce59 refactor(git-sync): consume @docmost/prosemirror-markdown, drop the duplicate lib (#293 stage 3 / no-op)
git-sync's converter-core (src/lib) was a byte-identical duplicate of the new
@docmost/prosemirror-markdown package (created in the previous commit). Switch
git-sync to consume the package and delete its copy — ending the duplication
that the whole #293 effort targets. Pure no-op: NO format/behavior change.

- git-sync depends on @docmost/prosemirror-markdown (workspace:*); engine
  (stabilize/push/pull) + src/index barrel + 12 engine tests re-point their
  converter imports to the package.
- Delete git-sync/src/lib (8 files) and the 23 duplicate converter-core test
  files + their fixtures — the converter and its ~440 tests now live once, in the
  package. git-sync keeps only its ENGINE tests, which exercise the converter
  through the package (the no-op proof). Kept roundtrip-helpers.ts (an engine
  test imports firstDivergence from it; pure helper, no double-run).
- Added docmostExtensions to the package barrel (a kept engine schema-validity
  test needs it).

Verified: editor-ext + prosemirror-markdown + git-sync all tsc EXIT 0;
git-sync vitest 28 files, 268 passed, 0 failures (engine cycle/roundtrip/push/
pull/reconcile green = no-op proof); prosemirror-markdown vitest still 443 passed
| 1 expected-fail; pnpm --frozen-lockfile EXIT 0; no ../lib refs remain in git-sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:19:29 +03:00
claude code agent 227 d6d7dd82f6 feat(prosemirror-markdown): new headless converter package seeded from git-sync (#293 stage 1)
Create @docmost/prosemirror-markdown — the single framework-free ProseMirror<->
Markdown converter + schema mirror that git-sync and mcp will both consume,
ending the three-hand-synced-copies drift (#293). This step only CREATES the
package (no consumer yet; git-sync untouched); the switch of git-sync and mcp
onto it, plus the canonical format decisions, come in later commits of this PR.

- packages/prosemirror-markdown/src/lib/: the 8 converter-core files copied
  VERBATIM from packages/git-sync/src/lib (docmost-schema, markdown-converter,
  markdown-to-prosemirror, canonicalize, markdown-document, node-ops, page-file,
  index). Confirmed byte-identical — no behavioral drift introduced.
- src/index.ts barrel; package.json (@tiptap/* + jsdom/marked/zod, editor-ext
  workspace devDep for the contract test); tsconfig/vitest configs.
- 24 converter-core test files + fixtures copied (engine-coupled layout/
  redteam-layout-title tests correctly excluded — they import ../src/engine).
- pnpm-lock importer added; build/ gitignored (CI-built).

Verified (clean checkout, no network): pnpm --frozen-lockfile EXIT 0; tsc EXIT 0;
vitest 23 files, 443 passed | 1 expected-fail (the same image-diagrams
known-limitation carried from git-sync) — faithful extraction. git-sync untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 07:10:04 +03:00
vvzvlad f5d19f9728 Merge pull request 'build(git-sync): пакет @docmost/git-sync в develop, code-only (#326 step 1 / PR-A)' (#327) from feat/293-A-git-sync-package into develop
Reviewed-on: #327
2026-07-04 07:02:25 +03:00
agent_vscode 351615e5bc prompt(mcp): fix inaccurate and misleading tool descriptions
Audit of all 41 tool descriptions against the actual implementation found
factually wrong or misleading texts:

- list_comments claimed '(paginated)' — it takes only pageId and returns ALL
  comments in one call (internal pagination); now also states that RESOLVED
  threads are included and how to filter them. In-app twin synced.
- search claimed the limit default is 'applied by the client' — the client
  deliberately omits it so the SERVER applies its default.
- create_page's '(automatically moves it to the correct hierarchy)' said
  nothing useful — now documents parentPageId nesting semantics; move_page
  drops the stale 'essential for organizing pages created via create_page'.
- share_page now warns the page becomes accessible to ANYONE with the URL.
- get_page (both transports) now explains inline <span data-comment-id> tags
  are comment anchors (incl. resolved) — markup, not page text.
- patch_node/delete_node/insert_node pointed only at the expensive page-JSON
  view for block ids — now route through the cheap page outline first.
- docmost_transform marks 'Примечания переводчика' as the DEFAULT
  notesHeading, overridable for non-Russian pages.

Checks: @docmost/mcp tests 450/450 (incl. the server-instructions guard);
server ai-chat-tools spec 20/20; mcp build/ artifacts rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 07:00:16 +03:00
agent_vscode 1fda0ec8b0 prompt(mcp): rewrite SERVER_INSTRUCTIONS to cover all tools + guard test
The intent-routing guide had rotted: 17 of 41 registered tools were absent
(get_outline, get_node, the whole table_* family, search, stash_page, sharing,
page lifecycle), and two tips were actively harmful — 'read block ids via
get_page_json' told agents to pull the whole ~100KB document when get_outline
exists precisely to grab ids cheaply, and 'table cell -> patch_node by
attrs.id' dead-ends because table nodes carry no attrs.id.

- Rewrite SERVER_INSTRUCTIONS as intent clusters (READ / EDIT / PAGES /
  COMMENTS / HISTORY) covering every tool except get_workspace; add safety
  notes (share_page = PUBLIC, delete_page = soft) and a comment-anchor
  markup warning for get_page.
- delete_page tool description: state SOFT delete / restorable explicitly.
- MAINTENANCE RULE comments at both registration sites (index.ts,
  tool-specs.ts) + an AGENTS.md convention bullet: adding/renaming/removing
  a tool REQUIRES updating the guide.
- New guard test (test/unit/server-instructions.test.mjs): extracts every
  registered tool name from source and fails when one is not mentioned in
  the shipped SERVER_INSTRUCTIONS (word-boundary match, so get_page can't
  hide behind get_page_json); EXCEPTIONS list is itself validated against
  the registry. SERVER_INSTRUCTIONS exported for the test.

Tests: @docmost/mcp 450/450 (448 + 2 new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 06:51:01 +03:00
claude code agent 227 5edd75da42 build(git-sync): remove committed node_modules + close the nested-node_modules gitignore class (#327 review)
PR-A inherited a committed packages/git-sync/node_modules (31 files: pnpm store
symlinks, .bin shims, and a committed vitest .vite cache) that arrived in develop
with the dead build/ — the F2 junk class. The root .gitignore `/node_modules` is
anchored, so nested packages/*/node_modules slipped through.

- git rm --cached the 31 files.
- .gitignore: `/node_modules` -> `node_modules/` (non-anchored) so nested package
  node_modules are ignored at any depth — closes the class, not just this instance.
- Add explicit "@docmost/editor-ext": "workspace:*" devDependency to git-sync
  (schema-editor-ext-contract.test imported it via hoist; now declared).

Re-verified in a clean checkout (all from local store, no network):
pnpm install --frozen-lockfile EXIT 0; git-sync tsc EXIT 0; vitest 51 files,
711 passed | 1 expected-fail, 0 failures; schema-editor-ext-contract 2/2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 06:33:56 +03:00
claude code agent 227 24b903aaf3 build(git-sync): land the @docmost/git-sync package into develop, code-only (#326 step 1 / PR-A)
The git-sync converter + engine source lived only on the #119 branch; develop
had just the dead compiled build/. Bring the whole package (src + ~700 tests)
onto develop under CI, with NO consumer wired — git-sync stays fully inert in
develop (nothing in apps/server imports it), so runtime behavior is unchanged.
This unblocks #293 (extract the shared converter package from the landed source)
and lets #119's functionality land LAST, already writing the canonical format
(per the #326 landing order).

- packages/git-sync: src (lib converter + engine) + test corpus + configs.
- Remove develop's dead committed packages/git-sync/build/; gitignore it
  (built in CI/Docker via pnpm build, never committed — no src/build drift).
- pnpm-lock.yaml: add the @docmost/git-sync importer (a missing workspace
  package in the lock is a CI blocker). `pnpm install --frozen-lockfile` passes.
- NO server integration / loader / Dockerfile runtime changes (those come with
  #119 at step 6).

Verified: tsc clean; vitest 711 passed | 1 expected-fail, 0 failures, 0 type
errors; pnpm --frozen-lockfile EXIT 0; apps/server has no git-sync import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 06:21:41 +03:00
agent_vscode 2637640291 prompt(agents): drop the role-name label prefix from comments (#315)
Chat now shows the agent's name in the comment header, so the '[Copyedit]' /
'[Structure]' / '[Style]' / '[Facts]' prefix each role prepended just
duplicated the visible author.

- Remove the 'open the comment with the label [Role]' instruction from all four
  labelled roles (structural-editor, line-editor, fact-checker, proofreader),
  ru + en; the narrator was already label-free.
- Severity tags ([Critical]/[Major]/[Minor]) and the fact-checker's verdicts
  ([Incorrect]/[Unverified]/…) are kept — they carry meaning, not the role name.
- Versions bumped: structural-editor 3->4, line-editor 3->4, fact-checker 4->5,
  proofreader 6->7; content-hash lock refreshed.

Check: agent-roles-catalog check.mjs OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 06:08:12 +03:00
agent_vscode aa0428e28b prompt(agents): make the copyeditor exhaustive in one pass (#315)
The copyeditor had to be re-run several times to surface all issues: it has no
'work the whole document' instruction (unlike the developmental editor and the
narrator), and the severity labels nudge it toward reporting only the salient
few.

- Add a HOW TO WORK section (ru + en): one pass over the whole text start to
  finish; flag EVERY violation including all repeat occurrences and [Minor]
  items; don't summarize instead of marking up; one run covers the whole text,
  not just 'the most important'.
- proofreader version 5 -> 6, content-hash lock refreshed.

Check: agent-roles-catalog check.mjs OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 05:50:39 +03:00
vvzvlad 0a3e32e7f6 Merge pull request 'perf(ai-chat): раскрытый Thinking-блок больше не ре-парсит markdown на каждую дельту (дыра #302, фриз в Safari)' (#323) from perf/ai-chat-open-lag into develop
Reviewed-on: #323
2026-07-04 05:00:29 +03:00
claude code agent 227 b1ede48319 test(ai-chat): pin the streaming plain-text text-sink invariant + fix stale CSS ref (#323 F1/F2)
F1: StreamingPlainText/PlainChunk render untrusted model reasoning as a React
text node (escaped), NOT via innerHTML — the load-bearing security property. The
existing tests asserted via textContent, which strips tags, so they couldn't
tell an escaped literal from injected DOM: a future switch to
dangerouslySetInnerHTML would reintroduce XSS with zero failing tests. Add a test
feeding an <img onerror> + <b> payload and asserting querySelector("img"/"b") is
null AND the raw markup survives in textContent — non-vacuous (fails if the
string were parsed as HTML).

F2: the .reasoningText CSS note still described the removed <Text> pre-wrap
fallback and pointed at reasoning-block.tsx (both stale), while PlainChunk's JSDoc
points back to this note — a broken mutual reference. Update the note to point at
PlainChunk / streaming-plain-text.tsx, where pre-wrap is now applied.

No production rendering logic changed. vitest: 8 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 04:25:53 +03:00
agent_vscode d4d05c8e8b test(ai-chat): add dev-only perf harness for the chat stream pipeline
Mounts the real ChatThread against a synthetic AI SDK v6 UI-message SSE
stream (multi-step reasoning + getPage tool calls + markdown answer;
5k/20k/50k-token presets, 15/5 ms chunk cadence) with long-task, FPS
and mount-time instrumentation. Two scenarios: mount a persisted
transcript (open-chat cost) and stream a live turn through the real
useChat pipeline via a window.fetch patch scoped to /api/ai-chat/stream.

Served only by the vite dev server at /perf/ai-chat-perf.html; the
production build keeps its single index.html entry, so none of this
ships. Also ignore local trace dumps under .claude/perf-traces/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:51:22 +03:00
agent_vscode 351860ba4b perf(ai-chat): stop per-delta markdown re-parse in expanded streaming reasoning (#302 follow-up)
The expanded "Thinking" block re-ran marked+DOMPurify and re-set
dangerouslySetInnerHTML with the whole growing reasoning text on every
throttled stream delta (~20 Hz) — the O(n²) hole #302 deliberately left
open ("expanded while streaming"). In Safari this saturates the main
thread and freezes the entire tab during long agent runs, including
while the window is minimized (the JS storm keeps running) and on
re-expanding it mid-turn (one huge layout burst).

- streaming-plain-text.tsx (new): chunked plain-text renderer; chunks
  split at blank-line boundaries with an append-only stable-prefix
  invariant, so per delta only the tail chunk's text node updates —
  no marked, no DOMPurify, no innerHTML swaps.
- reasoning-block.tsx: parse markdown only when expanded AND finalized
  (one-time); while streaming, render chunked plain text; collapsed
  stays parse-free (#302 unchanged).
- message-item.tsx / message-list.tsx: reasoning liveness = part
  state:"streaming" AND the turn is live AND the row is the tail —
  a part stranded at state:"streaming" (manual Stop during thinking,
  or a provider that never emits reasoning-end) finalizes at turn end
  and never re-activates when later turns stream.

Verified with the Chrome perf harness: per-delta marked/DOMPurify work
is gone from the hot path; collapsed streaming stays at 0 long tasks
up to 143k tokens even at 4x CPU throttle; finalized expanded blocks
still render parsed markdown. 245 client tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 03:50:48 +03:00
claude_code 795dde463b prompt(agents): forbid the copyeditor's blanket 'change everywhere' notes (#315)
The copyeditor was emitting summary comments ('throughout, unify KB'; 'switch
the decimal separator everywhere') that carry no applicable suggestedText — a
human can't one-click any of them. This was actively instructed: the TONE
section told it to 'group repeated fixes so you don't spawn dozens of identical
comments'.

- Invert that TONE guidance: spread repeated fixes across the specific spots;
  ten targeted comments each with a ready replacement beat one blanket note;
  'spawning' comments is normal for a copyeditor.
- HOW-TO: explicitly forbid summary/consistency notes and require a separate
  targeted comment with its own suggestedText on EVERY occurrence; the only
  exception is a note that genuinely can't be a fragment replacement.
- ru + en mirrored; proofreader version 4 -> 5, content-hash lock refreshed.

Check: agent-roles-catalog check.mjs OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:05:51 +03:00
vvzvlad 0392566af9 Merge pull request 'fix(temporary-notes): баннер временной заметки на мобильном — адаптивные icon-only действия + flex-basis (#321)' (#322) from fix/321-banner-mobile into develop
Reviewed-on: #322
2026-07-04 00:00:37 +03:00
vvzvlad f43696a1c4 Merge pull request 'feat(#300 ui): генерируемая OKLCH-палитра аватарок агентов (модуль + многоканальность)' (#320) from feat/300-avatar-oklch into develop
Reviewed-on: #320
2026-07-03 23:57:40 +03:00
claude code agent 227 8971912d9e test(#320): make the palette WCAG/gamut check non-vacuous per entry (F1)
The old avatar-palette test only did expect(["white","black"]).toContain(
entry.text), which can never fail (text is typed "white"|"black" and always
assigned) — so the load-bearing property "all 20 colors are readable" was only
really checked for the single golden name. A generator bug producing a
low-contrast or out-of-gamut slot would survive the suite.

Export the four existing color-math helpers (oklchToSrgb, isInGamut,
relativeLuminance, contrastRatio — no logic change) and assert, for EVERY
PALETTE entry:
- (a) real contrast of the chosen text on the entry hex >= 3 (the code's
  threshold), scale-matched (hex 0..255 → /255 before relativeLuminance). Since
  buildPalette PREFERS white and only falls back to black when white fails 3:1,
  the test also asserts: if text=="black" then white's contrast is < 3 (black was
  mandatory) — matching the code's actual decision, not a max-contrast pick.
- (b) the OKLCH is in sRGB gamut post-clamp: isInGamut(oklchToSrgb(L,C,h)).

Demonstrated non-vacuous: a light bg mislabeled text:"white" → chosen contrast
1.67 (< 3) fails; an out-of-gamut component fails isInGamut. Golden-name and
minPairwiseDistance tests untouched.

vitest: 15 passed. No palette/hash/consumer logic changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:40:55 +03:00
claude_code 588596fb2f prompt(agents): teach agent prompts to use comment suggestedText fixes (#315)
- editorial roles (ru/en): proofreader and line editor attach suggestedText
  replacements to targeted fixes; fact-checker ALWAYS attaches the ready
  correction for [Incorrect] verdicts; structural editor and narrator get a
  light-touch rule for in-place rewordings; role versions bumped and the
  content-hash lock refreshed
- MCP SERVER_INSTRUCTIONS: route 'propose a concrete text fix for one-click
  human approval' to create_comment with suggestedText (unique-selection
  reminder); build/ artifacts rebuilt
- AI-chat SAFETY_FRAMEWORK: mention the comment-suggestion capability so the
  default assistant offers ready fixes instead of only describing changes

Checks: catalog check.mjs OK; @docmost/mcp tests 448/448; server
ai-chat.prompt spec 28/28.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:22:37 +03:00
claude code agent 227 ba94def3c8 fix(temporary-notes): keep the banner usable on mobile (#321)
On narrow screens the temporary-note banner squeezed its text into a
one-word-per-line ladder and overflowing words slid under the subtle
"Move to trash" button. Two layout causes, both fixed here (layout-only; no
handler/logic/i18n changes):

- The text Group had `flex: 1` (= basis 0), so the outer `wrap="wrap"` never
  wrapped the buttons to a second row — it crushed the text instead. Give it a
  non-zero basis (`flex: 1 1 16rem`) so the wrap engages on narrow containers.
- Mirror DeletedPageBanner's adaptive actions: labeled Buttons visibleFrom="sm",
  icon-only ActionIcon + Tooltip + aria-label hiddenFrom="sm" (same handlers,
  loading flags, and t() keys). This also fixes the ru locale, whose long labels
  no longer render on mobile.

The sibling DeletedPageBanner already uses this pattern; adding the second button
in #273/#277 didn't carry the adaptive part over.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:17:22 +03:00
vvzvlad e1b8f81b15 Merge pull request 'feat(dictation): reason-модель — говорящий tooltip на серой иконке + общий резолвер ошибок (#309)' (#314) from feat/309-dictation-reasons into develop
Reviewed-on: #314
2026-07-03 23:14:57 +03:00
claude_code 45478098f5 refactor(#300 ui): extract avatar palette into generated OKLCH module
Replace the inline hand-transcribed palette with the self-contained
src/lib/avatar-palette.ts: the 20-color palette is GENERATED at module load
from an OKLCH ring config (chroma clamped to sRGB, WCAG text color per color),
so it is fully tunable and validated (min pairwise ΔE-OK ≈ 0.066).

avatarStyle() slices one cyrb53 hash of the normalized name into independent
channels: base color (20) × color-wheel scheme (analogous ±20–45° / complement
180° / triadic ±120°) × split angle (24 dirs). avatarBackgroundCss() renders a
two-stop gradient with a soft boundary. Pure, cross-platform, deterministic —
same name → same avatar everywhere, nothing persisted.

The glyph now consumes avatarStyle/avatarBackgroundCss from the module;
agent-avatar-stack no longer defines its own hash/palette.

Tests: avatar-palette.test.ts pins minPairwiseDistance ≥ 0.06, PALETTE length,
normalization, and a golden name→style slice (Backend Developer →
#a55795/#90355e/150°) so a config change that repaints every avatar can't slip
through unnoticed. client tsc clean, 30 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:09:49 +03:00
claude_code 62b818bb36 feat(#300 ui): quantized OKLCH palette + multi-channel agent avatar
Replaces the ad-hoc 14-color hsl palette with a perceptually-even, validated
scheme so agent glyphs are reliably distinguishable:

- cyrb53 deterministic, cross-platform 53-bit hash over a normalized name
  (NFC + trim + lowercase + collapse whitespace) — no built-in/rand hash, so
  the same name renders the same avatar on every device without persistence.
- 20-color OKLCH palette (12 light / 8 dark), chroma clamped to sRGB, min
  pairwise ΔEOK ≈ 0.066: any two entries are identical or clearly distinct —
  "almost the same" colors are impossible by construction.
- Disjoint hash-bit channels: base color (20) × gradient partner (2) ×
  gradient angle (8) = 320 combinations, so a base-color collision (inevitable
  past ~20 agents) is still disambiguated by the gradient — and by the emoji
  drawn on top. Text color (black on light ring, white on dark) is
  WCAG-checked.

Glyph now renders an explicit solid backgroundColor (fallback + testable) plus
a linear-gradient backgroundImage. avatarStyle() replaces agentGlyphBackground().
client tsc clean, 26 tests pass (avatarStyle determinism/normalization/structure
+ DOM base-color).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:50:19 +03:00
claude code agent 227 b7c16dc634 i18n(dictation): add "Dictation" aria-label key to en-US + ru-RU (#314 F6)
The F4 fix introduced t("Dictation") as the neutral aria-label for a disabled
mic with no reason (reachable via the AI chat mic while the assistant streams),
but the key wasn't in either locale — a ru-RU screen-reader user would hear the
English "Dictation". Add it to both locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:43:06 +03:00
vvzvlad da952ca536 Merge pull request 'fix(#300 ui): различимые per-agent цвета глифа + аватар пользователя на переднем плане' (#319) from feat/300-avatar-colors into develop
Reviewed-on: #319
2026-07-03 22:28:44 +03:00
claude code agent 227 1458e3e152 fix(dictation): cover read-only precedence, suppress misleading disabled tooltip, drop dead "busy" (#314 F3/F4/F5)
F3: add computeDictationAvailability assertions for the read-only ∩ pre-sync
intersection (editable:false, inEditMode:true, showStatic:true) → read-only for
both isDisconnected states, pinning that lack of edit permission takes
precedence over the pre-sync reason (kills a mutant dropping `editable &&`).

F4: switching native disabled → data-disabled made a disabled mic hoverable — good
for the byline mic (shows the reason), but a consumer passing bare `disabled`
without a reason (AI chat's isStreaming) got a misleading, actionable
"Start dictation" tooltip on a click-rejecting control. Now: disabled + no reason
→ render the icon with NO Tooltip and a neutral aria-label; disabled + reason →
reason tooltip; enabled → "Start dictation". Click guard/data-disabled preserved.

F5: remove the dead "busy" DictationUnavailableReason (never produced) — union
member, its resolver case (folded into default), and the vacuous test assert.

vitest (dictation + editor-sync + dictation-group): 41 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:18:16 +03:00
claude code agent 227 13a333632a test(#319): pin per-agent glyph color reaches the DOM + fix stale z-order docs (F1/F2)
F1: the bug was the color never reaching the DOM (Mantine Avatar's --avatar-bg
overrode it); the pure agentGlyphBackground always returned distinct colors, so
the existing unit tests would pass even against the broken Avatar. Add a
data-testid on the glyph Box and two render tests: one asserts the emoji glyph's
applied inline background equals agentGlyphBackground(name); one asserts two
palette-distinct agents reach the DOM as different backgrounds. React applies
styles via the CSSOM (hsl→rgb), so the assertion normalizes both sides through
the same path and compares against the real function output (no frozen literal).
Fails against the pre-fix Avatar (no inline background / no glyph testid).

F2: the top-level AgentAvatarStack JSDoc and two test titles still described the
old z-order (agent glyph in front, human behind); the PR flipped it (human
launcher badge in front, zIndex 2 > glyph 1). Updated the JSDoc + both titles to
match.

vitest: 10 passed (+2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:15:42 +03:00
claude_code 344b9723b2 fix(#300 ui): distinct per-agent glyph colors + launcher on top
The per-agent glyph color never showed: the circle was a Mantine
`Avatar variant="filled"` whose background was overridden by Mantine's
`--avatar-bg`, so every agent fell back to the theme's violet. Also raw
`hue = hash % 360` put many names in the same "purple" arc.

- Render the emoji/sparkles circle as a plain Box with an explicit
  background — the color is now guaranteed.
- Pick the color from a curated palette of categorically-distinct dark
  hues (red/orange/green/teal/blue/violet/magenta/slate) by name hash, so
  different agents read as different colors, not shades of one violet.
- Bring the launcher (human) badge ABOVE the agent glyph (zIndex) so it is
  fully visible at the top-right instead of half-hidden behind the circle.

client tsc clean, tests pass (added a color-distinctness assertion).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:46:31 +03:00
claude code agent 227 d57392b5af fix(dictation): extract tested computeDictationAvailability + gate mic from the reactive atom (#314 F1/F2)
F1: the availability publish-effect duplicated the #218 editability gate
(editable && inEditMode && !showStatic) inline — a copy that could silently
diverge from the tested isBodyEditable — and the reason computation (the core of
#309) had no tests. Extract computeDictationAvailability into editor-sync-state.ts
REUSING isBodyEditable; the effect is now a one-line call. Unit tests cover the
branches (synced→null; pre-sync disconnected→offline / else connecting;
!editable/!edit→read-only).

F2: DictationGroup gated the mic on the non-reactive editor.isEditable while the
PR already publishes the reactive dictationAvailability.isEditable (same signals)
— so gate and reason came from different sources and the mic could stick. Gate on
dictationAvailability.isEditable: one reactive source of truth for both.

vitest (editor-sync-state + dictation): 37 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:32:06 +03:00
claude code agent 227 a86e5f409f feat(dictation): reason model — speaking tooltip on a disabled mic + shared error resolver (#309)
The dictation mic could be grey/disabled while silently showing "Start
dictation", and Mantine's native `disabled` set pointer-events:none so the
Tooltip never fired at all — the UI knew the cause but told the user nothing.
Runtime error strings were also duplicated verbatim across the two dictation
hooks.

- New dictation-status.ts: the single source of truth. A DictationUnavailableReason
  enum (connecting/offline/read-only/unsupported/busy) + a DictationErrorCode enum,
  pure classifiers (classifyGetUserMediaError / classifyTranscriptionError) and
  resolvers (resolveUnavailableLabel / dictationErrorMessage). All user-facing
  dictation strings are formed here; the verbatim server message still wins for
  transcription errors.
- page-editor publishes dictationAvailabilityAtom { isEditable, reason } computed
  at the source (editable/edit-mode/showStatic/collab status): connecting vs
  offline (stuck) vs read-only. DictationGroup forwards the reason to MicButton.
- MicButton is reason-aware: a disabled mic shows the cause-specific tooltip. The
  disabled-hover silence is fixed by marking disabled the Mantine way
  (data-disabled/aria-disabled + click guard) instead of the native attribute, so
  the Tooltip fires — applied to both the idle (reason) and error (errorMessage)
  states.
- Both hooks route every error through the shared resolver (deleting the
  duplicated transcriptionErrorMessage), and expose errorMessage for the tooltip.
  Wording is byte-identical to each hook's original (incl. the batch hook's
  DOMException name prefix and the verbatim server message).
- i18n: 3 new reason keys in en-US + ru-RU, and the previously-missing ru-RU
  dictation error translations.

Tests: dictation-status.test.ts (all classifier/resolver branches, incl. server
message passthrough) + mic-button.test.tsx (disabled mic shows the reason text,
uses data-disabled not native disabled — fails against the pre-fix code).
vitest: 5 files / 32 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:29:39 +03:00
vvzvlad 33d22ff164 Merge pull request 'feat(comment): предложения правок агента + кнопка «Применить» (server-side atomic apply, #315)' (#318) from feat/315-comment-suggestions into develop
Reviewed-on: #318
2026-07-03 21:29:28 +03:00
vvzvlad b861266ff8 Merge pull request 'fix(ai-chat): резолв slugId→uuid в bound-chat — 500 (22P02) на открытии страницы (#312)' (#313) from fix/312-bound-chat-slug into develop
Reviewed-on: #313
2026-07-03 21:27:14 +03:00
vvzvlad 8b99b70d73 Merge pull request 'feat(#300 ui): launcher в правый верхний угол + тёмный per-agent цвет глифа' (#307) from feat/300-avatar-polish into develop
Reviewed-on: #307
2026-07-03 21:26:28 +03:00
vvzvlad b3d4922efa Merge pull request 'fix(editor): резервировать высоту документа на свопе static→live — скролл переживает своп (корень дрыга, #266)' (#308) from fix/scroll-restore-swap-height into develop
Reviewed-on: #308
2026-07-03 21:26:04 +03:00
vvzvlad 49c7c4bb64 Merge pull request 'fix(editor): реактивное чтение editor.isEditable в DictationGroup — иконка диктовки больше не залипает серой (#311)' (#316) from fix/311-reactive-editable into develop
Reviewed-on: #316
2026-07-03 21:25:49 +03:00
vvzvlad d9517ff3f1 Merge pull request 'fix(ai): устойчивость pre-response ECONNRESET — бюджет ретраев, jittered backoff, keep-alive (#310)' (#317) from fix/310-econnreset-tuning into develop
Reviewed-on: #317
2026-07-03 21:25:33 +03:00
claude code agent 227 48c1ec46f7 fix(comment): store the real anchored substring as expectedText + pin authz (#318 F1/F2)
F1 [blocking]: a suggestion whose anchor matched via normalization could never
be applied (spurious 409). The comment mark lands on the doc's ACTUAL text
(Docmost auto-converts to typographic quotes/dashes/nbsp), but the stored
selection — used as expectedText at apply — was the raw ASCII agent input
(+substring(0,250)). So replaceYjsMarkedText's strict joined!==expectedText
always failed and threw "text changed" though nobody edited. Fix: new pure
getAnchoredText(doc, selection) reconstructs the exact raw doc substring the mark
covers (slicing identical to spliceCommentMark); on the suggestion path
client.createComment stores THAT as selection, so expectedText equals the marked
text and apply returns applied:true. Live anchoring still uses the raw agent
selection (normalization still finds the anchor). Truncation raised 250->2000
(+ DTO @MaxLength(2000)) so the anchored substring is never cut below the mark
span. Ordinary comments unchanged. AI-chat shares client.createComment, so
covered. Regression tests: getAnchoredText raw-vs-ASCII; create payload selection
is the typographic substring; apply with typographic expectedText -> applied.

F2 [blocking]: added comment.controller.spec.ts pinning that validateCanEdit runs
before applySuggestion (Forbidden -> applySuggestion never called; happy path ->
called; missing comment -> 404 without authorizing).

MCP 448 pass; server comment+yjs 54 pass. MCP build/ rebuilt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:29:42 +03:00
claude code agent 227 cd539558ed feat(agent-tools): suggestedText on create_comment with strict anchor uniqueness (#315 phase 6)
Agents can attach a suggested replacement when creating an inline comment, via
both the MCP create_comment tool and the AI-chat createComment tool.

Because applying a suggestion edits the EXACT anchored text, an ambiguous anchor
would let Apply corrupt the wrong occurrence. So when suggestedText is set the
selection must occur EXACTLY ONCE:
- new countAnchorMatches(doc, selection) counts occurrences across all blocks
  (same normalization/traversal as canAnchorInDoc), counting occurrences (2 in
  one block => 2) — stricter than block-count, never under-counting distinct
  occurrences (false-unique is the dangerous direction).
- client.createComment gains suggestedText: a pre-check (getPageJson +
  countAnchorMatches: 0 => not-found, >=2 => ambiguity error) before create, and
  an AUTHORITATIVE live check inside the anchoring mutation that recomputes on the
  live doc and, if != 1, aborts and rolls back the just-created comment (reusing
  the existing safeDeleteComment "anchor not found" path). Ordinary comments keep
  first-occurrence behavior unchanged.
- suggestedText is rejected on a reply or without selection in all three layers
  (MCP handler, MCP client, AI-chat tool), mirroring the server DTO/service.
- filterComment surfaces suggestedText/suggestionAppliedAt/suggestionAppliedById.
- DocmostClientLike.createComment signature updated. MCP build/ rebuilt.

Tests: countAnchorMatches (0/1/N, within/across/nested block, span nodes,
quote normalization); createComment (ambiguous refused pre-create, reply and
no-selection rejected, unique succeeds and forwards suggestedText, filterComment
surfaces it); ai-chat schema accepts suggestedText. MCP 443 pass; ai-chat 601 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:35:47 +03:00
claude code agent 227 b62db917de feat(comment): suggestion diff block + Apply button + mutation (#315 phase 5)
Client UI for agent comment suggestions.

- IComment gains suggestedText / suggestionAppliedAt / suggestionAppliedById.
- comment-list-item shows a "было → стало" block (old selection struck/red, new
  suggestedText green) for a top-level comment with a suggestion, plus an Apply
  button — gated by canShowApply(comment, canEdit): edit permission AND a
  suggestion AND not applied AND not resolved AND top-level. Once applied, an
  "Applied" badge replaces the button.
- canEdit comes from page.permissions.canEdit (real edit permission, NOT the
  looser canComment) and is threaded through CommentListItem and nested
  ChildComments; fail-closed when undefined.
- useApplySuggestionMutation posts to /comments/apply-suggestion; on success it
  writes the applied + server auto-resolve fields into the react-query cache
  (UI flips to Applied + resolved without a refetch); on 409 it shows a specific
  message with the server's currentText, else a generic error.
- i18n keys added in en-US + ru-RU.

Tests (comment-list-item.test.tsx + canShowApply unit suite): Apply visibility
across canEdit/applied/resolved/reply, click dispatches the mutation, diff
rendering. 34 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:19:36 +03:00
claude code agent 227 ec542a924b feat(comment): store suggestedText + POST /comments/apply-suggestion (#315 phase 4)
Server side of agent comment suggestions.

- CreateCommentDto gains optional suggestedText (<=2000). CommentService.create
  accepts it ONLY for a top-level inline comment with a non-empty selection,
  requires it be non-empty and differ from selection (else BadRequest), and
  stores it.
- POST /comments/apply-suggestion (ApplySuggestionDto { commentId }): authorizes
  with validateCanEdit (applying edits page text) BEFORE any structural check or
  mutation, then CommentService.applySuggestion:
  - runs the phase-3 collab event applyCommentSuggestion on `page.<pageId>` to
    atomically check-and-replace the marked text, returning { applied, currentText };
  - applied → stamp suggestion_applied_at/by, auto-resolve the thread, ws
    commentUpdated, audit COMMENT_SUGGESTION_APPLIED;
  - already-applied (DB) → idempotent success (no re-apply), self-healing the
    resolve if it was missed — satisfies the issue's double-click / two-user
    race requirement;
  - collab verdict applied:false && currentText===suggestedText → idempotent
    success (crash between doc mutation and DB write);
  - text changed → 409 ConflictException carrying currentText;
  - gateway undefined/throw → hard error, never a silent success.
- audit-events: COMMENT_SUGGESTION_APPLIED.

Tests: create validation (reply/no-selection/equal-to-selection rejected;
valid stored) + applySuggestion verdict branches incl. both idempotent paths.
jest src/core/comment: 33 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:09:23 +03:00
claude code agent 227 a9da8f7f15 feat(collab): applyCommentSuggestion event + no-Redis local fallback (#315 phase 3)
New custom collab event applyCommentSuggestion runs replaceYjsMarkedText inside
the document's Yjs transaction on the owning instance and returns the
{ applied, currentText } verdict to the API-server caller (cross-process via the
Redis bridge, whose customEventComplete/replyId already carries handler return
values).

- withYdocConnection is now generic and returns the callback's result (captured
  in a closure, since hocuspocus connection.transact does not forward it). The
  callback is typed synchronous-only: transact runs fn synchronously without
  awaiting, so an async fn would mutate outside the transaction and lose
  atomicity.
- collaboration.gateway.handleYjsEvent: when Redis is disabled
  (COLLAB_DISABLE_REDIS), dispatch the handler locally against the single
  hocuspocus instance and return its verdict instead of silently returning
  undefined (which would make apply a no-op). Also fixes the pre-existing silent
  no-op of setCommentMark/resolveCommentMark without Redis.

Tests: handler spec (applied mutates doc + returns verdict; changed-text returns
{applied:false} without mutating; args forwarded; withYdocConnection returns the
value) and gateway spec (no-Redis path dispatches locally, returns the verdict,
not undefined).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:52:44 +03:00
claude code agent 227 7c0664d2b3 feat(collab): replaceYjsMarkedText — atomic check-and-replace of comment-marked text (#315 phase 2)
The primitive behind "Apply comment suggestion": walk the XmlFragment, collect
the delta segments carrying the `comment` mark for a commentId, and replace them
with new text ONLY if the run is intact (single Y.XmlText, contiguous, and the
joined text still equals the expected anchor). Otherwise return a verdict
{ applied:false, currentText } — null when the anchor is gone, else the current
text — so the caller can report "someone changed it". On apply it deletes the
run and re-inserts the new text re-attaching the same comment mark (thread stays
anchored). Mutates in place for the caller's connection.transact(); opens no
transaction of its own.

Non-string inserts (embeds) advance the offset by their 1-unit index length so a
marked segment after an embed gets the right position and an embed inside a run
is correctly rejected as a changed anchor.

Tests (yjs.util.spec.ts): happy path (mark preserved, surrounding text and no
mark-bleed), resolved-mark match, changed text, deleted anchor, paragraph split,
interleaved unmarked text, and embed before/inside the run. 17 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:41:32 +03:00
claude code agent 227 a32fba63ec feat(comment): db columns for comment suggestions (#315 phase 1)
Add suggested_text / suggestion_applied_at / suggestion_applied_by_id to the
comments table (migration) and mirror them in the hand-curated db.d.ts Comments
interface. suggested_text holds a proposed replacement for the comment's
anchored selection; the applied_* columns record who applied it and when.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:29:03 +03:00
claude code agent 227 808a5c70df fix(ai): harden pre-response ECONNRESET retry — bigger budget, jittered backoff, shorter keep-alive (#310)
In prod the AI provider resets the connection pre-response (ECONNRESET); the
#175 pre-response retry recovers it, but 2 of the 3 allowed attempts were burned
in a single turn — no headroom, and one more reset would surface an error to the
user. This is tuning for resilience (not a diagnosis of who resets):

- Retry budget 2 → 4 (total 5 attempts), env-configurable via
  AI_STREAM_PRE_RESPONSE_RETRIES (0 = no retry; empty/invalid → default 4).
- Backoff: linear 150*(attempt+1) → capped exponential + full jitter
  (preResponseBackoffMs, a pure injectable helper): base 150ms, ×2 per attempt,
  capped 2000ms, delay = random in [0, capped]. Avoids a synchronized retry
  storm and spreads reconnects across the reset window.
- Keep-alive default 10_000 → 4_000 ms so undici recycles idle sockets before a
  ~5s upstream/middlebox idle cutoff can poison them (a common pre-response
  reset cause). Still env-overridable via AI_STREAM_KEEPALIVE_MS.
- .env.example documents both knobs.

Timeout (900s), RETRYABLE_CONNECT_CODES, and the instrumentation are unchanged.

refs #310

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:24:09 +03:00
claude code agent 227 0210faabea fix(editor): read editor.isEditable reactively in DictationGroup so the mic un-greys after sync (#311)
On an editable page the dictation mic in the byline stayed grey/disabled after
collab finished syncing, until an unrelated re-render (view↔edit toggle,
navigation) happened. DictationGroup read the NON-reactive `editor.isEditable`
field directly in render; `editor` comes from pageEditorAtom (a stable object
reference), so `editor.setEditable(true)` after sync (#218 gate) mutates TipTap
state without changing the atom reference — the byline never re-renders and
disabled=true sticks.

Read editable via `useEditorState` (the same reactive read the editor body
already uses), so the mic re-enables when the body flips editable and disables
again when it loses editable. The #218 pre-sync intent is preserved — just made
reactive. Test flips isEditable false→true and asserts the mic goes
disabled→enabled (fails against the pre-fix raw-field read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:19:52 +03:00
claude code agent 227 17003fbbc1 test(editor): extract swap height-reservation into a tested hook (#308 F1)
The +42-line height-reservation logic lived inline in PageEditor on the risky
static→live swap path with zero tests (F1). Extract it into
useSwapHeightReservation(showStatic, menuContainerRef) → { reservedHeight,
captureReservation }, mirroring the sibling useScrollRestoreOnSwap extraction,
so its release guard and cap are directly unit-testable.

Pure extraction — behavior identical. The capture stays a synchronous callback
the editor invokes in the collab-sync effect (reading swapWrapperRef.offsetHeight
while the static content is still mounted, before setShowStatic(false)); a
post-transition effect inside the hook would read the collapsed live height and
be wrong. The rAF release loop (release at scrollHeight >= reserved, or the
RELEASE_CAP_MS=4000 cap) and cancelAnimationFrame cleanup moved verbatim.

Tests (use-swap-height-reservation.test.ts) cover 4 branches, mutation-verified:
(a) capture → reservedHeight; (b) release when live content reaches reserved;
(c) release at the cap when it never does; (d) non-swap → stays null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:13:30 +03:00
claude code agent 227 0df6242128 fix(ai-chat): resolve page slugId to uuid in bound-chat, fixing 22P02 500 (#312)
POST /api/ai-chat/bound-chat 500'd with Postgres 22P02 because the client
sends a page slugId (10-char nanoid) in the request `pageId` field, which the
server passed straight into the UUID `page_id` column. The chat-to-document
binding silently broke (client fail-softs to a new chat) and every slug-URL
page open logged a 500.

Fix: resolve the incoming id to a real page UUID on the server. PageRepo.findById
already accepts both a uuid and a slugId (isValidUUID→slugId fallback), so
boundChat now resolves the page first, guards it against a foreign/unknown
workspace (returns {chatId:null} before any chat lookup — no cross-workspace
probe), and looks up the latest chat by the resolved page.id (real uuid).

Client: renamed the local pageId→slugId for clarity (the value is a slugId);
the wire body key stays `pageId` so the DTO is unchanged. DTO left @IsString()
(a @IsUUID() would only turn the 500 into a 400 and still break binding).

Test: bound-chat spec asserts a slugId resolves and findLatestByPage is called
with the real uuid; a foreign-workspace page → {chatId:null} without a chat
lookup (no leak); an unknown id → {chatId:null}, no throw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:07:23 +03:00
vvzvlad 36b3539571 Merge pull request 'refactor(ai-chat): move patch_node/insert_node into the shared tool-spec registry (#294)' (#305) from refactor/294-tool-spec-registry into develop
Reviewed-on: #305
2026-07-03 18:02:40 +03:00
vvzvlad a63efa6920 Merge pull request 'fix(ai-chat): stop the reasoning-stream hang — parse markdown only when expanded (#302)' (#303) from fix/302-reasoning-parse-when-open into develop
Reviewed-on: #303
2026-07-03 18:02:16 +03:00
claude code agent 227 ccd38152ab docs(ai-chat): correct AgentGlyph docstring — per-agent dark circle, not violet (#307 F1)
The launcher-polish commit replaced the fixed violet AGENT_COLOR background
with a per-agent dark hashed circle (agentGlyphBackground). Points 2 and 3 of
the AgentGlyph image-source docstring still said 'violet circle' — update both
to 'per-agent dark circle' so the doc matches the code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:01:18 +03:00
claude_code 8f95c5808e fix(editor): reserve document height across the static→live swap so scroll survives (#266)
Root cause (confirmed via Chrome DevTools on the live app, and the fix validated
there too): after the reading-position restore lands correctly, the static→live
editor swap momentarily SHRINKS the document (the live editor lays out its content
over a few frames — measured height 32005 → 22050), so the browser CLAMPS window
scroll to the top. That is what produced all of:
- "lands correct → jumps to top → back down" (restore#2 recovering from the clamp),
- the final position overshooting (~6000px) via scroll-anchoring during recovery,
- "scroll a little → jumps to 0" (the clamp catching the reader mid-scroll).

Fixing the restore logic was chasing symptoms. This reserves the pre-swap content
height (a min-height on a wrapper around the static/live editor) until the live
editor has laid out (or a short safety cap), so the document never collapses and
window scroll simply survives the swap. Validated live: with the height pinned the
restore fires ONCE and the position stays put (no reset, no jitter, no overshoot);
the existing post-swap re-assert becomes a silent no-op.

No change to the restore hook or its tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:36:10 +03:00
claude_code 6f7d439811 feat(#300 ui): move launcher to top-right, per-agent dark glyph color
- Launcher (human) avatar moves from the bottom-right to the TOP-RIGHT
  corner of the agent glyph.
- The emoji/sparkles glyph circle is no longer a fixed violet: its
  background is derived from a hash of the agent name (hue) and pinned to a
  fixed dark shade (hsl(h, 45%, 24%)) so distinct agents get distinct colors
  while the emoji / white sparkles icon stays readable. Agents with an
  uploaded avatar image are unaffected.

Add a unit test for agentGlyphBackground (deterministic, name-varying, dark).
client tsc clean, 11 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:28:06 +03:00
vvzvlad 88d96c41b5 Merge pull request 'feat(ai-chat): agent avatar stack — agent front, launcher behind (#300)' (#304) from feat/300-agent-avatar-stack into develop
Reviewed-on: #304
2026-07-03 16:01:17 +03:00
claude_code ef16743406 fix(#300 ui): flip avatar hierarchy on comments, make launcher visible
Two visual defects in the agent avatar stack (PR #304), missed by the
code-only review:

- The launcher (human) avatar was fully occluded behind the opaque agent
  glyph — the container was exactly the glyph size, so the launcher sat
  underneath it. Enlarge the container by an overhang and vertically center
  the glyph so the launcher peeks out at the bottom-right and stays visible.
- On comments the human creator stayed the PRIMARY avatar and name while the
  stack was crammed into the old badge slot, duplicating the identity and
  failing the "agent is primary" requirement. AgentAvatarStack gains a
  showName prop; with showName=false it now replaces the leading avatar for
  agent comments, and the name slot renders agent.name (+ dimmed
  · launcher.name). Non-agent comments are byte-identical to before;
  history-item keeps the default (names shown).

Tests: add showName=false and external-MCP (no-launcher) coverage, assert
no identity duplication. client tsc clean, 9 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:08:16 +03:00
vvzvlad 6c208a965f Merge pull request 'fix(editor): убрать рывок восстановления позиции чтения при reload — авто-фокус заголовка (#266)' (#301) from feat/scroll-restore-ux into develop
Reviewed-on: #301
2026-07-03 13:50:14 +03:00
agent_coder 86c1307ed2 fix(#300 review): drop stray symlink, re-fetch enriched on comment update, cover history mapping (F1/F2/F3)
F1: remove an accidentally-committed self-referential symlink
packages/mcp/node_modules/node_modules -> an absolute build-machine path (leaked a dev
home path, a pnpm artifact useless in the repo), and add a targeted ignore so it can't
recommit.
F2: the commentUpdated broadcast re-emitted the caller's pre-loaded comment mutated in
place, so the {agent,launcher} stack survived only because the controller happened to
load it with includeCreator:true — the fragile coupling that let the stack vanish on
edit once already. update() now RE-FETCHES the enriched comment before broadcasting,
symmetric with create()/resolveComment() (the row is already persisted), so all three
broadcasts carry the stack regardless of any caller's pre-load. Adds a caller-contract
test asserting all three broadcasts emit agent/launcher for an agent comment and neither
for a non-agent one, spotlighting the update path (non-vacuous vs the old re-emit).
F3: add a direct test of the page-history attachPageHistoryAgent mapping (its distinct
lastUpdatedSource/lastUpdatedAiChatId/lastUpdatedBy column set): role / no-role / MCP /
non-agent, and that the internal agentRole join column is stripped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 06:38:25 +03:00
agent_coder 0968ea97d2 feat(ai-chat): agent avatar stack — agent in front, launcher behind (#300)
For AI-agent-authored content (comments + page history), replace the text AI-AGENT
badge with an avatar stack: the agent in front, the human who launched it smaller and
behind. This fixes the inverted hierarchy (the action was the agent's; the human just
launched it). closes #300.

Backend: a single server-authoritative resolver resolveAgentProvenance normalizes to
{ agent, launcher } from server columns only (createdSource/lastUpdatedSource, aiChatId,
creator, chat role) — nothing from request input, so agent identity can't be spoofed.
Internal chat -> agent = chat role (name/emoji), launcher = human; external MCP
(aiChatId null) -> agent = the agent account, launcher = null; non-agent -> neither.
The role join (aiChatId -> ai_chats.role_id -> ai_agent_roles) deliberately does NOT
filter enabled/deleted_at, so a later-disabled role still labels historical content
(mirrors findById, not findLiveEnabled). Enrichment is applied on BOTH findPageComments
(list) AND findById (the create/resolve/update broadcast path), so the stack shows on
live comment events and doesn't vanish on resolve/edit.

Frontend: new AgentAvatarStack + AgentGlyph (avatarUrl -> role emoji on violet ->
IconSparkles on violet), integrated into comment-list-item and history-item where the
badge was; the deep-link-to-chat click moved onto the stack. ai-agent-badge removed.

Tests: AgentAvatarStack (role/no-role/MCP/click/non-clickable), the provenance resolver
+ recorder tests proving the role join never filters enabled/deleted, and findById
enrichment (guards the live-broadcast regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:28:53 +03:00
claude_code 4af21494af fix(editor): stop the title auto-focus from yanking scroll on reload (#266)
Root cause (confirmed via Chrome DevTools on the live app): the reading-position
restore jittered on reload — it landed at the saved spot, jumped to the top, then
back. The jump was NOT a height collapse: the title editor auto-focuses ~300ms
after mount, and TipTap's focus scrolls the focused node into view. Since the
title sits at the top of the page, that yanked window scroll to the top.

Minimal fix (the fast restore mechanism is left unchanged):
- Focus the title with { scrollIntoView: false } so placing the caret no longer
  moves the viewport.
- Skip the title auto-focus entirely when a saved reading position will be
  restored (otherwise the caret lands in the now-off-screen title). Exported
  hasSavedReadingPosition() as the single source of truth.
- Extracted the decision into a testable useTitleAutofocus hook (which also adds
  a clearTimeout cleanup, fixing a pre-existing uncancelled/destroyed-editor
  timer), and covered it + hasSavedReadingPosition with unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 05:15:28 +03:00
agent_coder 2d30ad1fa2 fix(ai-chat): parse reasoning markdown only while expanded to stop the thinking-stream hang (#302)
The reasoning block memoized its markdown render on [trimmed] alone, so as the
reasoning text streamed in it re-parsed the whole, ever-growing text (marked +
DOMPurify) on every throttled delta (~20Hz) — an O(n^2) CPU storm that pinned the
main thread and froze the chat during a long "thinking" phase. Worse, the block is
collapsed by default, so all that parsing was for a hidden body the user never sees
(html is only shown inside <Collapse in={open}>).

Gate the parse on `open`: collapsed shows the cheap raw-text fallback and does no
markdown parsing; expanding parses the current text once (an instant user click), and
further streaming while open is the normal per-delta append render, like the answer.

Test: assert renderChatMarkdown is not called while collapsed and is called once on
expand.

closes #302

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:01:37 +03:00
351 changed files with 40955 additions and 21785 deletions
+22 -3
View File
@@ -173,9 +173,21 @@ MCP_DOCMOST_PASSWORD=
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls. # Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
# A pooled connection idle longer than this is closed instead of reused, so a # A pooled connection idle longer than this is closed instead of reused, so a
# NAT / egress firewall / reverse proxy that silently drops idle connections # NAT / egress firewall / reverse proxy that silently drops idle connections
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Lower it if # cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Kept under
# your egress drops idle connections faster than ~10s. Default 10000 (10 s). # common ~5s upstream/middlebox idle cutoffs so undici recycles the socket before
# AI_STREAM_KEEPALIVE_MS=10000 # the network kills it (fewer resets), while still reusing within a burst of
# back-to-back calls. Lower it further if your egress drops idle connections even
# faster. Default 4000 (4 s).
# AI_STREAM_KEEPALIVE_MS=4000
# Number of PRE-RESPONSE connection retries for streaming chat/agent AI calls: a
# reset/timeout BEFORE any response byte (e.g. `read ECONNRESET` on a stale pooled
# socket) is retried on a fresh connection with jittered exponential backoff.
# Total attempts = value + 1, so the default 4 gives 5 attempts — headroom to
# absorb a short BURST of upstream resets without exhausting the budget. Safe to
# retry: a started stream is never replayed, only a connect that never responded.
# 0 disables the retry. Default 4.
# AI_STREAM_PRE_RESPONSE_RETRIES=4
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider). # Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in # Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
@@ -190,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
+8
View File
@@ -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
+16 -1
View File
@@ -4,7 +4,20 @@
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,
# so src/ and prod can never silently diverge).
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
@@ -43,6 +56,8 @@ lerna-debug.log*
.nx/cache .nx/cache
.claude/worktrees/ .claude/worktrees/
.claude/tmp/ .claude/tmp/
# Local Chrome performance traces recorded by the AI-chat perf harness
.claude/perf-traces/
# TypeScript incremental build artifacts # TypeScript incremental build artifacts
*.tsbuildinfo *.tsbuildinfo
+4 -2
View File
@@ -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,6 +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). `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
+8
View File
@@ -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
+11 -6
View File
@@ -34,11 +34,13 @@ roles:
Read the whole text first. Think at the level of sections and paragraphs, not sentences. Read the whole text first. Think at the level of sections and paragraphs, not sentences.
HOW TO LEAVE COMMENTS HOW TO LEAVE COMMENTS
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity: You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing. - [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline. - [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
- [Minor] — an optional improvement to framing or flow. - [Minor] — an optional improvement to framing or flow.
Structural fixes (move, merge, cut) can't be expressed as a fragment replacement — a comment is enough for those. But when your proposal boils down to replacing a specific wording in place (a headline, a lead phrase), attach a suggested replacement to the comment (the `suggestedText` parameter): the exact new 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.
TONE TONE
Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question. Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question.
@@ -85,7 +87,7 @@ roles:
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it. - Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
HOW TO LEAVE COMMENTS HOW TO LEAVE COMMENTS
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise". Tag severity: You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new 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. Tag severity:
- [Critical] — the sentence is unclear or distorts the meaning. - [Critical] — the sentence is unclear or distorts the meaning.
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading. - [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
- [Minor] — a stylistic improvement to taste. - [Minor] — a stylistic improvement to taste.
@@ -126,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. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. 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.
@@ -166,14 +168,17 @@ roles:
- Don't verify facts — that's the Fact-checker. - Don't verify facts — that's the Fact-checker.
- Don't make substantive changes. Edits are minimal and mechanical. - Don't make substantive changes. Edits are minimal and mechanical.
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". 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. Open the comment with the label `[Copyedit]`. 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:
- [Critical] — a grammar/spelling error or typo visible to the reader. - [Critical] — a grammar/spelling error or typo visible to the reader.
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it). - [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
- [Minor] — optional polish. - [Minor] — optional polish.
TONE TONE
To the point, no explaining the obvious. Group repeated fixes (e.g. "throughout: straight quotes → curly") so you don't spawn dozens of identical comments. To the point, no explaining the obvious. Don't fold repeated fixes into a single "change it everywhere" note — spread them across the specific spots: ten targeted comments each carrying a ready replacement beat one blanket comment that can't be applied with a button. Don't worry about "spawning" comments — for a copyeditor that's normal.
WHEN UNSURE WHEN UNSURE
If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant. If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant.
@@ -272,7 +277,7 @@ roles:
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy. First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
═══ HOW TO LEAVE NOTES ═══ ═══ HOW TO LEAVE NOTES ═══
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. Comment on what will strengthen the story, not on every little thing. You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. When one of your options is a single ready-made text (e.g. a new lead phrase), you may attach it as a suggested replacement (the `suggestedText` parameter: the exact new text for the selected fragment, no markup; the fragment must occur exactly once in the text, otherwise extend the selection) — the button imposes nothing, the author is free not to apply it. Comment on what will strengthen the story, not on every little thing.
═══ TONE ═══ ═══ TONE ═══
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it. Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
+11 -6
View File
@@ -34,11 +34,13 @@ roles:
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений. Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность: Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента. - [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок. - [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
- [Незначительно] — улучшение подачи или стройности, не обязательное. - [Незначительно] — улучшение подачи или стройности, не обязательное.
Структурные правки (перенести, объединить, вырезать) через замену фрагмента не выражаются — для них достаточно комментария. Но если предложение сводится к замене конкретной формулировки на месте (заголовок, лид-фраза), приложи к комментарию предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом.
ТОН ТОН
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом. Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
@@ -85,7 +87,7 @@ roles:
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой. - Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать». Помечай важность: Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
- [Критично] — предложение непонятно или искажает смысл. - [Критично] — предложение непонятно или искажает смысл.
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение. - [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
- [Незначительно] — стилистическое улучшение на вкус. - [Незначительно] — стилистическое улучшение на вкус.
@@ -126,7 +128,7 @@ roles:
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо]. - Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. Помечай важность: Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации. - [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника. - [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить. - [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
@@ -167,14 +169,17 @@ roles:
- Не проверяешь достоверность фактов — это фактчекер. - Не проверяешь достоверность фактов — это фактчекер.
- Не вносишь содержательных изменений. Правки — минимальные и механические. - Не вносишь содержательных изменений. Правки — минимальные и механические.
КАК РАБОТАТЬ
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. Начинай комментарий с метки `[Корректура]`. Помечай важность: Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю. - [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте). - [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
- [Незначительно] — необязательная шлифовка. - [Незначительно] — необязательная шлифовка.
ТОН ТОН
По делу, без объяснений очевидного. Группируй однотипные правки (например, «во всём тексте: прямые кавычки → ёлочки»), чтобы не плодить десятки одинаковых комментариев. По делу, без объяснений очевидного. Не сворачивай однотипные правки в одно сводное замечание «поменять везде» — разнеси их по конкретным местам: десять целевых комментариев с готовой заменой в каждом лучше одного общего, который нельзя применить кнопкой. Не бойся «плодить» комментарии: для корректора это норма.
ПРИ НЕУВЕРЕННОСТИ ПРИ НЕУВЕРЕННОСТИ
Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант. Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант.
@@ -273,7 +278,7 @@ roles:
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью. Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══ ═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Комментируй то, что усилит историю, а не каждую мелочь. Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Если среди вариантов есть один готовый текст (например, новая формулировка лида), можешь приложить его к комментарию как предложение-замену (параметр `suggestedText`: точный новый текст взамен выделенного фрагмента, без разметки; фрагмент должен встречаться в тексте ровно один раз, иначе расширь выделение) — кнопка ничего не навязывает, автор волен не применять. Комментируй то, что усилит историю, а не каждую мелочь.
═══ ТОН ═══ ═══ ТОН ═══
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть. Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
+5 -5
View File
@@ -12,15 +12,15 @@ bundles:
- en - en
roles: roles:
- slug: structural-editor - slug: structural-editor
version: 2 version: 4
- slug: line-editor - slug: line-editor
version: 2 version: 4
- slug: fact-checker - slug: fact-checker
version: 3 version: 6
- slug: proofreader - slug: proofreader
version: 3 version: 8
- slug: narrator - slug: narrator
version: 1 version: 2
- id: research - id: research
name: name:
ru: Исследование ru: Исследование
+10 -10
View File
@@ -1,26 +1,26 @@
{ {
"fact-checker": { "fact-checker": {
"version": 3, "version": 6,
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf" "hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
}, },
"line-editor": { "line-editor": {
"version": 2, "version": 4,
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb" "hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
}, },
"narrator": { "narrator": {
"version": 1, "version": 2,
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c" "hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
}, },
"proofreader": { "proofreader": {
"version": 3, "version": 8,
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952" "hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
}, },
"researcher": { "researcher": {
"version": 1, "version": 1,
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace" "hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
}, },
"structural-editor": { "structural-editor": {
"version": 2, "version": 4,
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74" "hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
} }
} }
+2
View File
@@ -40,6 +40,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 +82,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",
+50
View File
@@ -0,0 +1,50 @@
/**
* DEV-ONLY entry for the AI chat perf harness (served by the vite dev server at
* /perf/ai-chat-perf.html; never part of the production build, which uses the
* single default index.html entry).
*
* Mounts the minimal provider stack the real ChatThread needs (Mantine, router
* for tool-card Links, react-query, i18n) and patches `window.fetch` BEFORE
* React mounts so ChatThread's DefaultChatTransport requests to
* /api/ai-chat/stream are answered by the synthetic SSE generator.
*/
import "@mantine/core/styles.css";
import ReactDOM from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { mantineCssResolver, theme } from "../src/theme.ts";
// i18n side-effect init (http-backend). Translations load from /locales in dev;
// missing keys fall back to the key text, which is fine for the harness.
import "../src/i18n.ts";
import { installAiChatStreamFetchPatch } from "./synthetic-turn.ts";
import PerfHarness from "./harness.tsx";
// MUST run before React mounts: ChatThread creates its transport with the
// global fetch, so the patch has to be in place before the first send.
installAiChatStreamFetchPatch();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
staleTime: 5 * 60 * 1000,
},
},
});
const container = document.getElementById("root") as HTMLElement;
ReactDOM.createRoot(container).render(
<MemoryRouter>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<QueryClientProvider client={queryClient}>
<PerfHarness />
</QueryClientProvider>
</MantineProvider>
</MemoryRouter>,
);
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI chat perf harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./ai-chat-perf-main.tsx"></script>
</body>
</html>
+390
View File
@@ -0,0 +1,390 @@
/**
* DEV-ONLY perf harness UI for the AI chat feature.
*
* Left panel: controls + live stats. Right side: a bordered box (~real chat
* window size) hosting the REAL ChatThread component.
*
* Scenario A "Open existing chat": mount ChatThread seeded with a large
* persisted transcript and measure click -> post-mount-paint time.
* Scenario B "Live agent stream": mount an empty chat and auto-send a message;
* the fetch patch (see synthetic-turn.ts) answers with a synthetic SSE stream
* through the real useChat pipeline.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, MutableRefObject } from "react";
import ChatThread from "../src/features/ai-chat/components/chat-thread.tsx";
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
import {
PRESETS,
buildPersistedRows,
buildTurnScript,
setLiveStreamSettings,
type PresetKey,
} from "./synthetic-turn.ts";
const AUTO_SEND_TEXT = "Run the synthetic perf turn";
const AUTO_SEND_TIMEOUT_MS = 1000;
/** Stats display refresh period — 2x/s so the display itself stays cheap. */
const STATS_FLUSH_MS = 500;
// ---------------------------------------------------------------------------
// Shared mutable stats (written from callbacks, flushed to state at 2 Hz)
// ---------------------------------------------------------------------------
interface PerfStats {
longtaskCount: number;
longtaskTotalMs: number;
longtaskMaxMs: number;
fps: number;
sseChunks: number;
sseChars: number;
mountAMs: number | null;
streamState: "idle" | "streaming" | "done" | "aborted";
}
function emptyStats(): PerfStats {
return {
longtaskCount: 0,
longtaskTotalMs: 0,
longtaskMaxMs: 0,
fps: 0,
sseChunks: 0,
sseChars: 0,
mountAMs: null,
streamState: "idle",
};
}
/**
* Self-contained stats panel: owns the longtask observer, the FPS meter and the
* 2 Hz flush interval. Isolated in its OWN component so its periodic setState
* re-renders only this panel — NOT the ChatThread under measurement.
*/
function StatsPanel({ stats }: { stats: MutableRefObject<PerfStats> }) {
const [snapshot, setSnapshot] = useState<PerfStats>(() => ({ ...stats.current }));
// Long tasks (main-thread blocks > 50ms).
useEffect(() => {
let observer: PerformanceObserver | null = null;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
stats.current.longtaskCount += 1;
stats.current.longtaskTotalMs += entry.duration;
stats.current.longtaskMaxMs = Math.max(stats.current.longtaskMaxMs, entry.duration);
}
});
observer.observe({ type: "longtask", buffered: true });
} catch {
// longtask entries unsupported in this browser — panel shows zeros.
}
return () => observer?.disconnect();
}, [stats]);
// FPS: frames rendered within the trailing 1s window.
useEffect(() => {
let raf = 0;
const frames: number[] = [];
const loop = (now: number) => {
frames.push(now);
while (frames.length > 0 && frames[0] <= now - 1000) frames.shift();
stats.current.fps = frames.length;
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [stats]);
// Flush the mutable stats into the display at most 2x/s.
useEffect(() => {
const id = window.setInterval(() => setSnapshot({ ...stats.current }), STATS_FLUSH_MS);
return () => window.clearInterval(id);
}, [stats]);
const resetLongtasks = () => {
stats.current.longtaskCount = 0;
stats.current.longtaskTotalMs = 0;
stats.current.longtaskMaxMs = 0;
setSnapshot({ ...stats.current });
};
const row: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8 };
return (
<div style={{ fontFamily: "monospace", fontSize: 12, lineHeight: 1.7 }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>Stats</div>
<div style={row}><span>FPS (1s)</span><span>{snapshot.fps}</span></div>
<div style={row}><span>Long tasks</span><span>{snapshot.longtaskCount}</span></div>
<div style={row}><span>Long total</span><span>{snapshot.longtaskTotalMs.toFixed(0)} ms</span></div>
<div style={row}><span>Long max</span><span>{snapshot.longtaskMaxMs.toFixed(0)} ms</span></div>
<div style={row}><span>SSE chunks</span><span>{snapshot.sseChunks}</span></div>
<div style={row}><span>SSE chars</span><span>{snapshot.sseChars.toLocaleString()}</span></div>
<div style={row}><span>Stream</span><span>{snapshot.streamState}</span></div>
<div style={row}>
<span>Mount A</span>
<span>{snapshot.mountAMs === null ? "—" : `${snapshot.mountAMs.toFixed(0)} ms`}</span>
</div>
<button type="button" onClick={resetLongtasks} style={{ marginTop: 6 }}>
Reset long tasks
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Auto-send (scenario B): drive the REAL composer in the mounted DOM
// ---------------------------------------------------------------------------
/**
* Fill the composer textarea via the native value setter + an `input` event
* (React 18 controlled-input pattern), then click the enabled "Send" button.
* Retried on rAF until the elements exist (ChatThread mounts asynchronously).
*/
function autoSend(host: HTMLElement, text: string): void {
const deadline = performance.now() + AUTO_SEND_TIMEOUT_MS;
const tryClick = () => {
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Send"]');
if (button && !button.disabled) {
button.click();
return;
}
if (performance.now() < deadline) requestAnimationFrame(tryClick);
else console.error("[perf] auto-send: Send button never became clickable");
};
const trySetValue = () => {
const textarea = host.querySelector("textarea");
if (!textarea) {
if (performance.now() < deadline) requestAnimationFrame(trySetValue);
else console.error("[perf] auto-send: textarea not found");
return;
}
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(textarea, text);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
// Click on a later frame so React commits the controlled value (which
// enables the Send button) before we press it.
requestAnimationFrame(tryClick);
};
requestAnimationFrame(trySetValue);
}
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
interface MountState {
mode: "A" | "B";
key: number;
chatId: string | null;
rows: IAiChatMessageRow[];
}
const noop = (): void => {};
export default function PerfHarness() {
const [preset, setPreset] = useState<PresetKey>("20k");
const [intervalMs, setIntervalMs] = useState<number>(15);
const [mounted, setMounted] = useState<MountState | null>(null);
const [fixtureInfo, setFixtureInfo] = useState<string | null>(null);
const statsRef = useRef<PerfStats>(emptyStats());
const hostRef = useRef<HTMLDivElement>(null);
const keyCounterRef = useRef(0);
const mountStartRef = useRef(0);
const pendingMountMeasureRef = useRef(false);
// The scripted live turn for the current preset (reused across B runs; the
// script is immutable data, so rebuilding per run is unnecessary).
const liveScript = useMemo(() => buildTurnScript(PRESETS[preset], "live"), [preset]);
const openPage = useMemo(() => ({ id: "page-1", title: "Perf test page" }), []);
// Scenario A: mount ChatThread seeded with a large persisted transcript.
const handleMountA = () => {
const fixture = buildPersistedRows(PRESETS[preset]);
setFixtureInfo(
`Persisted fixture: ${fixture.rows.length} rows, ` +
`${fixture.totalChars.toLocaleString()} chars ≈ ${fixture.approxTokens.toLocaleString()} tokens`,
);
statsRef.current.mountAMs = null;
// Mark AFTER fixture generation: we measure mount cost, not generation cost
// (production receives its rows from the network).
performance.mark("perf:mountA:start");
mountStartRef.current = performance.now();
pendingMountMeasureRef.current = true;
keyCounterRef.current += 1;
setMounted({ mode: "A", key: keyCounterRef.current, chatId: "perf-chat", rows: fixture.rows });
};
// Measure scenario A: effect runs after the mount commit; double rAF lands
// after the first paint of the mounted transcript.
useEffect(() => {
if (!pendingMountMeasureRef.current) return;
pendingMountMeasureRef.current = false;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
statsRef.current.mountAMs = performance.now() - mountStartRef.current;
performance.mark("perf:mountA:end");
try {
performance.measure("perf:mountA", "perf:mountA:start", "perf:mountA:end");
} catch {
// Marks cleared mid-run — ignore.
}
});
});
}, [mounted]);
// Scenario B: mount an empty chat, arm the synthetic stream, auto-send.
const handleStartB = () => {
statsRef.current.sseChunks = 0;
statsRef.current.sseChars = 0;
statsRef.current.streamState = "streaming";
setLiveStreamSettings({
script: liveScript,
chunkIntervalMs: intervalMs,
onProgress: (chunks, chars) => {
statsRef.current.sseChunks = chunks;
statsRef.current.sseChars = chars;
},
onDone: () => {
statsRef.current.streamState = "done";
performance.mark("perf:streamB:end");
try {
performance.measure("perf:streamB", "perf:streamB:start", "perf:streamB:end");
} catch {
// Start mark missing (e.g. marks cleared) — ignore.
}
},
onAbort: () => {
statsRef.current.streamState = "aborted";
},
});
performance.mark("perf:streamB:start");
keyCounterRef.current += 1;
setMounted({ mode: "B", key: keyCounterRef.current, chatId: null, rows: [] });
if (hostRef.current) autoSend(hostRef.current, AUTO_SEND_TEXT);
};
const handleUnmount = () => setMounted(null);
const label: CSSProperties = { display: "block", fontSize: 12, margin: "10px 0 2px" };
const button: CSSProperties = { display: "block", width: "100%", margin: "6px 0", padding: "6px 8px" };
return (
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
{/* Left: controls + stats */}
<div
style={{
width: 260,
flex: "0 0 260px",
padding: 12,
borderRight: "1px solid #ccc",
overflowY: "auto",
boxSizing: "border-box",
}}
>
<div style={{ fontWeight: 700, marginBottom: 4 }}>AI chat perf harness</div>
<label style={label}>Preset</label>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as PresetKey)}
style={{ width: "100%" }}
>
<option value="5k">5k tokens</option>
<option value="20k">20k tokens</option>
<option value="50k">50k tokens</option>
</select>
<label style={label}>Chunk interval (scenario B)</label>
<select
value={intervalMs}
onChange={(e) => setIntervalMs(Number(e.target.value))}
style={{ width: "100%" }}
>
<option value={15}>15 ms (normal)</option>
<option value={5}>5 ms (stress)</option>
</select>
<div style={{ marginTop: 12 }}>
<button type="button" style={button} onClick={handleMountA}>
Mount persisted chat (A)
</button>
<button type="button" style={button} onClick={handleStartB}>
Start live stream (B)
</button>
<button type="button" style={button} onClick={handleUnmount} disabled={!mounted}>
Unmount
</button>
</div>
<div style={{ fontSize: 11, color: "#555", margin: "8px 0" }}>
<div>
Live turn: {liveScript.totalChars.toLocaleString()} chars {" "}
{liveScript.approxTokens.toLocaleString()} tokens
</div>
{fixtureInfo && <div>{fixtureInfo}</div>}
{mounted && (
<div>
Mounted: scenario {mounted.mode} (key {mounted.key})
</div>
)}
</div>
<hr style={{ border: "none", borderTop: "1px solid #ddd" }} />
<StatsPanel stats={statsRef} />
</div>
{/* Right: the real ChatThread inside a real-window-sized box */}
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f4f4f5",
}}
>
<div
ref={hostRef}
style={{
width: 540,
height: 680,
border: "1px solid #bbb",
borderRadius: 8,
background: "#fff",
padding: 8,
boxSizing: "border-box",
overflow: "hidden",
}}
>
{mounted ? (
<ChatThread
key={mounted.key}
chatId={mounted.chatId}
threadKey={`perf-${mounted.key}`}
initialRows={mounted.rows}
openPage={openPage}
roleId={null}
roles={[]}
onRolePicked={noop}
assistantName="Perf agent"
onTurnFinished={noop}
onServerChatId={noop}
/>
) : (
<div style={{ color: "#888", fontSize: 13, padding: 16 }}>
ChatThread unmounted. Use the controls on the left.
</div>
)}
</div>
</div>
</div>
);
}
+517
View File
@@ -0,0 +1,517 @@
/**
* DEV-ONLY synthetic agent-turn generator for the AI chat perf harness.
*
* Produces one scripted agent turn (reasoning + tool calls + markdown answer)
* from a size config, and materializes it two ways:
* - as an AI SDK v6 UI-message SSE stream (scenario B "live agent stream"),
* served by a `window.fetch` patch that intercepts `/api/ai-chat/stream`;
* - as persisted `IAiChatMessageRow[]` history (scenario A "open existing chat").
*
* Wire format verified against the installed ai@6.0.207 `uiMessageChunkSchema`
* (strict objects — only the exact field names below are accepted).
*/
import type { UIMessage } from "@ai-sdk/react";
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
// ---------------------------------------------------------------------------
// Config / presets
// ---------------------------------------------------------------------------
/** 1 token ~= 4 chars — the approximation used throughout this module. */
const CHARS_PER_TOKEN = 4;
export interface TurnConfig {
/** Number of agent steps; each step = one reasoning block + one tool call. */
steps: number;
/** Approximate reasoning tokens generated per step. */
reasoningTokensPerStep: number;
/** Size of each tool call's output `content` filler, in bytes (ASCII). */
toolOutputBytes: number;
/** Approximate size of the final markdown answer, in tokens. */
answerTokens: number;
}
export type PresetKey = "5k" | "20k" | "50k";
export const PRESETS: Record<PresetKey, TurnConfig> = {
"5k": {
steps: 3,
reasoningTokensPerStep: 500,
toolOutputBytes: 10_000,
answerTokens: 600,
},
"20k": {
steps: 6,
reasoningTokensPerStep: 2500,
toolOutputBytes: 20_000,
answerTokens: 1500,
},
"50k": {
steps: 10,
reasoningTokensPerStep: 4000,
toolOutputBytes: 40_000,
answerTokens: 3000,
},
};
// ---------------------------------------------------------------------------
// Text generators
// ---------------------------------------------------------------------------
/** Mixed Russian/English prose sentences cycled to build reasoning text. */
const REASONING_SENTENCES = [
"Пользователь просит проанализировать документ и выделить ключевые тезисы по каждому разделу.",
"First I need to inspect the current page content to understand its overall structure.",
"Судя по оглавлению, раздел с техническими требованиями находится ближе к концу документа.",
"The table in section three contains the migration matrix that I should cross-check against the summary.",
"Проверю, нет ли противоречий между описанием API и приведёнными в тексте примерами вызовов.",
"Let me compare the numbers from the executive summary with the raw data in the appendix.",
"Похоже, автор использует термины «воркспейс» и workspace взаимозаменяемо — это стоит нормализовать.",
"I should keep the page ids from the tool output so the final answer can cite the source pages.",
"Осталось свести найденные несоответствия в одну таблицу и предложить порядок исправлений.",
"The remaining sections look consistent, so I can move on to drafting the structured answer.",
];
/**
* Build realistic prose of ~`targetChars` characters, inserting a newline
* roughly every 200 characters (mirrors how reasoning text tends to wrap).
*/
function makeProse(targetChars: number): string {
const pieces: string[] = [];
let length = 0;
let sinceNewline = 0;
let i = 0;
while (length < targetChars) {
const sentence = REASONING_SENTENCES[i % REASONING_SENTENCES.length];
i += 1;
pieces.push(sentence);
length += sentence.length + 1;
sinceNewline += sentence.length + 1;
if (sinceNewline >= 200) {
pieces.push("\n");
sinceNewline = 0;
} else {
pieces.push(" ");
}
}
return pieces.join("").trimEnd();
}
/** One markdown section (~700 chars): heading, prose, bullets, GFM table, code. */
function markdownSection(n: number): string {
return [
`## Section ${n}: migration analysis`,
``,
`The workspace contains **${n * 12} pages** that still reference the legacy API. ` +
`Most of them live under [Perf test page](/p/page-1) and need the new transport. ` +
`Ниже приведена сводка по разделу с оценкой трудозатрат и основных рисков.`,
``,
`- Update the fetch layer to the v6 transport`,
`- Перенести таблицы соответствия идентификаторов`,
`- Verify citation links after the move`,
`- Проверить отображение длинных ответов в узкой панели`,
``,
`| Область | Страниц | Статус | Риск |`,
`| --- | --- | --- | --- |`,
`| API reference | ${n + 4} | migrated | low |`,
`| Onboarding | ${n + 2} | in progress | medium |`,
`| Release notes | ${n * 3} | pending | high |`,
``,
"```ts",
`export function migrateSection${n}(rows: Row[]): Row[] {`,
` return rows`,
` .filter((row) => row.section === ${n})`,
` .map((row) => ({ ...row, migrated: true }));`,
`}`,
"```",
].join("\n");
}
/** Realistic markdown answer of ~`targetChars` chars (sections repeated to size). */
function makeMarkdownAnswer(targetChars: number): string {
const sections: string[] = [];
let length = 0;
let n = 1;
while (length < targetChars) {
const section = markdownSection(n);
sections.push(section);
length += section.length + 2;
n += 1;
}
return sections.join("\n\n");
}
/** Plain ASCII filler of exactly `bytes` characters for tool outputs. */
function makeFiller(bytes: number): string {
const unit = "Perf filler content for the synthetic getPage tool output. ";
return unit.repeat(Math.ceil(bytes / unit.length)).slice(0, bytes);
}
// ---------------------------------------------------------------------------
// Turn script
// ---------------------------------------------------------------------------
export interface TurnToolCall {
toolCallId: string;
toolName: "getPage";
input: { pageId: string };
output: { id: string; title: string; content: string };
}
export interface TurnStep {
reasoningText: string;
tool: TurnToolCall;
}
export interface TurnScript {
steps: TurnStep[];
answerText: string;
/** Approximate reasoning tokens for the whole turn (chars / 4). */
reasoningTokens: number;
/** Approximate context size after this turn, in tokens. */
contextTokens: number;
maxContextTokens: number;
/** Actual generated visible chars: reasoning + tool outputs + answer. */
totalChars: number;
/** totalChars / 4, rounded. */
approxTokens: number;
}
/**
* Build the scripted agent turn for a config. `idPrefix` keeps tool call ids
* unique when several scripts coexist (e.g. 3 persisted turns in one chat).
*/
export function buildTurnScript(config: TurnConfig, idPrefix = "live"): TurnScript {
const steps: TurnStep[] = [];
let reasoningChars = 0;
let toolChars = 0;
for (let i = 0; i < config.steps; i++) {
const reasoningText = makeProse(config.reasoningTokensPerStep * CHARS_PER_TOKEN);
const content = makeFiller(config.toolOutputBytes);
reasoningChars += reasoningText.length;
toolChars += content.length;
steps.push({
reasoningText,
tool: {
toolCallId: `${idPrefix}-call-${i + 1}`,
toolName: "getPage",
input: { pageId: "page-1" },
output: { id: "page-1", title: "Perf test page", content },
},
});
}
const answerText = makeMarkdownAnswer(config.answerTokens * CHARS_PER_TOKEN);
const totalChars = reasoningChars + toolChars + answerText.length;
return {
steps,
answerText,
reasoningTokens: Math.round(reasoningChars / CHARS_PER_TOKEN),
contextTokens: Math.round(totalChars / CHARS_PER_TOKEN),
maxContextTokens: 200_000,
totalChars,
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
};
}
// ---------------------------------------------------------------------------
// Scenario A: persisted rows
// ---------------------------------------------------------------------------
/** Number of user+assistant pairs the preset is split across for history. */
const HISTORY_TURNS = 3;
const USER_PROMPTS = [
"Проанализируй документ и выдели ключевые тезисы по каждому разделу.",
"Now cross-check the migration matrix against the summary and list every mismatch.",
"Собери финальный план миграции с оценкой рисков по каждой области.",
];
/** Persisted UIMessage parts for one finished assistant turn. */
function scriptToPersistedParts(script: TurnScript): UIMessage["parts"] {
const parts: unknown[] = [];
for (const step of script.steps) {
parts.push({ type: "reasoning", text: step.reasoningText, state: "done" });
parts.push({
type: `tool-${step.tool.toolName}`,
toolCallId: step.tool.toolCallId,
state: "output-available",
input: step.tool.input,
output: step.tool.output,
});
}
parts.push({ type: "text", text: script.answerText, state: "done" });
return parts as UIMessage["parts"];
}
export interface PersistedFixture {
rows: IAiChatMessageRow[];
totalChars: number;
approxTokens: number;
}
/**
* Materialize the preset as a finished 3-turn transcript: user row + assistant
* row per turn, with the preset's steps/answer split across the assistant turns.
* Approximate accounting — the actual totals are reported back for display.
*/
export function buildPersistedRows(config: TurnConfig): PersistedFixture {
const rows: IAiChatMessageRow[] = [];
const baseTime = Date.now() - HISTORY_TURNS * 60_000;
let totalChars = 0;
for (let t = 0; t < HISTORY_TURNS; t++) {
// Distribute steps as evenly as possible (earlier turns get the remainder).
const stepsForTurn =
Math.floor(config.steps / HISTORY_TURNS) +
(t < config.steps % HISTORY_TURNS ? 1 : 0);
const turnConfig: TurnConfig = {
steps: Math.max(1, stepsForTurn),
reasoningTokensPerStep: config.reasoningTokensPerStep,
toolOutputBytes: config.toolOutputBytes,
answerTokens: Math.max(50, Math.round(config.answerTokens / HISTORY_TURNS)),
};
const script = buildTurnScript(turnConfig, `hist-${t + 1}`);
totalChars += script.totalChars;
const userText = USER_PROMPTS[t % USER_PROMPTS.length];
rows.push({
id: `perf-row-u${t + 1}`,
role: "user",
content: userText,
metadata: null,
createdAt: new Date(baseTime + t * 60_000).toISOString(),
});
rows.push({
id: `perf-row-a${t + 1}`,
role: "assistant",
content: script.answerText,
metadata: {
parts: scriptToPersistedParts(script),
usage: { reasoningTokens: script.reasoningTokens },
contextTokens: script.contextTokens,
maxContextTokens: script.maxContextTokens,
finishReason: "stop",
},
createdAt: new Date(baseTime + t * 60_000 + 30_000).toISOString(),
});
}
return {
rows,
totalChars,
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
};
}
// ---------------------------------------------------------------------------
// Scenario B: SSE stream
// ---------------------------------------------------------------------------
/** Streaming delta size in chars (reasoning/answer text is split into these). */
const DELTA_CHARS = 200;
function splitDeltas(text: string, size = DELTA_CHARS): string[] {
const deltas: string[] = [];
for (let i = 0; i < text.length; i += size) {
deltas.push(text.slice(i, i + size));
}
return deltas;
}
/** One pre-serialized SSE frame plus its visible-char contribution for stats. */
interface SseFrame {
data: string;
chars: number;
}
function frame(chunk: Record<string, unknown>, chars = 0): SseFrame {
return { data: `data: ${JSON.stringify(chunk)}\n\n`, chars };
}
/**
* Serialize the whole scripted turn into AI SDK v6 UI-message SSE frames
* (excluding the final `data: [DONE]` terminator, appended by the pump).
*/
function buildSseFrames(script: TurnScript, messageId: string, chatId: string): SseFrame[] {
const frames: SseFrame[] = [];
frames.push(frame({ type: "start", messageId, messageMetadata: { chatId } }));
script.steps.forEach((step, i) => {
frames.push(frame({ type: "start-step" }));
const reasoningId = `${messageId}-r${i + 1}`;
frames.push(frame({ type: "reasoning-start", id: reasoningId }));
for (const delta of splitDeltas(step.reasoningText)) {
frames.push(frame({ type: "reasoning-delta", id: reasoningId, delta }, delta.length));
}
frames.push(frame({ type: "reasoning-end", id: reasoningId }));
const { toolCallId, toolName, input, output } = step.tool;
frames.push(frame({ type: "tool-input-start", toolCallId, toolName }));
frames.push(frame({ type: "tool-input-available", toolCallId, toolName, input }));
// The tool result arrives as ONE chunk, like the real server sends it.
frames.push(frame({ type: "tool-output-available", toolCallId, output }, output.content.length));
frames.push(frame({ type: "finish-step" }));
});
// Final step: the markdown answer.
frames.push(frame({ type: "start-step" }));
const textId = `${messageId}-answer`;
frames.push(frame({ type: "text-start", id: textId }));
for (const delta of splitDeltas(script.answerText)) {
frames.push(frame({ type: "text-delta", id: textId, delta }, delta.length));
}
frames.push(frame({ type: "text-end", id: textId }));
frames.push(frame({ type: "finish-step" }));
frames.push(
frame({
type: "finish",
messageMetadata: {
usage: { reasoningTokens: script.reasoningTokens },
contextTokens: script.contextTokens,
maxContextTokens: script.maxContextTokens,
finishReason: "stop",
},
}),
);
return frames;
}
export interface LiveStreamSettings {
script: TurnScript;
/** Delay between SSE chunks (one chunk per tick). */
chunkIntervalMs: number;
/** Progress callback: cumulative emitted chunk count and visible chars. */
onProgress?: (chunks: number, chars: number) => void;
/** Fired once after the `[DONE]` terminator is enqueued. */
onDone?: () => void;
/** Fired if the client aborted the stream (Stop button). */
onAbort?: () => void;
}
/**
* Build a synthetic SSE Response streaming the scripted turn, one chunk every
* `chunkIntervalMs`. Honors the fetch `AbortSignal` so the real Stop button works.
*/
export function buildSseResponse(
settings: LiveStreamSettings,
signal?: AbortSignal | null,
): Response {
const messageId = `m-live-${Date.now()}`;
const frames = buildSseFrames(settings.script, messageId, "perf-chat");
const encoder = new TextEncoder();
let index = 0;
let emittedChars = 0;
let timer: number | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const stopPump = () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
const pump = () => {
timer = undefined;
if (signal?.aborted) {
stopPump();
try {
controller.close();
} catch {
// Already closed/cancelled — nothing to do.
}
return;
}
if (index >= frames.length) {
try {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch {
// Cancelled mid-flight.
}
settings.onDone?.();
return;
}
const next = frames[index];
index += 1;
try {
controller.enqueue(encoder.encode(next.data));
} catch {
stopPump();
return;
}
emittedChars += next.chars;
settings.onProgress?.(index, emittedChars);
timer = window.setTimeout(pump, settings.chunkIntervalMs);
};
signal?.addEventListener(
"abort",
() => {
stopPump();
try {
controller.close();
} catch {
// Reader already cancelled.
}
settings.onAbort?.();
},
{ once: true },
);
timer = window.setTimeout(pump, settings.chunkIntervalMs);
},
cancel() {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
},
});
return new Response(stream, {
status: 200,
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
"x-vercel-ai-ui-message-stream": "v1",
},
});
}
// ---------------------------------------------------------------------------
// window.fetch patch
// ---------------------------------------------------------------------------
let currentLiveSettings: LiveStreamSettings | null = null;
/** Arm the next `/api/ai-chat/stream` request with a scripted turn. */
export function setLiveStreamSettings(settings: LiveStreamSettings): void {
currentLiveSettings = settings;
}
/**
* Patch `window.fetch` BEFORE React mounts: requests to `/api/ai-chat/stream`
* get the synthetic SSE Response; everything else passes through untouched.
*/
export function installAiChatStreamFetchPatch(): void {
const originalFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (url.includes("/api/ai-chat/stream")) {
const settings = currentLiveSettings;
if (!settings) {
return Promise.resolve(
new Response("perf harness: no live stream configured", { status: 500 }),
);
}
return Promise.resolve(buildSseResponse(settings, init?.signal ?? null));
}
return originalFetch(input, init);
};
}
@@ -1222,8 +1222,8 @@
"Commented": "Commented", "Commented": "Commented",
"Resolved comment": "Resolved comment", "Resolved comment": "Resolved comment",
"Ran tool {{name}}": "Ran tool {{name}}", "Ran tool {{name}}": "Ran tool {{name}}",
"AI-agent": "AI-agent", "AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}", "AI agent {{name}}": "AI agent {{name}}",
"Endpoints": "Endpoints", "Endpoints": "Endpoints",
"where we fetch models": "where we fetch models", "where we fetch models": "where we fetch models",
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.", "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
@@ -1274,6 +1274,10 @@
"Voice dictation is not configured": "Voice dictation is not configured", "Voice dictation is not configured": "Voice dictation is not configured",
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use", "Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context", "Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
"Dictation": "Dictation",
"Dictation becomes available once the page finishes connecting": "Dictation becomes available once the page finishes connecting",
"No connection to the collaboration server — dictation unavailable": "No connection to the collaboration server — dictation unavailable",
"This page is read-only": "This page is read-only",
"Request format": "Request format", "Request format": "Request format",
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint", "How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)", "OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
@@ -1373,5 +1377,13 @@
"Updated to the latest version": "Updated to the latest version", "Updated to the latest version": "Updated to the latest version",
"This role is no longer in the catalog": "This role is no longer in the catalog", "This role is no longer in the catalog": "This role is no longer in the catalog",
"This language is no longer available in the catalog": "This language is no longer available in the catalog", "This language is no longer available in the catalog": "This language is no longer available in the catalog",
"Connecting… (read-only)": "Connecting… (read-only)" "Connecting… (read-only)": "Connecting… (read-only)",
"Apply": "Apply",
"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": "Dismiss",
"Suggestion dismissed": "Suggestion dismissed",
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
} }
@@ -393,6 +393,17 @@
"No speech detected": "Речь не распознана", "No speech detected": "Речь не распознана",
"Transcription failed": "Не удалось распознать речь", "Transcription failed": "Не удалось распознать речь",
"Voice dictation is not configured": "Голосовой ввод не настроен", "Voice dictation is not configured": "Голосовой ввод не настроен",
"Start dictation": "Начать диктовку",
"Stop recording": "Остановить запись",
"Microphone access denied": "Доступ к микрофону запрещён",
"No microphone found": "Микрофон не найден",
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
"Could not start recording": "Не удалось начать запись",
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
"Dictation": "Диктовка",
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
"This page is read-only": "Страница открыта только для чтения",
"Embed PDF": "Встроить PDF", "Embed PDF": "Встроить PDF",
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.", "Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
"Embed as PDF": "Встроить как PDF", "Embed as PDF": "Встроить как PDF",
@@ -724,7 +735,8 @@
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.", "Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?", "Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено", "Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}", "AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
"AI agent {{name}}": "AI-агент {{name}}",
"Failed to delete chat": "Не удалось удалить чат", "Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат", "Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка", "Failed": "Ошибка",
@@ -1228,5 +1240,13 @@
"Updated to the latest version": "Обновлено до последней версии", "Updated to the latest version": "Обновлено до последней версии",
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге", "This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге", "This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
"Connecting… (read-only)": "Подключение… (только чтение)" "Connecting… (read-only)": "Подключение… (только чтение)",
"Apply": "Применить",
"Applied": "Применено",
"Suggestion applied": "Предложение применено",
"Failed to apply suggestion": "Не удалось применить предложение",
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
"Dismiss": "Не применять",
"Suggestion dismissed": "Предложение отклонено",
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
} }
@@ -0,0 +1,183 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AgentAvatarStack } from "./agent-avatar-stack";
import { avatarStyle } from "@/lib/avatar-palette";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
type Props = React.ComponentProps<typeof AgentAvatarStack>;
// The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the
// expected color through the same CSSOM path so the comparison stays exact and
// non-vacuous (an empty string — i.e. no inline background, as in the pre-fix
// Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not
// round-trip a `linear-gradient` in the `background` shorthand, which is why the
// glyph carries an explicit solid `background-color` we assert on here.
function normalizeColor(value: string): string {
const probe = document.createElement("div");
probe.style.backgroundColor = value;
return probe.style.backgroundColor;
}
function renderStack(props: Props) {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const utils = render(
<Provider store={store}>
<MantineProvider>
<AgentAvatarStack {...props} />
</MantineProvider>
</Provider>,
);
return { store, ...utils };
}
describe("AgentAvatarStack", () => {
it("internal chat WITH role: emoji glyph + human launcher badge in front", () => {
const { container } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
// Emoji is used as the glyph (priority 2), NOT the sparkles fallback.
expect(screen.getByText("🔬")).toBeDefined();
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
// Label: bold role name + dimmed "· launcher".
expect(screen.getByText("Researcher")).toBeDefined();
expect(screen.getByText(/·/)).toBeDefined();
expect(screen.getByText("Alice")).toBeDefined();
});
it("emoji glyph applies its per-agent gradient as an inline DOM background", () => {
// Pins the actual fix: the hashed gradient must reach the DOM as an inline
// `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no
// inline background (Mantine's --avatar-bg overrode it), so this fails there.
const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null };
const { container } = renderStack({
agent,
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
const glyph = container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]',
);
expect(glyph).not.toBeNull();
const expected = normalizeColor(avatarStyle(agent.name).bg);
// Non-vacuous: the pre-fix Avatar set no inline background at all.
expect(expected).not.toBe("");
expect(glyph!.style.backgroundColor).toBe(expected);
// (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does
// not round-trip linear-gradient — so its stops/angle are covered by the
// avatarStyle unit tests above, not asserted on the DOM here.)
});
it("agents with distinct styles reach the DOM as distinct backgrounds", () => {
// "Researcher" and "Нарратор" hash to different palette entries, so their
// applied DOM backgrounds must differ — pins "distinct colors reach the DOM".
expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg);
const a = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: null,
aiChatId: null,
});
const b = renderStack({
agent: { name: "Нарратор", emoji: "📖", avatarUrl: null },
launcher: null,
aiChatId: null,
});
const glyphA = a.container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]',
);
const glyphB = b.container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]',
);
expect(glyphA!.style.backgroundColor).not.toBe("");
// Different base colors reach the DOM (the serialized rgb values differ).
expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor);
});
it("showName=false: renders only the avatars, no inline name label", () => {
renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
showName: false,
});
// The agent glyph is still rendered...
expect(screen.getByText("🔬")).toBeDefined();
// ...but neither the agent NOR the launcher inline name label is rendered
// (they live only in the hover tooltip, which is not mounted in the initial
// DOM) — guards against suppressing only the agent name and leaking the
// launcher name.
expect(screen.queryByText("Researcher")).toBeNull();
expect(screen.queryByText("Alice")).toBeNull();
});
it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => {
const { container } = renderStack({
agent: { name: "AI agent", avatarUrl: null },
launcher: { name: "Bob", avatarUrl: null },
aiChatId: "chat-2",
});
// No avatarUrl and no emoji => sparkles glyph (priority 3).
expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull();
expect(screen.getByText("AI agent")).toBeDefined();
expect(screen.getByText("Bob")).toBeDefined();
});
it("external MCP: agent avatar only, NO human launcher badge", () => {
const { container } = renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
});
// avatarUrl provided (priority 1) => not the sparkles fallback.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
expect(screen.getByText("MCP Bot")).toBeDefined();
// No human behind => no "·" separator is rendered.
expect(screen.queryByText(/·/)).toBeNull();
// No internal chat => the stack is not an interactive deep-link button.
expect(screen.queryByRole("button")).toBeNull();
});
it("click deep-links into the chat when aiChatId is present", () => {
const { store } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
const button = screen.getByRole("button");
fireEvent.click(button);
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch
});
it("click is a no-op / not interactive without a chat target", () => {
const onActivate = vi.fn();
renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
onActivate,
});
expect(screen.queryByRole("button")).toBeNull();
expect(onActivate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,234 @@
import { Box, Group, Text, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// The FRONT identity (the acting agent) and the BEHIND identity (the human who
// launched it). Both are computed server-side (#300) so the client never branches
// on the internal-vs-MCP provenance — it just renders whatever it is handed.
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
const GLYPH_SIZE = 38;
const LAUNCHER_SIZE = 22;
// How far the launcher avatar sticks out past the agent's top-right corner — it
// sits as a small badge over that corner (above the glyph) and stays fully visible.
const LAUNCHER_OVERHANG = 8;
/**
* The front avatar. Image-source priority (#300):
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
* 2. agent.emoji -> the role emoji on a per-agent gradient circle.
* 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle.
*/
function AgentGlyph({ agent }: { agent: AgentInfo }) {
if (agent.avatarUrl) {
return (
<CustomAvatar
size={GLYPH_SIZE}
avatarUrl={agent.avatarUrl}
name={agent.name}
/>
);
}
// Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner
// and split angle all hashed from the agent name via avatarStyle — see
// @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine
// `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background
// (every agent fell back to the theme's violet). The foreground (the sparkles
// icon) uses the ring's WCAG-checked readable text color.
const style = avatarStyle(agent.name);
return (
<Box
data-testid="agent-glyph"
style={{
width: GLYPH_SIZE,
height: GLYPH_SIZE,
borderRadius: "50%",
// Solid base color is the fallback (and the testable value); the gradient
// paints over it in browsers that support it.
backgroundColor: style.bg,
backgroundImage: avatarBackgroundCss(style),
color:
style.text === "white"
? "var(--mantine-color-white)"
: "var(--mantine-color-black)",
display: "flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 1,
}}
>
{agent.emoji ? (
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
{agent.emoji}
</span>
) : (
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
)}
</Box>
);
}
export interface AgentAvatarStackProps {
agent: AgentInfo;
// null/absent => external MCP (front agent avatar only, no human behind).
launcher?: LauncherInfo | null;
// Deep-links into the internal AI chat when present (null for external MCP).
aiChatId?: string | null;
// Fired after the stack deep-links into its chat, so the caller can react
// (e.g. the page-history row closes the history modal). Keeps this ui/ primitive
// free of cross-feature coupling (inherited from the old AiAgentBadge, #143).
onActivate?: () => void;
// Whether to render the inline name label next to the avatars (default true).
// Set false when the caller renders the name itself (e.g. the comment row).
showName?: boolean;
}
/**
* The "agent avatar stack" (#300): the AGENT glyph, and — for an internal AI
* chat — the HUMAN who launched it as a smaller avatar badge on top, overhanging
* the glyph's top-right corner in FRONT (zIndex 2 > the glyph's zIndex 1) so the
* launcher stays fully visible rather than being half-hidden behind the glyph.
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
* whole stack is a deep-link into that chat (the click the old badge owned moved
* here); the click is contained (stopPropagation) so it does not also trigger an
* enclosing row handler.
*/
export function AgentAvatarStack({
agent,
launcher,
aiChatId,
onActivate,
showName = true,
}: AgentAvatarStackProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const clickable = !!aiChatId;
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching chats must start with a clean composer — clear any unsent draft
// so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
// Internal chat => "role on behalf of person"; external MCP => just the agent.
const tooltip = launcher
? t("AI agent «{{role}}» on behalf of {{person}}", {
role: agent.name,
person: launcher.name,
})
: t("AI agent {{name}}", { name: agent.name });
// The container is only enlarged when there is a launcher to overhang; with no
// human behind it stays tight at the agent glyph size.
const stackSize = launcher ? GLYPH_SIZE + LAUNCHER_OVERHANG : GLYPH_SIZE;
const stack = (
<Box
pos="relative"
style={{
width: stackSize,
height: stackSize,
flexShrink: 0,
// Center the (in-flow) agent glyph vertically so it lines up with its
// name label; the absolutely-positioned launcher is unaffected by flex.
display: "flex",
alignItems: "center",
cursor: clickable ? "pointer" : undefined,
}}
{...(clickable
? {
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{launcher && (
// Launcher badge sits ABOVE the agent glyph (zIndex) at the top-right so
// it is fully visible, not half-hidden behind the agent circle.
<Box pos="absolute" top={0} right={0} style={{ zIndex: 2 }}>
<CustomAvatar
size={LAUNCHER_SIZE}
avatarUrl={launcher.avatarUrl}
name={launcher.name}
style={{ border: "2px solid var(--mantine-color-body)" }}
/>
</Box>
)}
{/* The agent glyph keeps its own size (flex-centered in the container); the
launcher overhangs it by LAUNCHER_OVERHANG at the top-right and stays visible. */}
<Box
style={{
position: "relative",
zIndex: 1,
width: GLYPH_SIZE,
height: GLYPH_SIZE,
}}
>
<AgentGlyph agent={agent} />
</Box>
</Box>
);
return (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Tooltip label={tooltip} withArrow>
{stack}
</Tooltip>
{showName && (
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{agent.name}
</Text>
{launcher && (
<>
<Text size="xs" c="dimmed" fw={400} aria-hidden>
·
</Text>
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
{launcher.name}
</Text>
</>
)}
</Group>
)}
</Group>
);
}
export default AgentAvatarStack;
@@ -1,96 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AiAgentBadge } from "./ai-agent-badge";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
return render(
<MantineProvider>
<AiAgentBadge {...props} />
</MantineProvider>,
);
}
// Render a clickable badge inside an explicit jotai store, with a leftover draft
// and an onActivate + parent-click spy, so the deep-link side effects are
// assertable. Returns the store and spies.
function setupClickable() {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const onActivate = vi.fn();
const onParentClick = vi.fn();
render(
<Provider store={store}>
<MantineProvider>
<div onClick={onParentClick}>
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
</div>
</MantineProvider>
</Provider>,
);
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
}
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
}
describe("AiAgentBadge", () => {
it("renders the AI-agent label", () => {
renderBadge({ authorName: "Bot" });
expect(screen.getByText("AI-agent")).toBeDefined();
});
it("is clickable (accessible button) when aiChatId is present", () => {
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
const badge = screen.getByRole("button");
expect(badge).toBeDefined();
expect(badge.textContent).toContain("AI-agent");
});
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
const { store, onActivate, onParentClick, badge } = setupClickable();
fireEvent.click(badge);
expectDeepLinked(store, onActivate);
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
});
it.each(["Enter", " "])(
"keyboard %j activates the deep-link (same side effects as click)",
(key) => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key });
expectDeepLinked(store, onActivate);
},
);
it("an unrelated key does NOT activate the badge", () => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key: "Tab" });
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
expect(onActivate).not.toHaveBeenCalled();
});
it.each([{ aiChatId: null }, {}])(
"is a plain non-clickable label without a chat target (%o)",
(props) => {
renderBadge({ authorName: "Bot", ...props });
expect(screen.getByText("AI-agent")).toBeDefined();
// No interactive role is exposed when there is no chat to deep-link into.
expect(screen.queryByRole("button")).toBeNull();
},
);
});
@@ -1,99 +0,0 @@
import { Badge, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
interface AiAgentBadgeProps {
authorName?: string;
aiChatId?: string | null;
// Fired after the badge deep-links into its chat. The caller handles its own
// context (e.g. the page-history row closes the history modal) so this generic
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
onActivate?: () => void;
}
/**
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
* page-history list and the comments sidebar.
*
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
* badge deep-links into that chat: it sets the active-chat atom and opens the
* floating AI-chat window, then invokes `onActivate` so the caller can react
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
* external MCP write with no internal ai_chats row), the badge is a plain
* non-clickable label. The click is contained (stopPropagation) so it does not
* also trigger an enclosing row's click handler.
*/
export function AiAgentBadge({
authorName,
aiChatId,
onActivate,
}: AiAgentBadgeProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
name: authorName ?? "",
});
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching to another chat must start with a clean composer — clear any
// unsent draft so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
const badge = (
<Badge
size="sm"
variant="light"
color="violet"
radius="sm"
leftSection={<IconSparkles size={12} stroke={2} />}
style={aiChatId ? { cursor: "pointer" } : undefined}
{...(aiChatId
? {
// Keep the default Badge root element (not a <button>) to avoid an
// invalid <button>-in-<button> nesting inside a row's
// UnstyledButton; expose it as an accessible button via
// role/keyboard.
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{t("AI-agent")}
</Badge>
);
return (
<Tooltip label={tooltip} withArrow>
{badge}
</Tooltip>
);
}
export default AiAgentBadge;
@@ -164,8 +164,8 @@
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the /* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
rendered markdown <div> it would turn the newlines between block tags rendered markdown <div> it would turn the newlines between block tags
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the (</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
margins. The plain-text fallback <Text> that needs pre-wrap sets it margins. The streaming plain-text path that needs pre-wrap sets it
inline itself (see reasoning-block.tsx). */ per chunk instead, in PlainChunk (see streaming-plain-text.tsx). */
} }
.reasoningText p { .reasoningText p {
@@ -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();
}
});
});
@@ -65,6 +65,25 @@ describe("arePropsEqual", () => {
expect(arePropsEqual(props(m), props(m))).toBe(true); expect(arePropsEqual(props(m), props(m))).toBe(true);
}); });
// REGRESSION (stranded reasoning part): a reasoning part is left at
// `state:"streaming"` forever when the turn ends without `reasoning-end`
// (manual Stop during thinking). The signature is EQUAL across that turn-end
// flip (nothing in the message changed), so the comparator must ALSO compare
// `turnStreaming` — otherwise the memo swallows the flip and ReasoningBlock
// never switches from chunked plain text to its one-time markdown parse.
it("returns false when turnStreaming differs despite an equal signature", () => {
const m = msg([
{ type: "reasoning", text: "thinking", state: "streaming" },
{ type: "text", text: "answer" },
]);
expect(
arePropsEqual(
props(m, { turnStreaming: true }),
props(m, { turnStreaming: false }),
),
).toBe(false);
});
it("returns true for the same content in a different message object", () => { it("returns true for the same content in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]); const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer" }]); const b = msg([{ type: "text", text: "answer" }]);
@@ -52,6 +52,20 @@ interface MessageItemProps {
* absent; the public share passes the configured identity (agent role) name. * absent; the public share passes the configured identity (agent role) name.
*/ */
assistantName?: string; assistantName?: string;
/**
* Whether the WHOLE turn is still streaming (MessageList's `isStreaming`).
* A reasoning part may be left `state: "streaming"` forever when the turn
* ends without a `reasoning-end` chunk (manual Stop during the thinking
* phase, or a provider that never emits it) — the AI SDK finalizes reasoning
* state ONLY on `reasoning-end`, not on `finish-step`/`finish`. So part-level
* state alone cannot prove liveness; the reasoning part is treated as live
* only while the whole turn is still streaming. Defaults to false.
*
* The parent passes it as "turn is live AND this is the tail row", so a
* stranded part in an EARLIER row never re-activates when a later turn
* streams.
*/
turnStreaming?: boolean;
} }
/** /**
@@ -105,6 +119,7 @@ function MessageItem({
showCitations = true, showCitations = true,
neutralizeInternalLinks = false, neutralizeInternalLinks = false,
assistantName, assistantName,
turnStreaming = false,
}: MessageItemProps) { }: MessageItemProps) {
// `signature` is intentionally not read in the body — it exists solely as the // `signature` is intentionally not read in the body — it exists solely as the
// memo key (see arePropsEqual). The render reads `message` directly. // memo key (see arePropsEqual). The render reads `message` directly.
@@ -155,8 +170,23 @@ function MessageItem({
const text = (part as { text?: string }).text ?? ""; const text = (part as { text?: string }).text ?? "";
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0)) if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
return null; return null;
// Absent state (persisted rows) and "done" both mean finalized.
// `messageSignature` already includes each part's `state`, so the
// streaming→done flip changes the row signature and re-renders this
// row — which is what lets ReasoningBlock switch from chunked plain
// text to its one-time markdown parse (see reasoning-block.tsx).
// ALSO require the turn to be live: a part stranded at
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
// the `turnStreaming` prop doc) must still finalize and parse.
const streaming =
turnStreaming && (part as { state?: string }).state === "streaming";
return ( return (
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} /> <ReasoningBlock
key={index}
text={text}
tokens={reasoningTokens}
streaming={streaming}
/>
); );
} }
@@ -245,7 +275,11 @@ export function arePropsEqual(
prev.signature === next.signature && prev.signature === next.signature &&
prev.showCitations === next.showCitations && prev.showCitations === next.showCitations &&
prev.neutralizeInternalLinks === next.neutralizeInternalLinks && prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
prev.assistantName === next.assistantName prev.assistantName === next.assistantName &&
// The turn-end flip re-renders every row once (cheap, terminal event) —
// that is what converts a stranded `state:"streaming"` reasoning part to
// its one-time markdown parse (see the `turnStreaming` prop doc).
prev.turnStreaming === next.turnStreaming
); );
} }
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react"; import { fireEvent, render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react"; import type { UIMessage } from "@ai-sdk/react";
@@ -50,8 +50,9 @@ vi.stubGlobal(
// One assistant message wrapping the given `parts`. Reused across renders in the // One assistant message wrapping the given `parts`. Reused across renders in the
// regression test to model how the AI SDK hands back the SAME message object. // regression test to model how the AI SDK hands back the SAME message object.
const msg = (parts: UIMessage["parts"]): UIMessage => // Pass an explicit `id` when a test renders several rows at once.
({ id: "m1", role: "assistant", parts }) as UIMessage; const msg = (parts: UIMessage["parts"], id = "m1"): UIMessage =>
({ id, role: "assistant", parts }) as UIMessage;
describe("MessageList", () => { describe("MessageList", () => {
it("wires the real MessageItem and supplies a valid signature end-to-end", () => { it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
@@ -116,4 +117,102 @@ describe("MessageList", () => {
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"), renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
).toBe(true); ).toBe(true);
}); });
// REGRESSION (stranded reasoning part): the AI SDK sets a reasoning part's
// state to "done" ONLY on the `reasoning-end` chunk — `finish-step`/`finish`
// do NOT finalize it. A manual Stop during the thinking phase (or a provider
// that never emits `reasoning-end`) therefore leaves the part at
// `state:"streaming"` forever. MessageItem must derive ReasoningBlock's
// `streaming` from part state AND turn liveness (MessageList's `isStreaming`,
// forwarded as `turnStreaming`): while the turn streams the expanded block
// shows chunked plain text (no parse); once the turn ends — even though the
// part is still `state:"streaming"` — the block finalizes and does its
// one-time markdown parse. Note the message signature does NOT change across
// that flip, so this also exercises the `turnStreaming` memo comparison in
// arePropsEqual (without it the row would never re-render).
it("finalizes a reasoning part stranded at state:'streaming' when the turn ends", () => {
renderChatMarkdownSpy.mockClear();
const reasoningText = "**bold** thinking";
// Reasoning part stranded mid-stream + a non-empty answer part (a
// reasoning-only message renders nothing — see message-content.ts).
const message = msg([
{ type: "reasoning", text: reasoningText, state: "streaming" },
{ type: "text", text: "partial answer" },
]);
const parsesOfReasoning = () =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
.length;
const { rerender, getByRole, queryByText } = render(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// Expand the reasoning block (its toggle is the only button in the list).
fireEvent.click(getByRole("button"));
// Turn live + part streaming -> ReasoningBlock received streaming=true:
// the body is chunked plain text (raw markdown syntax), NOT parsed.
expect(queryByText(/bold/)).not.toBeNull();
expect(parsesOfReasoning()).toBe(0);
// The turn ends WITHOUT `reasoning-end`: the part object is untouched
// (still state:"streaming"), only the turn-level flag flips.
rerender(
<MantineProvider>
<MessageList messages={[message]} isStreaming={false} />
</MantineProvider>,
);
// ReasoningBlock now received streaming=false and did its one-time parse.
expect(parsesOfReasoning()).toBe(1);
});
// REGRESSION (turn-global liveness leaking into earlier rows): `isStreaming`
// is turn-global, so forwarding it to EVERY row would re-mark a reasoning
// part stranded at `state:"streaming"` in a PREVIOUS message (see the test
// above) as live again whenever a LATER turn streams — an expanded stranded
// block would flip markdown -> raw plain text -> markdown across turn
// boundaries, re-parsing each time. MessageList must gate `turnStreaming`
// to the TAIL row only.
it("keeps a stranded reasoning part in an earlier message finalized while a later turn streams", () => {
renderChatMarkdownSpy.mockClear();
const reasoningText = "**bold** thinking";
// First (earlier) assistant message: its turn was stopped during the
// thinking phase, leaving the reasoning part at state:"streaming".
const first = msg(
[
{ type: "reasoning", text: reasoningText, state: "streaming" },
{ type: "text", text: "first answer" },
],
"m1",
);
// Second assistant message: the LATER turn, currently streaming.
const second = msg([{ type: "text", text: "second answer" }], "m2");
const parsesOfReasoning = () =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
.length;
const { rerender, getByRole, queryByText } = render(
<MantineProvider>
<MessageList messages={[first, second]} isStreaming />
</MantineProvider>,
);
// Expand the first row's reasoning block (the only toggle in the list —
// the second message has no reasoning or tool parts).
fireEvent.click(getByRole("button"));
// The turn is live but the first row is NOT the tail: its ReasoningBlock
// received streaming=false, so the stranded part stays finalized and does
// its one-time markdown parse instead of dropping to chunked plain text.
expect(queryByText(/bold/)).not.toBeNull();
expect(parsesOfReasoning()).toBe(1);
// A later-turn delta re-renders the list; the earlier block must neither
// flip back to streaming nor re-parse.
(second.parts[0] as { text: string }).text = "second answer grows";
rerender(
<MantineProvider>
<MessageList messages={[first, second]} isStreaming />
</MantineProvider>,
);
expect(parsesOfReasoning()).toBe(1);
});
}); });
@@ -196,7 +196,7 @@ export default function MessageList({
return ( return (
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll"> <ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs"> <Stack gap={0} pr="xs">
{messages.map((message) => ( {messages.map((message, index) => (
// `signature` is snapshotted HERE (parent render) into an immutable // `signature` is snapshotted HERE (parent render) into an immutable
// string and handed to MessageItem as its memo key. It must NOT be // string and handed to MessageItem as its memo key. It must NOT be
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the // recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
@@ -210,6 +210,13 @@ export default function MessageList({
showCitations={showCitations} showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks} neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName} assistantName={assistantName}
// Turn-level liveness, gated to the TAIL row: only the tail message
// can belong to the in-flight turn, so a reasoning part stranded at
// `state:"streaming"` in an EARLIER message (its turn ended without
// `reasoning-end`) stays finalized and doesn't flip back to plain
// text (and re-parse) whenever a later turn streams — see
// message-item.tsx.
turnStreaming={isStreaming && index === messages.length - 1}
/> />
))} ))}
{typing && ( {typing && (
@@ -1,7 +1,14 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
// Spy on the markdown renderer so we can assert it is NOT called while the block
// is collapsed (the #302 fix) and IS called once on expand. The count/fallback
// tests don't depend on real markdown, so a light stub is safe.
vi.mock("@/features/ai-chat/utils/markdown.ts", () => ({
renderChatMarkdown: vi.fn((md: string) => `<p>${md}</p>`),
}));
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This // Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
// keeps the assertions on the component's OWN count logic (authoritative vs // keeps the assertions on the component's OWN count logic (authoritative vs
// estimate) rather than on translation, and mirrors the t-mock pattern used by // estimate) rather than on translation, and mirrors the t-mock pattern used by
@@ -17,10 +24,15 @@ vi.mock("react-i18next", () => ({
import ReasoningBlock from "./reasoning-block"; import ReasoningBlock from "./reasoning-block";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts"; import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. // matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBlock(props: { text: string; tokens?: number }) { function renderBlock(props: {
text: string;
tokens?: number;
streaming?: boolean;
}) {
return render( return render(
<MantineProvider> <MantineProvider>
<ReasoningBlock {...props} /> <ReasoningBlock {...props} />
@@ -62,4 +74,68 @@ describe("ReasoningBlock", () => {
// either way the text is present in the document. // either way the text is present in the document.
expect(screen.getByText(/reasoning/)).toBeDefined(); expect(screen.getByText(/reasoning/)).toBeDefined();
}); });
it("does not parse the reasoning markdown while collapsed; parses on expand (#302)", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
renderBlock({ text: "**bold** reasoning", tokens: 5 });
// Collapsed is the default. The expensive markdown parse (marked + DOMPurify)
// must NOT run for the hidden body — that O(n^2) re-parse on every streamed
// delta is exactly what froze the chat (#302). The collapsed body shows the
// cheap raw-text fallback instead.
expect(renderSpy).not.toHaveBeenCalled();
// Expanding parses the current text exactly once (a user-initiated click).
fireEvent.click(screen.getByRole("button"));
expect(renderSpy).toHaveBeenCalledTimes(1);
});
it("does not parse while expanded and STREAMING; shows chunked plain text", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
renderBlock({
text: "первый абзац размышлений\n\nвторой абзац растёт",
tokens: 5,
streaming: true,
});
fireEvent.click(screen.getByRole("button"));
// Expanded + still streaming: NO markdown parse and NO innerHTML swaps per
// delta — the body is chunked plain text (only the tail chunk updates).
// This is the O(n²) hole #302 left open (Safari whole-tab freeze).
expect(renderSpy).not.toHaveBeenCalled();
// Both paragraph chunks' raw text is present in the body.
expect(screen.getByText(/первый абзац размышлений/)).toBeDefined();
expect(screen.getByText(/второй абзац растёт/)).toBeDefined();
});
it("parses exactly once when streaming flips to done while expanded", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
const { rerender } = renderBlock({
text: "**bold** reasoning",
tokens: 5,
streaming: true,
});
fireEvent.click(screen.getByRole("button"));
expect(renderSpy).not.toHaveBeenCalled();
// Finalization: the part's state flips streaming→done, the parent
// re-renders the row (the flip changes the message signature), and the
// block does its ONE markdown parse of the now-stable text.
rerender(
<MantineProvider>
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
</MantineProvider>,
);
expect(renderSpy).toHaveBeenCalledTimes(1);
// The parsed html branch rendered (the mock wraps the input in <p>…</p>).
expect(screen.getByText(/reasoning/)).toBeDefined();
// Further re-renders with unchanged props do not re-parse.
rerender(
<MantineProvider>
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
</MantineProvider>,
);
expect(renderSpy).toHaveBeenCalledTimes(1);
});
}); });
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts"; import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts"; import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { StreamingPlainText } from "@/features/ai-chat/components/streaming-plain-text.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css"; import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ReasoningBlockProps { interface ReasoningBlockProps {
@@ -15,6 +16,10 @@ interface ReasoningBlockProps {
* step/turn has finished. When absent (or 0) the count is estimated from the * step/turn has finished. When absent (or 0) the count is estimated from the
* text length so it ticks live as the reasoning streams in. */ * text length so it ticks live as the reasoning streams in. */
tokens?: number; tokens?: number;
/** True while the reasoning part is still streaming (part `state ===
* "streaming"`). False means finalized: persisted history or `state ===
* "done"`. Gates the markdown parse — see the invariant on the memo below. */
streaming?: boolean;
} }
/** /**
@@ -27,22 +32,30 @@ interface ReasoningBlockProps {
* Providers that don't stream reasoning TEXT still render this block from the * Providers that don't stream reasoning TEXT still render this block from the
* authoritative count alone (header only, empty body) so the cost is visible. * authoritative count alone (header only, empty body) so the cost is visible.
*/ */
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) { function ReasoningBlock({ text, tokens, streaming = false }: ReasoningBlockProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Authoritative count wins; otherwise estimate live from the streamed text. // Authoritative count wins; otherwise estimate live from the streamed text.
const count = tokens && tokens > 0 ? tokens : estimateTokens(text); const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
const trimmed = text.trim(); const trimmed = text.trim();
// Memoize the markdown render so toggling `open` (or a parent re-render caused // Markdown parse invariant (per throttled ~20Hz stream delta the text GROWS):
// by an unrelated streamed delta) does not re-parse the reasoning text; it // 1. Collapsed -> never parse (#302): the html is only shown inside
// recomputes only when the reasoning text itself changes (while it streams in). // <Collapse in={open}>, so parsing for a hidden body would be an O(n²)
// collapseBlankLines collapses the blank-line gaps the model emits between every // marked + DOMPurify storm.
// list item / paragraph so the reasoning renders compactly (tight lists, joined // 2. Expanded + STREAMING -> no parse and no innerHTML swaps either: the body
// paragraphs) — ONLY here, not in the normal answer. // renders as chunked plain text (StreamingPlainText) with a memoized
// stable prefix, so each delta updates only the tail chunk's text node.
// This closes the O(n²) hole #302 left open ("expanded while streaming")
// that froze the whole tab in Safari when watching the thinking stream.
// 3. Finalized + expanded -> exactly one parse: `trimmed` and `streaming`
// are stable after the part is done, so this memo runs once per expand.
const html = useMemo( const html = useMemo(
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""), () =>
[trimmed], open && trimmed && !streaming
? renderChatMarkdown(collapseBlankLines(trimmed), {})
: "",
[open, trimmed, streaming],
); );
return ( return (
@@ -79,12 +92,12 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
) : ( ) : (
<Text // Still streaming (or markdown yielded nothing): chunked plain text.
className={classes.reasoningText} // The wrapper carries the reasoningText styling; each chunk sets its
style={{ whiteSpace: "pre-wrap" }} // own pre-wrap inline (NOT on this div — see ai-chat.module.css).
> <div className={classes.reasoningText}>
{trimmed} <StreamingPlainText text={trimmed} />
</Text> </div>
)} )}
</Collapse> </Collapse>
)} )}
@@ -92,7 +105,7 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
); );
} }
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default // Memoized: re-renders only when `text`/`tokens`/`streaming` change (primitive
// shallow compare), so a parent re-render during streaming of OTHER content does // props, default shallow compare), so a parent re-render during streaming of OTHER
// not re-run the markdown parse for an already-finalized reasoning block. // content does not re-run the markdown parse for an already-finalized reasoning block.
export default memo(ReasoningBlock); export default memo(ReasoningBlock);
@@ -0,0 +1,146 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import {
splitPlainChunks,
StreamingPlainText,
} from "./streaming-plain-text";
describe("splitPlainChunks", () => {
// THE load-bearing property (see the invariant comment in the module): under
// append-only growth, every chunk except the LAST must be byte-identical
// between successive calls, so the memoized chunk components never re-render
// for the stable prefix and each stream delta touches only the tail chunk.
it("keeps all non-last chunks byte-identical across append-only growth", () => {
// A simulated reasoning stream covering: appends inside the last paragraph,
// appends that ADD new blank lines, growth of a trailing newline run, and a
// trailing separator later followed by text.
const steps = [
"Пер",
"Первый абзац",
"Первый абзац\n",
"Первый абзац\n\n",
"Первый абзац\n\n\n",
"Первый абзац\n\n\nВторой",
"Первый абзац\n\n\nВторой абзац растёт",
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий",
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\n",
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\nЧетвёртый",
];
let prev: string[] = [];
for (const text of steps) {
const next = splitPlainChunks(text);
// Lossless: chunks always reassemble into the exact input.
expect(next.join("")).toBe(text);
// Chunk count never shrinks (boundaries never disappear).
expect(next.length).toBeGreaterThanOrEqual(prev.length);
// Every previously-FINAL chunk (all but prev's last) is unchanged.
for (let i = 0; i < prev.length - 1; i++) {
expect(next[i]).toBe(prev[i]);
}
prev = next;
}
// Guard against a vacuous pass: the final split must be multi-chunk.
expect(prev.length).toBeGreaterThanOrEqual(4);
});
it("attaches the blank-line separator run to the preceding chunk", () => {
expect(splitPlainChunks("a\n\nb")).toEqual(["a\n\n", "b"]);
// A longer run is ONE separator, not several boundaries.
expect(splitPlainChunks("a\n\n\n\nb")).toEqual(["a\n\n\n\n", "b"]);
expect(splitPlainChunks("a\n\nb\n\n\nc")).toEqual(["a\n\n", "b\n\n\n", "c"]);
});
it("single newlines are not boundaries", () => {
expect(splitPlainChunks("a\nb\nc")).toEqual(["a\nb\nc"]);
});
// INTENTIONAL: CRLF blank lines are NOT boundaries (the regex is `\n{2,}`
// only). Supporting `(?:\r?\n){2,}` would break the stable-prefix invariant:
// a lone trailing `\r` is not a boundary, but a later-appended `\n` would
// merge with it into a new separator unit and retroactively create a boundary
// INSIDE previously-emitted text, moving old chunk edges. So CRLF input stays
// in one (still lossless) chunk — only granularity is coarser; LLM output is
// `\n` in practice. See the doc comment on splitPlainChunks.
it("keeps CRLF blank lines inside one chunk", () => {
expect(splitPlainChunks("a\r\n\r\nb")).toEqual(["a\r\n\r\nb"]);
// Mixed input: only pure-`\n` runs split.
expect(splitPlainChunks("a\r\n\r\nb\n\nc")).toEqual(["a\r\n\r\nb\n\n", "c"]);
});
it("never emits empty phantom chunks (multi-blank-line / trailing newlines)", () => {
expect(splitPlainChunks("")).toEqual([]);
// A trailing newline run stays inside the last chunk (it may still grow).
expect(splitPlainChunks("a\n")).toEqual(["a\n"]);
expect(splitPlainChunks("a\n\n")).toEqual(["a\n\n"]);
expect(splitPlainChunks("a\n\nb\n\n")).toEqual(["a\n\n", "b\n\n"]);
// Degenerate all-newlines input is a single deterministic chunk.
expect(splitPlainChunks("\n\n\n")).toEqual(["\n\n\n"]);
for (const text of ["a\n\n\nb\n\n", "x\n\n\n\n\ny\n\nz\n"]) {
for (const chunk of splitPlainChunks(text)) {
expect(chunk.length).toBeGreaterThan(0);
}
}
});
});
describe("StreamingPlainText", () => {
it("renders one block per chunk, stripping trailing separator newlines at display time", () => {
const text = "первый абзац\n\nвторой абзац\n\n\nтретий";
const { container } = render(<StreamingPlainText text={text} />);
const blocks = Array.from(container.querySelectorAll("div"));
// One block element per chunk.
expect(blocks.length).toBe(splitPlainChunks(text).length);
// DISPLAY-ONLY strip: each rendered block drops its chunk's trailing
// separator newlines — rendering them inside a pre-wrap block would add an
// empty line ON TOP of the block break (a doubled gap). The RAW chunks
// keep their separators (losslessness is asserted on splitPlainChunks
// above); multi-blank-line runs collapse to one uniform gap, consistent
// with collapseBlankLines on the finalized markdown path.
expect(blocks.map((b) => b.textContent)).toEqual([
"первый абзац",
"второй абзац",
"третий",
]);
// The uniform paragraph gap comes from the block margin instead (matches
// the `.reasoningText p { margin: 0 0 4px }` rhythm of the markdown path).
for (const block of blocks) {
expect((block as HTMLElement).style.marginBottom).toBe("4px");
}
});
it("keeps interior newlines intact — only the trailing run is stripped", () => {
const text = "строка один\nстрока два\n\nхвост";
const { container } = render(<StreamingPlainText text={text} />);
const blocks = Array.from(container.querySelectorAll("div"));
expect(blocks.map((b) => b.textContent)).toEqual([
"строка один\nстрока два",
"хвост",
]);
});
// SECURITY INVARIANT — the load-bearing property of the streaming path: the
// reasoning text is raw, untrusted model output rendered WITHOUT a sanitizer
// (no marked/DOMPurify, no innerHTML). PlainChunk emits it as a React text
// node, which escapes it, so HTML in the model output is inert. This test
// pins that the path is a TEXT sink, not an HTML sink: a future change to
// `dangerouslySetInnerHTML` (reintroducing XSS) MUST fail here.
//
// The existing tests assert via textContent, which strips tags and so cannot
// distinguish an escaped literal from injected DOM. This one asserts on the
// parsed DOM directly: if the markup were injected as HTML, the <img>/<b>
// would become real elements and querySelector would find them.
it("renders HTML-like reasoning as an escaped literal, never as injected DOM", () => {
const text = "<img src=x onerror=alert(1)>\n\n<b>hi</b>";
const { container } = render(<StreamingPlainText text={text} />);
// No DOM elements were created from the payload — it was NOT parsed as HTML.
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("b")).toBeNull();
// The raw markup survived verbatim as text (proving it is escaped, not
// interpreted). textContent alone can't prove this, but combined with the
// querySelector assertions above it does: the literals are present AND no
// elements exist.
expect(container.textContent).toContain("<b>hi</b>");
expect(container.textContent).toContain("<img src=x onerror=alert(1)>");
});
});
@@ -0,0 +1,90 @@
import { memo, useMemo } from "react";
/**
* Split plain text into chunks at blank-line (paragraph) boundaries, keeping
* each separator run attached to the END of the preceding chunk, so the chunks
* always reassemble byte-for-byte into the input.
*
* A boundary is the end of a maximal `\n{2,}` run that is followed by at least
* one more character. A newline run that is a SUFFIX of the text is NOT a
* boundary yet: under append-only growth it may still gain more newlines, and
* cutting there would move the boundary on the next call.
*
* CRITICAL INVARIANT (load-bearing for StreamingPlainText's memoization): for
* APPEND-ONLY growth of `text`, every chunk except the LAST is byte-identical
* between successive calls — previously-emitted boundaries never move. Proof
* sketch: appending never modifies existing characters, so (a) an existing
* boundary's newline run and its following character are untouched and the
* boundary persists at the same offset; (b) no NEW boundary can appear strictly
* inside the old text, because a `\n{2,}` run followed by a character entirely
* within the old text would already have been a boundary. New boundaries can
* only materialize at or after the old text's end, i.e. inside the last chunk.
*
* CRLF is deliberately NOT a boundary: supporting `(?:\r?\n){2,}` would BREAK
* the invariant above — a lone trailing `\r` is not a boundary, but a later-
* appended `\n` would merge with it into a new separator unit and retroactively
* create a boundary INSIDE previously-emitted text, moving old chunk edges.
* With `\n`-only runs, appended characters can never extend a run that is
* already followed by a non-`\n` character, so old boundaries are immutable.
* CRLF blank lines therefore intentionally stay inside one chunk: correctness/
* losslessness are unaffected, only chunk granularity for CRLF input (LLM
* output is `\n` in practice).
*/
export function splitPlainChunks(text: string): string[] {
const chunks: string[] = [];
let start = 0;
for (const match of text.matchAll(/\n{2,}/g)) {
const end = match.index + match[0].length;
// Suffix run: not a stable boundary yet (see the invariant above).
if (end >= text.length) break;
chunks.push(text.slice(start, end));
start = end;
}
if (start < text.length) chunks.push(text.slice(start));
return chunks;
}
/**
* One immutable chunk. Memoized on its string prop: during streaming only the
* TAIL chunk's text changes (see the splitPlainChunks invariant), so React
* skips every stable chunk and the per-delta DOM work is a single text-node
* update. `pre-wrap` is set per chunk (like the old raw-text fallback did), NOT
* on the surrounding markdown-styled container — see the note in
* ai-chat.module.css. Font/size/color are inherited from that container.
*
* DISPLAY-ONLY newline strip: the raw chunk keeps its trailing `\n{2,}`
* separator run attached (the splitPlainChunks invariant, load-bearing for the
* memo), but rendering those newlines inside a pre-wrap block would add an
* empty line ON TOP of the block break — a doubled gap. So the RENDERED string
* drops trailing newlines and the paragraph gap comes from `marginBottom: 4`
* instead, matching the `.reasoningText p { margin: 0 0 4px }` rhythm of the
* finalized markdown. Multi-blank-line runs thus collapse to one uniform gap,
* consistent with `collapseBlankLines` on the markdown path. The last chunk
* usually has no trailing newlines (strip is a no-op); its margin is harmless.
*/
const PlainChunk = memo(function PlainChunk({ text }: { text: string }) {
return (
<div style={{ whiteSpace: "pre-wrap", marginBottom: 4 }}>
{text.replace(/\n+$/, "")}
</div>
);
});
/**
* Renders still-streaming plain text as a list of paragraph chunks where only
* the tail chunk changes per delta. No markdown, no sanitizer, no innerHTML —
* this is the cheap streaming-time stand-in for the one-time markdown parse
* that happens after the part is finalized (see reasoning-block.tsx).
*/
export function StreamingPlainText({ text }: { text: string }) {
const chunks = useMemo(() => splitPlainChunks(text), [text]);
return (
<>
{chunks.map((chunk, index) => (
// Index keys are stable here: chunks are append-only (the invariant),
// so an index never gets a different chunk's content mid-stream.
<PlainChunk key={index} text={chunk} />
))}
</>
);
}
@@ -27,7 +27,9 @@ export function useOpenAiChatForCurrentPage() {
// AiChatWindow lives in a pathless parent layout route, so useParams() can't // AiChatWindow lives in a pathless parent layout route, so useParams() can't
// see :pageSlug — match the full path against the authenticated page route. // see :pageSlug — match the full path against the authenticated page route.
const match = useMatch("/s/:spaceSlug/p/:pageSlug"); const match = useMatch("/s/:spaceSlug/p/:pageSlug");
const pageId = extractPageSlugId(match?.params?.pageSlug); // A page slugId (10-char nanoid), NOT a uuid; the server resolves it to the
// real page uuid (PageRepo.findById accepts slugId or uuid).
const slugId = extractPageSlugId(match?.params?.pageSlug);
return useCallback(async () => { return useCallback(async () => {
// Re-clicks while the window is already open (incl. minimized) must NOT // Re-clicks while the window is already open (incl. minimized) must NOT
@@ -40,9 +42,9 @@ export function useOpenAiChatForCurrentPage() {
// connection the first click reads as a hung control until the POST returns. // connection the first click reads as a hung control until the POST returns.
setWindowOpen(true); setWindowOpen(true);
let resolved: string | null = activeChatId; // off-a-page: keep current let resolved: string | null = activeChatId; // off-a-page: keep current
if (pageId) { if (slugId) {
try { try {
resolved = await getBoundChat(pageId); // null => fresh chat resolved = await getBoundChat(slugId); // null => fresh chat
} catch { } catch {
resolved = null; // fail-soft: a fresh chat is always a safe fallback resolved = null; // fail-soft: a fresh chat is always a safe fallback
} }
@@ -58,7 +60,7 @@ export function useOpenAiChatForCurrentPage() {
}, [ }, [
windowOpen, windowOpen,
activeChatId, activeChatId,
pageId, slugId,
setWindowOpen, setWindowOpen,
setActiveChatId, setActiveChatId,
setDraft, setDraft,
@@ -46,9 +46,11 @@ export async function getAiChatMessages(
* Resolve the chat bound to a document (the current user's most-recent chat * Resolve the chat bound to a document (the current user's most-recent chat
* created on that page), or null when there is none. Drives auto-open-on-page. * created on that page), or null when there is none. Drives auto-open-on-page.
*/ */
export async function getBoundChat(pageId: string): Promise<string | null> { export async function getBoundChat(slugId: string): Promise<string | null> {
// The `pageId` body field accepts a page slugId or a uuid; the server resolves
// it to the real page uuid (the wire key stays `pageId` for the DTO).
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", { const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
pageId, pageId: slugId,
}); });
return req.data.chatId; return req.data.chatId;
} }
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, fireEvent } 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";
@@ -7,10 +7,20 @@ 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 dismissMutateAsync = 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: vi.fn() }),
useApplySuggestionMutation: () => ({
mutateAsync: applyMutateAsync,
isPending: false,
}),
useDismissSuggestionMutation: () => ({
mutateAsync: dismissMutateAsync,
isPending: false,
}),
})); }));
// 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.
@@ -19,6 +29,10 @@ vi.mock("@/features/comment/components/comment-editor", () => ({
})); }));
import CommentListItem from "./comment-list-item"; import CommentListItem from "./comment-list-item";
import {
canShowApply,
canShowDismiss,
} from "@/features/comment/utils/suggestion";
const baseComment = (over?: Partial<IComment>): IComment => const baseComment = (over?: Partial<IComment>): IComment =>
({ ({
@@ -32,28 +46,243 @@ const baseComment = (over?: Partial<IComment>): IComment =>
...over, ...over,
}) as IComment; }) as IComment;
function renderItem(comment: IComment) { function renderItem(
comment: IComment,
canEdit = true,
canComment = true,
userSpaceRole?: string,
) {
return render( return render(
<MantineProvider> <MantineProvider>
<CommentListItem comment={comment} pageId="page-1" canComment={true} /> <CommentListItem
comment={comment}
pageId="page-1"
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
</MantineProvider>, </MantineProvider>,
); );
} }
describe("CommentListItem — AI badge", () => { describe("CommentListItem — agent avatar stack", () => {
it('renders the AI-agent badge when createdSource === "agent"', () => { it('flips the hierarchy for an agent comment: agent primary, launcher shown once', () => {
renderItem(baseComment({ createdSource: "agent", aiChatId: null })); // Internal-chat shape with DISTINCT names so absence-of-duplication is
expect(screen.getByText("AI-agent")).toBeDefined(); // assertable: creator is the human "Alice", the acting agent is "Researcher".
renderItem(
baseComment({
creator: { id: "user-1", name: "Alice", avatarUrl: null } as any,
createdSource: "agent",
aiChatId: "chat-1",
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
}),
);
// The AGENT is the primary label (the flipped hierarchy).
expect(screen.getByText("Researcher")).toBeDefined();
// The human launcher name shows exactly once — it is no longer duplicated as
// a separate creator name (that duplication is the bug this fixes).
expect(screen.getAllByText("Alice").length).toBe(1);
});
it('external MCP agent comment (no launcher): shows the agent name, no separator', () => {
// aiChatId null => external MCP: the agent IS the account, no human behind.
renderItem(
baseComment({
creator: { id: "bot-1", name: "MCP Bot", avatarUrl: null } as any,
createdSource: "agent",
aiChatId: null,
agent: { name: "MCP Bot", avatarUrl: null },
launcher: null,
}),
);
expect(screen.getByText("MCP Bot")).toBeDefined();
// No launcher => no dimmed "·" separator in the header.
expect(screen.queryByText("·")).toBeNull();
});
it('does NOT render the stack for a normal user comment (createdSource "user")', () => {
const { container } = renderItem(baseComment({ createdSource: "user" }));
// No agent glyph (sparkles) is present for a plain human comment.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
expect(screen.getByText("Service Bot")).toBeDefined(); expect(screen.getByText("Service Bot")).toBeDefined();
}); });
it('does NOT render the badge for a normal user comment (createdSource "user")', () => { // The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
renderItem(baseComment({ createdSource: "user" })); // are covered directly in agent-avatar-stack.test.tsx; this integration suite
expect(screen.queryByText("AI-agent")).toBeNull(); // only guards the insertion gate (agent → stack, user → no stack).
expect(screen.getByText("Service Bot")).toBeDefined(); });
});
describe("CommentListItem — suggested edit (#315)", () => {
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself const suggestion = (over?: Partial<IComment>): IComment =>
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs baseComment({
// the insertion gate (agent → badge, user → no badge) above (#143 review). selection: "old wording here",
suggestedText: "new wording here",
...over,
});
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
const { container } = renderItem(suggestion(), true);
// Old text appears as the selection quote (a single unsplit Text node).
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
// 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.
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
// No Applied badge yet.
expect(screen.queryByText("Applied")).toBeNull();
});
it("hides the Apply button when canEdit is false", () => {
const { container } = renderItem(suggestion(), false);
// Diff still renders (as per-fragment spans, #331)...
expect(container.textContent).toContain("new wording here");
// ...but no Apply button.
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
expect(screen.getByText("Applied")).toBeDefined();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides the Apply button once the thread is resolved", () => {
renderItem(suggestion({ resolvedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("calls the apply mutation when the Apply button is clicked", () => {
applyMutateAsync.mockClear();
renderItem(suggestion(), true);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(applyMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
it("does not render the diff block for a reply (child) comment", () => {
renderItem(
suggestion({ parentCommentId: "c-0" }),
true,
);
expect(screen.queryByText("new wording here")).toBeNull();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
});
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", () => {
const c = (over?: Partial<IComment>): IComment =>
({ suggestedText: "x", ...over }) as IComment;
it("true when suggestion present, editable, not applied/resolved, top-level", () => {
expect(canShowApply(c(), true)).toBe(true);
});
it("false without edit permission", () => {
expect(canShowApply(c(), false)).toBe(false);
});
it("false when no suggestion", () => {
expect(canShowApply(c({ suggestedText: null }), true)).toBe(false);
});
it("false when already applied", () => {
expect(canShowApply(c({ suggestionAppliedAt: new Date() }), true)).toBe(
false,
);
});
it("false when resolved", () => {
expect(canShowApply(c({ resolvedAt: new Date() }), true)).toBe(false);
});
it("false for a reply comment", () => {
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);
});
}); });
@@ -1,6 +1,6 @@
import { Group, Text, Box } from "@mantine/core"; import { Group, Text, Box, Badge, Button } from "@mantine/core";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx"; import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, 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";
@@ -11,11 +11,18 @@ import CommentMenu from "@/features/comment/components/comment-menu";
import ResolveComment from "@/features/comment/components/resolve-comment"; import ResolveComment from "@/features/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks"; import { useHover } from "@mantine/hooks";
import { import {
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,
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";
@@ -24,6 +31,10 @@ interface CommentListItemProps {
comment: IComment; comment: IComment;
pageId: string; pageId: string;
canComment: boolean; canComment: boolean;
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
// allowed to comment cannot apply edits).
canEdit?: boolean;
userSpaceRole?: string; userSpaceRole?: string;
} }
@@ -31,6 +42,7 @@ function CommentListItem({
comment, comment,
pageId, pageId,
canComment, canComment,
canEdit,
userSpaceRole, userSpaceRole,
}: CommentListItemProps) { }: CommentListItemProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -43,9 +55,29 @@ function CommentListItem({
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 dismissSuggestionMutation = useDismissSuggestionMutation();
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const createdAtAgo = useTimeAgo(comment.createdAt); const createdAtAgo = useTimeAgo(comment.createdAt);
// Intraline "before -> after" diff (#331) for a suggested edit: only the
// fragments that actually changed get emphasised inside the red/green block,
// 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";
useEffect(() => { useEffect(() => {
setContent(comment.content); setContent(comment.content);
}, [comment]); }, [comment]);
@@ -95,6 +127,31 @@ function CommentListItem({
} }
} }
async function handleApplySuggestion() {
try {
await applySuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Errors surface via the mutation's onError notification (incl. 409).
console.error("Failed to apply suggestion:", error);
}
}
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}"]`,
@@ -119,24 +176,44 @@ function CommentListItem({
return ( return (
<Box ref={ref} pb={6}> <Box ref={ref} pb={6}>
<Group gap="xs"> <Group gap="xs">
<CustomAvatar {comment.createdSource === "agent" && comment.agent ? (
size="sm" <AgentAvatarStack
avatarUrl={comment.creator.avatarUrl} agent={comment.agent}
name={comment.creator.name} launcher={comment.launcher}
/> aiChatId={comment.aiChatId}
showName={false}
/>
) : (
<CustomAvatar
size="sm"
avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
/>
)}
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={500} lineClamp={1} lh={1.2}> {comment.createdSource === "agent" && comment.agent ? (
{comment.creator.name} <>
</Text> <Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{comment.agent.name}
{comment.createdSource === "agent" && ( </Text>
<AiAgentBadge {comment.launcher && (
authorName={comment.creator?.name} <>
aiChatId={comment.aiChatId} <Text size="xs" c="dimmed" fw={400} aria-hidden>
/> ·
</Text>
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
{comment.launcher.name}
</Text>
</>
)}
</>
) : (
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name}
</Text>
)} )}
</Group> </Group>
@@ -150,7 +227,7 @@ function CommentListItem({
/> />
)} )}
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && ( {isOwnerOrAdmin && (
<CommentMenu <CommentMenu
onEditComment={handleEditToggle} onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment} onDeleteComment={handleDeleteComment}
@@ -191,6 +268,87 @@ function CommentListItem({
</Box> </Box>
)} )}
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
carrying a suggestion. Old text struck-through/red, new text green. */}
{!comment.parentCommentId && comment.suggestedText && (
<Box className={classes.suggestionBlock}>
{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}>
{suggestionDiff?.old.map((segment, index) => (
<span
key={index}
className={segment.changed ? classes.suggestionChanged : undefined}
>
{segment.text}
</span>
))}
</Text>
)}
<Text size="xs" className={classes.suggestionNew}>
{suggestionDiff?.new.map((segment, index) => (
<span
key={index}
className={segment.changed ? classes.suggestionChanged : undefined}
>
{segment.text}
</span>
))}
</Text>
{comment.suggestionAppliedAt ? (
<Badge
size="sm"
color="green"
variant="light"
mt={6}
aria-label={t("Applied")}
>
{t("Applied")}
</Badge>
) : (
(canShowApply(comment, canEdit) ||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
<Group gap="xs" mt={6}>
{canShowApply(comment, canEdit) && (
<Button
size="compact-xs"
variant="light"
color="green"
onClick={handleApplySuggestion}
loading={applySuggestionMutation.isPending}
disabled={
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>
)}
{!isEditing ? ( {!isEditing ? (
<CommentEditor defaultContent={content} editable={false} /> <CommentEditor defaultContent={content} editable={false} />
) : ( ) : (
@@ -49,8 +49,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const canComment = const canComment =
(page?.permissions?.canEdit ?? false) || canEdit ||
(space?.settings?.comments?.allowViewerComments === true); (space?.settings?.comments?.allowViewerComments === true);
// Separate active and resolved comments // Separate active and resolved comments
@@ -137,6 +139,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
comment={comment} comment={comment}
pageId={page?.id} pageId={page?.id}
canComment={canComment} canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role} userSpaceRole={space?.membership?.role}
/> />
<MemoizedChildComments <MemoizedChildComments
@@ -144,6 +147,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
parentId={comment.id} parentId={comment.id}
pageId={page?.id} pageId={page?.id}
canComment={canComment} canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role} userSpaceRole={space?.membership?.role}
/> />
</div> </div>
@@ -160,7 +164,14 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
)} )}
</Paper> </Paper>
), ),
[comments, handleAddReply, isLoading, space?.membership?.role, canComment], [
comments,
handleAddReply,
isLoading,
space?.membership?.role,
canComment,
canEdit,
],
); );
if (isCommentsLoading) { if (isCommentsLoading) {
@@ -300,6 +311,7 @@ interface ChildCommentsProps {
parentId: string; parentId: string;
pageId: string; pageId: string;
canComment: boolean; canComment: boolean;
canEdit?: boolean;
userSpaceRole?: string; userSpaceRole?: string;
} }
const ChildComments = ({ const ChildComments = ({
@@ -307,6 +319,7 @@ const ChildComments = ({
parentId, parentId,
pageId, pageId,
canComment, canComment,
canEdit,
userSpaceRole, userSpaceRole,
}: ChildCommentsProps) => { }: ChildCommentsProps) => {
const getChildComments = useCallback( const getChildComments = useCallback(
@@ -325,6 +338,7 @@ const ChildComments = ({
comment={childComment} comment={childComment}
pageId={pageId} pageId={pageId}
canComment={canComment} canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole} userSpaceRole={userSpaceRole}
/> />
<MemoizedChildComments <MemoizedChildComments
@@ -332,6 +346,7 @@ const ChildComments = ({
parentId={childComment.id} parentId={childComment.id}
pageId={pageId} pageId={pageId}
canComment={canComment} canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole} userSpaceRole={userSpaceRole}
/> />
</div> </div>
@@ -21,6 +21,53 @@
box-sizing: border-box; box-sizing: border-box;
} }
/* Suggested-edit (#315) "было → стало" diff block. */
.suggestionBlock {
margin-top: 8px;
margin-left: 6px;
padding: 6px;
border-radius: var(--mantine-radius-sm);
border: 1px solid var(--mantine-color-default-border);
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.suggestionOld {
text-decoration: line-through;
color: var(--mantine-color-red-7);
background: var(--mantine-color-red-light);
border-radius: 2px;
padding: 1px 3px;
}
.suggestionNew {
color: var(--mantine-color-green-9);
background: var(--mantine-color-green-light);
border-radius: 2px;
padding: 1px 3px;
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",
});
});
});
@@ -5,8 +5,10 @@ import {
InfiniteData, InfiniteData,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
applySuggestion,
createComment, createComment,
deleteComment, deleteComment,
dismissSuggestion,
getPageComments, getPageComments,
resolveComment, resolveComment,
updateComment, updateComment,
@@ -15,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";
@@ -176,6 +179,196 @@ 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() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
ISuggestionOutcome,
any,
{ commentId: string; pageId: string }
>({
// No optimistic update: apply can fail with 409 (the commented text drifted),
// so we only mutate the cache once the server confirms.
mutationFn: ({ commentId }) => applySuggestion(commentId),
onSuccess: (data, variables) => {
// Ephemeral (#329): the server hard-deletes the applied 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 applied") });
},
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
// a specific message (with the current text) rather than a generic error.
const currentText = err?.response?.data?.currentText;
if (status === 409 && typeof currentText === "string") {
const shortText =
currentText.length > 80
? `${currentText.slice(0, 80)}`
: currentText;
notifications.show({
title: t(
"The commented text changed since this suggestion was made; it was not applied.",
),
message: shortText,
color: "red",
});
return;
}
notifications.show({
message: t("Failed to apply suggestion"),
color: "red",
});
},
});
}
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,6 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
return req.data; return req.data;
} }
export async function applySuggestion(
commentId: string,
): Promise<ISuggestionOutcome> {
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
// the 409 body (`{ message, currentText }`) off err.response.data.
const req = await api.post("/comments/apply-suggestion", { commentId });
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> {
@@ -1,5 +1,9 @@
import { IUser } from "@/features/user/types/user.types"; import { IUser } from "@/features/user/types/user.types";
import { QueryParams } from "@/lib/types.ts"; import { QueryParams } from "@/lib/types.ts";
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
export interface IComment { export interface IComment {
id: string; id: string;
@@ -24,6 +28,18 @@ export interface IComment {
createdSource?: string; createdSource?: string;
aiChatId?: string | null; aiChatId?: string | null;
resolvedSource?: string | null; resolvedSource?: string | null;
// Suggested-edit (#315): when an agent proposes a replacement for the
// commented `selection`, `suggestedText` holds the "стало" text. Once a user
// applies it server-side the backend stamps `suggestionAppliedAt` /
// `suggestionAppliedById` and auto-resolves the thread.
suggestedText?: string | null;
suggestionAppliedAt?: Date | string | null;
suggestionAppliedById?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// createdSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
yjsSelection?: { yjsSelection?: {
anchor: any; anchor: any;
head: any; head: any;
@@ -44,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([]);
});
});
@@ -0,0 +1,139 @@
import { diffWordsWithSpace, diffChars } from "diff";
import { IComment } from "@/features/comment/types/comment.types";
// Whether the suggested-edit (#315) "Apply" button should be shown for a
// comment: it must carry a suggestion, not already be applied or resolved, be a
// top-level comment, and the viewer must be able to edit the page.
export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
return Boolean(
canEdit &&
comment.suggestedText &&
!comment.suggestionAppliedAt &&
!comment.resolvedAt &&
!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,
);
}
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// A disabled mic must explain WHY it is unavailable rather than silently saying
// "Start dictation". This renders MicButton in its idle+disabled state with a
// forwarded reason and asserts the accessible label resolves to that reason's
// text via the shared resolver (dictation-status.resolveUnavailableLabel).
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// Pass i18n keys through verbatim so we assert the exact resolved string.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (s: string) => s }),
}));
// Keep both controllers inert and idle so MicButton renders the idle branch.
const idleCtl = {
status: "idle" as const,
start: vi.fn(async () => {}),
stop: vi.fn(),
cancel: vi.fn(),
audioLevel: 0,
errorMessage: null,
};
vi.mock("@/features/dictation/hooks/use-dictation", () => ({
useDictation: () => idleCtl,
}));
vi.mock("@/features/dictation/hooks/use-streaming-dictation", () => ({
useStreamingDictation: () => idleCtl,
}));
import { MicButton } from "./mic-button";
function renderButton(props: React.ComponentProps<typeof MicButton>) {
render(
<MantineProvider>
<MicButton {...props} />
</MantineProvider>,
);
}
describe("MicButton — disabled reason label", () => {
// jsdom has no MediaRecorder / mediaDevices, so isDictationSupported() would
// report "unsupported" and mask the forwarded reason. Stub both so the button
// is considered supported and the availability reason is what surfaces.
beforeEach(() => {
(globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder =
class {};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: vi.fn() },
});
});
afterEach(() => {
delete (globalThis as unknown as { MediaRecorder?: unknown }).MediaRecorder;
});
it("shows the cause-specific reason instead of 'Start dictation' when disabled with a reason", () => {
renderButton({ onText: () => {}, disabled: true, unavailableReason: "offline" });
const expected =
"No connection to the collaboration server — dictation unavailable";
// The reason surfaces as the accessible label (and the tooltip text).
const button = screen.getByRole("button", { name: expected });
expect(button).toBeDefined();
// It is marked disabled the Mantine way (data-disabled), NOT the native
// `disabled` attribute — otherwise pointer-events:none would kill the tooltip.
expect(button.getAttribute("data-disabled")).toBe("true");
expect(button.hasAttribute("disabled")).toBe(false);
// And it no longer silently reads "Start dictation".
expect(screen.queryByRole("button", { name: "Start dictation" })).toBeNull();
});
it("reads 'Start dictation' when enabled with no reason", () => {
renderButton({ onText: () => {} });
expect(
screen.getByRole("button", { name: "Start dictation" }),
).toBeDefined();
});
it("does not advertise 'Start dictation' when disabled with no reason", () => {
// A consumer passing bare `disabled` (e.g. the AI chat's isStreaming) with no
// unavailableReason must not get a hoverable mic whose tooltip invites
// "Start dictation" on a click that is rejected.
renderButton({ onText: () => {}, disabled: true });
expect(
screen.queryByRole("button", { name: "Start dictation" }),
).toBeNull();
const button = screen.getByRole("button");
expect(button.getAttribute("data-disabled")).toBe("true");
});
});
@@ -4,6 +4,11 @@ import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useDictation } from "@/features/dictation/hooks/use-dictation"; import { useDictation } from "@/features/dictation/hooks/use-dictation";
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation"; import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
import {
isDictationSupported,
resolveUnavailableLabel,
type DictationUnavailableReason,
} from "@/features/dictation/dictation-status";
import classes from "./mic-button.module.css"; import classes from "./mic-button.module.css";
interface MicButtonProps { interface MicButtonProps {
@@ -21,6 +26,9 @@ interface MicButtonProps {
// When true, use the streaming (Silero-VAD) dictation controller, which emits // When true, use the streaming (Silero-VAD) dictation controller, which emits
// text progressively as the user pauses; otherwise use the batch controller. // text progressively as the user pauses; otherwise use the batch controller.
streaming?: boolean; streaming?: boolean;
// When the mic is disabled for an availability reason, this is the cause the
// idle tooltip explains (e.g. pre-sync "connecting", "offline", "read-only").
unavailableReason?: DictationUnavailableReason;
} }
/** /**
@@ -37,6 +45,7 @@ export const MicButton: FC<MicButtonProps> = ({
color, color,
iconSize, iconSize,
streaming = false, streaming = false,
unavailableReason,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is // Call BOTH hooks unconditionally to respect the rules of hooks: which one is
@@ -46,7 +55,7 @@ export const MicButton: FC<MicButtonProps> = ({
const batchCtl = useDictation({ onText, onStart }); const batchCtl = useDictation({ onText, onStart });
const streamingCtl = useStreamingDictation({ onText, onStart }); const streamingCtl = useStreamingDictation({ onText, onStart });
const ctl = streaming ? streamingCtl : batchCtl; const ctl = streaming ? streamingCtl : batchCtl;
const { status, start, stop, audioLevel } = ctl; const { status, start, stop, audioLevel, errorMessage } = ctl;
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16); const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
if (status === "recording") { if (status === "recording") {
@@ -82,15 +91,28 @@ export const MicButton: FC<MicButtonProps> = ({
) { ) {
// "loading" (streaming hook fetching the VAD model on first use) shows the // "loading" (streaming hook fetching the VAD model on first use) shows the
// same spinner+disabled state so the first click is visibly acknowledged and // same spinner+disabled state so the first click is visibly acknowledged and
// a confusing second click can't fire while the model loads. // a confusing second click can't fire while the model loads. The error case
const label = status === "loading" ? t("Preparing…") : t("Transcribing…"); // explains the failure via the hook's resolved errorMessage instead of the
// transient "Transcribing…" label.
const label =
status === "error"
? (errorMessage ?? t("Transcription failed"))
: status === "loading"
? t("Preparing…")
: t("Transcribing…");
return ( return (
<Tooltip label={label} withArrow> <Tooltip label={label} withArrow>
<ActionIcon <ActionIcon
size={size} size={size}
variant="subtle" variant="subtle"
color={color} color={color}
disabled // Mark disabled the Mantine way (data-disabled/aria-disabled) rather
// than the native `disabled` attribute: native `disabled` sets
// `pointer-events:none`, which suppresses hover so the Tooltip never
// fires. This is a status display with no click action to guard, so
// keeping it hoverable simply lets the error reason be read on hover.
data-disabled
aria-disabled
aria-label={label} aria-label={label}
> >
<Loader size="xs" /> <Loader size="xs" />
@@ -99,18 +121,56 @@ export const MicButton: FC<MicButtonProps> = ({
); );
} }
// Idle branch. A grey/disabled mic must explain WHY it can't record. An
// unsupported browser/context is detected here; otherwise the parent forwards
// a cause-specific reason. We must NOT pass the native `disabled` prop: Mantine
// renders `<button disabled>` with `pointer-events:none`, which suppresses
// hover so the Tooltip never fires. Instead mark it disabled the Mantine way
// (data-disabled/aria-disabled) — keeping it hoverable and in the a11y tree —
// and guard the click ourselves.
const unsupported = !isDictationSupported();
const isDisabled = disabled || unsupported;
const reason: DictationUnavailableReason | undefined = unsupported
? "unsupported"
: unavailableReason;
const reasonLabel = reason ? resolveUnavailableLabel(reason, t) : undefined;
// A disabled mic with a known reason surfaces it on hover; an enabled mic
// invites "Start dictation". But a mic disabled with NO reason (e.g. a
// consumer that passes bare `disabled` — the AI chat's isStreaming, with no
// unavailableReason) must NOT hover a misleading, actionable "Start dictation"
// tooltip on a control that rejects the click. In that case we render the icon
// without a Tooltip and give it a neutral accessible label instead.
const ariaLabel = reasonLabel ?? (isDisabled ? t("Dictation") : t("Start dictation"));
const icon = (
<ActionIcon
size={size}
variant="subtle"
color={color}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
return;
}
void start();
}}
data-disabled={isDisabled || undefined}
aria-disabled={isDisabled}
aria-label={ariaLabel}
>
<IconMicrophone size={resolvedIconSize} />
</ActionIcon>
);
// Suppress the tooltip on a disabled mic that has nothing to explain — hovering
// a grey, unclickable mic should not advertise "Start dictation".
if (isDisabled && !reasonLabel) {
return icon;
}
return ( return (
<Tooltip label={t("Start dictation")} withArrow> <Tooltip
<ActionIcon label={reasonLabel ?? t("Start dictation")}
size={size} withArrow
variant="subtle" >
color={color} {icon}
onClick={() => void start()}
disabled={disabled}
aria-label={t("Start dictation")}
>
<IconMicrophone size={resolvedIconSize} />
</ActionIcon>
</Tooltip> </Tooltip>
); );
}; };
@@ -0,0 +1,156 @@
import { describe, it, expect } from "vitest";
import {
classifyGetUserMediaError,
classifyTranscriptionError,
dictationErrorMessage,
resolveUnavailableLabel,
isDictationSupported,
} from "./dictation-status";
// Unit tests for the shared dictation-status resolvers (dictation-status.ts).
// Both dictation hooks and the mic button form their user-facing strings here,
// so a regression in the classification or message mapping would silently swap
// what a user reads when the mic is grey or a recording fails. A fake `t`
// returns its key verbatim so we assert the exact i18n key each branch selects.
const t = (k: string) => k;
describe("classifyGetUserMediaError", () => {
it("maps NotAllowedError / SecurityError to mic-denied", () => {
expect(classifyGetUserMediaError({ name: "NotAllowedError" })).toBe(
"mic-denied",
);
expect(classifyGetUserMediaError({ name: "SecurityError" })).toBe(
"mic-denied",
);
});
it("maps NotFoundError / OverconstrainedError to no-mic", () => {
expect(classifyGetUserMediaError({ name: "NotFoundError" })).toBe("no-mic");
expect(classifyGetUserMediaError({ name: "OverconstrainedError" })).toBe(
"no-mic",
);
});
it("maps NotReadableError / AbortError to mic-in-use", () => {
expect(classifyGetUserMediaError({ name: "NotReadableError" })).toBe(
"mic-in-use",
);
expect(classifyGetUserMediaError({ name: "AbortError" })).toBe(
"mic-in-use",
);
});
it("maps anything else / undefined to unknown", () => {
expect(classifyGetUserMediaError({ name: "WeirdError" })).toBe("unknown");
expect(classifyGetUserMediaError(undefined)).toBe("unknown");
expect(classifyGetUserMediaError({})).toBe("unknown");
});
});
describe("classifyTranscriptionError", () => {
it("returns the verbatim server message when present", () => {
const err = { response: { status: 500, data: { message: "provider 404" } } };
expect(classifyTranscriptionError(err)).toEqual({
code: "transcription-failed",
serverMessage: "provider 404",
});
});
it("maps 503 / 403 (no server message) to stt-not-configured", () => {
expect(classifyTranscriptionError({ response: { status: 503 } })).toEqual({
code: "stt-not-configured",
});
expect(classifyTranscriptionError({ response: { status: 403 } })).toEqual({
code: "stt-not-configured",
});
});
it("falls back to transcription-failed with no server message otherwise", () => {
expect(classifyTranscriptionError({ response: { status: 500 } })).toEqual({
code: "transcription-failed",
});
expect(classifyTranscriptionError(new Error("network"))).toEqual({
code: "transcription-failed",
});
// Blank server message is ignored (does not win as verbatim text).
expect(
classifyTranscriptionError({ response: { data: { message: " " } } }),
).toEqual({ code: "transcription-failed" });
});
});
describe("dictationErrorMessage", () => {
it("maps each code to the expected i18n key", () => {
expect(dictationErrorMessage("mic-denied", t)).toBe(
"Microphone access denied",
);
expect(dictationErrorMessage("no-mic", t)).toBe("No microphone found");
expect(dictationErrorMessage("mic-in-use", t)).toBe(
"Microphone is unavailable or already in use",
);
expect(dictationErrorMessage("no-media-devices", t)).toBe(
"Audio recording is not available in this browser/context",
);
expect(dictationErrorMessage("stt-not-configured", t)).toBe(
"Voice dictation is not configured",
);
expect(dictationErrorMessage("transcription-failed", t)).toBe(
"Transcription failed",
);
expect(dictationErrorMessage("recorder-failed", t)).toBe(
"Could not start recording",
);
expect(dictationErrorMessage("vad-init-failed", t)).toBe(
"Could not start recording",
);
expect(dictationErrorMessage("unknown", t)).toBe(
"Could not start recording",
);
});
it("returns the server message verbatim for transcription-failed (not the t key)", () => {
expect(
dictationErrorMessage("transcription-failed", t, {
serverMessage: "quota exceeded",
}),
).toBe("quota exceeded");
});
it("appends the detail to recorder-failed / unknown", () => {
expect(
dictationErrorMessage("recorder-failed", t, { detail: "boom" }),
).toBe("Could not start recording: boom");
expect(dictationErrorMessage("unknown", t, { detail: "nope" })).toBe(
"Could not start recording: nope",
);
});
it("appends the detail to transcription-failed when there is no server message", () => {
expect(
dictationErrorMessage("transcription-failed", t, { detail: "timeout" }),
).toBe("Transcription failed: timeout");
});
});
describe("resolveUnavailableLabel", () => {
it("maps each reason to its expected i18n key", () => {
expect(resolveUnavailableLabel("connecting", t)).toBe(
"Dictation becomes available once the page finishes connecting",
);
expect(resolveUnavailableLabel("offline", t)).toBe(
"No connection to the collaboration server — dictation unavailable",
);
expect(resolveUnavailableLabel("read-only", t)).toBe(
"This page is read-only",
);
expect(resolveUnavailableLabel("unsupported", t)).toBe(
"Audio recording is not available in this browser/context",
);
});
});
describe("isDictationSupported", () => {
it("returns a boolean", () => {
expect(typeof isDictationSupported()).toBe("boolean");
});
});
@@ -0,0 +1,110 @@
// Single source of truth for "why dictation is unavailable" and "why it failed".
// Both dictation hooks and the mic button pull their user-facing strings from
// the resolvers here so the wording lives in exactly one place.
export type DictationUnavailableReason =
| "connecting"
| "offline"
| "read-only"
| "unsupported";
export type DictationErrorCode =
| "no-media-devices"
| "mic-denied"
| "no-mic"
| "mic-in-use"
| "recorder-failed"
| "vad-init-failed"
| "stt-not-configured"
| "transcription-failed"
| "unknown";
// True if this browser/context can record audio.
export function isDictationSupported(): boolean {
return (
typeof MediaRecorder !== "undefined" &&
typeof navigator !== "undefined" &&
!!navigator.mediaDevices?.getUserMedia
);
}
// getUserMedia / VAD.start rejection -> code, by DOMException .name.
export function classifyGetUserMediaError(err: unknown): DictationErrorCode {
const name = (err as { name?: string })?.name;
if (name === "NotAllowedError" || name === "SecurityError")
return "mic-denied";
if (name === "NotFoundError" || name === "OverconstrainedError")
return "no-mic";
if (name === "NotReadableError" || name === "AbortError") return "mic-in-use";
return "unknown";
}
// Transcription HTTP failure -> code (+ verbatim server message when present).
export function classifyTranscriptionError(err: unknown): {
code: DictationErrorCode;
serverMessage?: string;
} {
const resp = (
err as { response?: { status?: number; data?: { message?: string } } }
)?.response;
const serverMessage = resp?.data?.message;
if (serverMessage && serverMessage.trim().length > 0)
return { code: "transcription-failed", serverMessage };
if (resp?.status === 503 || resp?.status === 403)
return { code: "stt-not-configured" };
return { code: "transcription-failed" };
}
type TFn = (key: string) => string;
// Code -> user text. The ONE place runtime error strings are formed.
// serverMessage (verbatim) wins for transcription-failed; detail is appended
// to the generic "could not start"/"transcription failed" strings.
export function dictationErrorMessage(
code: DictationErrorCode,
t: TFn,
extra?: { serverMessage?: string; detail?: string },
): string {
const detail = extra?.detail;
switch (code) {
case "mic-denied":
return t("Microphone access denied");
case "no-mic":
return t("No microphone found");
case "mic-in-use":
return t("Microphone is unavailable or already in use");
case "no-media-devices":
return t("Audio recording is not available in this browser/context");
case "stt-not-configured":
return t("Voice dictation is not configured");
case "transcription-failed":
if (extra?.serverMessage && extra.serverMessage.trim().length > 0)
return extra.serverMessage;
return `${t("Transcription failed")}${detail ? `: ${detail}` : ""}`;
case "recorder-failed":
case "vad-init-failed":
case "unknown":
default:
return `${t("Could not start recording")}${detail ? `: ${detail}` : ""}`;
}
}
// Unavailable reason -> tooltip text (the ONE place these strings are formed).
export function resolveUnavailableLabel(
r: DictationUnavailableReason,
t: TFn,
): string {
switch (r) {
case "connecting":
return t("Dictation becomes available once the page finishes connecting");
case "offline":
return t(
"No connection to the collaboration server — dictation unavailable",
);
case "read-only":
return t("This page is read-only");
case "unsupported":
default:
return t("Audio recording is not available in this browser/context");
}
}
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { transcribeAudio } from "@/features/dictation/services/dictation-service"; import { transcribeAudio } from "@/features/dictation/services/dictation-service";
import {
classifyGetUserMediaError,
classifyTranscriptionError,
dictationErrorMessage,
} from "@/features/dictation/dictation-status";
// "loading" is set only by the streaming hook while it lazily loads the VAD // "loading" is set only by the streaming hook while it lazily loads the VAD
// model on first use; the batch hook never sets it. It exists so the streaming // model on first use; the batch hook never sets it. It exists so the streaming
@@ -26,6 +31,8 @@ interface UseDictationResult {
cancel: () => void; cancel: () => void;
// Smoothed live microphone level in the 0..1 range while recording (0 when idle). // Smoothed live microphone level in the 0..1 range while recording (0 when idle).
audioLevel: number; audioLevel: number;
// The last error shown to the user (null until one occurs / on a new start).
errorMessage: string | null;
} }
// Candidate container/codec combinations in preference order. The first one the // Candidate container/codec combinations in preference order. The first one the
@@ -67,6 +74,8 @@ export function useDictation(
const { t } = useTranslation(); const { t } = useTranslation();
const [status, setStatus] = useState<DictationStatus>("idle"); const [status, setStatus] = useState<DictationStatus>("idle");
const [audioLevel, setAudioLevel] = useState(0); const [audioLevel, setAudioLevel] = useState(0);
// Last error message shown to the user; the mic button reads it for its tooltip.
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Keep the latest callbacks in a ref so the recorder's onstop closure always // Keep the latest callbacks in a ref so the recorder's onstop closure always
// calls the current handlers without re-creating the recorder. // calls the current handlers without re-creating the recorder.
@@ -194,15 +203,16 @@ export function useDictation(
if (startingRef.current || recorderRef.current || streamRef.current) return; if (startingRef.current || recorderRef.current || streamRef.current) return;
if (status !== "idle") return; if (status !== "idle") return;
startingRef.current = true; startingRef.current = true;
// Clear any stale error from a previous attempt.
setErrorMessage(null);
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
const reason = const reason =
"navigator.mediaDevices.getUserMedia is unavailable in this context"; "navigator.mediaDevices.getUserMedia is unavailable in this context";
console.error("[dictation] " + reason); console.error("[dictation] " + reason);
notifications.show({ const message = dictationErrorMessage("no-media-devices", t);
color: "red", notifications.show({ color: "red", message });
message: t("Audio recording is not available in this browser/context"), setErrorMessage(message);
});
setStatus("idle"); setStatus("idle");
startingRef.current = false; startingRef.current = false;
return; return;
@@ -215,19 +225,16 @@ export function useDictation(
// Always log the full error for diagnosis (name, message, stack). // Always log the full error for diagnosis (name, message, stack).
console.error("[dictation] getUserMedia failed", err); console.error("[dictation] getUserMedia failed", err);
const name = (err as { name?: string })?.name; const name = (err as { name?: string })?.name;
const detail = (err as { message?: string })?.message ?? String(err); const rawDetail = (err as { message?: string })?.message ?? String(err);
let message: string; // Prefix the DOMException name (e.g. "TypeError: …") so the generic
if (name === "NotAllowedError" || name === "SecurityError") { // resolver branch reproduces this hook's original "Could not start
message = t("Microphone access denied"); // recording: <name>: <detail>" text. Each caller owns its own detail; the
} else if (name === "NotFoundError" || name === "OverconstrainedError") { // streaming hook intentionally does not add the name.
message = t("No microphone found"); const detail = `${name ? `${name}: ` : ""}${rawDetail}`;
} else if (name === "NotReadableError" || name === "AbortError") { const code = classifyGetUserMediaError(err);
message = t("Microphone is unavailable or already in use"); const message = dictationErrorMessage(code, t, { detail });
} else {
// Unknown failure: show the real reason instead of a generic string.
message = `${t("Could not start recording")}: ${name ? `${name}: ` : ""}${detail}`;
}
notifications.show({ color: "red", message }); notifications.show({ color: "red", message });
setErrorMessage(message);
setStatus("idle"); setStatus("idle");
startingRef.current = false; startingRef.current = false;
return; return;
@@ -249,10 +256,10 @@ export function useDictation(
// The stream was acquired but the recorder failed to construct; stop the // The stream was acquired but the recorder failed to construct; stop the
// tracks so the MediaStream does not leak before bailing out. // tracks so the MediaStream does not leak before bailing out.
stopTracks(); stopTracks();
notifications.show({ const detail = (err as { message?: string })?.message ?? String(err);
color: "red", const message = dictationErrorMessage("recorder-failed", t, { detail });
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`, notifications.show({ color: "red", message });
}); setErrorMessage(message);
setStatus("idle"); setStatus("idle");
startingRef.current = false; startingRef.current = false;
return; return;
@@ -293,21 +300,14 @@ export function useDictation(
.catch((err: unknown) => { .catch((err: unknown) => {
// Log the full error for diagnosis (status + body + stack). // Log the full error for diagnosis (status + body + stack).
console.error("[dictation] transcription failed", err); console.error("[dictation] transcription failed", err);
const resp = ( const { code, serverMessage } = classifyTranscriptionError(err);
err as { response?: { status?: number; data?: { message?: string } } } const detail = (err as { message?: string })?.message ?? String(err);
)?.response; const message = dictationErrorMessage(code, t, {
const serverMsg = resp?.data?.message; serverMessage,
let message: string; detail,
if (serverMsg && serverMsg.trim().length > 0) { });
// The server already explains the cause (e.g. provider 404, bad
// format, STT not configured) — show it verbatim.
message = serverMsg;
} else if (resp?.status === 503 || resp?.status === 403) {
message = t("Voice dictation is not configured");
} else {
message = `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
}
notifications.show({ color: "red", message }); notifications.show({ color: "red", message });
setErrorMessage(message);
setStatus("error"); setStatus("error");
if (errorTimerRef.current !== null) { if (errorTimerRef.current !== null) {
clearTimeout(errorTimerRef.current); clearTimeout(errorTimerRef.current);
@@ -332,10 +332,10 @@ export function useDictation(
stopTracks(); stopTracks();
recorderRef.current = null; recorderRef.current = null;
startingRef.current = false; startingRef.current = false;
notifications.show({ const detail = (err as { message?: string })?.message ?? String(err);
color: "red", const message = dictationErrorMessage("recorder-failed", t, { detail });
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`, notifications.show({ color: "red", message });
}); setErrorMessage(message);
setStatus("idle"); setStatus("idle");
return; return;
} }
@@ -405,5 +405,5 @@ export function useDictation(
}; };
}, [clearTimer, stopTracks, stopMeter]); }, [clearTimer, stopTracks, stopMeter]);
return { status, start, stop, cancel, audioLevel }; return { status, start, stop, cancel, audioLevel, errorMessage };
} }
@@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next";
import { transcribeAudio } from "@/features/dictation/services/dictation-service"; import { transcribeAudio } from "@/features/dictation/services/dictation-service";
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav"; import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation"; import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
import {
classifyGetUserMediaError,
classifyTranscriptionError,
dictationErrorMessage,
} from "@/features/dictation/dictation-status";
// Lazily-imported MicVAD type. The runtime import happens inside start() so the // Lazily-imported MicVAD type. The runtime import happens inside start() so the
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and // heavy onnxruntime-web / Silero model is code-split out of the main bundle and
@@ -27,6 +32,8 @@ interface UseStreamingDictationResult {
cancel: () => void; cancel: () => void;
// Smoothed live speech level in the 0..1 range while recording (0 when idle). // Smoothed live speech level in the 0..1 range while recording (0 when idle).
audioLevel: number; audioLevel: number;
// The last error shown to the user (null until one occurs / on a new start).
errorMessage: string | null;
} }
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k). // Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
@@ -60,6 +67,8 @@ export function useStreamingDictation(
const { t } = useTranslation(); const { t } = useTranslation();
const [status, setStatus] = useState<DictationStatus>("idle"); const [status, setStatus] = useState<DictationStatus>("idle");
const [audioLevel, setAudioLevel] = useState(0); const [audioLevel, setAudioLevel] = useState(0);
// Last error message shown to the user; the mic button reads it for its tooltip.
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the // Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
// current handlers without re-creating the VAD. // current handlers without re-creating the VAD.
@@ -158,26 +167,6 @@ export function useStreamingDictation(
} }
}, []); }, []);
// Map a transcription error to a user-facing message, mirroring the batch hook.
const transcriptionErrorMessage = useCallback(
(err: unknown): string => {
const resp = (
err as { response?: { status?: number; data?: { message?: string } } }
)?.response;
const serverMsg = resp?.data?.message;
if (serverMsg && serverMsg.trim().length > 0) {
// The server already explains the cause (e.g. provider 404, bad format,
// STT not configured) — show it verbatim.
return serverMsg;
}
if (resp?.status === 503 || resp?.status === 403) {
return t("Voice dictation is not configured");
}
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
},
[t],
);
// Handle one ended speech segment: encode to WAV and transcribe. Results are // Handle one ended speech segment: encode to WAV and transcribe. Results are
// buffered by seq and flushed in order. A single failed segment does NOT kill // buffered by seq and flushed in order. A single failed segment does NOT kill
// the session: log + one notification, then advance past that seq so later // the session: log + one notification, then advance past that seq so later
@@ -204,10 +193,14 @@ export function useStreamingDictation(
if (epoch !== epochRef.current) return; if (epoch !== epochRef.current) return;
// Log the full error for diagnosis (status + body + stack). // Log the full error for diagnosis (status + body + stack).
console.error("[dictation] segment transcription failed", err); console.error("[dictation] segment transcription failed", err);
notifications.show({ const { code, serverMessage } = classifyTranscriptionError(err);
color: "red", const detail = (err as { message?: string })?.message ?? String(err);
message: transcriptionErrorMessage(err), const message = dictationErrorMessage(code, t, {
serverMessage,
detail,
}); });
notifications.show({ color: "red", message });
setErrorMessage(message);
// Skip this seq so later segments can still flush in order. // Skip this seq so later segments can still flush in order.
if (nextEmitSeqRef.current === seq) { if (nextEmitSeqRef.current === seq) {
nextEmitSeqRef.current += 1; nextEmitSeqRef.current += 1;
@@ -226,7 +219,7 @@ export function useStreamingDictation(
} }
}); });
}, },
[drainResults, transcriptionErrorMessage], [drainResults, t],
); );
const start = useCallback(async (): Promise<void> => { const start = useCallback(async (): Promise<void> => {
@@ -236,6 +229,8 @@ export function useStreamingDictation(
if (startingRef.current || vadRef.current || activeRef.current) return; if (startingRef.current || vadRef.current || activeRef.current) return;
if (status !== "idle") return; if (status !== "idle") return;
startingRef.current = true; startingRef.current = true;
// Clear any stale error from a previous attempt.
setErrorMessage(null);
// Notify the caller right when dictation begins (before any async work) so the // Notify the caller right when dictation begins (before any async work) so the
// editor can snapshot the caret position. // editor can snapshot the caret position.
@@ -354,10 +349,9 @@ export function useStreamingDictation(
// actually runs.) // actually runs.)
console.error("[dictation] VAD init failed", err); console.error("[dictation] VAD init failed", err);
const detail = (err as { message?: string })?.message ?? String(err); const detail = (err as { message?: string })?.message ?? String(err);
notifications.show({ const message = dictationErrorMessage("vad-init-failed", t, { detail });
color: "red", notifications.show({ color: "red", message });
message: `${t("Could not start recording")}: ${detail}`, setErrorMessage(message);
});
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we // Defensive: if MicVAD.new partially succeeded before throwing, make sure we
// don't leak it. // don't leak it.
destroyVad(); destroyVad();
@@ -379,19 +373,11 @@ export function useStreamingDictation(
} catch (err) { } catch (err) {
// Always log the full error for diagnosis (name, message, stack). // Always log the full error for diagnosis (name, message, stack).
console.error("[dictation] VAD.start failed", err); console.error("[dictation] VAD.start failed", err);
const name = (err as { name?: string })?.name;
const detail = (err as { message?: string })?.message ?? String(err); const detail = (err as { message?: string })?.message ?? String(err);
let message: string; const code = classifyGetUserMediaError(err);
if (name === "NotAllowedError" || name === "SecurityError") { const message = dictationErrorMessage(code, t, { detail });
message = t("Microphone access denied");
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
message = t("No microphone found");
} else if (name === "NotReadableError" || name === "AbortError") {
message = t("Microphone is unavailable or already in use");
} else {
message = `${t("Could not start recording")}: ${detail}`;
}
notifications.show({ color: "red", message }); notifications.show({ color: "red", message });
setErrorMessage(message);
activeRef.current = false; activeRef.current = false;
destroyVad(); destroyVad();
setStatus("idle"); setStatus("idle");
@@ -470,5 +456,5 @@ export function useStreamingDictation(
}; };
}, [clearTimer, destroyVad]); }, [clearTimer, destroyVad]);
return { status, start, stop, cancel, audioLevel }; return { status, start, stop, cancel, audioLevel, errorMessage };
} }
@@ -1,6 +1,7 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { Editor } from "@tiptap/core"; import { 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";
export const pageEditorAtom = atom<Editor | null>(null); export const pageEditorAtom = atom<Editor | null>(null);
@@ -15,3 +16,15 @@ export const showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on // Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server. // first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit); export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
// Whether the dictation mic can start, and (when it can't) the cause-specific
// reason the mic button surfaces as a tooltip. Published by the page editor,
// consumed by DictationGroup -> MicButton.
export type DictationAvailability = {
isEditable: boolean;
reason: DictationUnavailableReason | null;
};
export const dictationAvailabilityAtom = atom<DictationAvailability>({
isEditable: false,
reason: null,
});
@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from "vitest";
import { render, act } from "@testing-library/react";
import { Provider, createStore } from "jotai";
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
// Regression test for the byline mic staying stuck disabled (#311 / #309): on a
// page the user can edit, the mic must un-grey once the body becomes editable.
// #311 first fixed this by reading `editor.isEditable` via `useEditorState`; #309
// superseded that with a reactive `dictationAvailabilityAtom` that page-editor
// publishes (carrying both the editable gate AND the unavailable reason). The mic
// now gates on `dictationAvailability.isEditable`, so a change to that atom must
// re-render the group and flip the disabled state (jotai drives the subscription).
// Detectable stand-in that surfaces the `disabled` prop the component computes.
vi.mock("@/features/dictation/components/mic-button", () => ({
MicButton: ({ disabled }: any) => (
<button data-testid="mic" disabled={disabled} />
),
}));
import { DictationGroup } from "./dictation-group";
// Minimal editor stand-in matching the surface DictationGroup uses (handleStart /
// handleText). The disabled gate no longer reads this — it reads the atom.
function makeFakeEditor() {
return {
isEditable: false,
isDestroyed: false,
state: { selection: { from: 0, to: 0 }, doc: { content: { size: 0 } } },
} as any;
}
describe("DictationGroup editable reactivity (#309 atom / #311)", () => {
it("re-enables the mic when dictationAvailability flips isEditable false -> true", () => {
const editor = makeFakeEditor();
const store = createStore();
// Pre-sync: page editor publishes not-editable (with a reason).
store.set(dictationAvailabilityAtom, {
isEditable: false,
reason: "connecting",
});
const { getByTestId } = render(
<Provider store={store}>
<DictationGroup editor={editor} />
</Provider>,
);
// Not editable yet -> disabled (preserves the #218 pre-sync intent).
expect(getByTestId("mic").hasAttribute("disabled")).toBe(true);
// Collab sync -> page editor republishes editable; the atom change must
// re-render the group and enable the mic.
act(() => {
store.set(dictationAvailabilityAtom, { isEditable: true, reason: null });
});
expect(getByTestId("mic").hasAttribute("disabled")).toBe(false);
});
});
@@ -1,7 +1,8 @@
import { FC, useRef } from "react"; import { FC, useRef } from "react";
import type { Editor } from "@tiptap/react"; import { Editor } from "@tiptap/react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { MicButton } from "@/features/dictation/components/mic-button"; import { MicButton } from "@/features/dictation/components/mic-button";
interface Props { interface Props {
@@ -16,6 +17,8 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const streamingDictation = const streamingDictation =
workspace?.settings?.ai?.dictationStreaming === true; workspace?.settings?.ai?.dictationStreaming === true;
// Cause-specific reason the mic is unavailable (published by the page editor).
const dictationAvailability = useAtomValue(dictationAvailabilityAtom);
// Caret snapshot taken when dictation starts (where the first segment lands). // Caret snapshot taken when dictation starts (where the first segment lands).
const rangeRef = useRef<{ from: number; to: number } | null>(null); const rangeRef = useRef<{ from: number; to: number } | null>(null);
// Running insertion point: after each inserted segment we remember the caret // Running insertion point: after each inserted segment we remember the caret
@@ -80,7 +83,8 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
streaming={streamingDictation} streaming={streamingDictation}
onStart={handleStart} onStart={handleStart}
onText={handleText} onText={handleText}
disabled={!editor.isEditable} disabled={!dictationAvailability.isEditable}
unavailableReason={dictationAvailability.reason ?? undefined}
color={color} color={color}
iconSize={iconSize} iconSize={iconSize}
/> />
@@ -1,6 +1,10 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { WebSocketStatus } from "@hocuspocus/provider"; import { WebSocketStatus } from "@hocuspocus/provider";
import { isCollabSynced, isBodyEditable } from "./editor-sync-state"; import {
isCollabSynced,
isBodyEditable,
computeDictationAvailability,
} from "./editor-sync-state";
describe("isCollabSynced", () => { describe("isCollabSynced", () => {
it("is true only when Connected and synced", () => { it("is true only when Connected and synced", () => {
@@ -30,3 +34,77 @@ describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false); expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
}); });
}); });
describe("computeDictationAvailability (mic reason precedence, #309)", () => {
const base = {
editable: true,
inEditMode: true,
showStatic: false,
isDisconnected: false,
};
it("is available with no reason once synced (showStatic false)", () => {
expect(computeDictationAvailability(base)).toEqual({
isEditable: true,
reason: null,
});
});
it("reports 'offline' during pre-sync while disconnected", () => {
expect(
computeDictationAvailability({
...base,
showStatic: true,
isDisconnected: true,
}),
).toEqual({ isEditable: false, reason: "offline" });
});
it("reports 'connecting' during pre-sync while still connecting", () => {
expect(
computeDictationAvailability({
...base,
showStatic: true,
isDisconnected: false,
}),
).toEqual({ isEditable: false, reason: "connecting" });
});
it("reports 'read-only' without edit permission", () => {
expect(
computeDictationAvailability({ ...base, editable: false }),
).toEqual({ isEditable: false, reason: "read-only" });
});
it("reports 'read-only' when not in edit mode", () => {
expect(
computeDictationAvailability({ ...base, inEditMode: false }),
).toEqual({ isEditable: false, reason: "read-only" });
});
// Lack of edit permission takes precedence over the pre-sync reason: a
// read-only viewer who is ALSO inside the pre-sync window (showStatic) must
// still read "read-only", never "offline"/"connecting". This pins the
// `opts.editable &&` guard on the pre-sync branch.
it("prefers 'read-only' over pre-sync when a read-only viewer is disconnected", () => {
expect(
computeDictationAvailability({
editable: false,
inEditMode: true,
showStatic: true,
isDisconnected: true,
}),
).toEqual({ isEditable: false, reason: "read-only" });
});
it("prefers 'read-only' over pre-sync when a read-only viewer is still connecting", () => {
expect(
computeDictationAvailability({
editable: false,
inEditMode: true,
showStatic: true,
isDisconnected: false,
}),
).toEqual({ isEditable: false, reason: "read-only" });
});
});
@@ -1,4 +1,5 @@
import { WebSocketStatus } from "@hocuspocus/provider"; import { WebSocketStatus } from "@hocuspocus/provider";
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
/** /**
* The collab document is usable only once the provider is Connected AND has * The collab document is usable only once the provider is Connected AND has
@@ -30,3 +31,32 @@ export function isBodyEditable(opts: {
}): boolean { }): boolean {
return opts.editable && opts.inEditMode && !opts.showStatic; return opts.editable && opts.inEditMode && !opts.showStatic;
} }
/**
* Whether dictation can start and, when it can't, the cause-specific reason the
* mic button surfaces. Derives editability from `isBodyEditable` (the single,
* tested gate) so the published `isEditable` can never diverge from the actual
* body-editable state and make the tooltip lie (#309).
*
* `isDisconnected` is the caller's own boolean (collab connection is in the
* Disconnected state), passed in so this module stays free of the collab enum.
*/
export function computeDictationAvailability(opts: {
editable: boolean;
inEditMode: boolean;
showStatic: boolean;
isDisconnected: boolean;
}): { isEditable: boolean; reason: DictationUnavailableReason | null } {
const isEditable = isBodyEditable({
editable: opts.editable,
inEditMode: opts.inEditMode,
showStatic: opts.showStatic,
});
if (isEditable) return { isEditable, reason: null };
// Permitted to edit and in edit mode but not yet synced (showStatic) → pre-sync.
if (opts.editable && opts.inEditMode && opts.showStatic) {
return { isEditable, reason: opts.isDisconnected ? "offline" : "connecting" };
}
// No edit permission or not in edit mode.
return { isEditable, reason: "read-only" };
}
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react"; import { renderHook, act } from "@testing-library/react";
import { useScrollPosition } from "./use-scroll-position"; import { useScrollPosition, hasSavedReadingPosition } from "./use-scroll-position";
const KEY_PREFIX = "gitmost:scroll-position:"; const KEY_PREFIX = "gitmost:scroll-position:";
@@ -372,3 +372,23 @@ describe("useScrollPosition", () => {
}).not.toThrow(); }).not.toThrow();
}); });
}); });
describe("hasSavedReadingPosition", () => {
beforeEach(() => {
window.sessionStorage.clear();
});
it("returns false when nothing is saved for the page", () => {
expect(hasSavedReadingPosition("none")).toBe(false);
});
it("returns false when the saved value is 0 (page stays at the top)", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
expect(hasSavedReadingPosition("zero")).toBe(false);
});
it("returns true when a positive position is saved", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}deep`, "500");
expect(hasSavedReadingPosition("deep")).toBe(true);
});
});
@@ -57,6 +57,17 @@ function writeStorage(pageId: string, scrollY: number): void {
} }
} }
/**
* Whether a positive reading position is saved for this page — i.e. the page
* will be scrolled away from the top on load. Used by the title editor to avoid
* auto-focusing (and thus placing the caret in) the now-off-screen title.
* Returns false when nothing is saved or storage is unavailable.
*/
export function hasSavedReadingPosition(pageId: string): boolean {
const y = readStorage(pageId);
return typeof y === "number" && y > 0;
}
/** /**
* Persists and restores the window scroll position per page so a reader keeps * Persists and restores the window scroll position per page so a reader keeps
* their place across a reload (F5) or reopening the document. * their place across a reload (F5) or reopening the document.
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { RefObject } from "react";
import { useSwapHeightReservation } from "./use-swap-height-reservation";
// Controllable fake requestAnimationFrame. jsdom's rAF is timer-driven and hard
// to step deterministically, so we install a manual queue: `tickRaf()` drains the
// callbacks scheduled so far (a callback that reschedules enqueues a new one for
// the NEXT tick), letting each test advance the release loop frame by frame.
let rafQueue: Array<{ id: number; cb: FrameRequestCallback }> = [];
let nextRafId = 1;
let realRaf: typeof globalThis.requestAnimationFrame;
let realCancel: typeof globalThis.cancelAnimationFrame;
function tickRaf(): void {
const current = rafQueue;
rafQueue = [];
for (const { cb } of current) cb(0);
}
// A mutable stand-in for the live-content container. The hook only reads
// `scrollHeight`, so tests drive the release condition by mutating this.
function makeMenuRef(): {
ref: RefObject<HTMLElement | null>;
setScrollHeight: (h: number) => void;
} {
const el = { scrollHeight: 0 };
return {
ref: { current: el } as unknown as RefObject<HTMLElement | null>,
setScrollHeight: (h: number) => {
el.scrollHeight = h;
},
};
}
const H = 1000;
describe("useSwapHeightReservation", () => {
beforeEach(() => {
rafQueue = [];
nextRafId = 1;
realRaf = globalThis.requestAnimationFrame;
realCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
const id = nextRafId++;
rafQueue.push({ id, cb });
return id;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame = ((id: number) => {
rafQueue = rafQueue.filter((e) => e.id !== id);
}) as typeof globalThis.cancelAnimationFrame;
});
afterEach(() => {
globalThis.requestAnimationFrame = realRaf;
globalThis.cancelAnimationFrame = realCancel;
vi.useRealTimers();
vi.restoreAllMocks();
});
// (a) reserve-on-swap: the captured height becomes `reservedHeight`, the value
// that drives the swap wrapper's minHeight. Captured while static is still up,
// then the swap flips showStatic; before any release frame runs the reservation
// is held at exactly H.
it("(a) holds the captured height as reservedHeight after the swap (drives minHeight)", () => {
const { ref, setScrollHeight } = makeMenuRef();
setScrollHeight(0); // live content not laid out yet -> release cannot fire.
const { result, rerender } = renderHook(
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
{ initialProps: { showStatic: true } },
);
// Capture happens synchronously at the swap point (static still shown).
act(() => {
result.current.captureReservation(H);
});
// The swap flips to the live branch.
rerender({ showStatic: false });
expect(result.current.reservedHeight).toBe(H);
});
// (b) release when the live content is tall enough. Guard is `>=`: with
// liveHeight === H the reservation releases. This FAILS if the guard direction
// were `<` (liveHeight === H is not `< H`, so it would never release).
it("(b) releases once live content reaches the reserved height", () => {
const { ref, setScrollHeight } = makeMenuRef();
setScrollHeight(0);
const { result, rerender } = renderHook(
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
{ initialProps: { showStatic: true } },
);
act(() => {
result.current.captureReservation(H);
});
rerender({ showStatic: false });
expect(result.current.reservedHeight).toBe(H); // still reserved (short live doc)
// Live editor finishes laying out to the reserved height.
setScrollHeight(H);
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBeNull();
});
// (c) cap escape: the live content never reaches the reserved height, so the
// height match never fires; the reservation must still release at the 4000ms
// cap (no stuck reservation / dead space). This FAILS if there were no cap: the
// loop would poll forever while scrollHeight stays below H.
it("(c) releases at the 4000ms cap when live content stays too short", () => {
// Only fake Date so `Date.now()` (the cap clock) is controllable; leave our
// manual rAF queue in place (default fake timers would replace it).
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(0);
const { ref, setScrollHeight } = makeMenuRef();
setScrollHeight(H - 100); // always shorter than reserved -> height match never fires.
const { result, rerender } = renderHook(
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
{ initialProps: { showStatic: true } },
);
act(() => {
result.current.captureReservation(H);
});
rerender({ showStatic: false });
// A few frames pass but time has not reached the cap: still reserved.
act(() => {
tickRaf();
});
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBe(H);
// Advance past the cap; the next frame releases even though the live content
// is still shorter than the reservation.
vi.setSystemTime(4001);
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBeNull();
});
// (d) non-swap: without a capture (and while static is shown) there is no
// reservation and the release loop never arms, so no rAF is scheduled.
it("(d) reserves nothing and arms no loop when the swap never happens", () => {
const { ref } = makeMenuRef();
const { result } = renderHook(() =>
useSwapHeightReservation(true, ref),
);
expect(result.current.reservedHeight).toBeNull();
expect(rafQueue.length).toBe(0); // release loop never armed
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBeNull();
});
});
@@ -0,0 +1,79 @@
import { RefObject, useCallback, useEffect, useState } from "react";
// Last-resort release deadline. The primary release is the live-content height
// match below; this cap only exists so a slow/short live doc can never pin the
// reservation forever. It is generous (well past when the live content normally
// reaches the reserved height — it renders the SAME content as the static copy)
// so a slow load doesn't release mid-render and reintroduce the collapse.
const RELEASE_CAP_MS = 4000;
/**
* Reserves the document height across the static -> live editor swap.
*
* The live editor lays out its content over a few frames, so replacing the
* (full-height) static copy with it momentarily shrinks the document; the
* browser then clamps window scroll to the top, which yanked the reader off
* their restored reading position (and threw their scroll to 0 if they were
* scrolling at that moment). Pinning a min-height on the swap wrapper keeps the
* document tall through the swap so the scroll position simply survives (#266).
* `reservedHeight === null` means no reservation is active.
*
* The capture is intentionally a CALLBACK the page editor invokes, NOT something
* this hook derives by watching `showStatic`. The height MUST be read
* synchronously while the static content is still mounted (full natural height),
* right before the flip to the live branch. By the time any post-transition
* effect here could run, `showStatic` is already false and the wrapper shows the
* live/collapsed content, so `offsetHeight` would be wrong. So page-editor calls
* `captureReservation(wrapper.offsetHeight)` inside its collab-sync effect,
* before `setShowStatic(false)`, preserving that exact timing.
*
* @param showStatic whether the static (cached) content is still shown.
* @param menuContainerRef the live-branch content container. It is a descendant
* of the swap wrapper inside the live branch, so its `scrollHeight` is the live
* content height (not inflated by the ancestor min-height reservation).
*/
export function useSwapHeightReservation(
showStatic: boolean,
menuContainerRef: RefObject<HTMLElement | null>,
): {
reservedHeight: number | null;
captureReservation: (height: number | null) => void;
} {
const [reservedHeight, setReservedHeight] = useState<number | null>(null);
// Capture the current (static, full-height) content height BEFORE the swap so
// the wrapper can reserve it while the live editor lays out — otherwise the
// transient shrink clamps window scroll to the top. The caller reads
// `offsetHeight` synchronously at the swap point and hands it here.
const captureReservation = useCallback(
(height: number | null) => setReservedHeight(height),
[],
);
// Release the reserved height once the live editor's content has laid out to
// at least the reserved height (so removing the reservation cannot collapse
// the document). The primary release is that height match; the cap is only a
// last-resort so we never pin forever. A shorter-than-reserved live doc (rare:
// stale/longer cache) releases at the cap, leaving only harmless bottom dead
// space until then.
useEffect(() => {
if (showStatic || reservedHeight == null) return;
let raf = 0;
const startedAt = Date.now();
const check = () => {
const liveHeight = menuContainerRef.current?.scrollHeight ?? 0;
if (
liveHeight >= reservedHeight ||
Date.now() - startedAt > RELEASE_CAP_MS
) {
setReservedHeight(null);
return;
}
raf = requestAnimationFrame(check);
};
raf = requestAnimationFrame(check);
return () => cancelAnimationFrame(raf);
}, [showStatic, reservedHeight, menuContainerRef]);
return { reservedHeight, captureReservation };
}
@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTitleAutofocus } from "./use-title-autofocus";
const KEY_PREFIX = "gitmost:scroll-position:";
function fakeEditor(overrides = {}) {
return { isInitialized: true, commands: { focus: vi.fn() }, ...overrides } as any;
}
describe("useTitleAutofocus", () => {
beforeEach(() => {
window.sessionStorage.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("skips auto-focus when a saved reading position exists", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}saved`, "500");
const editor = fakeEditor();
renderHook(() => useTitleAutofocus(editor, "saved"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
it("auto-focuses a new page (no saved position) with scrollIntoView: false", () => {
const editor = fakeEditor();
renderHook(() => useTitleAutofocus(editor, "fresh"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).toHaveBeenCalledWith("end", { scrollIntoView: false });
});
it("does not focus before initialization", () => {
const editor = fakeEditor({ isInitialized: false });
renderHook(() => useTitleAutofocus(editor, "fresh2"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
it("cancels the pending focus on unmount", () => {
const editor = fakeEditor();
const { unmount } = renderHook(() => useTitleAutofocus(editor, "fresh3"));
unmount();
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
import type { Editor } from "@tiptap/react";
import { hasSavedReadingPosition } from "./use-scroll-position";
// Delay before auto-focusing the title on load — guards a tiptap init race
// ("Cannot access view['hasFocus']" if focused too early).
const TITLE_AUTOFOCUS_DELAY_MS = 300;
/**
* Auto-focus the page title shortly after mount — UNLESS a saved reading position
* will be restored (then the viewport scrolls away from the top, and focusing the
* top-of-page title would drop the caret off-screen). When it does focus, it uses
* `{ scrollIntoView: false }` so placing the caret never moves the viewport
* (tiptap's focus scrolls the focused node into view by default, which otherwise
* yanks the window to the top and fights scroll-position restoration).
*
* Extracted from TitleEditor so this exact decision is unit-testable.
*
* CONTRACT: relies on TitleEditor remounting per page (page.tsx renders
* `<MemoizedFullEditor key={page.id}>`), so `hasSavedScrollRef` is captured fresh
* per page. It is read synchronously on first render, before any scroll-save
* handler can clobber the stored value to 0 — matching `useScrollPosition`'s own
* synchronous capture of `initialTargetRef`.
*/
export function useTitleAutofocus(
titleEditor: Editor | null,
pageId: string,
): void {
const hasSavedScrollRef = useRef<boolean | null>(null);
if (hasSavedScrollRef.current === null) {
hasSavedScrollRef.current = hasSavedReadingPosition(pageId);
}
useEffect(() => {
if (hasSavedScrollRef.current) return;
const timer = setTimeout(() => {
// guard against "Cannot access view['hasFocus']" before init
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end", { scrollIntoView: false });
}, TITLE_AUTOFOCUS_DELAY_MS);
// Clear the pending focus if the editor changes or the component unmounts
// (also fixes the previously-uncancelled timer).
return () => clearTimeout(timer);
}, [titleEditor]);
}
@@ -27,11 +27,12 @@ import {
collabExtensions, collabExtensions,
mainExtensions, mainExtensions,
} from "@/features/editor/extensions/extensions"; } from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url"; import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { import {
currentPageEditModeAtom, currentPageEditModeAtom,
dictationAvailabilityAtom,
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms"; } from "@/features/editor/atoms/editor-atoms";
@@ -79,6 +80,7 @@ import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll"; import { useEditorScroll } from "./hooks/use-editor-scroll";
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position"; import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
import { useSwapHeightReservation } from "./hooks/use-swap-height-reservation";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx"; import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context"; import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
@@ -87,6 +89,7 @@ import { PageEmbedAncestryProvider } from "@/features/editor/components/page-emb
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker"; import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
computeDictationAvailability,
isBodyEditable, isBodyEditable,
isCollabSynced, isCollabSynced,
} from "@/features/editor/editor-sync-state"; } from "@/features/editor/editor-sync-state";
@@ -138,6 +141,7 @@ export default function PageEditor({
const { pageSlug } = useParams(); const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom); const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const setDictationAvailability = useSetAtom(dictationAvailabilityAtom);
const canScroll = useCallback( const canScroll = useCallback(
() => Boolean(isComponentMounted.current && editorRef.current), () => Boolean(isComponentMounted.current && editorRef.current),
[isComponentMounted], [isComponentMounted],
@@ -449,6 +453,22 @@ export default function PageEditor({
const hasConnectedOnceRef = useRef(false); const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true); const [showStatic, setShowStatic] = useState(true);
// Reserved height held across the static -> live editor swap. The live editor
// lays out its content over a few frames, so replacing the (full-height) static
// copy with it momentarily shrinks the document; the browser then clamps window
// scroll to the top, which yanked the reader off their restored reading position
// (and threw their scroll to 0 if they were scrolling at that moment). Pinning a
// min-height on the swap wrapper keeps the document tall through the swap so the
// scroll position simply survives. `null` = no reservation active.
const swapWrapperRef = useRef<HTMLDivElement | null>(null);
// Reserve/release wiring lives in the hook so its capture trigger and release
// guard/cap are directly unit-testable. Capture stays synchronous at the swap
// point (see the collab-sync effect below); the hook only owns the release.
const { reservedHeight, captureReservation } = useSwapHeightReservation(
showStatic,
menuContainerRef,
);
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) { if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
@@ -471,12 +491,36 @@ export default function PageEditor({
); );
}, [currentPageEditMode, editor, editable, showStatic]); }, [currentPageEditMode, editor, editable, showStatic]);
// Publish whether dictation can start and, if not, the cause-specific reason
// the mic button surfaces. Recomputed on the same signals that drive body
// editability so the tooltip never lies about the current state.
useEffect(() => {
setDictationAvailability(
computeDictationAvailability({
editable,
inEditMode: currentPageEditMode === PageEditMode.Edit,
showStatic,
isDisconnected: yjsConnectionStatus === WebSocketStatus.Disconnected,
}),
);
}, [
editable,
currentPageEditMode,
showStatic,
yjsConnectionStatus,
setDictationAvailability,
]);
useEffect(() => { useEffect(() => {
if ( if (
!hasConnectedOnceRef.current && !hasConnectedOnceRef.current &&
isCollabSynced(yjsConnectionStatus, isSynced) isCollabSynced(yjsConnectionStatus, isSynced)
) { ) {
hasConnectedOnceRef.current = true; hasConnectedOnceRef.current = true;
// Capture the current (static, full-height) content height BEFORE the swap
// so the wrapper can reserve it while the live editor lays out — otherwise
// the transient shrink clamps window scroll to the top.
captureReservation(swapWrapperRef.current?.offsetHeight ?? null);
setShowStatic(false); setShowStatic(false);
} }
}, [yjsConnectionStatus, isSynced]); }, [yjsConnectionStatus, isSynced]);
@@ -490,6 +534,12 @@ export default function PageEditor({
<TransclusionLookupProvider> <TransclusionLookupProvider>
<PageEmbedLookupProvider> <PageEmbedLookupProvider>
<PageEmbedAncestryProvider hostPageId={pageId}> <PageEmbedAncestryProvider hostPageId={pageId}>
<div
ref={swapWrapperRef}
style={
reservedHeight != null ? { minHeight: reservedHeight } : undefined
}
>
{showStatic ? ( {showStatic ? (
<div style={{ position: "relative" }}> <div style={{ position: "relative" }}>
{/* Surface the pre-sync read-only window so edits typed before the {/* Surface the pre-sync read-only window so edits typed before the
@@ -577,6 +627,7 @@ export default function PageEditor({
></div> ></div>
</div> </div>
)} )}
</div>
</PageEmbedAncestryProvider> </PageEmbedAncestryProvider>
</PageEmbedLookupProvider> </PageEmbedLookupProvider>
</TransclusionLookupProvider> </TransclusionLookupProvider>
@@ -28,6 +28,7 @@ import localEmitter from "@/lib/local-emitter.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib"; import { platformModifierKey } from "@/lib";
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
export interface TitleEditorProps { export interface TitleEditorProps {
pageId: string; pageId: string;
@@ -167,13 +168,7 @@ export function TitleEditor({
} }
}, [pageId, title, titleEditor]); }, [pageId, title, titleEditor]);
useEffect(() => { useTitleAutofocus(titleEditor, pageId);
setTimeout(() => {
// guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 300);
}, [titleEditor]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1,6 +1,6 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx"; import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { formattedDate } from "@/lib/time"; import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css"; import classes from "./css/history.module.css";
import clsx from "clsx"; import clsx from "clsx";
@@ -99,12 +99,13 @@ const HistoryItem = memo(function HistoryItem({
</> </>
)} )}
{isAgentEdit && ( {isAgentEdit && historyItem.agent && (
<AiAgentBadge <AgentAvatarStack
authorName={historyItem.lastUpdatedBy?.name} agent={historyItem.agent}
launcher={historyItem.launcher}
aiChatId={historyItem.lastUpdatedAiChatId} aiChatId={historyItem.lastUpdatedAiChatId}
// The history row owns the modal: close it when the badge deep-links // The history row owns the modal: close it when the stack deep-links
// into the chat (the badge no longer reaches into page-history). // into the chat (the stack no longer reaches into page-history).
onActivate={() => setHistoryModalOpen(false)} onActivate={() => setHistoryModalOpen(false)}
/> />
)} )}
@@ -1,3 +1,8 @@
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
interface IPageHistoryUser { interface IPageHistoryUser {
id: string; id: string;
name: string; name: string;
@@ -24,4 +29,9 @@ export interface IPageHistory {
// (when present) deep-links to the chat that produced the edit. // (when present) deep-links to the chat that produced the edit.
lastUpdatedSource?: string; lastUpdatedSource?: string;
lastUpdatedAiChatId?: string | null; lastUpdatedAiChatId?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// lastUpdatedSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
} }
@@ -1,4 +1,11 @@
import { Button, Group, Paper, Text } from "@mantine/core"; import {
ActionIcon,
Button,
Group,
Paper,
Text,
Tooltip,
} from "@mantine/core";
import { IconClockHour4, IconTrash } from "@tabler/icons-react"; import { IconClockHour4, IconTrash } from "@tabler/icons-react";
import { useState } from "react"; import { useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
@@ -70,7 +77,14 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
return ( return (
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0"> <Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
<Group justify="space-between" wrap="wrap" gap="sm"> <Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}> {/* A non-zero flex-basis lets the outer wrap="wrap" drop the buttons to
their own row on narrow screens; flex:1 (basis 0) never wraps and
instead crushes the text into a one-word-per-line ladder. */}
<Group
gap="xs"
wrap="nowrap"
style={{ flex: "1 1 16rem", minWidth: 0 }}
>
<IconClockHour4 <IconClockHour4
size={18} size={18}
stroke={1.5} stroke={1.5}
@@ -87,28 +101,58 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
</Text> </Text>
</Group> </Group>
{canEdit && ( {canEdit && (
<Group gap="xs" wrap="nowrap"> <>
<Button {/* Desktop: full labeled buttons. */}
size="xs" <Group gap="xs" wrap="nowrap" visibleFrom="sm">
variant="subtle" <Button
color="red" size="xs"
leftSection={<IconTrash size={16} />} variant="subtle"
onClick={handleTrashNow} color="red"
loading={isDeleting} leftSection={<IconTrash size={16} />}
> onClick={handleTrashNow}
{t("Move to trash")} loading={isDeleting}
</Button> >
<Button {t("Move to trash")}
size="xs" </Button>
variant="light" <Button
color="orange" size="xs"
leftSection={<IconClockHour4 size={16} />} variant="light"
onClick={handleMakePermanent} color="orange"
loading={toggleTemporary.isPending} leftSection={<IconClockHour4 size={16} />}
> onClick={handleMakePermanent}
{t("Make permanent")} loading={toggleTemporary.isPending}
</Button> >
</Group> {t("Make permanent")}
</Button>
</Group>
{/* Mobile: icon-only actions so they never overflow the narrow row. */}
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
<Tooltip label={t("Move to trash")} withArrow>
<ActionIcon
size="lg"
variant="subtle"
color="red"
onClick={handleTrashNow}
loading={isDeleting}
aria-label={t("Move to trash")}
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Make permanent")} withArrow>
<ActionIcon
size="lg"
variant="light"
color="orange"
onClick={handleMakePermanent}
loading={toggleTemporary.isPending}
aria-label={t("Make permanent")}
>
<IconClockHour4 size={18} />
</ActionIcon>
</Tooltip>
</Group>
</>
)} )}
</Group> </Group>
</Paper> </Paper>
@@ -1,5 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useAtom, useStore } from "jotai"; import { useAtom, useSetAtom, useStore } from "jotai";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
@@ -20,6 +20,7 @@ import {
} from "@/features/page/queries/page-query.ts"; } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { getSpaceUrl } from "@/lib/config.ts"; import { getSpaceUrl } from "@/lib/config.ts";
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
export type UseTreeMutation = { export type UseTreeMutation = {
handleMove: (sourceId: string, op: DropOp) => Promise<void>; handleMove: (sourceId: string, op: DropOp) => Promise<void>;
@@ -43,6 +44,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
const removePageMutation = useRemovePageMutation(); const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation(); const movePageMutation = useMovePageMutation();
const navigate = useNavigate(); const navigate = useNavigate();
const setMobileSidebar = useSetAtom(mobileSidebarAtom);
const { spaceSlug, pageSlug } = useParams(); const { spaceSlug, pageSlug } = useParams();
const handleMove = useCallback( const handleMove = useCallback(
@@ -201,8 +203,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
createdPage.title, createdPage.title,
); );
navigate(pageUrl); navigate(pageUrl);
// On mobile the create action is triggered from inside the off-canvas
// sidebar drawer (space sidebar "+", tree-row "add subpage"). Navigating
// alone leaves that drawer open on top of the freshly created page, so the
// editor stays hidden behind the tree. Close it here so the new page opens
// in the editor — mirrors the row-click drawer-close in space-tree-row.
// No-op on desktop, where the mobile drawer atom is already false.
setMobileSidebar(false);
}, },
[spaceId, createPageMutation, setData, store, navigate, spaceSlug], [
spaceId,
createPageMutation,
setData,
store,
navigate,
spaceSlug,
setMobileSidebar,
],
); );
const handleRename = useCallback( const handleRename = useCallback(
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from "vitest";
import {
PALETTE,
avatarStyle,
avatarBackgroundCss,
normalizeName,
minPairwiseDistance,
relativeLuminance,
contrastRatio,
oklchToSrgb,
isInGamut,
} from "./avatar-palette";
/** Parse "#rrggbb" into sRGB components on the 0..1 scale relativeLuminance expects. */
function hexToRgb01(hex: string): [number, number, number] {
return [
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255,
];
}
describe("avatar-palette validation", () => {
it("palette colors stay distinguishable", () => {
// 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future
// RINGS tweak drops this, "almost identical" colors would reappear.
expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06);
expect(PALETTE.length).toBe(20);
});
it("every palette entry is WCAG-readable and in sRGB gamut", () => {
// white text = luminance 1, black text = luminance 0 (per buildPalette).
const textLum = { white: 1, black: 0 } as const;
for (const entry of PALETTE) {
expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/);
// (a) The chosen text color really clears the code's 3:1 threshold on the
// actual background hex — recomputed independently from the hex, not from
// the build-time luminance. A slot that picked the wrong text (or a color
// too dim for either text) would fail here.
const hexLum = relativeLuminance(hexToRgb01(entry.hex));
const chosen = contrastRatio(textLum[entry.text], hexLum);
expect(chosen).toBeGreaterThanOrEqual(3);
// buildPalette prefers white and only falls back to black when white
// fails 3:1. Mirror that decision: black is used *only* when white would
// not clear the threshold — so a mis-assigned "black" on a dark color
// (where white was fine) fails here.
if (entry.text === "black") {
expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3);
}
// (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping;
// an out-of-gamut slot (e.g. un-clamped chroma) would produce components
// outside [0,1] and fail here.
expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true);
}
});
});
describe("avatarStyle", () => {
it("name-to-avatar mapping is frozen (golden values)", () => {
// Golden slice: if this breaks, all existing avatars change — make sure
// that is intentional (a config change in avatar-palette.ts).
const s = avatarStyle("Backend Developer");
expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]);
expect(s.text).toBe("white");
});
it("is deterministic and normalizes the name", () => {
expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher"));
// Casing, surrounding and repeated whitespace must not change the avatar.
expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher"));
expect(avatarStyle("Backend Developer")).toEqual(
avatarStyle("backend developer"),
);
expect(normalizeName(" PM ")).toBe("pm");
});
it("returns a valid base color, angle and matching text", () => {
const s = avatarStyle("Нарратор");
const idx = PALETTE.findIndex((e) => e.hex === s.bg);
expect(idx).toBe(s.paletteIndex);
expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry
// Text color comes from the chosen palette entry.
expect(s.text).toBe(PALETTE[idx].text);
// Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15.
expect(s.angleDeg % 15).toBe(0);
expect(s.angleDeg).toBeGreaterThanOrEqual(0);
expect(s.angleDeg).toBeLessThan(360);
});
it("distinguishes the agents that used to collide as violet", () => {
// "Структурный редактор" and "Фактчекер" looked identically violet before.
expect(avatarStyle("Структурный редактор")).not.toEqual(
avatarStyle("Фактчекер"),
);
});
});
describe("avatarBackgroundCss", () => {
it("renders a two-stop gradient with a soft boundary", () => {
const s = avatarStyle("Backend Developer");
expect(avatarBackgroundCss(s)).toBe(
"linear-gradient(150deg, #a55795 42%, #90355e 58%)",
);
});
});
+267
View File
@@ -0,0 +1,267 @@
/**
* Deterministic avatar backgrounds for agent roles.
*
* The palette is generated from scratch at module load in OKLCH (a perceptually
* uniform color space), so every value below is tunable: change the ring
* configuration or the partner shifts and the whole palette regenerates.
*
* Pipeline: name -> normalize -> cyrb53 hash -> split into independent fields:
* - base color index (one of the validated palette colors)
* - partner hue shift: analogous 20..45deg (either side), complementary 180deg,
* or triadic +/-120deg — classic color-wheel schemes; partner is also darker
* - split angle (SPLIT_ANGLE_STEPS directions, soft boundary)
* The same name always yields the same avatar, on any platform, forever.
*/
// ------------------------- Tunable configuration -------------------------
export interface RingConfig {
/** OKLCH lightness, 0..1 */
L: number;
/** OKLCH chroma target; clamped down per-hue to fit the sRGB gamut */
C: number;
/** Hue of the first color in the ring, degrees */
hueStart: number;
/** Number of evenly spaced hues in the ring */
count: number;
}
/**
* Two lightness rings. 12 light + 8 dark = 20 base colors with a validated
* min pairwise deltaE-OK of ~0.066 (clearly distinguishable at avatar size).
* Don't add more hues per ring without re-checking minPairwiseDistance():
* beyond ~20-24 colors humans stop telling them apart reliably.
*/
const RINGS: readonly RingConfig[] = [
{ L: 0.70, C: 0.14, hueStart: 15, count: 12 }, // light ring
{ L: 0.57, C: 0.13, hueStart: 20, count: 8 }, // darker ring
];
/** Partner color: lightness shifted by this much (negative = darker) */
const PARTNER_L_SHIFT = -0.10;
/** Analogous scheme: hue shift magnitude range, degrees (inclusive, 5-deg steps) */
const ANALOG_MIN_SHIFT = 20;
const ANALOG_SHIFT_STEP = 5;
const ANALOG_SHIFT_STEPS = 6; // 20, 25, 30, 35, 40, 45
/** Complementary scheme: fixed hue shift, degrees */
const COMPLEMENTARY_SHIFT = 180;
/** Triadic scheme: fixed hue shift magnitude, degrees (either side) */
const TRIADIC_SHIFT = 120;
/** Number of split directions (24 -> 15deg per step) */
const SPLIT_ANGLE_STEPS = 24;
/** Position of the color boundary, percent of the gradient axis */
const SPLIT_PERCENT = 50;
/** Width of the soft transition zone around the boundary, percent (0 = hard edge) */
const SPLIT_SOFTNESS = 16;
// ------------------------- OKLCH -> sRGB math -------------------------
// Matrices from Bjorn Ottosson's OKLab reference implementation.
function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number] {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
return [
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
];
}
function gammaEncode(c: number): number {
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
}
export function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] {
const h = (hDeg * Math.PI) / 180;
const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h));
return [gammaEncode(r), gammaEncode(g), gammaEncode(b)];
}
export function isInGamut(rgb: readonly number[]): boolean {
return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6);
}
/** Binary-search the max chroma <= C that fits into the sRGB gamut. */
function clampChroma(L: number, C: number, hDeg: number): number {
if (isInGamut(oklchToSrgb(L, C, hDeg))) return C;
let lo = 0, hi = C;
for (let i = 0; i < 40; i++) {
const mid = (lo + hi) / 2;
if (isInGamut(oklchToSrgb(L, mid, hDeg))) lo = mid;
else hi = mid;
}
return lo;
}
function toHex(rgb: readonly number[]): string {
return (
"#" +
rgb
.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0"))
.join("")
);
}
/** WCAG relative luminance of an sRGB color (components 0..1). */
export function relativeLuminance(rgb: readonly number[]): number {
const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
export function contrastRatio(l1: number, l2: number): number {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// ------------------------- Palette generation -------------------------
export interface PaletteEntry {
/** Base background color */
hex: string;
/** OKLCH coordinates of the base color (used to derive partner colors) */
L: number;
C: number;
h: number;
/** Text/icon color with the best WCAG contrast on the base color */
text: "white" | "black";
/** OKLab coordinates of the base color (kept for validation) */
lab: readonly [number, number, number];
}
function buildPalette(): PaletteEntry[] {
const entries: PaletteEntry[] = [];
for (const ring of RINGS) {
const step = 360 / ring.count;
for (let i = 0; i < ring.count; i++) {
const h = (ring.hueStart + i * step) % 360;
const C = clampChroma(ring.L, ring.C, h);
const rgb = oklchToSrgb(ring.L, C, h);
const lum = relativeLuminance(rgb);
entries.push({
hex: toHex(rgb),
L: ring.L,
C,
h,
// White text needs >= 3:1 contrast; otherwise fall back to black.
text: contrastRatio(lum, 1) >= 3 ? "white" : "black",
lab: [
ring.L,
C * Math.cos((h * Math.PI) / 180),
C * Math.sin((h * Math.PI) / 180),
],
});
}
}
return entries;
}
/** Partner color for the split: base hue shifted by shiftDeg, darker by PARTNER_L_SHIFT. */
function partnerHex(entry: PaletteEntry, shiftDeg: number): string {
const h2 = (entry.h + shiftDeg + 360) % 360;
const L2 = entry.L + PARTNER_L_SHIFT;
return toHex(oklchToSrgb(L2, clampChroma(L2, entry.C, h2), h2));
}
/** Generated once at module load; regenerates on every build from the config above. */
export const PALETTE: readonly PaletteEntry[] = buildPalette();
// ------------------------- Name -> avatar style -------------------------
/** Normalize so that "PM ", "pm" and "Pm" map to the same avatar. */
export function normalizeName(name: string): string {
return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* cyrb53: deterministic 53-bit string hash with good avalanche.
* Pure JS, cross-platform — never use language built-in hashing here.
*/
function cyrb53(str: string, seed = 0): number {
let h1 = 0xdeadbeef ^ seed;
let h2 = 0x41c6ce57 ^ seed;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
export interface AvatarStyle {
/** Index of the base color in PALETTE */
paletteIndex: number;
/** Base color hex */
bg: string;
/** Second color hex (split partner) */
bg2: string;
/** Signed hue shift of the partner, degrees (e.g. -35, +45, 180, -120) */
hueShift: number;
/** Direction of the split, degrees */
angleDeg: number;
/** Text/icon color for the base color */
text: "white" | "black";
}
/** Pure function: the same (normalized) name always returns the same style. */
export function avatarStyle(agentName: string): AvatarStyle {
const h = cyrb53(normalizeName(agentName));
// Slice the hash into independent fields, like digits of a number:
const paletteIndex = h % PALETTE.length;
let rest = Math.floor(h / PALETTE.length);
const angleDeg = (rest % SPLIT_ANGLE_STEPS) * (360 / SPLIT_ANGLE_STEPS);
rest = Math.floor(rest / SPLIT_ANGLE_STEPS);
// Scheme: 0,1 -> analogous (minus/plus); 2 -> complementary; 3 -> triadic
const scheme = rest % 4;
rest = Math.floor(rest / 4);
let hueShift: number;
if (scheme === 2) {
hueShift = COMPLEMENTARY_SHIFT;
} else if (scheme === 3) {
hueShift = rest % 2 ? TRIADIC_SHIFT : -TRIADIC_SHIFT;
} else {
const magnitude = ANALOG_MIN_SHIFT + (rest % ANALOG_SHIFT_STEPS) * ANALOG_SHIFT_STEP;
hueShift = scheme === 0 ? -magnitude : magnitude;
}
const entry = PALETTE[paletteIndex];
return {
paletteIndex,
bg: entry.hex,
bg2: partnerHex(entry, hueShift),
hueShift,
angleDeg,
text: entry.text,
};
}
/** CSS background value: two colors with a slightly blurred boundary. */
export function avatarBackgroundCss(style: AvatarStyle): string {
const from = SPLIT_PERCENT - SPLIT_SOFTNESS / 2;
const to = SPLIT_PERCENT + SPLIT_SOFTNESS / 2;
return `linear-gradient(${style.angleDeg}deg, ${style.bg} ${from}%, ${style.bg2} ${to}%)`;
}
// ------------------------- Validation -------------------------
/**
* Min pairwise deltaE-OK (euclidean distance in OKLab) between base colors.
* Re-check after tweaking RINGS: keep it >= ~0.06 so no two palette colors
* look alike. Intended for a unit test or a dev-time assertion.
*/
export function minPairwiseDistance(): { distance: number; pair: [string, string] } {
let min = Infinity;
let pair: [string, string] = ["", ""];
for (let i = 0; i < PALETTE.length; i++) {
for (let j = i + 1; j < PALETTE.length; j++) {
const a = PALETTE[i].lab, b = PALETTE[j].lab;
const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
if (d < min) {
min = d;
pair = [PALETTE[i].hex, PALETTE[j].hex];
}
}
}
return { distance: min, pair };
}
+17
View File
@@ -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,
},
},
}, },
}); });
@@ -0,0 +1,60 @@
import { CollaborationGateway } from './collaboration.gateway';
import { CollaborationHandler } from './collaboration.handler';
/**
* Focused test for the COLLAB_DISABLE_REDIS fallback in handleYjsEvent.
*
* With Redis disabled the gateway builds no RedisSyncExtension, so the old code
* (`return this.redisSync?.handleEvent(...)`) returned undefined and every
* doc-mutation event silently no-opped. The fallback must instead invoke the
* handler locally against the single hocuspocus instance and return its verdict.
*
* We construct the gateway with stub extensions and an EnvironmentService whose
* isCollabDisableRedis() returns true (redisSync stays null, real hocuspocus is
* still built), then spy getHandlers so no real direct connection is opened.
*/
const stubExtension = {} as any;
function makeEnv() {
return {
getRedisUrl: () => 'redis://localhost:6379',
isCollabDisableRedis: () => true,
} as any;
}
describe('CollaborationGateway.handleYjsEvent (no-Redis fallback)', () => {
it('invokes the handler locally and returns its verdict instead of undefined', async () => {
const collabHandler = new CollaborationHandler();
const verdict = { applied: true, currentText: 'new' };
const fakeHandler = jest.fn().mockResolvedValue(verdict);
// Bypass the real direct-connection code path — assert dispatch only.
jest
.spyOn(collabHandler, 'getHandlers')
.mockReturnValue({ applyCommentSuggestion: fakeHandler } as any);
const gateway = new CollaborationGateway(
stubExtension,
stubExtension,
stubExtension,
makeEnv(),
collabHandler,
);
const payload = {
commentId: 'c1',
expectedText: 'old',
newText: 'new',
user: { id: 'u1' } as any,
};
const result = await gateway.handleYjsEvent(
'applyCommentSuggestion' as any,
'doc-1',
payload as any,
);
expect(fakeHandler).toHaveBeenCalledWith('doc-1', payload);
expect(result).toEqual(verdict);
expect(result).not.toBeUndefined();
});
});
@@ -147,8 +147,41 @@ export class CollaborationGateway {
eventName: TName, eventName: TName,
documentName: string, documentName: string,
payload: Parameters<CollabEventHandlers[TName]>[1], payload: Parameters<CollabEventHandlers[TName]>[1],
) { ): ReturnType<CollabEventHandlers[TName]> {
return this.redisSync?.handleEvent(eventName, documentName, payload); if (this.redisSync) {
// Normal path: the Redis bridge routes the event to the instance that owns
// the document (local or another worker) and carries the handler's return
// value back to us (customEventComplete + replyId).
return this.redisSync.handleEvent(
eventName,
documentName,
payload,
) as ReturnType<CollabEventHandlers[TName]>;
}
// COLLAB_DISABLE_REDIS: there is no cross-process bridge, so a single local
// hocuspocus instance owns every document. Invoke the handler directly
// against it instead of returning undefined — otherwise doc-mutation events
// (setCommentMark / resolveCommentMark / applyCommentSuggestion) would
// silently no-op and, for suggestions, the caller could never learn the
// verdict. openDirectConnection loads the doc via the persistence extension
// if it is not already in memory.
if (this.hocuspocus) {
const handlers = this.collabEventsService.getHandlers(this.hocuspocus);
const handler = handlers[eventName] as (
documentName: string,
payload: unknown,
) => ReturnType<CollabEventHandlers[TName]>;
return handler(documentName, payload);
}
// Collaboration was never initialized (no live instance). Fail loudly rather
// than silently dropping a mutation; phase 4's caller maps this to a 5xx.
throw new Error(
`Cannot handle collaboration event "${String(
eventName,
)}": requires a live collaboration instance`,
);
} }
openDirectConnection(documentName: string, context?: any) { openDirectConnection(documentName: string, context?: any) {
@@ -0,0 +1,188 @@
import * as Y from 'yjs';
import { CollaborationHandler } from './collaboration.handler';
import * as yjsUtil from './yjs.util';
import { User } from '@docmost/db/types/entity.types';
/**
* Unit tests for the `applyCommentSuggestion` collab handler (phase 3 of #315).
*
* The handler runs `replaceYjsMarkedText` inside the owning instance's Y
* transaction and returns the verdict to the caller. We exercise it against a
* REAL in-memory Y.Doc carrying a marked comment run, driven through a FAKE
* hocuspocus whose openDirectConnection().transact(fn) simply runs fn(doc) —
* mirroring how the real hocuspocus DirectConnection invokes the callback with
* the shared document (it does not forward the callback's return value, which is
* exactly why withYdocConnection captures it via a closure).
*/
// Build a Y.Doc with a single paragraph whose text carries a `comment` mark for
// the given commentId — the shape `replaceYjsMarkedText` walks in production.
function buildDocWithComment(text: string, commentId: string) {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment('default');
const paragraph = new Y.XmlElement('paragraph');
const xmlText = new Y.XmlText();
xmlText.insert(0, text);
xmlText.format(0, text.length, { comment: { commentId, resolved: false } });
paragraph.insert(0, [xmlText]);
fragment.insert(0, [paragraph]);
return doc;
}
// Fake hocuspocus exposing only what withYdocConnection needs: a direct
// connection whose transact() runs the callback against `doc`.
function fakeHocuspocus(doc: Y.Doc) {
const connection = {
transact: jest.fn(async (fn: (d: Y.Doc) => void) => {
fn(doc);
}),
disconnect: jest.fn(async () => {}),
};
const hocuspocus = {
openDirectConnection: jest.fn(async () => connection),
} as any;
return { hocuspocus, connection };
}
const user = { id: 'u1' } as unknown as User;
describe('CollaborationHandler.applyCommentSuggestion', () => {
it('applies the replacement and returns the verdict when the marked text matches', async () => {
const doc = buildDocWithComment('Hello world', 'c1');
const { hocuspocus, connection } = fakeHocuspocus(doc);
const handler = new CollaborationHandler();
const handlers = handler.getHandlers(hocuspocus);
const result = await handlers.applyCommentSuggestion('doc-1', {
commentId: 'c1',
expectedText: 'Hello world',
newText: 'Goodbye world',
user,
});
expect(result).toEqual({ applied: true, currentText: 'Goodbye world' });
// The mutation ran inside the transaction and hit the real doc.
expect(connection.transact).toHaveBeenCalledTimes(1);
expect(connection.disconnect).toHaveBeenCalledTimes(1);
expect(doc.getXmlFragment('default').toString()).toContain(
'Goodbye world',
);
});
it('rejects (applied=false) and returns the current text when it changed', async () => {
const doc = buildDocWithComment('Hello world', 'c1');
const { hocuspocus } = fakeHocuspocus(doc);
const handler = new CollaborationHandler();
const handlers = handler.getHandlers(hocuspocus);
const result = await handlers.applyCommentSuggestion('doc-1', {
commentId: 'c1',
expectedText: 'Stale expected text',
newText: 'Goodbye world',
user,
});
expect(result).toEqual({ applied: false, currentText: 'Hello world' });
// Nothing was replaced.
expect(doc.getXmlFragment('default').toString()).toContain(
'Hello world',
);
});
it('forwards the exact args to replaceYjsMarkedText and returns its result', async () => {
const doc = buildDocWithComment('abc', 'c9');
const { hocuspocus } = fakeHocuspocus(doc);
const spy = jest
.spyOn(yjsUtil, 'replaceYjsMarkedText')
.mockReturnValue({ applied: true, currentText: 'xyz' });
const handler = new CollaborationHandler();
const handlers = handler.getHandlers(hocuspocus);
const result = await handlers.applyCommentSuggestion('doc-1', {
commentId: 'c9',
expectedText: 'abc',
newText: 'xyz',
user,
});
expect(spy).toHaveBeenCalledWith(
doc.getXmlFragment('default'),
'c9',
'abc',
'xyz',
);
expect(result).toEqual({ applied: true, currentText: 'xyz' });
spy.mockRestore();
});
it('withYdocConnection returns the callback result (transact does not forward it)', async () => {
const doc = new Y.Doc();
const { hocuspocus } = fakeHocuspocus(doc);
const handler = new CollaborationHandler();
const value = await handler.withYdocConnection(
hocuspocus,
'doc-1',
{},
() => 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 } },
},
]);
});
});
@@ -5,7 +5,13 @@ import {
prosemirrorNodeToYElement, prosemirrorNodeToYElement,
tiptapExtensions, tiptapExtensions,
} from './collaboration.util'; } from './collaboration.util';
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util'; import {
removeYjsMarkByAttribute,
replaceYjsMarkedText,
setYjsMark,
updateYjsMarkAttribute,
YjsSelection,
} from './yjs.util';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types'; import { User } from '@docmost/db/types/entity.types';
@@ -73,6 +79,69 @@ 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 (
documentName: string,
payload: {
commentId: string;
expectedText: string;
newText: string;
user: User;
},
): Promise<{ applied: boolean; currentText: string | null }> => {
const { commentId, expectedText, newText, user } = payload;
// Run the check-and-replace inside the owning instance's Y transaction so
// the delete+insert are atomic. The verdict from replaceYjsMarkedText is
// returned to the API-server caller (cross-process via the Redis bridge,
// or locally when Redis is disabled — see collaboration.gateway.ts).
return this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
return replaceYjsMarkedText(
fragment,
commentId,
expectedText,
newText,
);
},
);
},
updatePageContent: async ( updatePageContent: async (
documentName: string, documentName: string,
payload: { payload: {
@@ -115,18 +184,28 @@ export class CollaborationHandler {
}; };
} }
async withYdocConnection( async withYdocConnection<T>(
hocuspocus: Hocuspocus, hocuspocus: Hocuspocus,
documentName: string, documentName: string,
context: any = {}, context: any = {},
fn: (doc: Document) => void, // `fn` MUST be synchronous: hocuspocus `connection.transact(fn)` runs fn
): Promise<void> { // synchronously and does NOT await it, so any mutations after an `await`
// inside fn would execute OUTSIDE the Yjs transaction and lose atomicity.
fn: (doc: Document) => T,
): Promise<T> {
const connection = await hocuspocus.openDirectConnection( const connection = await hocuspocus.openDirectConnection(
documentName, documentName,
context, context,
); );
try { try {
await connection.transact(fn); // hocuspocus `connection.transact(fn)` invokes fn(document) but does NOT
// forward fn's return value, so we capture it in a closure and return it
// after the transaction (and its storeDocument hooks) resolve.
let result: T;
await connection.transact((doc) => {
result = fn(doc);
});
return result!;
} finally { } finally {
await connection.disconnect(); await connection.disconnect();
} }
@@ -10,6 +10,7 @@ import {
setYjsMark, setYjsMark,
removeYjsMarkByAttribute, removeYjsMarkByAttribute,
updateYjsMarkAttribute, updateYjsMarkAttribute,
replaceYjsMarkedText,
type YjsSelection, type YjsSelection,
} from './yjs.util'; } from './yjs.util';
@@ -276,3 +277,256 @@ describe('updateYjsMarkAttribute', () => {
expect(text.toDelta()).toEqual(before); expect(text.toDelta()).toEqual(before);
}); });
}); });
describe('replaceYjsMarkedText', () => {
// Build a single-paragraph XmlText from runs. Insert the whole string as
// plain text FIRST, then format only the marked ranges — otherwise text
// inserted right after a marked run inherits its comment mark (Yjs carries
// formatting from the left insertion boundary).
function buildRuns(
runs: Array<{
text: string;
comment?: { commentId: string; resolved: boolean };
}>,
): { fragment: Y.XmlFragment; text: Y.XmlText } {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, runs.map((r) => r.text).join(''));
let offset = 0;
for (const run of runs) {
if (run.comment) {
text.format(offset, run.text.length, { comment: run.comment });
}
offset += run.text.length;
}
return { fragment, text };
}
// Two paragraphs, each with its own XmlText, both marked with the same
// commentId — mirrors a suggestion anchor that got split across blocks.
function buildTwoParagraphs(
a: { text: string; comment?: { commentId: string; resolved: boolean } },
b: { text: string; comment?: { commentId: string; resolved: boolean } },
): { fragment: Y.XmlFragment; textA: Y.XmlText; textB: Y.XmlText } {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const build = (seg: typeof a) => {
const para = new Y.XmlElement('paragraph');
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, seg.text);
if (seg.comment) {
text.format(0, seg.text.length, { comment: seg.comment });
}
return { para, text };
};
const pa = build(a);
const pb = build(b);
fragment.insert(0, [pa.para, pb.para]);
return { fragment, textA: pa.text, textB: pb.text };
}
it('happy path: replaces marked text with newText and keeps the comment mark', () => {
const { fragment, text } = buildRuns([
{ text: 'Hello ' },
{ text: 'world', comment: { commentId: 'c1', resolved: false } },
{ text: '!' },
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'world', 'planet');
expect(result).toEqual({ applied: true, currentText: 'planet' });
// New text carries the SAME comment mark; surrounding text is untouched.
expect(text.toDelta()).toEqual([
{ insert: 'Hello ' },
{
insert: 'planet',
attributes: { comment: { commentId: 'c1', resolved: false } },
},
{ insert: '!' },
]);
});
it('matches by commentId even when the mark is resolved', () => {
const { fragment, text } = buildWithComments([
{ text: 'foo', comment: { commentId: 'c9', resolved: true } },
]);
const result = replaceYjsMarkedText(fragment, 'c9', 'foo', 'bar');
expect(result).toEqual({ applied: true, currentText: 'bar' });
expect(text.toDelta()).toEqual([
{
insert: 'bar',
attributes: { comment: { commentId: 'c9', resolved: true } },
},
]);
});
it('changed text: marked text differs from expected → no-op, doc unchanged', () => {
const { fragment, text } = buildWithComments([
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
]);
const before = text.toDelta();
const result = replaceYjsMarkedText(fragment, 'c1', 'expected', 'new');
expect(result).toEqual({ applied: false, currentText: 'abc' });
expect(text.toDelta()).toEqual(before);
});
// F1 regression: the marked doc text is TYPOGRAPHIC (smart quotes / em-dash)
// and expectedText equals that raw typographic text — as it now does, because
// the MCP client stores the RAW anchored substring (getAnchoredText) rather
// than the agent's ASCII input. The strict `joinedText !== expectedText`
// compare must therefore MATCH and the suggestion apply (not a spurious 409).
it('typographic marked text applies when expectedText is the raw typographic text', () => {
const marked = '“hello”—world';
const { fragment, text } = buildRuns([
{ text: 'say ' },
{ text: marked, comment: { commentId: 'c1', resolved: false } },
{ text: '!' },
]);
const result = replaceYjsMarkedText(fragment, 'c1', marked, 'bye');
expect(result).toEqual({ applied: true, currentText: 'bye' });
expect(text.toDelta()).toEqual([
{ insert: 'say ' },
{
insert: 'bye',
attributes: { comment: { commentId: 'c1', resolved: false } },
},
{ insert: '!' },
]);
});
it('anchor deleted: no mark with that commentId → { applied: false, currentText: null }', () => {
const { fragment, text } = buildWithComments([
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
]);
const before = text.toDelta();
const result = replaceYjsMarkedText(fragment, 'missing', 'abc', 'new');
expect(result).toEqual({ applied: false, currentText: null });
expect(text.toDelta()).toEqual(before);
});
it('paragraph split: same commentId in two XmlText nodes → no-op, doc unchanged', () => {
const { fragment, textA, textB } = buildTwoParagraphs(
{ text: 'Hello ', comment: { commentId: 'c1', resolved: false } },
{ text: 'world', comment: { commentId: 'c1', resolved: false } },
);
const beforeA = textA.toDelta();
const beforeB = textB.toDelta();
const result = replaceYjsMarkedText(fragment, 'c1', 'Hello world', 'new');
expect(result).toEqual({ applied: false, currentText: 'Hello world' });
expect(textA.toDelta()).toEqual(beforeA);
expect(textB.toDelta()).toEqual(beforeB);
});
it('interleaved unmarked text: marked run not contiguous → no-op, doc unchanged', () => {
const { fragment, text } = buildRuns([
{ text: 'abc', comment: { commentId: 'c1', resolved: false } },
{ text: 'X' },
{ text: 'def', comment: { commentId: 'c1', resolved: false } },
]);
const before = text.toDelta();
const result = replaceYjsMarkedText(fragment, 'c1', 'abcdef', 'new');
// Joined marked text ("abcdef") is returned, but the run is not contiguous.
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
expect(text.toDelta()).toEqual(before);
});
it('preserves surrounding text and merges adjacent marked segments on apply', () => {
// The marked run itself is split into two adjacent delta segments; they must
// be treated as one contiguous run and replaced as a whole.
const { fragment, text } = buildRuns([
{ text: 'pre ' },
{ text: 'ab', comment: { commentId: 'c1', resolved: false } },
{ text: 'cd', comment: { commentId: 'c1', resolved: false } },
{ text: ' post' },
]);
const result = replaceYjsMarkedText(fragment, 'c1', 'abcd', 'Z');
expect(result).toEqual({ applied: true, currentText: 'Z' });
expect(text.toDelta()).toEqual([
{ insert: 'pre ' },
{
insert: 'Z',
attributes: { comment: { commentId: 'c1', resolved: false } },
},
{ insert: ' post' },
]);
});
it('embed before the marked run: offset accounts for the embed unit → replaces the right text, embed intact', () => {
// "AB", then a Yjs embed (1 index unit), then marked "world". Before the
// fix the embed was skipped WITHOUT advancing offset, so the computed start
// for "world" was too low by 1 → delete/insert would have hit the embed/text
// instead of "world", mangling the embed. With the fix offset is correct.
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, 'AB');
text.insertEmbed(2, { image: { src: 'x' } });
text.insert(3, 'world');
text.format(3, 'world'.length, {
comment: { commentId: 'c1', resolved: false },
});
const result = replaceYjsMarkedText(fragment, 'c1', 'world', 'planet');
expect(result).toEqual({ applied: true, currentText: 'planet' });
// "AB" untouched, embed still present and intact, "world" → "planet"
// carrying the SAME comment mark.
expect(text.toDelta()).toEqual([
{ insert: 'AB' },
{ insert: { image: { src: 'x' } } },
{
insert: 'planet',
attributes: { comment: { commentId: 'c1', resolved: false } },
},
]);
});
it('embed inside the marked run: embed splits the run → non-contiguous → no-op, doc unchanged', () => {
// marked "abc", an embed, marked "def" — same commentId. The embed occupies
// one index unit between the two marked segments, so they are not contiguous
// → the guard rejects it and nothing is mutated (embed intact).
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment('default');
const para = new Y.XmlElement('paragraph');
fragment.insert(0, [para]);
const text = new Y.XmlText();
para.insert(0, [text]);
text.insert(0, 'abc');
text.insertEmbed(3, { image: { src: 'y' } });
text.insert(4, 'def');
text.format(0, 'abc'.length, {
comment: { commentId: 'c1', resolved: false },
});
text.format(4, 'def'.length, {
comment: { commentId: 'c1', resolved: false },
});
const before = text.toDelta();
const result = replaceYjsMarkedText(fragment, 'c1', 'abcdef', 'new');
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
expect(text.toDelta()).toEqual(before);
});
});
+131
View File
@@ -133,6 +133,137 @@ export function removeYjsMarkByAttribute(
} }
} }
/**
* A single marked delta segment collected during the walk, together with the
* Y.XmlText node that owns it, the segment's start offset within that node,
* and the full `comment` mark attributes object (needed to re-attach the mark
* to the replacement text).
*/
type MarkedSegment = {
node: Y.XmlText;
offset: number;
length: number;
text: string;
markAttrs: Record<string, any>;
};
/**
* Atomically check-and-replace the text currently under a comment mark.
*
* Walks the fragment collecting every delta segment whose `comment` mark has the
* given commentId. The replacement is applied ONLY if the marked run is intact:
* it lives in a single Y.XmlText node, is contiguous (no unmarked text spliced
* into the middle), and its joined text still equals `expectedText`. On success
* the run is deleted and `newText` is inserted at the same offset carrying the
* SAME comment attributes, so the comment thread stays anchored to the new text.
*
* This mutates the passed fragment/text directly and does NOT open its own Y
* transaction — the caller is expected to wrap the call in connection.transact()
* so the delete+insert are atomic (mirrors updateYjsMarkAttribute's direct
* mutation style).
*
* @returns `{ applied: true, currentText: newText }` on replacement, otherwise
* `{ applied: false, currentText }` where currentText is the text currently
* under the mark (or null when the mark/anchor no longer exists).
*/
export function replaceYjsMarkedText(
fragment: Y.XmlFragment,
commentId: string,
expectedText: string,
newText: string,
): { applied: boolean; currentText: string | null } {
// 1. Collect every marked segment in document order.
const segments: MarkedSegment[] = [];
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const insert = delta.insert;
// Non-string inserts (embeds) carry no text length we can splice on.
if (typeof insert !== 'string') {
// A Yjs embed occupies one unit in the index space used by delete/
// insert/format — advance offset so a marked segment after an embed
// gets the right position (and an embed inside a marked run creates a
// gap → the contiguity guard rejects it as a changed anchor).
offset += 1;
continue;
}
const length = insert.length;
const attributes = delta.attributes ?? {};
const markAttr = attributes['comment'];
if (markAttr && markAttr.commentId === commentId) {
segments.push({
node: item,
offset,
length,
text: insert,
markAttrs: markAttr,
});
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
const joinedText = segments.map((s) => s.text).join('');
// 2a. No segments — the mark/anchor was deleted.
if (segments.length === 0) {
return { applied: false, currentText: null };
}
// 2b. Segments span more than one Y.XmlText node (paragraph split by Enter,
// or the mark bled across blocks) — treat as changed.
const node = segments[0].node;
const sameNode = segments.every((s) => s.node === node);
if (!sameNode) {
return { applied: false, currentText: joinedText };
}
// 2c. Non-contiguous within the single node: unmarked text is spliced between
// the first and last marked segment. Since collected segments are in document
// order, contiguity holds iff each segment starts where the previous ended.
let contiguous = true;
for (let i = 1; i < segments.length; i++) {
if (segments[i].offset !== segments[i - 1].offset + segments[i - 1].length) {
contiguous = false;
break;
}
}
if (!contiguous) {
return { applied: false, currentText: joinedText };
}
// 2d. The text under the mark changed.
if (joinedText !== expectedText) {
return { applied: false, currentText: joinedText };
}
// 3. All guards passed: delete the marked run and re-insert newText with the
// same comment attributes at the same offset. Atomic within the caller's
// transaction.
const start = segments[0].offset;
const len = segments.reduce((sum, s) => sum + s.length, 0);
const markAttrs = segments[0].markAttrs;
node.delete(start, len);
node.insert(start, newText, { comment: markAttrs });
return { applied: true, currentText: newText };
}
/** /**
* Updates a mark's attributes for all text that has the specified attribute value. * Updates a mark's attributes for all text that has the specified attribute value.
* Useful for resolving/unresolving comments by commentId. * Useful for resolving/unresolving comments by commentId.
@@ -51,6 +51,8 @@ export const AuditEvent = {
COMMENT_UPDATED: 'comment.updated', COMMENT_UPDATED: 'comment.updated',
COMMENT_RESOLVED: 'comment.resolved', COMMENT_RESOLVED: 'comment.resolved',
COMMENT_REOPENED: 'comment.reopened', COMMENT_REOPENED: 'comment.reopened',
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
COMMENT_SUGGESTION_DISMISSED: 'comment.suggestion_dismissed',
// Page // Page
PAGE_CREATED: 'page.created', PAGE_CREATED: 'page.created',
@@ -2,43 +2,91 @@ import { AiChatController } from './ai-chat.controller';
import type { User, Workspace } from '@docmost/db/types/entity.types'; import type { User, Workspace } from '@docmost/db/types/entity.types';
/** /**
* Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint. It must forward * Wiring spec for the #191 `POST /ai-chat/bound-chat` endpoint, hardened for
* the requesting user + workspace + pageId to findLatestByPage and return the * #312. `dto.pageId` carries either a page slugId (10-char nanoid, off a slug
* matched chat's id, or `{ chatId: null }` when there is none. The repo already * URL) or a page uuid, so the controller must FIRST resolve it to a real page
* scopes to the caller's OWN chats, so a foreign pageId simply yields no match * uuid via PageRepo.findById (which accepts both) — passing the raw slugId into
* (null) — no extra page-access check is needed. Exercised with hand-rolled * the uuid ai_chats.page_id column caused a Postgres 22P02 500. Only then is the
* mocks, no Nest graph and no DB. * caller's most-recent OWN chat for that page looked up (by the resolved uuid),
* and a page in a different workspace (or an unknown id) yields { chatId: null }
* without ever touching the chat lookup. Exercised with hand-rolled mocks, no
* Nest graph and no DB.
*/ */
describe('AiChatController.boundChat', () => { describe('AiChatController.boundChat', () => {
const user = { id: 'u1' } as User; const user = { id: 'u1' } as User;
const workspace = { id: 'ws1' } as Workspace; const workspace = { id: 'ws1' } as Workspace;
function makeController(chat: unknown) { function makeController(opts: { page: unknown; chat?: unknown }) {
const aiChatRepo = { const aiChatRepo = {
findLatestByPage: jest.fn().mockResolvedValue(chat), findLatestByPage: jest.fn().mockResolvedValue(opts.chat),
};
const pageRepo = {
findById: jest.fn().mockResolvedValue(opts.page),
}; };
const controller = new AiChatController( const controller = new AiChatController(
{} as never, {} as never,
aiChatRepo as never, aiChatRepo as never,
{} as never, {} as never,
{} as never, {} as never,
pageRepo as never,
); );
return { controller, aiChatRepo }; return { controller, aiChatRepo, pageRepo };
} }
it('returns the owned chat id and scopes the lookup to user + workspace + page', async () => { it('resolves a slugId to the page uuid and returns the owned chat id', async () => {
const { controller, aiChatRepo } = makeController({ const { controller, aiChatRepo, pageRepo } = makeController({
id: 'c1', // findById accepts a slugId and returns the page with its real uuid.
creatorId: 'u1', page: { id: 'page-uuid-1', workspaceId: 'ws1' },
chat: { id: 'c1', creatorId: 'u1' },
}); });
const res = await controller.boundChat({ pageId: 'p1' }, user, workspace); // The client sends a 10-char nanoid slugId, NOT a uuid.
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith('u1', 'ws1', 'p1'); const res = await controller.boundChat(
{ pageId: 'i82qXsivsx' },
user,
workspace,
);
expect(pageRepo.findById).toHaveBeenCalledWith('i82qXsivsx');
// findLatestByPage must receive the RESOLVED uuid, never the raw slugId.
expect(aiChatRepo.findLatestByPage).toHaveBeenCalledWith(
'u1',
'ws1',
'page-uuid-1',
);
expect(res).toEqual({ chatId: 'c1' }); expect(res).toEqual({ chatId: 'c1' });
}); });
it('returns { chatId: null } for a page with no owned chat (incl. foreign pageId)', async () => { it('returns { chatId: null } for a page in a DIFFERENT workspace without a chat lookup', async () => {
const { controller } = makeController(undefined); const { controller, aiChatRepo, pageRepo } = makeController({
const res = await controller.boundChat({ pageId: 'foreign' }, user, workspace); page: { id: 'page-uuid-2', workspaceId: 'other-ws' },
});
const res = await controller.boundChat(
{ pageId: 'foreignSlug' },
user,
workspace,
);
expect(pageRepo.findById).toHaveBeenCalledWith('foreignSlug');
// No cross-workspace leak: the chat lookup must never run.
expect(aiChatRepo.findLatestByPage).not.toHaveBeenCalled();
expect(res).toEqual({ chatId: null });
});
it('returns { chatId: null } for an unknown id without throwing or looking up a chat', async () => {
const { controller, aiChatRepo } = makeController({ page: undefined });
const res = await controller.boundChat(
{ pageId: 'does-not-exist' },
user,
workspace,
);
expect(aiChatRepo.findLatestByPage).not.toHaveBeenCalled();
expect(res).toEqual({ chatId: null });
});
it('returns { chatId: null } when the resolved page has no owned chat', async () => {
const { controller } = makeController({
page: { id: 'page-uuid-3', workspaceId: 'ws1' },
chat: undefined,
});
const res = await controller.boundChat({ pageId: 'p3' }, user, workspace);
expect(res).toEqual({ chatId: null }); expect(res).toEqual({ chatId: null });
}); });
}); });
@@ -56,6 +56,7 @@ describe('AiChatController.export', () => {
aiChatRepo as never, aiChatRepo as never,
aiChatMessageRepo as never, aiChatMessageRepo as never,
{} as never, {} as never,
{} as never,
); );
return { controller, aiChatRepo, aiChatMessageRepo }; return { controller, aiChatRepo, aiChatMessageRepo };
} }
@@ -24,6 +24,7 @@ import { AiChat, User, Workspace } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
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';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard'; import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names'; import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import { FileInterceptor } from '../../common/interceptors/file.interceptor';
@@ -55,6 +56,7 @@ export class AiChatController {
private readonly aiChatRepo: AiChatRepo, private readonly aiChatRepo: AiChatRepo,
private readonly aiChatMessageRepo: AiChatMessageRepo, private readonly aiChatMessageRepo: AiChatMessageRepo,
private readonly aiTranscription: AiTranscriptionService, private readonly aiTranscription: AiTranscriptionService,
private readonly pageRepo: PageRepo,
) {} ) {}
/** List the requesting user's chats in this workspace (paginated). */ /** List the requesting user's chats in this workspace (paginated). */
@@ -71,9 +73,15 @@ export class AiChatController {
/** /**
* Resolve the chat bound to a document for the requesting user: the most-recent * Resolve the chat bound to a document for the requesting user: the most-recent
* non-deleted chat created on that page (ai_chats.page_id). Returns * non-deleted chat created on that page (ai_chats.page_id). Returns
* { chatId: null } when the page has no owned chat (-> a fresh chat). No page * { chatId: null } when the page has no owned chat (-> a fresh chat).
* access check needed: only the caller's OWN chats are matched, so a foreign *
* pageId reveals nothing. * `dto.pageId` carries EITHER a page slugId (10-char nanoid, sent by the client
* off a slug URL) OR a page uuid, so it must be resolved to a real page uuid
* before it touches the uuid ai_chats.page_id column — passing a slugId straight
* through triggered a Postgres 22P02 "invalid input syntax for type uuid" 500
* (#312). PageRepo.findById accepts both forms. The workspace guard rejects an
* unknown or cross-workspace page (-> { chatId: null }) so a foreign id cannot
* probe another workspace's chats. Only the caller's OWN chats are then matched.
*/ */
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('bound-chat') @Post('bound-chat')
@@ -82,10 +90,14 @@ export class AiChatController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
): Promise<{ chatId: string | null }> { ): Promise<{ chatId: string | null }> {
const page = await this.pageRepo.findById(dto.pageId); // accepts slugId OR uuid
if (!page || page.workspaceId !== workspace.id) {
return { chatId: null }; // unknown or foreign-workspace page — no binding, no leak
}
const chat = await this.aiChatRepo.findLatestByPage( const chat = await this.aiChatRepo.findLatestByPage(
user.id, user.id,
workspace.id, workspace.id,
dto.pageId, page.id, // the real uuid, never the incoming slugId
); );
return { chatId: chat?.id ?? null }; return { chatId: chat?.id ?? null };
} }
@@ -60,6 +60,7 @@ describe('AiChatController.generatePageTitle', () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
); );
return { controller, aiChatService }; return { controller, aiChatService };
} }
@@ -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 —');
});
});
+68 -1
View File
@@ -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
@@ -27,7 +28,11 @@ const SAFETY_FRAMEWORK = [
'- You can read pages, comments and page history, and modify the workspace:', '- You can read pages, comments and page history, and modify the workspace:',
' create/rename/move pages and make structural edits (text, nodes, tables);', ' create/rename/move pages and make structural edits (text, nodes, tables);',
' manage page history (diff/restore); copy, import and export content; and', ' manage page history (diff/restore); copy, import and export content; and',
' create/resolve comments. Page edits are REVERSIBLE — they keep page', ' create/resolve comments. An inline comment can carry a suggestedText — a',
' proposed replacement for its selected text that the user applies with one',
' click; when you propose a concrete rewording of a specific fragment,',
' attach it as suggestedText instead of only describing the change. Page',
' edits are REVERSIBLE — they keep page',
' history and a trashed page can be restored. One exception to keep in mind:', ' history and a trashed page can be restored. One exception to keep in mind:',
' sharing a page makes it PUBLICLY accessible — do that only when the user', ' sharing a page makes it PUBLICLY accessible — do that only when the user',
' asked.', ' asked.',
@@ -179,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');
} }
/** /**
@@ -225,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.
@@ -298,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
@@ -312,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
@@ -518,6 +518,20 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
}); });
}); });
it('createComment: accepts an optional suggestedText alongside a selection', async () => {
const tools = await buildTools();
const result = await inputSchemaOf(tools.createComment).validate({
pageId: '019efe44-0000-0000-0000-000000000000',
content: 'A remark',
selection: 'титановый проводник',
suggestedText: 'медный проводник',
});
expect(result.success).toBe(true);
expect(result.value).toMatchObject({
suggestedText: 'медный проводник',
});
});
it('sharedTool-built tools (getOutline) also get the friendly message on a dropped pageId', async () => { it('sharedTool-built tools (getOutline) also get the friendly message on a dropped pageId', async () => {
const tools = await buildTools(); const tools = await buildTools();
const result = await inputSchemaOf(tools.getOutline).validate({}); const result = await inputSchemaOf(tools.getOutline).validate({});
@@ -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,
@@ -303,7 +319,9 @@ export class AiChatToolsService {
getPage: tool({ getPage: tool({
description: description:
'Fetch a single page as Markdown by its page id. Returns the page ' + 'Fetch a single page as Markdown by its page id. Returns the page ' +
'title and its Markdown content.', 'title and its Markdown content. 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: modelFriendlyInput({ inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id (or slugId) of the page.'), pageId: z.string().describe('The id (or slugId) of the page.'),
}), }),
@@ -450,8 +468,10 @@ export class AiChatToolsService {
"new top-level comment REQUIRES a `selection`. Replies inherit the " + "new top-level comment REQUIRES a `selection`. Replies inherit the " +
"parent's anchor and take no selection. If the call fails with a " + "parent's anchor and take no selection. If the call fails with a " +
'"selection not found" error, retry with a corrected EXACT selection ' + '"selection not found" error, retry with a corrected EXACT selection ' +
'copied verbatim from a single paragraph/block. Reversible via the ' + 'copied verbatim from a single paragraph/block. You may also attach a ' +
'comment UI.', '`suggestedText` proposing a replacement for the `selection` (a human ' +
'applies it from the UI); when set, the `selection` must occur exactly ' +
'once in the page. Reversible via the comment UI.',
inputSchema: modelFriendlyInput({ inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to comment on.'), pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().describe('The comment body as Markdown.'), content: z.string().describe('The comment body as Markdown.'),
@@ -473,24 +493,57 @@ export class AiChatToolsService {
'Optional id of a TOP-LEVEL comment to reply to (one level ' + 'Optional id of a TOP-LEVEL comment to reply to (one level ' +
'of replies only).', 'of replies only).',
), ),
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.',
),
}), }),
execute: async ({ pageId, content, selection, parentCommentId }) => { execute: async ({
// createComment(pageId, content, type, selection?, parentCommentId?). pageId,
// Top-level comments are inline and must carry a selection to anchor content,
// on; replies inherit the parent's anchor (no selection). Throwing selection,
// here surfaces a tool error to the model (Vercel `ai` SDK) so the parentCommentId,
// agent retries with a better selection — do not catch/suppress it. suggestedText,
}) => {
// createComment(pageId, content, type, selection?, parentCommentId?,
// suggestedText?). Top-level comments are inline and must carry a
// selection to anchor on; replies inherit the parent's anchor (no
// selection). Throwing here surfaces a tool error to the model (Vercel
// `ai` SDK) so the agent retries with a better selection — do not
// catch/suppress it.
if (!parentCommentId && (!selection || !selection.trim())) { if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error( throw new Error(
"createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.", "createComment requires a 'selection' (exact text to anchor on) for a new top-level comment.",
); );
} }
if (suggestedText !== undefined) {
if (parentCommentId) {
throw new Error(
"createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
);
}
if (!selection || !selection.trim()) {
throw new Error(
"createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
);
}
}
const result = await client.createComment( const result = await client.createComment(
pageId, pageId,
content, content,
'inline', 'inline',
selection, selection,
parentCommentId, parentCommentId,
suggestedText,
); );
const data = (result?.data ?? {}) as { id?: string }; const data = (result?.data ?? {}) as { id?: string };
return { commentId: data.id, pageId }; return { commentId: data.id, pageId };
@@ -593,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 ' +
@@ -612,11 +675,21 @@ export class AiChatToolsService {
listComments: tool({ listComments: tool({
description: description:
'List all comments on a page (content as Markdown).', 'List comments on a page in one call. By DEFAULT only ACTIVE ' +
'threads are returned; resolved threads (a resolved top-level ' +
'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 }>;
@@ -177,6 +187,7 @@ export interface DocmostClientLike {
type?: 'page' | 'inline', type?: 'page' | 'inline',
selection?: string, selection?: string,
parentCommentId?: string, parentCommentId?: string,
suggestedText?: string,
): Promise<{ data: Record<string, unknown>; success: boolean }>; ): Promise<{ data: Record<string, unknown>; success: boolean }>;
resolveComment( resolveComment(
commentId: string, commentId: string,
@@ -230,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),
});
}
@@ -0,0 +1,324 @@
import {
BadRequestException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { CommentController } from './comment.controller';
/**
* Authz-gate tests for the apply-suggestion route. Applying a suggestion
* rewrites the page text, so the route MUST call
* pageAccessService.validateCanEdit BEFORE handing off to
* commentService.applySuggestion (which performs the document mutation + stamp).
* That ordering is a security boundary: an unauthorized user must never reach
* the mutation. These tests pin it against a fully mocked controller so any
* regression that drops the gate (or reorders it after the mutation) fails here.
*/
describe('CommentController apply-suggestion authz', () => {
function makeController() {
const commentService = {
applySuggestion: jest.fn(async () => ({ id: 'c-1', applied: true })),
};
const commentRepo = { findById: jest.fn() };
const pageRepo = { findById: jest.fn() };
const spaceAbility = {} as any;
const pageAccessService = {
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,
};
}
const user: any = { id: 'u-1' };
const workspace: any = { id: 'ws-1' };
const provenance: any = undefined;
const dto: any = { commentId: 'c-1' };
const comment = {
id: 'c-1',
pageId: 'p-1',
spaceId: 'sp-1',
suggestedText: 'new text',
selection: 'old text',
};
const page = { id: 'p-1', spaceId: 'sp-1', deletedAt: null };
it('validateCanEdit throwing Forbidden rejects AND applySuggestion is never called', async () => {
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
makeController();
commentRepo.findById.mockResolvedValue(comment);
pageRepo.findById.mockResolvedValue(page);
pageAccessService.validateCanEdit.mockRejectedValue(
new ForbiddenException('no edit access'),
);
await expect(
controller.applySuggestion(dto, user, workspace, provenance),
).rejects.toBeInstanceOf(ForbiddenException);
// The security boundary: the mutation/stamp must NOT run for an
// unauthorized user.
expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(page, user);
expect(commentService.applySuggestion).not.toHaveBeenCalled();
});
it('happy path: validateCanEdit resolves → applySuggestion is called and its result returned', async () => {
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
makeController();
commentRepo.findById.mockResolvedValue(comment);
pageRepo.findById.mockResolvedValue(page);
const applied = { id: 'c-1', applied: true };
commentService.applySuggestion.mockResolvedValue(applied);
const result = await controller.applySuggestion(
dto,
user,
workspace,
provenance,
);
// Authorization ran before the mutation, then the service was invoked.
expect(pageAccessService.validateCanEdit).toHaveBeenCalledWith(page, user);
expect(commentService.applySuggestion).toHaveBeenCalledWith(
comment,
user,
provenance,
);
expect(result).toBe(applied);
});
it('missing comment: NotFound is thrown without authorizing or applying', async () => {
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
makeController();
commentRepo.findById.mockResolvedValue(null);
await expect(
controller.applySuggestion(dto, user, workspace, provenance),
).rejects.toBeInstanceOf(NotFoundException);
expect(pageRepo.findById).not.toHaveBeenCalled();
expect(pageAccessService.validateCanEdit).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();
});
});
@@ -14,6 +14,8 @@ import { CommentService } from './comment.service';
import { CreateCommentDto } from './dto/create-comment.dto'; 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 { 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';
@@ -197,6 +199,95 @@ export class CommentController {
return updated; return updated;
} }
@HttpCode(HttpStatus.OK)
@Post('apply-suggestion')
async applySuggestion(
@Body() dto: ApplySuggestionDto,
@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 about the comment
// (metadata-disclosure hygiene). Applying a suggestion rewrites the page
// text, so require edit access (NOT just comment access). Running this
// first means a cross-workspace user with a guessed comment UUID gets a
// uniform 403 regardless of the comment's type or suggestion state — it can
// never distinguish those before the access check. The structural 400s
// (top-level / has-a-suggested-edit) are re-checked by the service below.
await this.pageAccessService.validateCanEdit(page, user);
// The service re-validates the comment's state, returns idempotent success
// for an already-applied suggestion, and lets ConflictException (409, with
// currentText in the payload) propagate untouched.
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) {
@@ -0,0 +1,312 @@
import {
BadRequestException,
ConflictException,
InternalServerErrorException,
} from '@nestjs/common';
import { CommentService } from './comment.service';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
/**
* Focused coverage for CommentService.applySuggestion (comment.service.ts).
* The service is constructed directly with jest-mocked deps (the @InjectQueue
* tokens can't be resolved by Test.createTestingModule see the sibling specs).
*
* The collaboration gateway verdict is the pivot of the whole flow, so each test
* pins a specific { applied, currentText } and asserts the DB persistence,
* 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', () => {
const UPDATED = { id: 'c-1', __updated: true } as any;
function makeService(verdict: unknown, hasChildren = false, deletedRows = 1) {
const commentRepo: any = {
// Both the applied-stamp re-read and resolveComment's re-read go through
// findById; return a recognizable enriched row.
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 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 () => verdict),
};
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 });
// Pull the updateComment patch that carries the applied stamps.
const appliedPatch = (commentRepo: any) =>
commentRepo.updateComment.mock.calls
.map((c: any[]) => c[0])
.find((patch: any) => 'suggestionAppliedAt' in patch);
// --- 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 } =
makeService({ applied: true, currentText: 'new text' });
const result = await service.applySuggestion(suggestionComment(), user());
// The atomic replace was requested against the exact marked text.
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
'applyCommentSuggestion',
'page.page-1',
expect.objectContaining({
commentId: 'c-1',
expectedText: 'old text',
newText: 'new text',
user: expect.objectContaining({ id: 'user-1' }),
}),
);
// Ephemeral: the redundant comment is hard-deleted (atomic-conditional) and
// its inline anchor mark removed via the deleteCommentMark collab event.
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-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();
// Broadcast a deletion, audit the (still-applied) suggestion, report outcome.
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_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
}),
);
expect(result.outcome).toBe('deleted');
});
it('applied=false but currentText === suggestedText, no replies → idempotent delete (no 409)', async () => {
const { service, commentRepo, auditService } = makeService({
applied: false,
currentText: 'new text',
});
const result = await service.applySuggestion(suggestionComment(), user());
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
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);
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
expect(patch.suggestionAppliedById).toBe('user-1');
// 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 () => {
const { service, commentRepo, auditService } = makeService({
applied: false,
currentText: 'someone else edited this',
});
const err = await service
.applySuggestion(suggestionComment(), user())
.catch((e) => e);
expect(err).toBeInstanceOf(ConflictException);
expect(err.getResponse()).toMatchObject({
currentText: 'someone else edited this',
});
// No delete and no audit on a conflict.
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
expect(auditService.log).not.toHaveBeenCalled();
});
it('already-applied WITH replies → idempotent success, no re-apply, resolve branch', async () => {
const { service, collaborationGateway, commentRepo, auditService } =
makeService({ applied: true, currentText: 'new text' }, true);
const result = await service.applySuggestion(
suggestionComment({
suggestionAppliedAt: new Date(),
resolvedAt: new Date(),
resolvedById: 'user-1',
}),
user(),
);
// Idempotent SUCCESS. The suggestion is already applied, so the document is
// never re-mutated (no applyCommentSuggestion) and nothing is re-stamped.
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
'applyCommentSuggestion',
expect.anything(),
expect.anything(),
);
expect(appliedPatch(commentRepo)).toBeUndefined();
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
expect(auditService.log).toHaveBeenCalledTimes(1);
expect(result.outcome).toBe('resolved');
});
it('already-applied, no replies (double-click after a delete) → deletes idempotently', async () => {
const { service, collaborationGateway, commentRepo } = makeService({
applied: true,
currentText: 'new text',
});
const result = await service.applySuggestion(
suggestionComment({ suggestionAppliedAt: new Date(), resolvedAt: null }),
user(),
);
// No re-apply to the document; the childless applied comment is removed.
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
'applyCommentSuggestion',
expect.anything(),
expect.anything(),
);
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
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
.map((c: any[]) => c[0])
.find((p: any) => 'resolvedAt' in p);
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
'resolveCommentMark',
'page.page-1',
expect.objectContaining({ commentId: 'c-1', resolved: true }),
);
expect(result.outcome).toBe('resolved');
});
it('rejects a comment with no suggestedText', async () => {
const { service, collaborationGateway } = makeService({
applied: true,
currentText: 'x',
});
await expect(
service.applySuggestion(
suggestionComment({ suggestedText: null }),
user(),
),
).rejects.toThrow(BadRequestException);
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
});
it('gateway returning undefined → hard error, not a silent success', async () => {
const { service, commentRepo, auditService } = makeService(undefined);
await expect(
service.applySuggestion(suggestionComment(), user()),
).rejects.toThrow(InternalServerErrorException);
// Nothing deleted, nothing audited.
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
expect(auditService.log).not.toHaveBeenCalled();
});
});
@@ -60,6 +60,7 @@ describe('CommentService — behavior', () => {
}; };
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) }; const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
const notificationQueue: any = { add: jest.fn(async () => undefined) }; const notificationQueue: any = { add: jest.fn(async () => undefined) };
const auditService: any = { log: jest.fn() };
const service = new CommentService( const service = new CommentService(
commentRepo, commentRepo,
@@ -68,14 +69,17 @@ describe('CommentService — behavior', () => {
collaborationGateway, collaborationGateway,
generalQueue, generalQueue,
notificationQueue, notificationQueue,
auditService,
); );
return { return {
service, service,
commentRepo, commentRepo,
wsService, wsService,
collaborationGateway,
generalQueue, generalQueue,
notificationQueue, notificationQueue,
auditService,
}; };
} }
@@ -181,6 +185,95 @@ describe('CommentService — behavior', () => {
}); });
}); });
describe('create — suggested edit validation & storage', () => {
it('rejects a suggestedText on a reply (not a top-level comment)', async () => {
const parentComment = {
id: 'parent-1',
pageId: 'page-1',
parentCommentId: null,
};
const { service, commentRepo } = makeService({ parentComment });
await expect(
service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{
content: JSON.stringify(docMentioning()),
parentCommentId: 'parent-1',
selection: 'hello world',
suggestedText: 'goodbye world',
} as any,
),
).rejects.toThrow(BadRequestException);
expect(commentRepo.insertComment).not.toHaveBeenCalled();
});
it('rejects a suggestedText without a selection', async () => {
const { service, commentRepo } = makeService();
await expect(
service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{
content: JSON.stringify(docMentioning()),
suggestedText: 'new text',
} as any,
),
).rejects.toThrow(BadRequestException);
expect(commentRepo.insertComment).not.toHaveBeenCalled();
});
it('rejects a suggestedText identical to the selection (no-op)', async () => {
const { service, commentRepo } = makeService();
await expect(
service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{
content: JSON.stringify(docMentioning()),
selection: 'same text',
// Only differs by surrounding whitespace → still a no-op after trim.
suggestedText: ' same text ',
} as any,
),
).rejects.toThrow(BadRequestException);
expect(commentRepo.insertComment).not.toHaveBeenCalled();
});
it('stores a valid suggestedText (trimmed) on the inserted row', async () => {
const { service, commentRepo } = makeService();
await service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{
content: JSON.stringify(docMentioning()),
selection: 'old text',
type: 'inline',
suggestedText: ' new text ',
} as any,
);
const insertArg = commentRepo.insertComment.mock.calls[0][0];
expect(insertArg.suggestedText).toBe('new text');
expect(insertArg.selection).toBe('old text');
});
it('leaves suggestedText null for an ordinary comment', async () => {
const { service, commentRepo } = makeService();
await service.create(
{ page: page(), workspaceId: 'ws-1', user: user() },
{ content: JSON.stringify(docMentioning()) } as any,
);
const insertArg = commentRepo.insertComment.mock.calls[0][0];
expect(insertArg.suggestedText).toBeNull();
});
});
describe('resolveComment — provenance & resolve notifications', () => { describe('resolveComment — provenance & resolve notifications', () => {
it('stamps resolvedSource:"agent" when an agent resolves', async () => { it('stamps resolvedSource:"agent" when an agent resolves', async () => {
const { service, commentRepo } = makeService(); const { service, commentRepo } = makeService();
@@ -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();
});
});
@@ -0,0 +1,240 @@
import { CommentService } from './comment.service';
/**
* Caller-contract coverage for the three live comment broadcasts (#300/#304):
* - commentCreated (create @153)
* - commentUpdated (update @214) the fragile path this suite spotlights
* - commentResolved (resolveComment @283)
*
* All three must emit a payload carrying the {agent,launcher} avatar stack for an
* AGENT comment, and NEITHER field for a non-agent comment. The enrichment lives
* in CommentRepo.findById(..., {includeCreator:true}); the service contract these
* tests pin is that every broadcast reads its payload from that enriched
* single-row load rather than from an un-enriched object.
*
* NON-VACUITY for the update path: the service is handed an UN-enriched input
* comment (no agent/launcher), while findById returns the ENRICHED shape. The
* pre-#304 update() re-emitted the caller's object in place, so it would emit the
* un-enriched input and the `agent`/`launcher` assertions would FAIL. The fix
* re-fetches via findById, so the broadcast carries the stack regardless of how
* the caller pre-loaded the comment.
*/
describe('CommentService — broadcast carries the agent avatar stack', () => {
// An enriched agent comment as CommentRepo.findById(..., includeCreator:true)
// returns it: the {agent,launcher} pair is attached and agentRole is stripped.
const enrichedAgentComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'agent',
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
...over,
});
// A plain human comment: findById attaches neither agent nor launcher.
const plainHumanComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'user',
...over,
});
function makeService(findByIdReturn: unknown) {
const commentRepo: any = {
// In these flows findById is only the post-write enriched re-read
// (no parentCommentId is set, so no parent lookup path is taken).
findById: jest.fn(async () => findByIdReturn),
insertComment: jest.fn(async () => ({ id: 'comment-new' })),
updateComment: jest.fn(async () => undefined),
};
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 };
}
// Pull the emitted event object (3rd arg of emitCommentEvent) for an operation.
const emittedEvent = (wsService: any, operation: string) =>
wsService.emitCommentEvent.mock.calls
.map((c: any[]) => c[2])
.find((e: any) => e.operation === operation);
const page = { id: 'page-1', spaceId: 'space-1' } as any;
const user = (id = 'user-1') => ({ id }) as any;
const emptyDoc = JSON.stringify({ type: 'doc', content: [] });
describe('commentCreated', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentUpdated — the fragile path (spotlight)', () => {
it('emits agent + launcher even when the caller pre-loaded an UN-enriched comment', async () => {
// findById (the re-fetch) returns the enriched shape...
const { service, wsService, commentRepo } = makeService(
enrichedAgentComment(),
);
// ...but the caller hands in an object with NO agent/launcher. The pre-#304
// update() re-emitted THIS object in place, so this test fails against it;
// the re-fetch fix makes the broadcast independent of the pre-load.
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
// deliberately no `agent` / `launcher`
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
// The broadcast must re-read the enriched row (persisted update, then load).
expect(commentRepo.updateComment).toHaveBeenCalled();
expect(commentRepo.findById).toHaveBeenCalledWith('comment-new', {
includeCreator: true,
includeResolvedBy: true,
});
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentResolved', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
});
@@ -14,6 +14,7 @@ describe('CommentService', () => {
{} as any, // collaborationGateway {} as any, // collaborationGateway
{} as any, // generalQueue {} as any, // generalQueue
{} as any, // notificationQueue {} as any, // notificationQueue
{} as any, // auditService
); );
}); });
+405 -6
View File
@@ -1,7 +1,10 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
ForbiddenException, ForbiddenException,
Inject,
Injectable, Injectable,
InternalServerErrorException,
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -26,6 +29,17 @@ import {
AuthProvenanceData, AuthProvenanceData,
agentSourceFields, agentSourceFields,
} from '../../common/decorators/auth-provenance.decorator'; } from '../../common/decorators/auth-provenance.decorator';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
import {
AUDIT_SERVICE,
IAuditService,
} 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 {
@@ -40,6 +54,7 @@ export class CommentService {
private generalQueue: Queue, private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) @InjectQueue(QueueName.NOTIFICATION_QUEUE)
private notificationQueue: Queue, private notificationQueue: Queue,
@Inject(AUDIT_SERVICE) private auditService: IAuditService,
) {} ) {}
async findById(commentId: string) { async findById(commentId: string) {
@@ -78,15 +93,58 @@ export class CommentService {
} }
} }
// Do NOT lossily truncate at 250: for a suggestion the client sends the RAW
// anchored document substring (the exact text under the comment mark) as the
// selection, which can be LONGER than the agent's <=250-char typed input
// (normalization collapses whitespace/typographic runs, so the raw span can
// exceed the normalized selection). Truncating it shorter than the mark span
// would break the apply-time equality check and make the suggestion
// un-appliable. Keep a generous 2000-char safety bound (matching
// suggestedText) so a legitimate anchored substring is never cut.
const selection = createCommentDto?.selection?.substring(0, 2000) ?? null;
// A suggested edit rewrites the exact text under an inline comment mark, so
// it is only meaningful on a top-level inline comment that carries a
// selection, and only if the suggestion actually changes that text.
let suggestedText: string | null = null;
if (
createCommentDto.suggestedText !== undefined &&
createCommentDto.suggestedText !== null
) {
if (createCommentDto.parentCommentId) {
throw new BadRequestException(
'A suggested edit can only be attached to a top-level comment, not a reply',
);
}
if (!selection || selection.trim().length === 0) {
throw new BadRequestException(
'A suggested edit requires an inline comment with a non-empty text selection',
);
}
const trimmed = createCommentDto.suggestedText.trim();
if (trimmed.length === 0) {
throw new BadRequestException('A suggested edit cannot be empty');
}
// A no-op suggestion (identical to the selection) is meaningless and would
// make "apply" indistinguishable from "already applied".
if (trimmed === selection.trim()) {
throw new BadRequestException(
'A suggested edit must differ from the selected text',
);
}
suggestedText = trimmed;
}
const inserted = await this.commentRepo.insertComment({ const inserted = await this.commentRepo.insertComment({
pageId: page.id, pageId: page.id,
content: commentContent, content: commentContent,
selection: createCommentDto?.selection?.substring(0, 250) ?? null, selection,
type: createCommentDto.type ?? 'page', type: createCommentDto.type ?? 'page',
parentCommentId: createCommentDto?.parentCommentId, parentCommentId: createCommentDto?.parentCommentId,
creatorId: user.id, creatorId: user.id,
workspaceId: workspaceId, workspaceId: workspaceId,
spaceId: page.spaceId, spaceId: page.spaceId,
suggestedText,
// Agent-edit provenance: the user stays creatorId; this only annotates the // Agent-edit provenance: the user stays creatorId; this only annotates the
// source. Normal user requests leave the column default ('user'). // source. Normal user requests leave the column default ('user').
...agentSourceFields(provenance, 'createdSource', 'aiChatId'), ...agentSourceFields(provenance, 'createdSource', 'aiChatId'),
@@ -207,17 +265,27 @@ export class CommentService {
false, false,
); );
comment.content = commentContent; // Re-fetch the enriched comment before broadcasting, symmetric with
comment.editedAt = editedAt; // create()/resolveComment(). updateComment() above has already persisted the
comment.updatedAt = editedAt; // new content/timestamps, so this single-row read reflects the edit AND
// carries the same {agent,launcher} avatar stack (via includeCreator) as the
// other two broadcasts. This deliberately does NOT reuse the caller's
// pre-loaded `comment`: relying on the controller happening to load it with
// includeCreator:true is exactly the fragile coupling that let the agent
// stack silently vanish on edit once already (#300/#304) — a future caller
// dropping that flag must not regress the broadcast.
const updatedComment = await this.commentRepo.findById(comment.id, {
includeCreator: true,
includeResolvedBy: true,
});
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, { this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated', operation: 'commentUpdated',
pageId: comment.pageId, pageId: comment.pageId,
comment, comment: updatedComment,
}); });
return comment; return updatedComment;
} }
async resolveComment( async resolveComment(
@@ -289,6 +357,337 @@ export class CommentService {
return updatedComment; return updatedComment;
} }
/**
* Apply the suggested edit carried by a top-level inline comment: atomically
* replace the text under the comment mark in the collaborative document with
* the comment's suggestedText, then stamp the applied fields and auto-resolve
* the thread. The controller authorizes (validateCanEdit); this re-checks the
* comment's own state so the invariant holds regardless of caller.
*/
async applySuggestion(
comment: Comment,
user: User,
provenance?: AuthProvenanceData,
): Promise<Comment & { outcome: SuggestionOutcome }> {
// Structural guards.
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 apply');
}
// State guards. Order matters — the already-applied check precedes the
// resolved check because an applied comment is normally also resolved.
//
// Already applied → IDEMPOTENT SUCCESS (issue #315 DoD: double-click /
// two-user race → idempotent "already applied", NOT a 409). The suggestion
// is already in the document, so do NOT call the collab gateway again.
// finalizeAppliedSuggestion re-fetches/broadcasts the same success shape as
// the applied branch and, when the thread is still open (the rare "applied
// but not resolved" crash window), self-heals it via resolveComment.
if (comment.suggestionAppliedAt) {
return this.finalizeAppliedSuggestion(comment, user, provenance);
}
// Not-yet-applied on a resolved thread → reject. The client hides the apply
// button once a thread is resolved; this is the defensive server check.
if (comment.resolvedAt) {
throw new BadRequestException(
'Cannot apply a suggested edit on a resolved comment thread',
);
}
// Derive the document name the same way create()/resolveComment() do for
// the comment marks: `page.${pageId}`.
const documentName = `page.${comment.pageId}`;
let verdict: { applied: boolean; currentText: string | null } | undefined;
try {
verdict = await this.collaborationGateway.handleYjsEvent(
'applyCommentSuggestion',
documentName,
{
commentId: comment.id,
expectedText: comment.selection,
newText: comment.suggestedText,
user,
},
);
} catch (error) {
// A throwing gateway (or the phase-3 fallback failing) is a hard error —
// never silently succeed, the document may or may not have changed.
this.logger.error(
`Failed to apply suggested edit for comment ${comment.id}`,
error,
);
throw new InternalServerErrorException('Failed to apply the suggested edit');
}
if (!verdict) {
// Should not happen given the phase-3 fallback; treat as a hard error
// rather than assuming success.
throw new InternalServerErrorException('Failed to apply the suggested edit');
}
if (verdict.applied === true) {
return this.finalizeAppliedSuggestion(comment, user, provenance);
}
// Idempotent branch: the mutation didn't run now, but the text under the
// mark is ALREADY the suggested text (double-click, two-user race, or a
// crash between the doc mutation and the DB write). Reconcile the DB /
// resolved state and report success — do NOT 409.
if (
verdict.applied === false &&
verdict.currentText === comment.suggestedText
) {
return this.finalizeAppliedSuggestion(comment, user, provenance);
}
// The commented text changed since the suggestion was made. Surface the
// current text so the client can tell the user what it is now.
throw new ConflictException({
message:
'The commented text changed since this suggestion was made; it was not applied.',
currentText: verdict.currentText,
});
}
/**
* Dismiss ("Не применять") a suggested edit without touching the page text:
* 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.
*
* 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(
comment: Comment,
user: User,
provenance?: AuthProvenanceData,
): Promise<Comment & { outcome: SuggestionOutcome }> {
const hasChildren = await this.commentRepo.hasChildren(comment.id);
if (hasChildren) {
// Thread has replies → preserve the discussion: stamp applied + resolve.
if (!comment.suggestionAppliedAt) {
await this.commentRepo.updateComment(
{
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' };
}
// No replies → ephemeral: the suggested text is already in the document, so
// the comment is redundant. Hard-delete it and strip its inline anchor. We
// deliberately do NOT write the applied stamps first (the row is about to be
// deleted); the audit event still records that the suggestion was applied.
// 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);
this.auditService.log({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
});
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(
content: any, content: any,
oldMentionIds: string[], oldMentionIds: string[],
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class ApplySuggestionDto {
@IsUUID()
commentId: string;
}
@@ -1,4 +1,12 @@
import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator'; import {
IsIn,
IsJSON,
IsObject,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { z } from 'zod'; import { z } from 'zod';
const yjsIdSchema = z.object({ const yjsIdSchema = z.object({
@@ -25,8 +33,15 @@ export class CreateCommentDto {
@IsJSON() @IsJSON()
content: any; content: any;
// The agent tool caps what it TYPES at 250 chars, but for a suggestion the
// client resolves and sends the RAW anchored document substring (the exact
// text under the mark), which can be longer once normalization is undone. Bound
// the stored value at 2000 (matching suggestedText) so a legitimate anchored
// substring is never rejected — the service used to lossily truncate at 250,
// which broke the apply-time equality check.
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000)
selection: string; selection: string;
@IsOptional() @IsOptional()
@@ -43,4 +58,12 @@ export class CreateCommentDto {
anchor: any; anchor: any;
head: any; head: any;
}; };
// Optional suggested replacement for the selected text (a "suggested edit").
// Only valid on a top-level inline comment that carries a non-empty selection;
// enforced in CommentService.create.
@IsOptional()
@IsString()
@MaxLength(2000)
suggestedText?: string;
} }

Some files were not shown because too many files have changed in this diff Show More