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>
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 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>
Review round 1 on the scroll-position feature:
- F1: add two tests for the hook's subtlest invariants — (a2) the restore
target is captured synchronously at mount and survives a fresh scroll@0
overwriting storage on load (a regression moving the capture into an effect
would now fail); (a3) restore runs at most once per mount even when called
again (the wiring effect can re-run).
- F2: log instead of silently swallowing sessionStorage errors in
readStorage/writeStorage (AGENTS.md "errors must never be swallowed" rule);
no user notification since a missed scroll restore is not actionable.
- F3: document the hard dependency on PageEditor remounting per page
(key={page.id}) at the refs declaration — the per-mount refs are not reset
on an in-place pageId change, so removing that key would break restore on
the 2nd page.
vitest 9/9, tsc 0, eslint 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds useScrollPosition(pageId): saves window.scrollY to sessionStorage
(key gitmost:scroll-position:<pageId>) on throttled scroll / pagehide /
visibilitychange / cleanup, capturing the previously-saved value
synchronously at mount before any handler can overwrite it with the fresh 0.
restoreScrollPosition() (wired in page-editor.tsx to fire once the live
content is laid out, !showStatic && editor) yields to a #hash anchor, then
polls the document height and scrolls to the saved Y once the content is
tall enough, with a 5s timeout clamped to the max reachable position. All
storage access is try/caught so a disabled/quota'd Storage never breaks the
page. The in-flight restore poll is held in a ref and cancelled on unmount,
so a fast SPA navigation can't scroll the next page. closes#266
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add coverage for the two untested branches in useGeneratePageTitle's
post-generation write: suppressing setContent when the live title editor
is focused (DB write + broadcast still happen, only the visible field
write is skipped), and the early return when the page editor is
destroyed (model never called).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CHANGELOG: add an [Unreleased]/Added bullet documenting the
"generate title from content" byline button (reads live editor
content, generates via the workspace AI provider, applies through
/pages/update, gated by settings.ai.generative, throttled per user).
- use-generate-page-title: guard the visible title write against page
navigation during generation. The mutation awaits the model for 1-3s;
its closure captures the editors from the starting render, but the
global page/title atoms re-point on navigation. We now keep a live ref
to the current editors and skip setContent unless the live page editor
still belongs to the page the title was generated for
(editor.storage.pageId === pageId, mirroring TitleEditor's
activePageId guard). The DB write stays correct (keyed by the captured
pageId) and the websocket broadcast is unchanged, so only the wrong-page
field write is suppressed.
- Add a vitest suite for the hook: empty content, empty model response,
happy path, the navigation guard, and 403/503/429/other onError mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
* feat: add heading extension with unique ID support and scroll functionality
* Added unique id for heading
* remove baseUrl heading storage
* move heading to extensions package
* WIP
* support anchors in mentions
* enhance scrolling functionality
* nodeId function
* fix nanoid import
* Bring unique-id extension local
* fixes
* fix internal link scroll in public pages
* add unique id server side
* rename mention anchor to anchorId
* capture first anchorId on paste
---------
Co-authored-by: Romik <40670677+RomikMakavana@users.noreply.github.com>