Commit Graph

206 Commits

Author SHA1 Message Date
agent_coder cb9c5dda59 perf(comment): static comment renderer + lazy editors + memoized list (#340)
The comment panel lagged for seconds on open and stuttered on every resolve/apply
with many comments (real case: 30 open + 326 resolved ≈ 356 threads), because each
comment body mounted a full TipTap/ProseMirror editor, both tabs mounted at once,
and any mutation re-rendered the whole list.

- CommentContentView: static recursive renderer of comment ProseMirror JSON (no
  editor instance) for the read-only body — supports exactly CommentEditor's node
  set (doc/paragraph/text/hardBreak/mention) + marks (bold/italic/strike/code/
  link), reproducing the 3-level DOM nesting for pixel-identical CSS. Unknown
  node/mark or unparseable content degrades that one comment to the read-only
  CommentEditor; legacy non-JSON strings render as plain text.
  SECURITY: link hrefs are protocol-allowlisted (safeHref, mirroring
  @tiptap/extension-link) so a stored comment with a `javascript:`/`data:` href
  cannot XSS — the old TipTap read-only path sanitized this; the static renderer
  must too. Control-char smuggling (java\tscript:) is stripped before the check.
- MentionContent extracted from MentionView, shared by the TipTap NodeView and the
  static renderer (identical user/page-mention behavior).
- keepMounted={false} on the tabs: the inactive tab no longer mounts its editors.
- Lazy reply editor: a stub until click/focus, then the real editor (kept mounted
  so the draft survives thread re-renders).
- React.memo(CommentListItem) + a childrenByParent map (replaces the per-thread
  O(n^2) filter) + localized reply-send pending state: resolve/apply/reply now
  re-render only the touched thread.
- Progressive first paint: useCommentsQuery no longer blocks on hasNextPage.

Gate: client comment+mention suites 22/22 passed, tsc --noEmit 0.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:19:52 +03:00
vvzvlad 3a5794894e Merge pull request 'feat(editor): inline image alignment — place several images side by side' (#284) from image-inline-row into develop
Reviewed-on: #284
2026-07-02 14:12:05 +03:00
vvzvlad 8d745352d1 Merge pull request 'fix(editor): вернуть фокус редактора после закрытия меню таблицы (Ctrl+Z undo)' (#279) from fix/269-table-menu-refocus into develop
Reviewed-on: #279
2026-07-02 14:11:55 +03:00
vvzvlad f0a69abd0f Merge pull request 'feat(editor): кнопка «Ударение» (U+0301) в bubble-меню' (#280) from feat/270-stress-accent into develop
Reviewed-on: #280
2026-07-02 14:11:38 +03:00
agent_coder 6190de14cc fix(editor): let short wrong-layout prefixes match by title (#283)
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>
2026-07-02 14:09:36 +03:00
vvzvlad e2646d8699 Merge pull request 'fix(editor): slash-меню находит команды в неправильной раскладке (ЙЦУКЕН↔QWERTY)' (#285) from fix/283-slash-layout into develop
Reviewed-on: #285
2026-07-02 13:34:47 +03:00
agent_coder d34b5f532f fix(#283 review r3): drop dead remap guard + use relative test import
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>
2026-07-02 08:16:08 +03:00
agent_coder 0f4b03d89f fix(#283 review r2): gate remapped layout candidates against short-query over-match
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>
2026-07-02 07:42:08 +03:00
agent_coder d70b80c449 fix(#283 review): gate remapped layout candidates to avoid short-query over-match
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>
2026-07-02 07:00:35 +03:00
agent_coder 5f02b7c80e fix(editor): match slash-menu commands typed in the wrong keyboard layout (closes #283)
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>
2026-07-02 05:44:58 +03:00
claude_code 20032be921 feat(editor): inline image alignment — place several images side by side
Add a new value "inline" to the image align attribute (alongside
left/center/right/floatLeft/floatRight). Inline images render as
inline-block containers, so consecutive ones form a row that wraps
naturally on narrow viewports; unlike the float modes, text does not
wrap around them.

- applyAlignment: reset-then-apply extended to display/vertical-align;
  the reset restores the constructor's inline display:flex so non-inline
  modes keep byte-identical styles and editor-ext stays independent of
  the client CSS class
- image bubble menu: new "Inline (side by side)" button (IconLayoutColumns)
  with active state, mirroring the float buttons
- i18n: key registered in en-US and ru-RU ("В ряд"), like the float labels
- tests: 3 new applyAlignment specs (apply, reset on switch-away, float->inline)
- no schema/MCP/markdown changes needed: align round-trips as data-align
2026-07-02 04:22:25 +03:00
agent_coder 0bdc9f98f5 refactor(editor): widen BubbleMenuItem.icon type, drop IconStress cast (#270 review F1)
Icons are rendered only as <item.icon style={...} stroke={2} />, so type the
field as ComponentType<{ style?; stroke? }> instead of typeof IconBold. stroke is
string|number to match Tabler's own prop type, so Tabler icons and the local
IconStress both satisfy it without the 'as unknown as' cast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 03:06:23 +03:00
agent_coder 6e70c7bd6a test(editor): cover refocusEditorAfterMenuClose guard (#269 review F1)
Unit-test the focus-restore guard: an external <input> active -> editor.view.focus
NOT called (deliberate move respected); a non-focusable element active -> focus
called once. Fake editor + fake timers (rAF via setTimeout stub); view.focus is a
spy. Regression lock for the guard that keeps focus out of the page-title input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:02:44 +03:00
agent_coder ba87f4ee24 test(editor): cover read-only code-block branch; drop dead justify prop (#275 review F1/F2)
F1: add code-block-view.test.tsx (mirrors the footnote structure harness) asserting
the language selector renders only when editor.isEditable, and the copy button is
present in both modes.
F2: remove the now-dead justify=flex-end on the absolutely-positioned menu Group.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:02:13 +03:00
agent_coder 23c80f727a feat(editor): add stress-accent (U+0301) toggle button to the bubble menu (closes #270)
Select a vowel and one click places a combining acute accent over it; clicking
again removes it (toggle). Inserts the literal Unicode char U+0301 right after
the letter — plain text, not a custom TipTap mark — so it survives HTML/Markdown
export, full-text search and public share with zero server/converter changes.
Insert/remove is a single transaction (one Ctrl+Z), inherits the letter's marks
(bold/italic/color), and restores the original selection so the active state
toggles correctly. Editable bubble menu only. New pure helper stress-accent.ts
(+ 5 unit tests). i18n: en 'Stress' / ru 'Ударение'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:28:39 +03:00
agent_coder 2b36997c63 fix(editor): restore editor focus after table menu closes so Ctrl+Z works (closes #269)
The row/column grip and cell-chevron menus are Mantine <Menu>s with
returnFocus:true whose targets live outside the editor's contenteditable. After
a menu action focus returns to that outside target, so ProseMirror's undo keymap
never sees Ctrl+Z until the user clicks back into a cell. Add
refocusEditorAfterMenuClose(editor): on the next frame (after Mantine's
returnFocus) restore editor focus via view.focus(), unless the user intentionally
moved to another input/editable. Wired into both onClose paths (the shared
row/column lifecycle hook + cell-chevron).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:27:21 +03:00
agent_coder 5280392fc4 feat(editor): overlay code-block controls, hide language selector until hover (closes #275)
The code-block control panel (language selector + copy) took a full row above
the code. Move both to an absolute overlay in the top-right corner and hide the
language selector until the block is hovered/focused; the copy button stays
always visible. In read-only the language selector isn't rendered at all. The
<pre> (editable contentDOM) stays FIRST in the DOM so click hit-testing (#146)
is not regressed; the panel leaves the flow via position:absolute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:20:43 +03:00
claude code agent 227 ef27b6d440 test(#244): cover dictation ordered-emitter + internal-link paste (Phase 2 tail)
Backfill the two genuinely-uncovered infra-free units from the #244 Part B
test backlog (the rest was already covered by #248/#257):

- use-streaming-dictation: the in-order transcription emitter. Drives the
  real hook via renderHook with mocked VAD + deferred transcribeAudio so the
  test controls response order. Asserts out-of-order HTTP responses still
  emit text in segment order; whitespace trimmed and empty results dropped
  while the sequence advances; a failed segment shows one notification and is
  skipped so later segments still flush; a response resolving after cancel()
  is dropped (stale-epoch guard).
- internal-link-paste (handleInternalLink / createMentionAction): validateFn
  reject → no resolve/dispatch; resolve → mention node with the resolved page
  + anchor dispatched via replaceWith at pos; "Untitled" fallback; reject →
  raw url inserted as text under a link mark; createMentionAction wiring to
  getPageById on success + failure.

Test-only; no production code changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:07:45 +03:00
claude_code 5c1187b864 feat(editor): add Clear formatting button to bubble menu
The floating bubble menu had no way to clear formatting, so in the
default configuration (fixed toolbar disabled) users could not reset
inline formatting at all. Mirror the fixed-toolbar action into the
bubble menu: a new "Clear formatting" item running unsetAllMarks().

- bubble-menu.tsx: import IconClearFormatting; append a non-toggle
  "Clear formatting" item (isActive: () => false) to the items array.
- No i18n changes — the "Clear formatting" key already exists in all
  locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 03:26:17 +03:00
vvzvlad 22ea387495 Merge pull request 'feat(#246): inline spoiler mark (blur + click-reveal, lossless Markdown)' (#259) from feat/246-spoiler into develop
Reviewed-on: #259
2026-06-30 01:47:46 +03:00
claude code agent 227 188c5f506c feat(editor): inline spoiler mark (blur + click-reveal, lossless Markdown) (#246)
Add an inline spoiler (Telegram/Discord-style hidden text): a TipTap mark
`spoiler` rendered as <span data-spoiler="true" class="spoiler">, blurred via
CSS and revealed on click (UI-only is-revealed class, never persisted).

- packages/editor-ext: the Spoiler mark (inclusive:false, set/toggle/unset
  commands, ||text|| input rule), exported; a lossless turndown rule emitting
  raw inline HTML; round-trip test.
- apps/client: SpoilerView mark-view (ReactMarkViewRenderer, Link pattern),
  registration in extensions, bubble-menu toggle button (editable only), CSS
  (blur + @media print reveal), en/ru i18n.
- apps/server: register Spoiler in collaboration.util tiptapExtensions so the
  mark survives HTML<->JSON export/index/import/Yjs; a test proving the public
  share keeps the spoiler (it isn't stripped with comments).

No keyboard shortcut: the proposed Mod-Shift-s collides with Strike (and
Mod-Shift-h with Highlight); the ||text|| input rule + the bubble-menu button
cover ergonomics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:22:30 +03:00
claude code agent 227 d39b7ae67c refactor(editor): dedupe alt/caption controls via shared hook (F4)
Extract the ~110 duplicated lines into one parameterized
useImageTextFieldControl and make useAltTextControl/useCaptionControl
thin wrappers. Behavior identical; t("...") literals stay in the
wrappers so i18n extraction keeps working. sanitizeCaption still
exported for its unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:38:48 +03:00
claude code agent 227 c124fb1f2c test(editor): fix wrong sanitizeCaption collapse-cap comment (F3)
The comment claimed 250 groups -> 499 chars -> slice past 500; the
input is 120 "a  b " groups collapsing to 479 chars, under the cap
with no slice. Correct the comment and assert the 479 length.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 23:38:41 +03:00
a dc14a9a540 chore(editor): address image-caption review (#221)
- docs: add CHANGELOG Unreleased/Added entry for editable image captions
- test: export sanitizeCaption and add vitest unit coverage
  (whitespace collapse, trim, 500-char boundary)
- refactor: drop duplicate .imageCaption CSS module class, keep the
  global .image-caption as the single source
- docs: fix turndown image-caption comment (video rule emits a markdown
  link, not a <div>)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 04:36:30 +03:00
claude code agent 227 2aa482f62d feat(editor): add editable image captions (#221)
Add a visible caption (<figcaption>) under images, editable from the
image bubble-menu and persisted across all formats: native Yjs/JSON,
HTML export, and Markdown.

- image node: new plain-text `caption` attribute (parse/render
  `data-caption` on <img>, emitted only when set) + `setImageCaption`
  command. The node stays an atom; the schema shape is unchanged, so the
  server's generateHTML/generateJSON path round-trips it for free.
- resize node-view: re-parent the resizable wrapper into a <figure> and
  render the caption in a <figcaption> BELOW it, outside nodeView.wrapper
  (so onCommit's offsetHeight measurement and the left/right resize
  handles still cover the image only). This path also drives read-only /
  share rendering. React placeholder view renders the caption too.
- bubble-menu: new useCaptionControl panel modeled on useAltTextControl
  (own icon, Caption strings, softer sanitizer, ~500 char limit).
- markdown lossless round-trip: a captioned image is emitted as a raw
  <img data-caption> wrapped in a block <div> (same trick as <video>) in
  both the editor-ext turndown rule and the MCP converter; caption-less
  images stay clean ![alt](src). Import restores the caption via the
  shared markdownToHtml + parseHTML.
- styles + i18n keys; tests for the schema attr round-trip, markdown
  round-trip (editor-ext) and the MCP converter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 04:33:00 +03:00
claude code agent 227 2d36641f28 test(coverage): add regression tests for issues #192, #206, #204
Additive test coverage across server, editor-ext, client and mcp.

#192 — AiChatService.stream integration (Section 3, against real Postgres):
- new apps/server/test/integration/ai-chat-stream.int-spec.ts drives the real
  streamText through a seeded ai/test MockLanguageModelV3 and a real Node
  ServerResponse, covering: onError persists an assistant error record
  (status 'error' + partial answer + provider cause in metadata); external MCP
  client closed exactly once on BOTH onFinish and onError; anti-tamper —
  history is rebuilt from the DB transcript, not from body.messages.

#206 — red-team findings (most already fixed+tested in #212):
- mdrt-2 (UNFIXED, data loss): turndown.dataloss.test.ts documents that
  pageBreak / transclusionReference / mention are silently dropped on Markdown
  export (characterization + it.fails for the desired survive-export contract).
- persist-6 (UNFIXED, data loss): persistence-store.spec.ts adds an it.failing
  documenting that a momentarily-empty live doc overwrites non-empty content
  (left unfixed — a store-side empty-guard is a behaviour change).

#204 — test-strategy plan, highest-priority subset:
- Phase 1: mcp-clients.lease.spec.ts covers the external MCP client
  lease/refcount/eviction lifecycle (leak / premature-close / double-close).
- Phase 2 data-integrity pure functions: editor-ext table-utils
  (transpose/moveRow/convert round-trip) and math tokenizer false-positive
  guard; client emoji-menu (+ it.fails for the unguarded localStorage
  JSON.parse bug), sort-cells, normalizeTableColumnWidths; mcp htmlEmbed/
  pageBreak markdown data-loss + footnote-diff; server export
  getInternalLinkPageName extensionless-path bug — FIXED (small/clear) + tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:15:55 +03:00
claude_code 276ccc0783 refactor(ai): drop Generative AI flag, gate title generation on AI chat
Remove the separate, un-toggleable `settings.ai.generative` workspace flag
(and its write-side alias `generativeAi`) along with the dead "Ask AI"
generative editor menu, and re-gate the AI page-title generation on the
general AI chat flag (`settings.ai.chat`) — the same toggle that enables
the chat agent and the chat stream endpoint.

Why: the `generative` flag had no UI toggle (its switch was already removed,
leaving orphaned i18n strings), so the title-generation button was
unreachable on self-hosted. The "Ask AI" menu was dead — its atom was never
rendered. Consolidating onto the AI chat flag makes the title button follow
the one AI switch users actually have.

Changes:
- server: title-gen endpoint gate generative -> chat (ai-chat.controller.ts);
  remove generativeAi from update DTO and workspace service (update block,
  delete line, cloud default now { ai: { chat: true } }); fix repo comment;
  migrate generate-page-title spec assertions generative -> chat.
- client: title-gen gate -> settings.ai.chat (full-editor.tsx); remove the
  dead Ask AI button + showAiMenu wiring from bubble-menu; remove AskAiGroup
  usage/import and commented block from fixed-toolbar; delete ask-ai-group.tsx;
  remove showAiMenuAtom; drop generative/generativeAi from workspace types.
- i18n: remove 3 orphaned generative-AI keys from all 12 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:35:30 +03:00
claude code agent 227 001ebe2e53 feat(ai): generate page title from content (#199)
Add an AI button in the page byline that generates a note's title from the
live editor content (including unsaved edits) and applies it immediately.

Server: one-shot, non-streaming POST /ai-chat/generate-page-title mirroring
the chat generateTitle path — gated by settings.ai.generative, throttled via
AI_CHAT_THROTTLER, resolves the workspace chat model and returns { title }.
The endpoint never touches the page; the client applies the title through the
existing /pages/update route (which enforces edit permission).

Client: ai-chat-service.generatePageTitle, a useGeneratePageTitle hook that
converts the editor HTML to markdown, calls the endpoint, applies the title
via updateTitle + updatePageData, reflects it in the unfocused title editor,
and broadcasts the UpdateEvent (mirroring TitleEditor.saveTitle). A sparkles
button (GenerateTitleGroup) renders next to dictation, edit-mode + flag gated.

Tests: pure cleanGeneratedTitle helper + controller gate/delegation/error-map.
i18n: en-US + ru-RU strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:03 +03:00
claude_code 50d3e7b476 style(editor): align footnote marker and center task checkbox
Remove top margin from the first paragraph in footnote definitions so the
marker aligns with the first line of text. Adjust the task‑list label to use
the editor line‑height variable for its height and center the checkbox
vertically, keeping it in line with the item’s first text line.
2026-06-26 19:24:13 +03:00
claude code agent 227 30c358a2f8 test(review): add the 4 new test-coverage points from PR #185 re-review
The re-review's blocking/structural points (lease leak, dup-id guard test,
body-before-title test, CHANGELOG, pg18, shared jsonb decoder) were already
addressed in commit 24264ef; this adds the 4 genuinely-new coverage requests:

- pt 6: `scrollToReference(id, index?)` exercised against a live editor DOM —
  selects the index-th `sup[data-footnote-ref][data-id]` occurrence, falls back
  to the first for out-of-range, returns false for an empty id (scrollIntoView
  stubbed). (#168)
- pt 7: export `backlinkLabel` and pin the base-26 carry boundary
  (25->z, 26->aa, 27->ab, 51->az, 52->ba). (#168)
- pt 8: integration fail-open — a PRESENT-but-corrupt tool_allowlist (jsonb
  string scalar holding non-array JSON) reads back as null ("no restriction"),
  covering normalizeRow's degrade branch. (#159 #172/#173)
- pt 9: getFootnoteRefCount cache invalidation — adding a `[^a]` reference bumps
  the cached count 2 -> 3. (#168)

Verified: editor-ext footnote 23; client structure 7 + tsc; server int 8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:08:21 +03:00
claude code agent 227 47a2ae420b feat(footnotes): multi-backlinks — definition returns to ALL its references (#168)
After #166 a repeated `[^a]` is one footnote (reuse): one number, one
definition, N forward links. But the definition's ↩ only returned to the
FIRST reference. Now a definition with N references shows ↩ a b c …, each
backlink scrolling to its own occurrence (Pandoc/Wikipedia convention); a
single-reference footnote keeps the plain ↩ unchanged.

- editor-ext: `computeFootnoteRefCounts(doc)` (id -> occurrence count) cached
  alongside the number map in the numbering plugin state; `getFootnoteRefCount`
  getter (O(1), no per-render doc walk). `scrollToReference(id, index?)` picks
  the index-th `sup[data-footnote-ref][data-id]` occurrence (document order),
  falling back to the first.
- client: FootnoteDefinitionView renders one lettered link (a, b, c, … aa …)
  per occurrence when refCount > 1; the chrome stays after the contentDOM so
  the #146 caret invariant holds. i18n keys (ru) added.

Tests: computeFootnoteRefCounts + getFootnoteRefCount (reuse counts, unknown
id => 0); structure test gains 3 cases (N lettered links render, click jumps
to the n-th occorrence, single ref => one ↩). NOTE: the visual layout of the
backlink row needs a real browser to verify (jsdom can't); the structural and
behavioral contract is covered headless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:36:01 +03:00
claude code agent 227 623c89554a refactor(subpages): address PR #155 review
- Extract buildSubtree/mapSharedNodes/countNodes/SubpageNode into
  subpages-view.utils.ts with a unit test (subpages-view.utils.test.ts)
  covering nesting, position order, missing/unreachable parent, self-parent
  guard, empty input, countNodes and mapSharedNodes remap.
- Replace the manual useState + editor.on("transaction") subscription in
  subpages-menu.tsx with useEditorState (the idiom the sibling bubble menus
  use), so the mode icon/tooltip track the live recursive attribute without
  re-rendering on every keystroke.
- i18n: add the 6 menu/tree strings and a pluralized
  "Showing {{count}} subpages" key to en-US and ru-RU.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:33:25 +03:00
claude code agent 227 f7b99f9fb3 feat(editor): recursive tree mode for the subpages node (#150)
The `subpages` node showed only one level of direct children. Add a `recursive`
attribute that renders the FULL descendant tree of the current page — fully
expanded, unlimited depth. Default `false`, so every previously-inserted node
stays flat (backward compatible). No backend changes: `POST /pages/tree` (via the
`getSpaceTree` wrapper) already returns the whole subtree as a flat `IPage[]`
(recursive CTE, permission-filtered); the nested tree is built on the client by
`parentPageId`.

- editor-ext `subpages.ts`: `recursive` attribute (parse/render `data-recursive`),
  shared by client + server so the collab ProseMirror schema keeps the attribute.
- `getSpaceTree`: arg loosened to `{ spaceId?; pageId? }` (the endpoint accepts
  either); new `useGetPageTreeQuery(pageId)` react-query hook.
- `subpages-view.tsx`: split into `FlatSubpages` (unchanged) and
  `RecursiveSubpages`; `buildSubtree` assembles the nested tree (cycle/self-parent
  guard, `sortPositionKeys` per level, root excluded) and a recursive `TreeNode`
  renders it (16px indent per depth, soft "showing N" note past 300 — data never
  capped). Shared/public context reads the already-nested shared tree, no
  `/pages/tree` request.
- toggles: bubble-menu flat⇄tree button + a second slash-menu item "Page tree".

Review follow-ups folded in: invalidate `["page-tree"]` from the create / update /
move / delete cache helpers so an open recursive tree refreshes (no stale data);
mode icon made reactive on editor transactions; `t` threaded into `TreeNode`
(no per-node useTranslation); shared-subtree hook deduped to a thin alias.

editor-ext build + client `tsc --noEmit` both clean. Backend untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:33:07 +03:00
claude code agent 227 43cf1913e0 style(editor): scope the float responsive :global to .container (#145 review)
Per review: the file's other :global is locally scoped (.container:global(...)),
but the new float-reset media rule was fully global in a *.module.css. Scope it to
.container — the image node-view container carries BOTH the .container class and
the data-image-align attribute (same element), so behavior is unchanged while the
selector no longer leaks globally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:08:44 +03:00
claude code agent 227 99359fa0fa fix(editor): load the float responsive rule + test applyAlignment (#145 review)
Review of #157 (Request changes) caught two blockers:

1. DEAD responsive CSS: the `@media (max-width:600px)` float-reset was added to
   `image-resize.module.css`, which is imported NOWHERE — the image container's
   classes come from `common/node-resize.module.css` (via buildResizeClasses).
   So on mobile a floated image kept its px width + float and crushed the text,
   exactly the failure the rule promised to prevent. Moved the rule to
   `common/node-resize.module.css` (the module actually imported by the resize
   node views); its `:global([data-image-align=...])` selectors are data-attr
   based, so they work unchanged. Reverted the dead addition from the (pre-existing,
   orphaned) image-resize.module.css.

2. `applyAlignment` was untested. Exported it and added `image.spec.ts` (vitest/
   jsdom) covering all five align values, the data-image-align mirror, and the
   floatLeft -> left reset-then-apply (the guard against a leaked float).
   Switched the float writes to the canonical CSSOM `cssFloat` property (portable:
   browsers + jsdom; behavior identical to the `.float` alias).

editor-ext build + client tsc clean; 6 image.spec tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:49:33 +03:00
claude code agent 227 8ef66ba712 feat(editor): float image with text wrap (#145, port from Forkmost)
Adds floatLeft / floatRight image alignment so text wraps beside the image,
beyond the existing block left/center/right. Ported from Forkmost PR #7 /
upstream Docmost PR #1132 (fuscodev), adapted to gitmost's imperative image
node-view (the upstream uses a React styled component; ours styles the node-view
container directly via applyAlignment).

- editor-ext image.ts: `setImageAlign` accepts `floatLeft`/`floatRight`;
  `applyAlignment` resets float/padding then, for a float mode, sets
  `float:left|right` + side padding on the (shrink-to-fit) container so text
  flows beside it (the inner <img> already has max-width:100%). The resolved
  align is mirrored onto the container as `data-image-align` for the responsive
  rule. `data-align` already round-trips the value through parse/renderHTML, so
  float survives serialization / collab / history with no schema change.
- image-menu.tsx: Float-left / Float-right bubble-menu buttons (IconFloatLeft/
  Right) with active state.
- image-resize.module.css: on narrow screens (<=600px) a floated image collapses
  to full width and drops the float (`!important`, keyed on data-image-align) —
  the upstream "100% width on small screen" follow-up.
- i18n: en-US + ru-RU strings.

editor-ext build + client tsc --noEmit clean. Visual wrap behavior is best
confirmed in-browser (logic/serialization verified by build + types).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:35:34 +03:00
claude_code 8bb441870a test(editor): address PR #147 review — reflow tests, code-block guard, a11y
Resolve the pre-merge review items for the #146 NodeView content-first fix:

- Export collectScrollAncestors/reflowAfterPaste and add editor-paste-handler
  unit tests covering ancestor selection (overlay included, non-overflowing
  auto excluded, X axis), the scrollHeight>clientHeight gate, scrollingElement
  dedup, the docEl==null branch, and the double-rAF nudge.
- Extend the structural guard with CodeBlockView and merge the two it.each
  blocks into one document-order assertion (handles the <pre> nesting where the
  contentDOM is not the literal first child).
- Simplify the post-paste nudge to a single scrollTo(scrollLeft, scrollTop).
- Document that the post-paste reflow runs on every paste path intentionally,
  and cross-reference the two #146 mitigations in both fixes.
- a11y: aria-hidden the decorative footnotes heading and number marker, and
  label the footnotes list via role="group" + aria-label so the visual reorder
  does not break screen-reader reading order (WCAG 1.3.2).
- CHANGELOG: add a Fixed entry noting the caret fix is macOS-verified manually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:36:39 +03:00
claude code agent 227 67057de214 test(editor): guard #146 contentDOM-first invariant + fix code-block comment (PR #147 review)
Addresses the #147 review (Approve with comments):
- Add footnote-views.structure.test.tsx: a structural regression guard asserting
  the editable NodeViewContent is the FIRST child of FootnotesListView and
  FootnoteDefinitionView, with no contenteditable=false chrome before it. The
  whole #146 fix rests on this DOM-order invariant; the macOS caret symptom needs
  a real browser, but the order proxy is testable in jsdom. Stubs @tiptap/react
  so the views render as plain DOM — the test passes on the fixed order and fails
  on the pre-fix chrome-first order.
- Reword the code-block-view comment: it claimed a "top-right overlay (the
  transclusion pattern)", but the menu stays fully in flow as a full-width row
  lifted via flex `order: -1` (the .codeBlock wrapper is a flex column). No
  overlay/absolute positioning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:47:29 +03:00
claude code agent 227 1c39a45bc5 fix(editor): reflow scroll containers after paste to refresh click hit-testing (#146)
Pasting markdown/code inserts React NodeViews that mount asynchronously; until
the next reflow the browser's hit-test geometry is stale, so ProseMirror's
posAtCoords/caretRangeFromPoint maps a click to the wrong (offset) line — which
users reported clears itself on any scroll. Reproduce that scroll's side effect
with a ZERO-delta nudge (re-assign scrollTop/scrollLeft to their current value)
on every scrollable ancestor + the document scrolling element, run across two
animation frames so it lands after the pasted content + NodeViews commit. The
nudge does not move the viewport.

Wired into editor-paste-handler's handlePaste, which ProseMirror's someProp runs
(as an editorProps handler) before the MarkdownClipboard plugin that performs the
markdown/code insert — so the nudge is scheduled on exactly the paste path that
triggers the bug. Complements the structural NodeViewContent-order fix in this
branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:37:56 +03:00
claude code agent 227 38544e2ddc fix(editor): render NodeViewContent first so click hit-testing isn't offset (#146)
Three editable NodeViews rendered a contentEditable=false "chrome" element IN
FLOW BEFORE NodeViewContent. On macOS the browser's click hit-testing
(posAtCoords → caretRangeFromPoint) then misses the contentDOM and snaps the
caret to the previous node — the caret/selection lands a line (code block) or
several lines (footnotes, into the body) above where the user clicked.

Fix (the transclusion pattern / issue #146 plan): make the editable
NodeViewContent the FIRST child in the DOM and move the non-editable chrome
AFTER it, restoring its visual position with CSS:

- code-block-view: <pre><NodeViewContent/></pre> first; the language/copy menu
  follows and is lifted above via flex `order` (.codeBlock is now a flex column).
- footnotes-list-view: NodeViewContent first; the "Footnotes" heading follows and
  is lifted above via flex `order` (.list is a flex column; the separator border
  stays on the container).
- footnote-definition-view: NodeViewContent first; the "N." marker follows with
  `order:-1` to stay on the left; the ↩ back-link stays on the right.

Layout is visually unchanged. Verified in a real browser (Chromium): the
contentDOM is now the first child of every editable NodeView wrapper (no
contentEditable=false element precedes it), and the menu/heading/marker still
render in their original positions.

NOTE: the caret-offset itself is macOS-specific text hit-testing and does not
reproduce in headless Chromium/WebKit on Linux (verified extensively), so the
visible fix is best confirmed on macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:48:21 +03:00
claude_code da058bb6a0 feat(editor): transcribe embedded audio blocks
Add a gated "Transcribe" action to the audio block's bubble menu so an
already-embedded audio file can be transcribed (previously only live
microphone dictation was supported). The button fetches the embedded
file, normalizes its MIME type to the STT whitelist, reuses the existing
POST /ai-chat/transcribe endpoint, and inserts the result as a paragraph
right below the audio block.

- Mount the previously-unwired AudioMenu in page-editor (edit mode only),
  which also surfaces the existing Download/Delete actions for audio.
- Gate the Transcribe button on settings.ai.dictation; show a spinner and
  block double-submits while transcribing; map errors like the mic hook.
- Disambiguate duplicate-src blocks by re-scanning the doc and inserting
  after the audio node closest to the originally selected one.
- Add i18n keys (en-US, ru-RU): Transcribe, Transcribing…, No speech
  detected, plus ru-RU translations for the transcription error messages.
2026-06-23 01:47:34 +03:00
claude_code 44a1b5b003 feat(dictation): gate streaming dictation behind a workspace toggle
Streaming (silence-cut) dictation was hardcoded on. Put it behind a per-workspace
flag settings.ai.dictationStreaming, default off, with batch dictation as the
default and fallback. Mirrors the existing settings.ai.dictation flag end to end:

- server: aiDictationStreaming on UpdateWorkspaceDto + workspace.service writes
  settings.ai.dictationStreaming via updateAiSettings (jsonb merge keeps siblings)
- client: IWorkspaceAiSettings.dictationStreaming, an optimistic "Streaming
  dictation" sub-toggle under "Voice dictation" (disabled when dictation is off)
- gate the MicButton streaming prop in the editor toolbar and chat composer on
  the flag instead of a literal true

When the flag is absent/false both call sites pass streaming=false, so the VAD
model/wasm are never fetched and behavior is unchanged. Reuses the existing STT
model and /ai-chat/transcribe — no new provider/model/endpoint fields.

Removes the backlog entry now that it is implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:59:35 +03:00
claude code agent 227 9e1d057878 fix(qa): resolve QA-pass issues #122–#134
Batch of fixes from the automated QA pass on develop. Each was reproduced and
then verified fixed live (browser/curl); logic-bearing fixes have unit tests.

Functional bugs:
- #122 collab-token was capped by the anonymous public-share-AI throttler (5/min);
  skip all non-AUTH named throttlers on this auth-guarded, client-cached route.
- #123 editor onAuthenticationFailed threw `jwtDecode(undefined)` and never
  reconnected; read the token via a ref, guard the decode (incl. missing exp),
  and refetch+reconnect on any auth failure.
- #124 a slash command containing a space ("/Heading 1") inserted literal text;
  enable allowSpaces and close the menu when the query matches no items.
- #125 space slug auto-gen produced uppercase initials for multi-word names;
  computeSpaceSlug now yields a lowercase alphanumeric slug.
- #126 AI chat window position/size now persisted (atomWithStorage) across reload;
  also fixes a latent ResizeObserver-attach bug on first open.
- #127 workspace name update accepted URLs; add @NoUrls (parity with setup).
- #132 icon-columns 4/5 passed calc() into SVG width/height attrs (console spam);
  size via style. share-for-page query returns null instead of undefined.
- #134 "Reindex now" counter looked stuck: reindex runs async; the client now
  polls coverage (bounded) so the counter climbs live; misleading server comment
  reworded.

UX / consistency:
- #128 add success toasts to favorite/label/avatar/member-(de)activate.
- #129 "1 result found" pluralization; hide the single-option Type filter.
- #130 replace raw Zod strings with friendly messages (name/password/group).
- #131 unify "Untitled" casing in tree/breadcrumb/tab; stop force-uppercasing
  space-name chips; fix confirm-dialog labels (Cancel / Remove), invite
  placeholder typo, Export/Move-to-space labels.
- #133 disable profile Save when clean; toast on unsupported avatar image;
  style the invalid-invitation page with a CTA; hide Share for read-only users;
  align the dictation "not configured" message; "Go to login page" typo.

Tests: computeSpaceSlug, workspace-name NoUrls DTO, share-query null
normalization, slash getSuggestionItems empty-close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:47:40 +03:00
claude_code 4f0da42d88 feat(dictation): streaming STT via silence cut (Silero VAD)
Add a lightweight "streaming" dictation mode as a simpler alternative to the
realtime-websocket path: detect speech with Silero VAD (@ricky0123/vad-web),
cut each segment on a pause and POST it to the existing /ai-chat/transcribe
endpoint, so text appears progressively. No server changes.

- new useStreamingDictation hook (same API as useDictation), lazy-loads VAD,
  in-order seq emission, session-epoch guard against stop->start races
- new encodeWavPcm16 util (Float32 -> mono PCM16 WAV, accepted by the server)
- MicButton gains a `streaming` prop; enabled in the editor toolbar and chat
- VAD tuning: redemptionMs 640 / preSpeechPadMs 320 / minSpeechMs 96
- batch dictation kept as the fallback (streaming=false)
- deps: @ricky0123/vad-web@0.0.30, onnxruntime-web@1.27.0

Note: VAD assets load from the library CDN by default; for self-hosted/offline
set VAD_BASE_ASSET_PATH/VAD_ONNX_WASM_BASE_PATH and copy assets to public/vad/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:52:05 +03:00
claude_code ef74058301 style(editor): align byline dictation mic with the info icon
The byline mic rendered blue and with a smaller (16px) glyph next to the
gray 20px info icon, so it looked misaligned with an uneven gap. Add
optional color/iconSize props to MicButton (forwarded through
DictationGroup) and render the byline mic gray at 20px, wrapping it and
the info icon in a tight nowrap group so they read as a snug, aligned
pair. The AI chat mic is unchanged (passes neither prop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:48:50 +03:00
claude_code 3a3d22ac55 refactor(editor): remove duplicate dictation mic from the fixed toolbar
The dictation button now lives in the always-rendered page byline, so the
copy in the fixed toolbar was redundant: with the toolbar enabled the mic
showed up twice. Drop the DictationGroup render, its isDictationEnabled
guard, and the unused import from the toolbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:33:07 +03:00
claude_code 3d4ad664b3 test(refactor-tail): extract pure cores + cover collab/share/ai-chat/client gate
Batches 6-9: behaviour-preserving extractions of testable pure cores plus the
tests they unblock, and a fix for the broken client test environment.
Full suites green: server 113 suites / 1117 + 1 todo, client 30 files / 338.

client (R0 infra):
- vitest.setup.ts: in-memory localStorage/sessionStorage Storage stub wired via
  setupFiles. Unblocks menu-items.gating.test.ts (was 9 failing) -> client suite
  fully green. + menu-items.suggestions.test.ts (getSuggestionItems filter/sort).

share:
- extract buildShareMetaHtml (share-seo.util.ts) from the SEO controller; tests
  for reflected-XSS escaping in <title>/og/twitter meta, noindex, truncation;
  extractPageSlugId; updateAttachmentAttr; prepareContentForShare comment-strip
  (anonymous-viewer metadata-leak guard).

ai-chat (security extractions):
- selectAccessibleHits: CASL post-filter for semantic search (restricted page in
  an accessible space must NOT leak to the agent).
- validateResolvedAddresses: SSRF connect-time guard (block if ANY resolved
  address is private).
- resolveAudioFormat: mime whitelist (dead `?? 'webm'` fallback dropped, set
  unchanged). + mcp-servers toView header-leak guard, MCP tool namespacing.

collaboration (data-loss area):
- extract computeHistoryJob (pins the "agent delay MUST stay 0" invariant) and
  resolveSource. Integration: onAuthenticate read-only matrix (collab auth
  bypass), HistoryProcessor (contributor restore on save failure), onStoreDocument
  Approach-A boundary snapshot (human revision pinned before agent overwrite).

Reviewed (APPROVE WITH SUGGESTIONS): extractions behaviour-preserving, security
tests mutation-resistant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:10:27 +03:00