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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The same tool metadata (zod schema + model-facing description) was hand-duplicated
between the standalone MCP server and the in-app AI-chat agent, so every tweak had to
land in two places and copies drifted (a materialized parity bug). The shared
transport-agnostic registry (packages/mcp/src/tool-specs.ts) already de-duplicates 14
tools; this migrates two more genuinely-identical ones — patch_node/patchNode and
insert_node/insertNode. The canonical description is a strict SUPERSET of both originals
(keeps MCP's "without resending the whole document" + table-structure/anchor guidance
AND the in-app "reversible via page history" / "exactly one of anchorNodeId or
anchorText" framing — no model-facing guidance dropped); the schema is identical (the
in-app side just gains MCP's .min(1) on ids, a safe tightening). Each transport keeps its
own execute/auth wrapper, and the in-app parseNodeArg node-arg normalization is unchanged.
The three table tools are intentionally NOT merged (a real param-name divergence:
table vs tableRef) — documented on both sides. Other per-transport divergences
(search/share/create_comment/transform/list_pages) are left separate with a short comment
explaining why (the issue asked to flag these as intentional). DocmostClientLike stays a
hand-mirror (the ESM/CJS boundary blocks a compile-time type import; a runtime drift-guard
already pins it). Also fixes a latent contract-spec bug: derive `required` from
`instanceof z.ZodOptional` (matches the emitted JSON schema) instead of `isOptional()`,
which wrongly reported z.any() fields as optional.
Partially addresses #294.
Co-Authored-By: Claude Opus 4.8 (1M context) <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>
F2: findChangedRange only normalized the repeated-content INSERTION case
(oldTo<start), leaving the symmetric DELETION case (newTo<start) to return a
degenerate DocChange (newTo<from). Push BOTH ends forward by start-min(oldTo,newTo)
so the range stays non-degenerate (from<=oldTo, from<=newTo), matching ProseMirror's
diff bounds. The insertion case is byte-identical to before (min=oldTo → oldTo→start,
newTo→newTo+delta); the deletion/both-below cases are fixed. Never spuriously arms
the guard (arming needs oldTo>from AND newTo>from; normalization leaves exactly one
end ==from).
F1: add custom-typography.test.ts (15 tests) via the real Editor path (mirrors
intentional-clear.test.ts): findChangedRange normalization (insertion + the fixed
deletion), mapRangeThroughChange release/boundary/shift, and arming (local
undo-replace arms; remote y-sync change-origin does NOT; ordinary edit does NOT).
Adds test-only exports (undoGuardKey, findChangedRange, mapRangeThroughChange).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: pin the shared restoreStartRef timeout budget — re-triggers share ONE budget
(measured from the first call), not a per-trigger restart. Test drives short content
(polls), triggers at t=0 and t=3s, and asserts the clamp fires at t=5s from the FIRST
call. Verified non-vacuous: a mutant that resets the budget on each trigger fails it.
F2: cover the two useLayoutEffect scroll-restore blocks. A full PageEditor mount has
no precedent in the client suite (it builds live Hocuspocus/IndexedDB providers +
collab tiptap; the static->live swap gates on isCollabSynced, only reproducible by
driving mocked provider callbacks = testing the mocks). Per the reviewer's allowance
for a justified lighter variant: page-editor.test.tsx reproduces the two effects and
(1) asserts the [showStatic, editor] deps + the '&& editor' guard via a stable spy,
(2) drives the REAL useScrollPosition end-to-end so the post-swap re-assert is the
sole cause of scrollTo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The existing tests assert only the classifier flags ({asMarkdown, wrapBareRows}),
not the resulting markdown. Add two output-level tests via htmlToMarkdown mirroring
the serializer's real path: (a) a header-less bare-rows selection wrapped as
<table><tbody><tr>… yields a VALID GFM pipe table (GFM plugin synthesizes an empty
header + separator), and (b) a whole table with a header round-trips to a proper
pipe table with header/separator/data rows. Both are non-vacuous — they fail
against the old one-value-per-line serialization (no separator row, no pipes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AppShell navbar breakpoint and both burger toggles' hiddenFrom/visibleFrom
must be equal, or the sidebar becomes unreachable on tablet widths (the round-1
regression). A comment guarded that before; now a shared const does. Add
NAVBAR_COLLAPSE_BREAKPOINT='md' to sidebar-atom.ts and reference it from the navbar
breakpoint (global-app-shell) + both toggles (app-header). aside.breakpoint and the
sm brand/search gates are intentionally separate contracts, left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
clipboardTextSerializer only produced Markdown for lists, so copying a table
and pasting into a plain-text/Markdown target emitted one cell value per line
(ProseMirror's default text serializer). Route tables through htmlToMarkdown
(turndown + GFM) as well.
- Extract the decision into a pure, exported classifyClipboardSelection()
helper; the existing list rule (2+ items) is preserved exactly.
- Handle whole-table selections (top-level `table` node) and partial cell
selections (bare `tableRow` nodes), wrapping bare rows in <table><tbody> so
the GFM turndown rule detects them.
- Add unit tests for classifyClipboardSelection (6 cases).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the navbar sm->md change on this branch: the two header sidebar
toggles were still gated at sm, so in the 768-991 band the DESKTOP toggle was
shown while the navbar used the MOBILE drawer collapse state — clicking it
flipped the wrong atom and the drawer could not be opened (sidebar unreachable
at 768/820, caught by QA). Gate the mobile toggle hiddenFrom=md and the desktop
toggle visibleFrom=md so the mobile toggle drives the drawer across the whole
tablet band.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
После срабатывания авто-подстановки Typography (например «1/2 » → «½») и её
отмены через Ctrl+Z повторное нажатие пробела снова триггерило то же input-rule
и подставляло символ заново.
Добавлено клиентское расширение CustomTypography (обёртка над
@tiptap/extension-typography) с ProseMirror-плагином «undo guard»:
- запоминает диапазон текста, восстановленный отменой (undo/redo), и подавляет
typography input-rules, чьё совпадение пересекается с этим диапазоном, пока
восстановленный текст не отредактируют;
- поддерживает обе системы истории: prosemirror-history (шаблонные редакторы) и
yjs UndoManager (основной collab-редактор). Undo в yjs приходит как замена
всего документа, поэтому регион вычисляется диффом документов
(findDiffStart/findDiffEnd), а не по step-map;
- детекция yjs-транзакций — через импортированный ySyncPluginKey и канонический
isChangeOrigin, без хрупких строковых ключей.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At tablet widths (~768px) the fixed ~300px global sidebar stayed pinned, leaving
too little room for content: the settings tables (Members etc.) overflowed the
offset content area and pushed the Role/actions columns off-screen with no
horizontal scroll (unreachable). Raise the AppShell navbar (and page aside)
breakpoint from `sm` (768px) to `md` (992px) so the whole tablet band uses the
toggle drawer (closed by default) and content gets the full width.
Verified with Playwright screenshots: 768px settings/members now fits all columns
(table right 736<768, no overflow); desktop (>=992px) unchanged (sidebar pinned,
content offset); mobile unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On page reload the sidebar tree rendered nothing until every root page
was fetched (paginated), and children of expanded branches arrived even
later (breadcrumbs effect / socket connect) — the tree visibly jumped a
couple of seconds after load.
- treeDataAtom is now a facade over atomFamily(atomWithStorage) keyed
treeData:v1:{workspaceId}:{userId} with getOnInit: true — the cached
tree hydrates synchronously and paints on the very first render,
together with the already-persisted open-branches map. Public atom
interface unchanged (value or functional updater), all call sites
untouched.
- Custom sync storage: debounced writes (500ms, coalesced, size guard,
beforeunload flush), defensive reads (corrupted JSON -> []), no
cross-tab subscribe (localStorage is a boot cache only).
- SpaceTree renders on cached data immediately; "No pages yet" still
waits for the server. Once server roots merge, open loaded branches
are re-fetched fresh and reconciled once per space (shared
refreshOpenBranches, also used by the socket reconnect handler).
- Logout hygiene: clearPersistedTreeCaches() purges treeData:v1:* and
openTreeNodes:* by prefix and disables further persistence (kill
switch closes the websocket-write-vs-beforeunload-flush resurrection
race). Wired into both handleLogout and the 401 redirectToLogin path,
since cached trees contain page titles.
- Tests: tree-data-atom.test.ts (hydration, debounce round-trip,
corrupted JSON, scope isolation, logout purge, persistence kill
switch); expand-all suite adapted. 144 tree tests / full client suite
green, tsc clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
F1: pc.title (untrusted cross-user page title) was interpolated raw into the
markdown export heading. Reusing escapeAttr alone (the prompt sink's XML-attribute
sanitizer, strips < > ") is insufficient here because the sink is MARKDOWN: link
/image syntax survives, so a title like  or [phish](http://evil)
injects a remote image / clickable link into the downloaded .md disguised as a
trusted system annotation. Add markdownHeadingSafe() = escapeAttr() + backslash-
escape [ and ] (disables both [text](url) and ; a bare (url) is inert).
F2: cover the title branch — a title that collapses to empty via escapeAttr falls
to the bare heading (no ("")), and a link/image-injection title is neutralized
(non-vacuous vs the escapeAttr-only version).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reading-position restore fired only after collab sync (`!showStatic`),
so the page painted at the top and then visibly jumped — and readers who had
already started scrolling were rudely yanked back to the saved position.
- Abort restore permanently on genuine user scroll intent: `wheel`/`touchstart`
unconditionally, and `keydown` only for real scroll keys (Arrow/Page/Home/End/
Space) so shortcuts and typing do not disable it. Our own `window.scrollTo`
never emits these, so restore cannot self-abort.
- Restore earlier via `useLayoutEffect` (before paint) while the static/cached
content is laid out, and re-assert once after the static->live editor swap.
- Make `restoreScrollPosition` idempotent with a redundancy guard so the two
triggers never double-scroll; share one bounded timeout budget across them.
- Add tests for interaction-abort, scroll-key filtering, idempotence.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to #284: rows of inline-aligned images were pinned left while
a single image defaults to centered — inconsistent. A row has no DOM
wrapper (each image is an independent block node), so its placement is
controlled by the text-align of the nearest block ancestor.
- media.css: enable text-align:center only on containers that actually
hold a direct inline-image child (:has), and reset every other child
back to text-align:start so ordinary text is unaffected; explicit
per-block toolbar alignment (inline style) still wins; browsers
without :has() keep the previous start-pinned rows
- image.ts: comment in the inline branch now points to the media.css
rule (cross-package discoverability), no code change
Reviewed: math/caption/table-header/footnote text-align rules audited;
React node views are wrapped in .react-renderer, so .mathBlock is not a
direct child and keeps its own centering (verified in happy-dom).