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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
Page/space export (Markdown & HTML, both via jsonToHtml -> generateHTML) crashed with
"Export failed:undefined" on any page carrying a `comment` mark. Root cause:
comment.renderHTML returned a LIVE DOM node (document.createElement + a click listener)
whenever a global `document` existed — and the in-process MCP module injects a jsdom
global.window+global.document into the Node server, defeating the old
`typeof document === "undefined"` guard. The server export runs happy-dom's
DOMSerializer, which crashes appending the foreign jsdom node
(NodeUtility.isInclusiveAncestor -> "Cannot read properties of undefined (reading
'length')"). comment is the only extension returning a live node.
Fix: widen the guard with an isNodeRuntime check (process.versions.node) so on any Node
runtime renderHTML returns the plain, serializable spec array — even when MCP injected
jsdom globals. The browser branch (createElement + click -> ACTIVE_COMMENT_EVENT) is
untouched, so in-editor comment interactivity is preserved (Vite defines only
process.env as a member-expression substitution, no `process` object in the browser
bundle, so isNodeRuntime is false there). The mcp schema mirror already returns a spec
array and is not on the export path (tiptapExtensions imports Comment from
@docmost/editor-ext), so no mirror change is needed.
Also: export-modal now reads the real error text from the response Blob
(responseType:'blob' made err.response.data.message always undefined) so a failed export
shows the server's message instead of "undefined".
Adds a regression test that runs the real jsonToHtml on a comment-marked doc with
jsdom globals injected (reproduces the crash on the unpatched code, passes after).
closes#298
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior test guarded a verbatim MIRROR of the two scroll-restore useLayoutEffect
blocks — the reviewer proved removing '&& editor' from the real page-editor.tsx left
the test green (a copy, not the original). Extract the wiring into an exported
useScrollRestoreOnSwap(pageId, editor, showStatic) hook (the two effects verbatim +
useScrollPosition inside; F1 budget logic untouched), call it once from page-editor.tsx
(replacing the removed useScrollPosition call + both effects), and rewrite the test to
render the REAL hook — deleting the mirror and the false 'regresses in lockstep' comment
(F2-doc). Non-vacuity proven: removing '&& editor' from the real hook reddens the guard
test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The F1 integration test mocks the open-set as {} so openIds is always empty — every
node hits the collapsed branch, and the open-keep + recursion path (keep an OPEN
branch's children, recurse to prune a nested collapsed child) runs in zero tests. Add
a unit test: open parent (kept with children) → nested collapsed child (pruned to []),
plus a top-level collapsed node (pruned), with hasChildren preserved and immutability
asserted. Non-vacuous: clearing an open branch fails (a); removing recursion fails (b).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1 (MEDIUM regression): a collapsed-but-cached branch showed STALE children on
re-expand after reload (the cache keeps children of any ever-expanded branch;
refreshOpenBranches only refreshes OPEN branches, but the fetch guard skips a branch
that has cached children). New pruneCollapsedChildren(tree, openIds) resets children
to [] (keeps hasChildren) for every node NOT in the persisted open-set, recursing
into open nodes — a once-per-mount boot effect. A pruned collapsed branch is then the
'unloaded' shape handleToggle re-fetches, so its first expand reconciles fresh (as
pre-cache). Open branches keep their children (refreshOpenBranches handles them, no
double fetch). Test: a collapsed cached branch with a stale child fetches fresh on
first expand after boot.
F2: gate the >4MB size-guard console.warn behind the writeFailureWarned once-flag
(like the quota branch) so editing a huge tree no longer re-warns every ~500ms; test
that an oversized tree is not persisted + warns exactly once.
F3: narrow the use-auth privacy comment (only tree caches are swept; other
localStorage entries remain).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>