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>
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).
The #274 page_changed marker lived only in the ephemeral system prompt, so the
diff the agent saw was invisible in the chat export/history, and the note was
too weak — the agent still overwrote the user's manual edits with a full-page
replace.
- Persist the diff the agent saw as metadata.pageChanged on the assistant row
(flushAssistant), threaded into all five flush call sites in stream(). Model
replay (rowToUiMessage/rowParts) reads only metadata.parts, so the sibling
never re-injects the note into the model context on later turns.
- Render the persisted diff as a labelled block (en/ru) before the message body
in the server-side Markdown export (chat-markdown.util.ts).
- Strengthen PAGE_CHANGED_NOTE: mandate a fresh getPage re-read and targeted
edits (editPageText/patchNode/insertNode/deleteNode) instead of a whole-page
replace, and never revert or overwrite the user's edits.
Tests: prompt, export and service specs updated; 114 pass, tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The #285 gate dropped every remapped (wrong-layout) candidate shorter than 3
chars, which broke the legitimate short prefix '/сщ' -> 'co' -> Code while '/co'
still worked. Replace the blanket length filter with a match-TYPE gate: the
original query and remaps >= 3 chars match fully (title/description/searchTerms);
a short (1-2 char) remap is restricted to a TITLE fuzzy-match. So '/сщ' -> 'co'
matches the 'Code' title again, while '/cy' -> 'сн' and '/b' -> 'и' still do not
surface Footnote (they only ever leaked in via the 'сноска'/'примечание'
searchTerm substrings, not the title).
Adds positive tests for /сщ and /co; keeps the /cy and /b negatives.
closes#283
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F4: menu-items.layout.test.ts imports from './menu-items' (relative, no extension),
matching the sibling test files (was still the aliased '@/.../menu-items.ts').
F5: remove the dead 'candidate !== originalCandidate' clause from the remapped-candidate
filter — buildLayoutCandidates dedupes remaps against the original via Set, so the tail
after destructuring can never equal the original; the length gate is the only real
condition. Comment updated to state the dedup invariant instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This actually lands F1+F2 (round 1 pushed only the test rename by mistake).
F1: only the ORIGINAL query matches without length limits; remapped (wrong-layout)
candidates must be >= 3 chars before they can match, via a shared candidateMatchesItem
helper applied to both the item filter and the tie-break sort. Stops a 1-2 char ASCII
query from spuriously substring-matching Cyrillic searchTerms (/cy->сн no longer hits
'сноска', /b->и no longer hits 'примечание'), while keeping real wrong-layout commands
(/сщву->Code, /cyjcrf->Footnote), genuine short queries (/p, /h1) and Cyrillic terms
(/сноска->Footnote) working.
F2: reword the buildLayoutCandidates JSDoc (an ASCII query yields multiple candidates;
dedup only collapses when nothing is remappable).
Adds negative tests for /cy and /b.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: only the ORIGINAL query does full matching; remapped (wrong-layout) candidates
must be >= 3 chars and differ from the original before they can match (via a shared
candidateMatchesItem helper, applied to both the filter and the tie-break sort). This
stops a short remapped candidate from substring-matching the only cyrillic searchTerms
(/cy->сн, /b->и no longer surface Footnote) while keeping real wrong-layout commands
(/сщву->Code, /cyjcrf->Footnote) and genuine cyrillic terms (/сноска->Footnote) working.
F2: fix the buildLayoutCandidates JSDoc (an ascii query yields multiple candidates,
not a single-element set).
F3: rename the test to menu-items.layout.test.ts + relative import, per sibling convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typing a command with the wrong layout (e.g. Russian ЙЦУКЕН -> /сщву for 'code')
matched nothing and collapsed the popup. Add ЙЦУКЕН<->QWERTY layout maps and a
buildLayoutCandidates(query) = [original, RU->EN, EN->RU]; getSuggestionItems now
matches an item if ANY candidate hits (fuzzy title / description / searchTerms),
and the tie-break sort is candidate-aware. Keeping the original among candidates
preserves genuine Cyrillic search terms (сноска -> Footnote). One-function change;
slash-command.ts allow() reuses it, so the popup-collapse is fixed transitively.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: escape the collaborative page title before interpolating into
<page_changed page="..."> (and the pre-existing openedPage attr) — strip
<>" and collapse whitespace, so a crafted title can't break out of the
attribute into the system prompt (cross-user injection).
F2: neutralize <page_changed>/</page_changed> occurrences inside the diff body
so a crafted line can't close the block early.
F3: remove the dead content_hash column (written every turn, never read) —
migration, repo, service hashing + crypto import, db.d.ts, spec asserts.
F4: test the best-effort catch branches (detectPageChange / snapshotOpenPage
swallow errors and don't break the turn).
F5: soften the overstated 'diff cannot smuggle instructions' comment to
defense-in-depth framing referencing the F1/F2 mitigations + safety sandwich.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>