The N1-D1 fix added an early `return {}` when `currentPage == null` in
importPageMarkdown. `currentPage` is a const, never reassigned, so from that
guard onward it is provably non-null — which made the cross-space (S2) gate's
comment false ("a not-found page still proceeds as before": a not-found page
now returns early above) and left dead null-handling around it.
- Rewrite the S2-gate comment: the null case is handled by the N1-D1 guard
above; here currentPage is guaranteed non-null. Confused-deputy / cross-space /
mirror-deletePage explanation kept intact.
- Drop the dead `currentPage &&` conjunct from the S2 condition (always true).
- Collapse downstream vestigial `currentPage?.` / `currentPage!` / the
`currentPage ? … : undefined` ternary to plain `currentPage.` — all
behavior-preserving (currentPage non-null after the guard). The unrelated
`page ? … : undefined` ternary (fresh findById that can be null) is untouched.
No runtime behavior change. jest gitmost-datasource.service.spec.ts: 34 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A vault file whose `gitmost_id` is a WELL-FORMED UUID that matches no page (a stale
id from a restored-from-backup file, or a copied/foreign id) fell through
importPageMarkdown to writeBody() on a non-existent page, throwing "Page … not
found". The push apply recorded that as a per-cycle failure that never cleared —
refs never advance, so the whole space's sync looped on the failure indefinitely
(observed live: a leftover orphan file kept a space stuck at "1 failure" every ~5s).
Same user-visible impact as C9-D1, but the id is a valid uuid so the 22P02 guard
does not catch it.
Add the missing `currentPage == null` branch in importPageMarkdown: skip the
unknown id as an inert no-op so the cycle succeeds and the rest of the space keeps
syncing. Verified on the stand: pushing a valid-but-nonexistent gitmost_id now stays
at 0 failures (was 1/cycle forever), logs a skip warn, and a concurrent legit edit
still syncs. Unit test added; server suite green (2146).
NOTE (separate design follow-up, not this commit): the reconcile still cleans the
orphan file (it maps to no live page). ADOPTING such a file as a fresh page (the
restore-from-backup use case, preserving the git-authored content) needs the title
from the filename, which lives in the engine classifier, not this method.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A non-UUID gitmost_id on a parent folder-note, used as parentPageId for a
git-sync createPage (or as a movePage destination), wedged the entire space:
the throw landed in `failures`, and push only advances refs when
failures.length === 0, so the space re-attempted forever.
createPage was the only user-influenced-uuid op left unguarded. The throw is a
NotFoundException, not a 22P02 error: PageRepo.findById falls back to a slugId
lookup for non-UUID input, finds no row, and PageService.create raises
NotFoundException — so skipIfMalformedId (22P02-only) would NOT have caught it.
Coerce-to-root is the correct fix: a non-UUID parentPageId is rewritten to root
(undefined/null) so the page is created/moved at the space root instead of
wedging. No data loss (page still created) and no duplication (push.ts writes
the assigned id back to frontmatter, so the next sync matches by id, and the
retry-adopt map re-parents once the vault id is fixed).
Applied to both createPage and movePage (the move destination is reachable via
two paths, one 22P02-swallowed-but-mislogged and one NotFound-wedging). The
child pageId stays guarded by skipIfMalformedId.
F2: softened the skipIfMalformedId comment (parentPageId is a second
user-influenced uuid in create/move) and made the swallow log op-generic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A vault file with a broken/hand-edited `gitmost_id` frontmatter (e.g.
`gitmost_id: [unclosed` or a non-uuid token) fed that value into a Postgres
`uuid` predicate (page update/delete), throwing 22P02 "invalid input syntax for
type uuid". The push apply recorded it as a per-cycle failure that never cleared —
refs never advance when failures>0, so the WHOLE space's sync looped on the same
failure indefinitely and no further legitimate change synced (found via web-test).
Wrap the id-scoped write ops (import/delete/move/rename/restore) at the bind()
seam: swallow exactly the 22P02 as an inert no-op so the cycle succeeds and the
rest of the space keeps syncing; re-throw anything else. pageId is the only
user-influenced uuid in these ops, so a 22P02 there unambiguously means it.
Verified on the stand: pushing a non-UUID gitmost_id now logs a skip warn and the
space stays at 0 failures (was 1 failure/cycle forever); a concurrent legit edit
to another page still syncs. Unit tests: import/delete swallow 22P02, non-22P02
re-throws. Full server suite green (2145).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QA follow-up on 5d45f5a8: that commit taught the converter to export heading
textAlign (<hN style>) but left the converter-gate heading test still asserting
the OLD dropped behavior (expects a bare '## text'), so jest was RED — the G1
green-suite gate was not actually met. Two gaps closed:
1. Flip the heading KNOWN-DIVERGENCE gate test to assert the round trip now
PRESERVES alignment (exported as <h2 style="text-align:center"> and recovered
on import), matching the shipped converter behavior. Suite is green again.
2. blockToHtml (the nested-container path: heading/paragraph inside a
column/table/callout) still emitted bare <hN>/<p>, dropping textAlign for
nested blocks. Carry the style there too, symmetric with the processNode path.
Also add #7 (table inside a column) and #8 (multi-block table cell) to the
lossless round-trip CORPUS so both survive export->import through the real
editor-ext schema (columns widthMode pre-authored at its normalize fixpoint).
Verified: server jest 193 suites / 2142 tests green, git-sync vitest 704 green,
no type errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F5 (HIGH data-loss): guard #2 (GS-EDIT-REVERT) called a local key-sorting equality that
never matched a real page (block ids + materialized defaults differ), so the guard was
dead and a web edit on a git-sync space was silently reverted within one poll cycle. Use
the package's authoritative docsCanonicallyEqual (strips block id + normalizes
KNOWN_DEFAULTS), wired through the git-sync loader like sanitizeTitle; delete the dead
local canonicalize/canonicalJsonEqual.
S2 (security): importPageMarkdown targeted a page by the vault-file id without a spaceId
check (deletePage had one) — a space-A vault file carrying space-B's page id could
resurrect/overwrite/clear B's page. Mirror deletePage's guard: skip when the loaded page
lives in a different space than ctx.spaceId.
G1 (jest green): add sanitizeTitle + docsCanonicallyEqual to the loadGitSync mock; update
the converter-gate + package golden expectations to the genuinely-fixed output (paragraph
textAlign now round-trips, multi-block table cells emit HTML tables); fix the orchestrator
spec's stale mock so the per-space enabled gate (added later) is satisfied.
A1: the converter dropped heading textAlign on export (bare '## text'); emit a styled
<hN> when aligned, symmetric to paragraphs — round-trips losslessly (level + align), no
churn for unaligned headings.
F7 (docs): reword the false 'single choke point' title-strip comment; correct push.ts
docstrings that still described the removed standalone-CLI/daemon model.
Adds regression tests: the F5 acceptance test (canonically-equal content with real uuids
=> writePageBody NOT called), the S2 cross-space import guard, and the A1 heading
round-trip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The existing name-level contract (packages/git-sync schema-editor-ext-contract)
only compares node/mark TYPE NAMES, so a new attribute added to an existing node
upstream slips through and is silently dropped on every git-sync round trip -- a
repeatedly-hit data-loss class (image caption #221, paragraph align #10).
This closes the attribute gap by comparing the RESOLVED ProseMirror Schema
objects (getSchema has already merged all addGlobalAttributes spreads into
concrete per-node attrs) of the server's canonical tiptapExtensions vs the
git-sync mirror, asserting equal attribute-key sets per shared node/mark modulo
a committed, self-checking allowlist of the 6 understood divergences. A forgotten
attribute now fails CI loudly instead of losing data. Comparing resolved schemas
(not raw extension configs) is what makes this stable rather than the fragile
config-shape compare the name-level test deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clearing a page's body in git advanced the vault ref past the empty commit, but
the persistence store-side empty-guard rejected the empty write (reloading the
non-empty DB content) — so Docmost kept the old body while the vault held the
empty one, a permanent silent vault<->Docmost divergence that never re-detects.
A git-sync write is authoritative and its content IS the vault file, so an empty
incoming doc there is a DELIBERATE clear (no transient-glitch empties for a
file-sourced write). Allow it (lastUpdatedSource==='git-sync'), mirroring the
#251 intentional-clear allowance for the user-signalled source.
Verified on stand: a git-side body clear takes the page body 28 -> 0 (page not
trashed); previously it stayed 28 (diverged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A git-side revert of a delete commit re-adds the page's .md; the push classifier
saw an add carrying a known pageId and emitted an UPDATE, writing the body to the
still-trashed page. It stayed in Trash and the next pull re-deleted the file, so
the revert was silently nullified (permanent vault<->Docmost divergence). In
importPageMarkdown, if the target page is soft-deleted, restorePage() it first
(restorePage was already in the client seam but never called), then apply the
body — so a git revert actually brings the page back.
Verified on stand: git rm -> page trashed; git revert -> deleted_at cleared
(page restored).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dead code / doc-vs-code cleanup from review #4404:
- Drop the unused Settings.docmostApiUrl/Email/Password fields (the native
in-process datasource never reads them; the engine only used docmostSpaceId).
Removed from the type, buildSettings, and the 4 engine test suites that
fabricated them.
- Warn ONCE at startup when GIT_SYNC_REMOTE_TEMPLATE is set — remote push is
deferred (SPEC §7) so the value is currently inert; the operator now gets a
log line instead of a silent no-op.
- Correct stale docstrings that claimed live-destructive code was 'FAKES only /
NEXT increment / no live wiring' (applyPushActions runs LIVE via
runCycle->orchestrator); that importPageMarkdown receives a 'self-contained
file (meta+body)' (it receives the stripped body); and that the Yjs body merge
is '2-way' (3-way runs end-to-end when the base is present).
No behavior change (except the new warning); build + git-sync smoke verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Review #4: forward HTTP_CONTENT_ENCODING to git http-backend so it inflates
gzip'd RPC bodies — a non-trivial `git pull` no longer fails with
`fatal: expected 'packfile'`. (git-http-backend + git-http.service)
- Review #5: the read-advertisement branch runs under the space lock AFTER
reply.hijack(); a reject there (e.g. Redis down) previously left the socket
open forever, hanging every clone/fetch. Mirror the push branch: catch, 500 if
unwritten, always end the socket. (git-http.service)
- GS-EXPORT-500 (QA): a page with an inline comment mark returned HTTP 500 on
Export/copy-as-markdown. The Comment mark's renderHTML took the imperative
document.createElement branch server-side (the DOM shim used by generateHTML
defines window/document), returning a live node with no content hole that
crashed prosemirror-model's DOMSerializer under happy-dom. Gate the imperative
branch on a real browser (navigator.userAgent contains 'Mozilla'); the server
now uses the static DOMOutputSpec form. Verified: export 200 (was 500).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses reviewer comment #4404 (critical + blocking):
- Critical #2: renamePage skips the echo where the incoming title equals
sanitizeTitle(current title) — a Docmost title with FS-hostile chars (: / " |,
newlines, double-space, >120) was pulled to a sanitized stem then written back,
permanently corrupting the real title. (datasource)
- Blocking #3: runOnce enforces per-space settings.gitSync.enabled (the event
path bypassed opt-in; any edited space would git-init + export). (orchestrator)
- Blocking #6: movePage no-ops the position-less same-parent echo that clobbered
the user's chosen sibling order. (datasource)
- Blocking #9: hasConflictMarkers is fence-aware — '<<<<<<< HEAD' inside a code
block (git-tutorial page) no longer trips the all-or-nothing gate that froze
the whole space's refs. (push.ts)
- Blocking #11: three-way tryMergeRegion short-circuits when live==target (diff3
agreement) instead of logging a false 'same-block conflict resolved to git' —
the echo noise that masked real data-loss signals. (three-way-merge)
- Blocking #12/#13: e2e-advanced — drop the delete-cap block (no such feature;
failed with a scary '(data loss!)'); non-member assert now expects 404 (existence
not leaked), not 403.
Verified on stand: sanitized-title rename preserves DB title (vault file
sanitized); non-enabled space creates no vault; fenced conflict markers ingest
without jamming; build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a git-sync space, a page's web edit was silently reverted within ~1 poll,
and idle spaces showed dozens of 'update' actions per cycle with no real change.
Root cause: the vault->Docmost body ingest (importPageMarkdown) is re-run every
poll for pages the upstream change-detection mis-flags as changed (the
markdown<->ProseMirror round-trip is not byte-stable: JSON key order / default
attrs differ though the content is identical). Each call re-imports the SAME body
into the live collab doc -- a no-op at idle, but it CLOBBERS a concurrent human
edit still in the debounced (not-yet-flushed) Yjs doc.
Fix: skip the ingest when it is genuinely a no-op --
1) baseMarkdown byte-identical to the current file (vault unchanged), or
2) the parsed incoming body is canonically-JSON-equal (key-order-insensitive)
to the page's current Docmost content.
A real git-side change is neither, so legitimate git->Docmost ingests still apply.
Verified: idle churn 38 update/cycle -> 0; web edit on an affected page 0/3 -> 3/3
persisted; genuine git-side edit still ingests. Found by autonomous QA
(web-test-orchestrator) + independent verifier.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: git-sync.orchestrator.spec bind assertion now includes spaceId ('space-1'),
matching driveCycle's dataSource.bind({workspaceId,userId,spaceId}).
F2: add 4 non-vacuous tests for the cross-space move data-loss guard in deletePage
(CTX_SPACE with spaceId): move-out skips removePage (returns skipped:'moved-to-other-space');
same-space / not-found / already-deleted all still call removePage.
F3: add 2 tests for the ~<slugId> title-strip guard in renamePage (own slugId stripped;
a foreign ~<slugId> tail left intact).
F4: reword the gitmost-datasource 'single choke point' comment — the strip covers the
rename/update path, not every git-sync title write (createPage's filename-derived title
does not funnel through here).
Co-Authored-By: Claude Opus 4.8 (1M context) <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>
A page moved to another space with git-sync enabled was sent to Trash and
vanished from BOTH vaults. The source space's push phase sees the moved-away
page's file gone from its vault and calls deletePage -> soft-delete, even though
the page still lives in the destination space.
Thread the reconciling spaceId into the bind context and, in deletePage, skip the
soft-delete when the page's CURRENT space differs from the space being reconciled
(a move-out): only the vault file is dropped, the page is preserved. Genuine
in-space deletions are unaffected (space matches).
Found by autonomous QA (web-test-orchestrator). Control: with git-sync OFF the
move keeps deleted_at NULL; with it ON the page was trashed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two sibling pages that share a title collide on one vault filename, so the
layout appends a cosmetic ` ~<slugId>` suffix (engine disambiguate()). That
suffix is a local filesystem artifact and must never become the page's real
Docmost title, but on ingest the filename-derived title carried it back into
the DB on some paths (observed: intermittent same-title collision left a page
permanently titled "Title ~<slugId>"). Strip it in renamePage() — the single
choke point every git-sync title write funnels through — but only when the
trailing token equals THIS page's own slugId, so a genuine user title that
legitimately ends in ` ~token` is never corrupted (slugId is a random nanoid).
Repro: create two pages with the same title; ~1 in 4 the second page's title
is permanently polluted. After fix: 0/6.
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>
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
F1: extract the navbar-visibility crux (width/height 0 or right<=0 -> hidden)
from getNavbarRect into a pure isNavbarRectVisible in dock-helpers.ts + 3 tests;
getNavbarRect calls it (identical null cases).
F2: base the dock/undock button's label/icon/title on the effective useDock state
(docked && dockRect present) rather than the raw docked flag, so a docked window
that fell back to floating (collapsed sidebar) doesn't show 'Undock'. Toggle
action unchanged; no remount.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
Drag the floating AI-chat window onto the sidebar and release over it to DOCK it
— the window pins to the live navbar rect, overlaying the page tree; a drop-zone
highlight shows while dragging over it. Closing the chat re-shows the tree.
Undock via a header button or by dragging the docked window back onto content
(pops out floating at the drop point). The docked/floating mode persists in
localStorage and the docked window follows the navbar width (manual resize,
space<->shared route change) via a ResizeObserver + sidebar-toggle/transitionend
re-sync; when the navbar is collapsed/absent the window falls back to floating
instead of vanishing. Dock/undock only flips a mode atom + geometry — ChatThread
is never remounted, so an in-flight response stream is not interrupted.
Frontend only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent rebuilds context from DB each turn and didn't know the user manually
edited the open page since its last response, so it could overwrite those edits.
Add a per-turn ephemeral <page_changed> note in the system prompt (twin of
INTERRUPT_NOTE, self-clearing) carrying a unified Markdown diff of what changed
since the END of the agent's previous turn.
- New ai_chat_page_snapshots table (migration + hand-declared db.d.ts/entity
types) storing the page Markdown per (chat,page) at each turn's end.
- Pure computePageChange util (whitespace-normalized unified diff via the
existing jsdiff dep, 6KB cap + getPage hint).
- Turn start: if the open page's updatedAt moved past the snapshot, diff current
vs snapshot; non-empty -> PAGE_CHANGED_NOTE in the safety sandwich.
- Turn end: upsert the snapshot on EVERY terminal path (onFinish/onError/onAbort,
once) so the agent's own edits are excluded by construction even on aborted
turns.
All best-effort (never breaks/latency-regresses a turn); fast path when updatedAt
is unchanged. Server-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
The banner only offered 'Make permanent'. Add a secondary destructive
'Move to trash' button that soft-deletes the note now instead of waiting for
TTL expiry, reusing the tree/header soft-delete path (useTreeMutation.handleDelete):
optimistic tree removal, the undo-toast, the deletedAt cache stamp, and the
redirect to space home. No confirm modal (project convention = undo-toast).
Gated on the existing Edit permission. Client-only, no server/i18n changes
(both labels already exist).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- F1: gate the card on rows-WITH-text (`thread.some(row => row.text.length > 0)`)
instead of thread length. A text-less root whose only reply is also text-less
would otherwise open an empty <Paper> (the render already filters empty rows).
New test locks it (parent + reply both empty → no card).
- F2: ESTIMATED_CARD_HEIGHT 200 -> 300 (= CARD_MAX_HEIGHT) so the flip-above
decision reserves the real worst-case height and a tall thread near the
viewport bottom flips up instead of overflowing off-screen.
vitest 19/19, tsc 0, eslint 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per maintainer feedback: show the comment author and the whole thread (parent
+ replies), but as simple "Author: text" lines — no avatars, timestamps, or
thread chrome ("it's already clear they're comments on one entry, one after
another"). Also lengthen the open delay so the card doesn't pop up on a
passing glance.
- Render each comment in the thread as a plain line: bold "Name:" + text,
parent first then replies (createdAt asc). Empty-text comments are skipped.
- OPEN_DELAY_MS 120 -> 350.
- Drop the avatar/relative-time/divider UI (and the CustomAvatar/timeAgo
imports). buildThread (root + direct replies) is unchanged — the comment
model is flat, so direct children of the root are the full thread.
Tests updated to the "Author: text" shape (textContent-based, incl. ordering).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lock the feature's central invariant — the tooltip must never intercept the
comment-mark's click (which opens the side panel). pointer-events:none is the
single property guaranteeing that, and it was unasserted: a regression dropping
it from the style object would let a lingering card swallow the click with no
test failing. Assert it in the "shows after delay" test.
Adds CommentHoverPreview, mounted in page-editor next to <EditorContent>:
hovering a `.comment-mark[data-comment-id]` span shows a small floating card
(createPortal, position:fixed, pointer-events:none so it never intercepts the
mark's click) with the parent comment's plain text. Uses useCommentsQuery
(shares the ["comments", pageId] cache with the side panel — no extra
request). Skips unknown/not-yet-loaded, resolved (data-resolved attr or
resolvedAt/resolvedById), and empty-text comments. A ~120ms open delay avoids
flicker; hides on mouseout / mousedown / scroll(capture) / resize / page
change. commentContentToText flattens the comment's ProseMirror doc
(stringified or parsed) to plain text, preserving hardBreaks as newlines and
never throwing. Main editor only (read-only / shares / history out of scope).
closes#268
Co-Authored-By: Claude Opus 4.8 (1M context) <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>