Compare commits

..

67 Commits

Author SHA1 Message Date
claude code agent 227
5141279e42 test(git-sync): mock db.transaction in movePage provenance specs
After rebasing onto develop, movePage runs its cycle-check + UPDATE inside
executeTx(this.db) (develop #207 advisory-lock/atomic cycle-guard). The
git-sync provenance specs still passed a bare `{}` db, so executeTx hit
`db.transaction is not a function`. Reuse the same trxStub Proxy + transaction
mock the develop movePage specs use so both the advisory-lock `sql.execute(trx)`
and updatePage resolve. Production movePage keeps BOTH develop's lock/cycle
guard AND git-sync's provenance stamping; this only updates the test harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:44:23 +03:00
claude code agent 227
b9875ba555 fix(editor,git-sync): parse details open as a boolean so open state survives render/round-trip
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
6d83a7b7a6 fix(git-sync): propagate nested details open; drop dead delete-cap wiring; cover lost-lock abort + lose-prone atom round-trips
Addresses review 1863 (delta) on PR #119.

MUST-FIX:
- detailsToHtml (the raw-HTML path used for a details nested inside
  columns/spanned cells) now emits `<details${open}>`, mirroring the
  top-level case, so `open` no longer silently drops every round trip.
- Remove the dead `resolveApplyClient` delete-cap hook from the engine
  `runCycle`: the orchestrator stopped passing it, so the hook + its
  dry-run pass were inert. Deletes are soft (Trash) + always logged and
  engine convergence is the guard, so no cap is re-added — just the dead
  wiring removed.

TEST COVERAGE:
- space-lock: heartbeat refresh CAS-miss (eval -> 0) and Redis-error
  (eval throws) both abort the in-flight fn's signal.
- cycle: a pre-aborted signal (and an abort during the pull read) throws
  before the push apply / first destructive phase.
- converter: htmlEmbed source VALUE + height survive; encode/decode
  UTF-8 symmetry and '' -> ''; footnote definition body + ref/def id
  match; transclusionReference both ids survive; fix the bad
  transclusionSource fixture (wrong `pageId` attr + empty content ->
  schema `id` + a block child); nested details `open` parity test.
- orchestrator: autoMergeConflicts:true reaches engine settings; default
  false on a missing settings row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
7ad4282651 fix(git-sync): normalize merge key against schema defaults — cover all node/mark default-attr duplication triggers (image, link, highlight, …)
The point-fix (7a7b840e) excluded only `indent: 0` via a hardcoded one-attribute
denylist (`DEFAULT_KEY_ATTRS`) applied solely to ELEMENT attributes. The same
divergence recurs for every attribute whose editor-ext (server) schema default the
LIVE Yjs doc materializes (`TiptapTransformer.toYdoc(tiptapExtensions)`) but the
git round-trip does not: the engine's `markdownToProseMirror` emits those attrs as
explicit `null` (verified live: link mark `internal: null`, heading/paragraph
`indent: null`), which `y-prosemirror` then drops — so the same block keys
differently on the two sides, the three-way merge anchors on nothing, and the body
is re-appended every reconcile cycle (unbounded, no client connected). The denylist
also could not reach MARK attributes at all (marks are serialized raw in the
XmlText delta), so the link mark's `internal` mismatch survived.

Replace the denylist with a normalization derived from the ACTUAL ProseMirror
schema (`getSchema(tiptapExtensions)`, memoized): in `serializeXmlNode`, drop any
ELEMENT attribute whose value equals its node's schema default (or is
null/undefined), and normalize each XmlText delta op's MARK attributes the same way
against `schema.marks[name].spec.attrs`. The volatile block `id` stays excluded and
genuine non-default values (a real `indent: 2`, `align: "left"`, `link.href`,
highlight color) stay in the key. This is general — it covers indent, image.align,
link.internal, highlight.colorName, youtube/pdf and any future node/mark — not
another per-attribute denylist. Schema build is wrapped so a degenerate test stub
(`tiptapExtensions: []`) degrades to dropping only null/undefined.

Tests: new `yjs-body-merge.schema-defaults.spec.ts` models image/link/highlight
both hand-built and through the REAL `TiptapTransformer.toYdoc` materialization
(live defaults vs engine-style explicit nulls, base stale-by-one) — RED before
(4 ops / growth), GREEN after (0 ops). Existing idempotency + open-editor
convergence suites still pass (261 server collab+git-sync tests, tsc clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
fe3baa0a23 fix(git-sync): make reconcile import truly idempotent — stop runaway whole-body duplication
The live Yjs document materializes the editor-schema default `indent: 0` on
every paragraph/heading (and on the paragraph inside every list item, callout,
and table cell), but a body re-imported from git — parsed from clean markdown —
carries no indent attribute. So every live block's merge key differed from the
same block coming back from git: the three-way merge could anchor on nothing,
and the trailing unit that git's export already contained (but the merge could
not match against the byte-identical live tail) was re-appended on every
reconcile cycle. Each grown export then diverged from the last-pushed base by one
more unit — a self-sustaining, unbounded whole-body duplication loop with no
client connected.

The prior fix (0c7b73f7) excluded the volatile block `id` from the key, which
was necessary but not sufficient: `indent: 0` is a CONTENT attribute the editor
stamps as a default, so it was never stripped. Normalize editor-materialized
schema defaults (`indent: 0`) out of the block key — only the default value, so a
genuine `indent: 2` still diffs and lands — so a live block compares equal to its
git-round-tripped twin and the resync is a true no-op.

Regression test (yjs-body-merge.idempotency.spec.ts) encodes the invariant on a
body of byte-identical units (heading + paragraph + callout + table with empty
cells): a live fragment carrying indent:0 + ids merged against the git-derived
fragment (neither) with a stale-by-one base applies 0 ops and does not grow — RED
before, GREEN after.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
d3e3297108 fix(git-sync): propagate remote custom-event handler errors instead of 30s timeout
When a git-sync body write (gitSyncWriteBody) is routed to the collab instance
that owns the doc, the handler runs remotely inside handleRedisMessage and CAN
throw (markdown->ProseMirror transform). Previously the throw was uncaught: the
customEventComplete reply was never published, so the origin's writePageBody
promise only rejected after customEventTTL (~30s) as a generic 'TIMEOUT', and an
unhandledRejection escaped the async messageBuffer listener on the owning
instance.

Now the owner wraps handleEventLocally in try/catch and, on throw, publishes a
customEventComplete carrying an `error` field on the same correlation channel.
The origin's pendingReplies holds {resolve, reject} and rejects promptly with the
real Error. The TTL TIMEOUT remains as the fallback for a genuinely lost reply.
The no-throw and local (same-instance) paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
6ec9666536 fix(git-sync): converge git-ingest with open editor sessions — stop silent revert/data-loss on live pages
A git push to a page with an OPEN editor was silently reverted: the git
commit landed and the DB body updated, but the page in the browser stayed
on the old content and the editor's next autosave overwrote the git change.

Root cause (distributed, not in the merge): writeBody applied the body
merge via collabGateway.openDirectConnection on whichever instance/process
runs git-sync (the api/worker). When an editor is connected to a DIFFERENT
collab instance/process, that opens a SEPARATE, detached Y.Doc. The merge
landed in the detached doc + DB, but the live editor's Y.Doc never received
the Yjs update; its debounced autosave then persisted its STALE state over
the DB, reverting the git change (and, for concurrent edits to different
paragraphs, losing the git side). In one process the bug is invisible
because the direct connection already shares the editor's doc.

Fix: route the body write through the existing custom-event channel (the
same mechanism comment-marks and updatePageContent use) so the merge runs
on the instance that OWNS the live doc. Its update is then broadcast to
every connection (Document.handleUpdate) and the editor's CRDT converges on
the merged result. New CollaborationGateway.writePageBody dispatches to a
new gitSyncWriteBody handler (builds incoming/base docs before opening the
connection — crash-safe — then 3-way/2-way merges into the live fragment);
without redis it runs locally on the single (owning) instance. writeBody
now just forwards the converted ProseMirror bodies + service userId.

Evidence:
- git-ingest-convergence.spec.ts: deterministic two-Y.Doc repro. PATH B
  (undelivered update) asserts the LOSS (the bug); PATH A (update delivered,
  as the owner-routed write does) asserts the git change SURVIVES and that
  concurrent edits to different paragraphs both survive.
- collaboration.handler.git-sync.spec.ts: exercises the real gitSyncWriteBody
  against a shared doc wired to a connected "editor" doc (models the
  owning-instance broadcast) — editor converges, concurrent edit preserved,
  crash-safe on transform failure.
- gitmost-datasource.service.spec.ts: writeBody now routes via writePageBody
  (RED before this change — it called openDirectConnection).

Honest scope: the failure is cross-instance; full multi-instance convergence
needs a live Hocuspocus + redis and is not provable in a unit test, so the
convergence invariant is captured at the Yjs update-exchange level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
ce9de6e5d3 fix(git-sync): idempotent first-block reconciliation — stop start-of-doc content duplicating every sync cycle
The block-level body merge keyed each block by its full attribute set,
including the per-block UniqueID the editor stamps on every heading/paragraph.
A body arriving from git is parsed from clean markdown and carries no block
ids, so a live block (id present) never matched the same block coming from git
(no id). The three-way merge's LCS could not anchor on it, and an incoming
block with no matching anchor — content inserted at the TOP of the page — was
re-added on every push/pull cycle: a non-convergent, unbounded duplication loop.

Exclude the volatile 'id' attribute from the block comparison key
(serializeXmlNode) so blocks compare by content across the git round-trip.
The merge keeps the live block INSTANCE (and its id, and any in-flight edit)
for an anchor — picks are by index, not key — so identity is preserved while
reconciliation becomes idempotent. Mirrors canonicalize.ts, which already
strips the regenerated block id from the round-trip idempotency comparison.

Adds a RED-before-fix repro modelling the live-id vs git-no-id asymmetry and
asserting no block growth across cycles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
c7ff298831 feat(git-sync): Obsidian-native callouts (> [!type]) instead of :::type
Callouts now export as Obsidian's blockquote-callout syntax — `> [!type]` opener
plus a `>`-prefixed body — so they render as real callouts when the vault is
opened in Obsidian, instead of `:::type` (Docusaurus-style) which Obsidian shows
as a plain blockquote.

- Export (markdown-converter `case "callout"`): `> [!type]` + each body line
  blockquote-prefixed (a blank line becomes a bare `>` so the callout is not
  split). Nested callouts naturally become `> > [!type]`.
- Import (preprocessCallouts): a new branch recognizes `> [!type]` openers and
  the contiguous `>`-prefixed body, strips one blockquote level and recurses (so
  nested callouts work), emitting the same callout div the `:::` path produces.
  The legacy `:::type` parser is KEPT so existing vaults keep importing. A plain
  blockquote (no `[!type]`) stays a blockquote.

Tests: 4 converter golden tests updated to the new `> [!type]` output; 4 new
import tests (simple, nested, round-trip, plain-blockquote-untouched). The §13.1
gate still round-trips callout losslessly through the real server schema.
git-sync vitest 675 (+1 expected-fail), gate 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
8e74a9a99d feat(git-sync): remove the per-cycle delete cap; deletes apply + are logged every cycle
The delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5) was a defense-in-depth
guard that SUPPRESSED a cycle's deletions when the planned count exceeded the
limit. In practice it was a crutch over engine correctness that also blocked
legitimate deletes: deleting a folder with many child pages is a normal action,
and git-sync deletes are SOFT (Trash, reversible), so a blocking limit has little
upside and real downside. There is also no user-facing surface to "confirm" a
large delete from a background sync — the only channel is the operator log.

So: drop the cap entirely. Deletes apply unconditionally; every cycle already
logs its full push plan, per-action `delete: <pageId>` lines, and completion
counts through the engine `log`, so what was deleted (and what was skipped) is
always recorded. Engine correctness (the reconcile/layout/round-trip tests) is
what prevents phantom deletions — not a blocking cap.

Removed: orchestrator `resolveApplyClient` cap hook + `maxDeletes`,
`getGitSyncMaxDeletesPerCycle`, the `GIT_SYNC_MAX_DELETES_PER_CYCLE` env/validation/.env.example,
and the cap tests. (The engine's generic optional `resolveApplyClient` hook is
left as an unused extension point.)

server tsc clean, git-sync + environment jest 174.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
6a6ccc3a8f fix(git-sync): preserve subpages.recursive and details.open on round trip
Found proactively by deepening the round-trip test from node-TYPE survival to
ATTRIBUTE fidelity (distinctive attr values per node). Two real losses (the
other 3 candidates — mathInline/mathBlock/pageEmbed — were verified to be
correct; the probe had used wrong attr names):

- subpages `recursive`: the converter emitted a bare div and the schema mirror
  didn't model the attr, so a recursive subpages reverted to non-recursive on a
  round trip. Now emits `data-recursive="true"` and the mirror parses it back
  (matching @docmost/editor-ext).
- details `open`: the `open` (collapsed/expanded) state lives on the details
  node, but the converter emitted the `<details>` wrapper from the summary case
  without it, so the state was dropped. The wrapper now carries `open`.

The round-trip test now also asserts attribute fidelity (12 cases) so these are
locked. Schema-surface snapshot updated for the new subpages attr.

git-sync vitest 671 (+1 expected-fail), §13.1 gate 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
4657a9c699 fix(git-sync): subpages round-trips (was {{SUBPAGES}} literal) + exhaustive all-node round-trip test
subpages exported to the literal `{{SUBPAGES}}`, which has no markdown/HTML
inverse, so on re-import it came back as a plain paragraph holding the visible
text "{{SUBPAGES}}" — the embed rendered as that literal string on the page
after a sync (round-trip data loss, seen live). It now emits the schema-matching
`<div data-type="subpages">` like every other embed node, so the schema's
parseHTML rebuilds the subpages node. Also dropped the leaf-atom content-hole
in the subpages renderHTML.

New committed regression coverage:
- packages/git-sync/test/roundtrip-all-nodes.test.ts — exhaustive serialize ->
  deserialize round trip for ALL 40 node/mark types; each asserts the node/mark
  survives and no `{{...}}` literal leaks. This is the test that caught subpages.
- §13.1 gate (git-sync-converter-gate.spec.ts): subpages added to the green
  corpus (round-trips through the REAL server schema).
- Corrected two PR-authored tests that asserted the old {{SUBPAGES}} loss as
  "by design" — they now assert the fixed round trip.

Also folds in review #1679 coverage-gap tests (no prod change): orchestrator
pollTick/enabledSpaces, datasource 3-way merge dispatch, page.repo
last_updated_source provenance SQL.

git-sync vitest 659 (+1 expected-fail), server tsc clean, server specs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
2f811a0aa8 fix(git-sync): don't run a Docmost cycle on receive-pack info/refs (fixes deterministic push 503)
A git push is a two-request exchange: GET info/refs?service=git-receive-pack
(ref advertisement) then POST git-receive-pack (the pack). The git-HTTP host
classified BOTH as serviceKind 'write' and routed both through
ingestExternalPush, which takes the per-space lock and runs a FULL Docmost
reconcile cycle. So the read-only info/refs advertisement held the lock while a
cycle ran, and the client's immediately-following POST git-receive-pack collided
with that still-running cycle and got 503 — deterministically, every push (and
Obsidian Git's "scan" failed for the same reason, since it probes push
capability via the same receive-pack info/refs).

Fix: only the actual pack-receiving write (POST git-receive-pack) runs under the
lock + cycle. Everything else streams the http-backend directly with no lock and
no cycle — a fetch/clone (read) AND the write-AUTHORIZED but read-only
info/refs?service=git-receive-pack advertisement. Authz is unchanged (the gate
still requires write permission for receive-pack refs); only the side effect of
running a cycle on a read-only request is removed.

Verified end-to-end on a live stand: clone, then `git push` of a new file lands
the page in Docmost (was 503 on every push before). Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
4ce3346cee feat(git-sync): per-space toggle for conflict-marker handling on push (#13)
Red-team #13 (conflict markers reaching Docmost) is now a per-space policy
exposed as a UI toggle, instead of a hardcoded behavior. New boolean
`gitSync.autoMergeConflicts` (default FALSE), mirroring the existing per-space
`gitSync.enabled` flag end-to-end (jsonb space settings -> update-space DTO ->
space.service -> client types -> space settings form switch):

- OFF (default, safe): a page whose committed body still has unresolved git
  conflict markers is NOT pushed — it is recorded as a per-page push FAILURE
  ("unresolved conflict markers — resolve in git first"). Recording a failure
  (not a soft skip) deliberately HOLDS refs/docmost/last-pushed so the conflict
  commit is never marked pushed and a later pull cannot clobber the user's
  in-progress resolution; the page retries until the conflict is resolved in git.
- ON: the marker lines are stripped and both sides' content is pushed (the prior
  behavior), so the conflict becomes visible/fixable inside Docmost.

The engine Settings carries `autoMergeConflicts`; runPush threads it into the
update AND create paths. The orchestrator's buildSettings reads the per-space
flag from jsonb (strict opt-in like `enabled`, default false).

Tests: redteam-push-cycle #13 rewritten (default -> not pushed + failure + refs
held; ON -> strip-and-push); space.service + edit-space-form + orchestrator
specs extended. git-sync vitest 618, server jest space+git-sync 163, client
edit-space-form 11, server/client tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
1a6ec0ff1f docs(git-sync): document GIT_SYNC_BACKEND_TIMEOUT_MS, drop dead consts, fix dangling plan refs
Address the non-red-team documentation/cleanup items from review #1679:
- Document the GIT_SYNC_BACKEND_TIMEOUT_MS watchdog (git http-backend) in
  .env.example and add it to the environment validation schema — it was used
  (getGitSyncBackendTimeoutMs, default 120000) but undocumented/unvalidated.
- Remove the dead GIT_SYNC_DEBOUNCE_MS_DEFAULT / GIT_SYNC_POLL_INTERVAL_MS_DEFAULT
  exports (never imported; environment.service is the single source of defaults).
- Redirect the dangling `plan §X.Y` comment references to issue #194 (the
  git-sync spec moved there when docs/git-sync-plan.md was deleted by this PR).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
bbe65d1de1 fix(git-sync): red-team hardening — 12 confirmed sync-breaking bugs + regression tests
A 10-agent red-team pass on the two-way Docmost<->git sync surfaced 16 ranked
findings (9 others triaged out as already-defended). Wrote a reproduction test
per finding (each asserts the CORRECT behavior, so it fails on the bug), then
fixed the production code so every repro goes green. All confirmed bugs:

Round-trip data loss (markdown-converter.ts + docmost-schema.ts mirror):
- #1 editor-ext node types silently dropped on export — ported the 8 missing
  canon nodes (footnoteReference/footnotesList/footnoteDefinition, htmlEmbed,
  status, pageEmbed, transclusionSource/Reference) into the git-sync schema
  mirror and added converter cases that emit their schema-matching HTML instead
  of flattening unknown nodes to '' (this was the critical data-loss flagged in
  review #1679: footnotes/htmlEmbed lost on sync). Snapshot surface updated.
- #2 top-level image lost width/height/align/attachmentId — now emits an HTML
  <img> (like video/diagrams) when it carries layout attrs; bare images stay
  ![](src). Image node parses width/height as strings so they re-import.
- #3 code block containing a ``` fence corrupted on round-trip — outer fence is
  now widened to (longest-inner-backtick-run + 1).
- #16 deep nesting threw RangeError (page never synced) — added a depth guard
  (MAX_NODE_DEPTH=400) so the converter never overflows the stack.

Push/layout/cycle (engine):
- #4 disambiguation ' ~slugId' suffix corrupted Docmost titles + order-dependent
  layout — deterministic, order-independent sibling disambiguation; suffix is
  stripped from a path-derived title ONLY when the new name is exactly the old
  title plus the suffix (never a genuine retitle ending in ' ~token').
- #6 retry-adopt by (parent,title) clobbered the wrong duplicate-title sibling —
  ambiguous (parent,title) is no longer adopted (falls back to fresh create).
- #12 a new child under a new parent was created at ROOT — creates are ordered
  parent-before-child with an in-memory created-id map for parent resolution.
- #13 git conflict markers could reach Docmost — bodies are scanned and the
  marker lines stripped (a '=======' line is only treated as a conflict
  separator inside a <<<<<<< ... >>>>>>> block, so setext headings are safe).
- #15 a divergent `docmost` mirror was escalated by runPush but dropped by
  runCycle — RunCycleResult now forwards divergentDocmost to the orchestrator.

Server (merge / lock / provenance):
- #9 3-way merge lost a human's block edit when git inserted an adjacent block —
  finer-grained diff3 region merge (via lcs) preserves non-overlapping human
  edits; genuine same-block conflicts still resolve git-wins.
- #10 single-writer race — module-static liveLocks closes the same-process TOCTOU
  window, and a heartbeat refresh that cannot confirm the lock now aborts the
  cycle at its next write checkpoint (cooperative AbortSignal threaded through
  runCycle). Cross-process fencing tokens remain a follow-up.
- #14 sticky-agent provenance overrode an explicit actor='git-sync' write,
  blinding the listener loop-guard — resolveSource now lets an explicit actor
  win over the sticky-agent fallback (explicit agent still wins).

Verified: git-sync vitest 617 pass (+1 expected-fail), server unit jest 1541
pass, server tsc clean. A review pass over the fixes caught and corrected a
title-suffix over-strip, an inert abort signal, a document-wide conflict-marker
strip, and two leaf-atom content-holes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
dd6eddcb42 chore(git-sync): drop stray build/ artifacts re-introduced during rebase
build/ is gitignored and compiled in CI/Docker; a few files leaked back into
the tree while replaying commits onto develop. Remove them so the package keeps
a single source of truth (src/).
2026-06-26 20:39:38 +03:00
claude_code
f31ba3dbc2 fix(git-sync): address PR #119 review (#1571)
Resolve the code-review findings from comment #1571 on PR #119.

Engine (packages/git-sync):
- Idempotent CREATE on retry: before createPage, look the page up in the
  live Docmost tree by (parentPageId, title) and ADOPT it instead of
  duplicating when a prior cycle created it but failed to persist the
  pageId back to disk. Only trust a COMPLETE tree for the lookup; fall
  back to createPage otherwise. Covered by new tests incl. a complete=false
  regression-lock.
- Route applyPullActions diagnostics through an injected logger instead of
  bare console (thread log from the cycle).
- Add a timeout to the git execFile chokepoint (runRaw) so a hung git
  subprocess cannot wedge a sync cycle.
- Translate remaining Russian code comments to English.
- Remove dead standalone-CLI code (parseArgs/PushParsedArgs,
  parseSettings/envSchema, loadSettingsOrExit + config-errors.ts) and the
  matching index exports/specs; keep the Settings type.
- Fix the dangling docs link in package.json.
- Add a schema-surface snapshot guard so any drift in the vendored
  document schema is a loud, must-review CI failure (+ provenance header).

Server (apps/server):
- Add a configurable watchdog timeout to the spawned git http-backend so a
  stalled push cannot hold the per-space lock forever
  (GIT_SYNC_BACKEND_TIMEOUT_MS).
- Close the in-process TOCTOU window in SpaceLockService.withSpaceLock by
  reserving the slot synchronously before acquire.
- Add tests: removePage git-sync provenance (both branches), ensureServable
  force-push-protection git configs, and the phase-B+ datasource methods.

Docs / build:
- AGENTS.md: list git-sync as the fifth workspace package and note the
  three schema mirrors; fix the dangling git-sync-plan.md backlog link.
- pnpm-lock.yaml: add the missing @docmost/git-sync workspace link so
  pnpm install --frozen-lockfile (CI default) succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:38 +03:00
claude code agent 227
54938780a4 chore(mcp): drop build/ + node_modules leftovers after rebase
These files (build/lib/footnote-analyze.js, build/lib/footnote-lex.js from the
merged footnote work, and the y-prosemirror node_modules symlink) survived the
rebase because this branch's earlier "stop committing build/ and node_modules"
commit predated them. They are gitignored (packages/mcp/build/) and generated /
symlinked, so untrack them to keep the branch consistent with that decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
5e63db575b refactor(git-sync): internalize the engine — first-class ESM, no vendoring bridge (#119 review)
Closes the architecture item from the #119 review: drop the "vendored from
docmost-sync" framing and the CJS↔ESM `Function('import()')` bridge so the engine
is a normal first-class gitmost package.

Part 1 — vendoring markers removed (prose only, zero behavior change): reworded
"VENDORED into gitmost" / "vendored from docmost-sync" / "Engine LOGIC is
byte-identical" / "it's a port" comments across the engine. Behavior-bearing
strings are untouched: BOT_AUTHOR_NAME/EMAIL and the `Docmost-Sync-Source:`
provenance trailers (changing them would break git authorship + the loop-guard).

Part 2 — the package is now ESM (matching the sibling @docmost/mcp): `type: module`,
tsconfig Node16, `.js` extensions on relative imports, and a static
`import { marked }` replacing the `new Function('return import(...)')` /
`loadMarked` hack — the bridge is GONE from the package. The CommonJS NestJS
server loads the now-ESM engine via a new `git-sync.loader.ts` that mirrors the
existing `docmost-client.loader.ts` mcp loader exactly (Function-indirected
dynamic import + cached promise + retry-on-reject). The 4 server consumers
(orchestrator/datasource/vault-registry/git-http-backend) call `await loadGitSync()`
for value exports; types stay `import type` (erased). The converter-gate spec —
which needs the real converter — loads the package's TS source via a jest
moduleNameMapper + isolatedModules (documented in that spec); the other git-sync
specs mock the loader.

Verified: engine builds pure ESM (no Function/require leftover), vitest 614,
editor-ext build, server + client tsc, full server jest 1397/0. Live stand
smoke-test: server starts clean on the ESM engine (no ERR_REQUIRE_ESM), a real
sync cycle runs through the loader, and the basic e2e suite is 12/12 (clone via
git-http-backend, push, pull, delete, 3-way merge — all through the new loader).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
c366190db6 test(git-sync): add missing DTO/User imports for the rebased git-sync provenance spec block
The rebase folded develop's agent-provenance PageService spec and the git-sync
provenance spec into one file; the appended git-sync block needs CreatePageDto /
UpdatePageDto / User imports that develop's spec (which used inline `as any`) did
not have. Server tsc + the suite (158 tests, both provenance blocks) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
b01802ec3e fix(git-sync): git-http stream error handlers + close test gaps (#119 review)
Addresses the stability + test-coverage warnings from the #119 review:

- git-http-backend.service.ts: add `'error'` handlers to child.stdout/stderr. An
  EventEmitter 'error' with no listener (e.g. EPIPE when the client aborts
  mid-response) is rethrown by Node as an uncaught exception and crashes the
  process; now swallowed + logged (never echoed to the client).
- TEST INFRA: a jest setupFile shims `navigator`/`MessageChannel` for the `node`
  testEnvironment. react-dom@18 reads `navigator` at module-init (pulled in via
  @docmost/editor-ext -> @tiptap/react), so every spec transitively importing the
  conversion engine — including git-http.service.spec.ts — previously FAILED TO
  LOAD ("navigator is not defined") and ran ZERO tests. With the shim those specs
  now run (git-sync integration: 11 suites / 133 tests green).
- git-http.service.spec.ts: cover the 503 lock-held push path — `ingestExternalPush`
  rejecting `GitSyncLockHeldError` -> 503 + Retry-After + "git-sync busy, retry",
  no double header write (+ the already-headers-sent no-rewrite path).
- git-http-backend.service.spec.ts: unit-test run() — child 'error'/'close' before
  headers -> 500; normal CGI parse+stream; stdout/stderr 'error' (EPIPE) swallowed;
  synchronous spawn throw -> 500.
- page-change.listener.ts: implement OnModuleDestroy to clearTimeout all pending
  debounce timers on shutdown (+ test).
- .env.example: vaults are non-bare working repos, not "bare repos".

(Docs deleted by the stray commit were restored in 9cdbce54.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
77ad20bd8a fix(git-sync): screen non-page files out of PUSH (CRITICAL — review)
Self-review of phase 3 caught a data-corruption regression: nativeMeta always
supplies the run's spaceId, so the planner's 'create-without-spaceId' skip — which
had doubled as the only filter for non-page files — went dead. An ADDED
.obsidian/*.json, attachment, or dotfile (committed to the vault, no .gitignore)
would then be classified as a CREATE: a junk Docmost page, plus a gitmost_id
frontmatter written INTO the file, corrupting it.

Fix: isPageFile(path) — a .md file with NO dot-segment anywhere — and filter the
diff to page files at the very top of computePushActions, BEFORE any
classification, so non-page A/M/D/R are ignored (design §Адопция). 2 unit tests
pin it (.obsidian/json, attachment, dotfile, dot-segment, .md dotfile all ignored;
real pages still created). 614 engine tests green.

Also: refreshed stale docmost:meta comments to gitmost_id (review SUGGESTION), and
documented the deferred adoption frontmatter-preservation gap (review WARNING) in
page-file.ts + the design doc (do NOT roll native onto a real vault with Obsidian
properties until phase 4 round-trips them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
76b9829562 docs(git-sync): mark thin-meta phases 2 + 3 done in the plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
1014f95c88 feat(git-sync): phase 3 — PUSH reads native gitmost_id + derives title/parent from path
PUSH now consumes the native-Obsidian format end-to-end:
- identity from the gitmost_id frontmatter (parsePageFile), not docmost:meta;
- title from the FILENAME, parentPageId from the enclosing folder's folder-note
  (parentFolderFile is now FOLDER-NOTE aware: a child's parent is dir/dir.md, and
  a folder-note's own parent is one level up), spaceId from the run (every vault
  file belongs to the vault's space);
- CREATE derives title/parent/space from path + run and writes the assigned
  pageId back as gitmost_id frontmatter (serializePageFile);
- UPDATE pushes the STRIPPED body (current + 3-way-merge base), so the frontmatter
  never leaks into Docmost content; the loop-guard hashes the body.

The PURE delete-sensitive classifier (computePushActions/classifyRenameMoves) is
UNCHANGED — only the injected IO resolvers (metaAt, parent, create write-back)
switched source. nativeMeta always carries the run spaceId, so the legacy
'create-without-spaceId' skip no longer fires through runPush.

Tests rewritten to native fixtures + folder-note parent paths; the noop case is
now a child under a renamed parent folder (filename=title, so a path-only-noop
needs an ancestor rename). parentFolderFile tests cover leaf/folder-note/nested/
dotted. 612 engine tests green; engine rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
e2a3380716 feat(git-sync): phase 2b — PULL writes native gitmost_id frontmatter
PULL now serializes each page as the native-Obsidian format (serializePageFile:
a minimal gitmost_id frontmatter + the fixpoint markdown body) instead of the
heavy docmost:meta envelope. title/parent/space are derived (filename / folder /
repo), so only the pageId is persisted. readExisting recovers identity from the
gitmost_id frontmatter (parsePageFile) instead of docmost:meta.

Extracted stabilizePageBody() (the export->import->export fixpoint, no meta) so
the native writer and the legacy serializer share the same deterministic body —
re-pulls of an unchanged page stay byte-identical (loop-guard).

Tests: read-existing fixtures rewritten to gitmost_id; apply-pull asserts the
written text is native frontmatter and carries NO docmost:meta (regression
guard). 611 engine tests green.

NOTE: PUSH still reads docmost:meta — the end-to-end cycle is intentionally NOT
runnable until phase 3 (PUSH reads frontmatter + derives title/parent from path)
lands; no vault is wiped/deployed until then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
3f8ef16a3a feat(git-sync): phase 2a — folder-note layout (parent -> Folder/Folder.md)
Native-Obsidian structure: a page WITH children now lives at its folder-note
<name>/<name>.md (LostPaul Folder Notes convention) with its children alongside;
a leaf stays <name>.md. Folder-notes claim their canonical path before a
same-named child, so the child (a leaf) is the one disambiguated, never the
folder-note — a folder X/ always contains its own note X.

Format-agnostic and safe in isolation: only the destination PATH changes, the
file content/serialization is untouched, so an existing parent relocates via the
move-by-id path (no delete). The frontmatter format flip (pull+push) is next.

6 new layout unit tests (leaf / parent / nested / child-named-as-parent /
twin-parents / childless). 611 engine tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
0c916ca086 feat(git-sync): drop legacy docmost:meta back-compat (vaults wipe+rebuild)
Per owner: test data, no migration. parsePageFile no longer reads the old
docmost:meta block — a file without a gitmost_id frontmatter is simply un-tracked
(adopt). Vaults are a cache: rm -rf on the transition, rebuilt native from
Docmost. Simplifies the format work (no fallback). Doc updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
d8007480ac feat(git-sync): native-Obsidian format — phase 1 = page-file (frontmatter gitmost_id)
Pivot the thin-meta design to "the vault IS a native Obsidian vault": clean
markdown + a minimal YAML frontmatter `gitmost_id:` (the durable pageId, travels
with the file so identity survives any move); folders mirror the page tree with
the parent's body as a folder-note `<Folder>/<Folder>.md` (LostPaul Folder Notes
convention); links as `[[wikilinks]]` (basename-resolved → reparent never breaks a
link, only retitle does); collisions disambiguated Obsidian-style; `.obsidian/`
and non-page files left untouched (no .gitignore). Verified the conventions
against the Obsidian/Folder-Notes docs.

Replaces the abandoned `.gitmost/index.json` sidecar (path-keyed → fragile to
git-undetected renames; the in-file id is self-sufficient): removes vault-index.ts.
Adds lib/page-file.ts — parsePageFile/serializePageFile (frontmatter id + clean
body) with a LEGACY `docmost:meta` fallback for migration. 6 unit tests; engine
suite green. Not yet wired into pull/push — no behavior change. Design doc
rewritten to the native-Obsidian format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
d163f43e12 docs(git-sync): thin-meta design — identity must travel with the file (B/C) + link-sync phase
Captures the design discussion: a path-keyed sidecar is NOT a safe source of
truth (a git-undetected rename loses the page), so the id must travel WITH the
file — either as a slugId suffix in the filename (B) or a minimal YAML frontmatter
`id:` (C); both robust, B/C is the open UX decision (author leans C for clean
names). The sidecar may remain an optional path->id cache. Adds phase 6 — link
sync between notes: Docmost links are by pageId (survive rename), vault markdown
links are by path (rewrite on rename, Obsidian-style); independent of B/C and the
format phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
05a55cedc7 feat(git-sync): thin-meta phase 1 — the .gitmost/index.json sidecar module
Pure read/write/lookup for the vault sidecar index that will hold page identity
(pageId) + collision token (slugId) keyed by file path, so the .md files can be
clean markdown. parseVaultIndex is tolerant (missing/garbage/bad entries degrade
to empty/skipped — never crashes a cycle); serializeVaultIndex is deterministic
(sorted keys -> stable diffs, no churn). Lookups (pageIdAt, pathForPageId reverse,
trackedPageIds) + mutations (set/remove/move). NOT wired into pull/push yet — no
behavior change. 5 unit tests; engine suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
c77ad472a2 docs(git-sync): design for thin meta + third-party-editor support
All service metadata moves into a single `.gitmost/index.json` sidecar; the `.md`
files become clean markdown (Obsidian & any editor work directly). The page tree
mirrors the folder structure (folder = parent page; the parent's body lives in
`<Folder>/index.md`); collisions disambiguate by a `~<slugId>` filename suffix
with identity tracked by pageId in the index (safe renames, never delete+create —
backed by 5133bb34). Bare files/folders from a third-party editor are adopted into
pages. Includes the migration path off the current `docmost:meta`-in-file format
and a phased plan (each phase gated by engine unit tests + the browser e2e +
isolated shell e2e). Agreed with the owner 2026-06-24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
1247e8ae39 test(git-sync): e2e suites provision a throwaway space — never touch real data
The shell e2e suites defaulted to the General space and created/edited pages
there, polluting real content (and, when several enabled spaces raised poll
contention, flaking on 503s). Now each suite creates its OWN throwaway,
git-sync-enabled space at setup, runs everything against it, and deletes the
space (+ its vault) on exit. Set SPACE_ID explicitly to opt into an existing
space. Also gives the basic suite the 503-retry push helper the advanced one
already had. Verified isolated: basic 12/12, advanced 23/23, no spaces/users/
pages left behind, the real space untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
da764079fc fix(git-sync): never trash a page whose pageId still exists in the tree (cross-cycle move) + browser e2e
Follow-up to 4376c5a6, found by a real BROWSER e2e (the flow the in-diff fix
missed). When the layout reshuffle's two halves land in SEPARATE sync cycles, the
later cycle's diff has only the DELETE of the old path — the matching add was
already pushed — so in-diff D+A coalescing can't see it, and the live page was
still trashed.

Robust fix on the identity invariant the reviewer (and the user) called out: a
page EXISTS iff its pageId is in the vault, regardless of filename. runPush now
collects the pageIds present at ANY path in the current `main` tree and passes
them to computePushActions; a deleted file whose pageId is still tracked
elsewhere is a MOVE, never a deletion. (Built only when the diff has deletes.)

Adds apps/server/test/git-sync-browser-e2e.cjs — a Playwright test that drives the
REAL Docmost web UI: log in, create several untitled pages, type a title, sync,
assert NOTHING is trashed. Reproduced the data loss before this fix; 5/5 green and
stable after. Engine suite 600 green (+2 computePushActions cases:
pageId-still-present -> skip; pageId-gone -> real delete).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
38d8fbfdd9 test(git-sync): e2e guard for the untitled-page + retitle data-loss reshuffle
Reproduces the browser bug at the API level: create several untitled pages (all
collapse to the `_` fallback name), retitle one, sync — assert NO page is
trashed and all survive. Caught the data-loss bug fixed in 4376c5a6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
547ecd9e53 fix(git-sync): never trash a page that only MOVED (pageId-identity, not git rename heuristics) — data loss
CRITICAL data-loss bug: creating pages in Docmost (which start UNTITLED) and then
typing a title could soft-delete OTHER pages. Untitled pages all serialize to the
`_` fallback filename; the layout disambiguates them (`_.md`, `_ ~slug.md`).
Retitling one frees the bare `_` and another untitled page's file relocates into
it. git's rename detection (`-M`) can't see the move (the tiny meta-only files are
too dissimilar), so `git diff` reports it as DELETE(old) + ADD/MODIFY(new). The
push took the DELETE literally and trashed a live page.

Root cause is that the push trusted git's path-level rename heuristic for page
IDENTITY. Identity is the pageId. Fix: before emitting any delete, coalesce by
pageId — a pageId that is BOTH deleted (pre-image) AND present on the surviving
side (current meta of an ADD or a MODIFY, since a relocation into an occupied path
shows as M) is one page that MOVED, classified as a rename/move and NEVER a delete.

Reproduced + verified on a live stand: 4 untitled pages + retitle one trashed a
different page before; after the fix, retitling one (and stress-retitling all)
trashes nothing. Engine suite 598 green; 3 new computePushActions cases (ghost
D+A move -> rename; real delete still deletes; unrelated D+A stay delete+update).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
2f52a8360c test(git-sync): basic e2e operates on a dedicated page + cleans up (no real-page pollution)
The push / 3-way-merge cases edited the FIRST real `.md` in the vault, leaving
`E2E-PUSH-*` / `E2E-MERGE-*` marker headings accumulating in a real page, and the
Docmost->git case left its created page in the Trash. Now the suite creates a
dedicated `E2E-SyncTarget-*` page and targets only that, and a teardown
hard-deletes every `E2E-*` fixture page and converges the vault on exit — so runs
never mutate real content and leave the stand clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
a0181f9f28 test(git-sync): add advanced e2e suite — authz, protocol hardening, concurrency, data-loss guard
Output of a generate→critique subagent pass on "what the feature's tests do NOT
cover", implemented + verified against the live stand (20/20). Complements the
basic two-way suite. Covers:

- protocol shape: unknown service subpath -> 400; unknown content-type -> 415
  (global allowlist); PUT/DELETE on pack endpoints -> 400;
- path-traversal: `..%2f..`, `%2e%2e%2f`, bare `.git` space-id -> 400/404, no
  escape, never a file leak;
- authz boundaries: a gitSync-DISABLED space -> 404 (existence hidden) and flips
  to 200 when enabled; a READER member can fetch (200) but is FORBIDDEN to push
  (403); a NON-member of an enabled space gets 403 (NOT 404 — the critic caught a
  wrong generator assumption here; pinned as a contract);
- concurrency: a push while the per-space Redis lock is held -> 503 + Retry-After,
  and the receive-pack does NOT mutate the vault;
- idempotency: repeated no-op cycles never churn `main` / `refs/docmost/last-pushed`;
- data-loss guard (PR #119): deleting MORE than GIT_SYNC_MAX_DELETES_PER_CYCLE is
  HELD — none trashed AND last-pushed does not advance past the delete commit
  (retry-safe, not silently dropped).

Auto-creates/tears down its fixtures (reader/non-member users, a 2nd space) and
resets the vault cache on exit so re-runs and the basic suite stay green. Needs
the vault dir + Redis container reachable (see header). A structural rename/move
case was intentionally left to the engine unit suite (git rename-similarity on
meta-only fixture pages is a fixture artifact, not a feature bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
d3b079ec95 chore(git-sync): drop now-unused dirname import (PR #119 review)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
784fa1a16b test(git-sync): add a live two-way smart-HTTP e2e suite
A runnable end-to-end suite that drives a LIVE git-sync stand over the real /git
remote — the integration counterpart to the unit tests. 10 checks across the full
feature:
- the auth/authz gate: no creds -> 401, wrong password -> 401, unknown space ->
  404 (existence never revealed), valid creds on a sync space -> 200;
- fetch: git clone over HTTP returns the vault markdown;
- push: a git-side edit propagates into the Docmost page;
- Docmost -> git: a page created via the API materializes as a vault file;
- delete: `git rm` + push soft-deletes the Docmost page (Trash);
- 3-way merge: a new git edit is added without clobbering prior page content.

Parameterized via env (SERVER/SPACE_ID/EMAIL/PASSWORD/DB_CONTAINER) and isolates
its own test page. It boots nothing — see the header for the stand prerequisites
(GIT_SYNC_ENABLED + a per-space gitSync flag + a service user). This is the suite
that caught the smart-HTTP PATH_INFO 404 bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
d1443c9a6c refactor(git-sync): move the PULL->PUSH cycle into the engine as runCycle (PR #119 review, arch #1)
The reconcile choreography (ensureRepo -> merge-check -> ensureBranch ->
checkout('docmost') -> pull -> push) was hand-rolled in the app orchestrator's
driveCycle, duplicating an order the vendored engine owns and could drift from on
upgrade — the failure mode is data clobber. Lift it into @docmost/git-sync as a
single entry point, `runCycle(deps)`. The orchestrator now calls runCycle and
keeps only the lock (its caller) and the gitmost-specific delete-cap POLICY,
injected as the `resolveApplyClient` hook (the engine does the dry-run, hands the
hook the planned delete count — Infinity if planning failed — and uses whatever
client it returns for the apply). driveCycle drops from ~150 lines to ~30.

Tests:
- engine test/cycle.test.ts: composition (merge-in-progress short-circuit;
  ensureRepo->ensureBranch->checkout staging order before the pull; the cap hook
  is consulted with the planned count; no dry-run when no hook).
- engine test/cycle-roundtrip.test.ts: runCycle against a REAL VaultGit in a temp
  repo with a faked Docmost client — a git-originated CREATE flows pull->push and
  the assigned pageId is written back; an unresolved merge short-circuits before
  any client call.
- orchestrator spec rewired to mock runCycle and assert the wiring + the
  resolveApplyClient cap policy (the engine-internal cycle-order/merge tests moved
  to the engine).

Validated end to end on a live stand (real Postgres/Redis + server): a git clone
-> edit -> push over the /git remote round-trips the change into the Docmost page
through the refactored cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
3c355de2be fix(git-sync): drop the .git suffix from git http-backend PATH_INFO (smart-HTTP 404)
The /git smart-HTTP host 404'd EVERY fetch and push: PATH_INFO was built as
`/<spaceId>.git/<subpath>`, so `git http-backend` resolved the repo at
`<GIT_PROJECT_ROOT>/<spaceId>.git` — which does not exist. The vault is a NON-bare
working repo (the engine needs a working tree) at `<dataDir>/<spaceId>`, so the
CGI repo path must be `<spaceId>` (git http-backend serves the `.git` inside).
The URL's conventional `.git` suffix is already stripped to `spaceId` by
parseGitPath; re-appending it for PATH_INFO was the bug.

Found by standing up a full e2e stand (real Postgres/Redis + server + a real git
clone/push over the /git remote): clone and push both 404'd until this fix, after
which a clone → edit → push round-trips the change all the way into the Docmost
page.

Also extracts the CGI-env construction into a pure, exported `buildGitBackendCgiEnv`
and adds unit tests (the env build was previously untested — the gap this bug hid
in): a regression guard pinning PATH_INFO to `/<spaceId>/<subpath>` (no `.git`),
plus method/query/content-type/remote-user forwarding and the conditional
GIT_PROTOCOL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
12b201d231 test(git-sync): cover ingestExternalPush in the orchestrator spec (PR #119 review)
Closes the test-coverage warning that the smart-HTTP push ingest path was
unexercised. Adds 5 cases: receive-pack streams BEFORE the Docmost cycle; a
held lock throws GitSyncLockHeldError and runs neither the receive-pack nor the
cycle; a post-push cycle error is swallowed (the push is durable, poll retries)
while the lock is still released; a missing service user runs the receive-pack
but skips the immediate cycle; and a globally-disabled git-sync refuses without
touching the lock.

(The 503/Retry-After mapping in git-http.service is the sibling warning; its spec
is in the repo's pre-existing set of jest suites that can't load locally via the
react-dom/tiptap transform chain, so that case is left for CI.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
77087104b2 refactor(git-sync): extract SpaceLockService from the orchestrator (PR #119 review, arch #2)
The per-space single-writer lock — Redis CAS leader lock (SET NX PX, DEL-CAS and
PEXPIRE-CAS Lua), the in-process mutex, the per-process instanceId and the
heartbeat — lived inline in GitSyncOrchestrator. Extract it into a dedicated
@Injectable() SpaceLockService exposing one narrow surface, withSpaceLock(spaceId,
fn), so the lock is the orchestrator's only Redis-lock touch-point and is testable
in isolation. The orchestrator now injects SpaceLockService and both consumers
(runOnce, ingestExternalPush) go through spaceLock.withSpaceLock — behavior
unchanged (same sentinel returns, same 503-on-lock-held contract). Orchestrator
drops 591→472 lines.

Adds space-lock.service.spec.ts asserting the lock SEMANTICS against a fake Redis
(the test-coverage warning from the review): the SET NX/PX args, the DEL-CAS and
PEXPIRE-CAS Lua + ARGV[1]=instanceId, plus the lock-held / in-progress / throw-
still-releases paths. The orchestrator spec is unchanged in count and stays green
(it now builds the real SpaceLockService over its mock Redis).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
a728093683 docs(git-sync): remove dangling references to the deleted git-sync-plan doc (PR #119 review)
The implementation spec docs/git-sync-plan.md was removed as completed, but ~44
code comments still cited it as "plan §N". Strip those citations (comments only),
keeping each comment grammatical. The vendored engine's own "SPEC §N" references
point at a different, still-present spec and are left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
c761701e21 refactor(git-sync): drop dead DebounceEntry.workspaceId field (PR #119 review)
The debounce map value carried `workspaceId`, but the scheduled cycle closes over
the `workspaceId` argument directly — the field was written and never read.
Replace the entry struct with `Map<string, NodeJS.Timeout>` (the timer handle is
all the map tracks). No behavior change. (page-change.listener.spec is in the
repo's pre-existing set of jest suites that can't load locally via the
react-dom/tiptap transform chain — unaffected by this change; tsc clean.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
2140f47c37 refactor(git-sync): extract shared buildLcsTable for the two block diffs (PR #119 review)
The two-way block diff (yjs-body-merge.diffBlocks) and the three-way merge
planner (three-way-merge.lcsPairs) built the identical backward-filled LCS DP
table inline. Extract it to lcs.ts (buildLcsTable); each caller keeps its own
traceback. No behavior change — merge specs unchanged and green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
0d9c61d090 fix(git-sync): hold refs on suppressed deletes + stamp delete/restore provenance (PR #119 review)
Two stability warnings from the #119 review:

1. delete-cap no longer drops deletions forever. When planned deletes exceed
   GIT_SYNC_MAX_DELETES_PER_CYCLE the apply client's deletePage now THROWS
   instead of resolving to a no-op. A throw is recorded by the engine as a
   per-page failure, so `refs/docmost/last-pushed` is NOT advanced past the
   commit that dropped the files — the next cycle re-diffs from the un-advanced
   ref and re-plans the same deletes (a transient over-cap is retried, not
   silently dropped and then recreated by the next pull). Previously a resolving
   no-op let the engine count `deleted++` with no failure, advance the ref, and
   never replay the deletions.

2. git-sync soft-delete and restore now stamp provenance. deletePage routes
   GIT_SYNC_PROVENANCE through pageService.removePage, and restorePage stamps
   lastUpdatedSource='git-sync' on the restore update — so the page-change
   listener's loop-guard (skip when lastUpdatedSource==='git-sync') recognizes
   both as its own writes instead of scheduling a wasted echo cycle. Done via a
   backward-compatible optional `lastUpdatedSource` param on
   pageRepo.removePage/restorePage (omitted for ordinary user deletes/restores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
59113a1d41 docs(git-sync): document GIT_SYNC_* env vars; fix stale/non-English comments (PR #119 review)
Addresses the documentation/convention warnings from the #119 review:
- .env.example: add the GIT-SYNC block (9 GIT_SYNC_* vars with defaults), noting
  GIT_SYNC_SERVICE_USER_ID is required when sync is enabled.
- yjs-body-merge.ts: translate the Russian review note in the docstring to
  English (comments-only-in-English rule).
- persistence.extension.ts: correct the stale "git-sync writes are full-body
  replaces" rationale — a git-sync write is now a block-level merge into the live
  doc, which is why it is debounced like a human edit rather than snapshotted.
- history-item.tsx: the GitSyncBadge version is created on the PUSH path (writing
  the git body back into the doc), not by the pull — fix the comment.
- edit-space-form.tsx: log the raw error in the git-sync toggle catch instead of
  swallowing it (AGENTS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:15 +03:00
claude code agent 227
9123e2a62f chore(mcp): stop committing build/ and node_modules; build in CI/Docker
Same hygiene fix as git-sync (review #2), applied to packages/mcp which had the
identical pre-existing problem: committed build/ (20 files) + node_modules (28,
pnpm symlinks with a baked /home/claude store path).

- git rm --cached packages/mcp/{build,node_modules}.
- .gitignore: add packages/mcp/build/ (packages/*/node_modules/ already covers it).
- Build where consumed: apps/server `pretest` and the CI Test workflow now build
  @docmost/mcp too. The Dockerfile builder already runs `pnpm build` (nx builds
  mcp) and already COPYs packages/mcp/build into the runtime image.

Verified: wiped build/, rebuilt via `pnpm --filter @docmost/mcp build`; the mcp
server suites (96 tests) pass against the freshly-built, non-committed output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:47 +03:00
claude code agent 227
4b2c275489 feat(git-sync): three-way body merge using the last-synced base (no edit loss)
Upgrades the 2-way body merge to a real diff3 three-way merge (review #5), so a
block ONLY the human changed is KEPT when git changed a DIFFERENT block — the
2-way merge would revert it to git's stale version.

Engine: the push update loop reads the last-synced pre-image
(`git.showFileAtRef(refs/docmost/last-pushed, path)`) and passes it as the
optional `baseMarkdown` to `client.importPageMarkdown` (the common ancestor).

Server: gitmost-datasource converts base+incoming, and writeBody runs a block-
level diff3 (new three-way-merge.ts `diff3Plan`): live-only change -> keep live,
git-only change -> take git, both-changed -> git wins (conflict policy), inserts/
deletes from either side preserved. Without a base (createPage) it falls back to
the 2-way merge. Crash-safety unchanged (docs built before the connection opens).

Tests: three-way-merge.spec.ts (14 — every diff3 case incl. the cross-block
preservation and conflict policy), yjs-body-merge 3-way (real Y.Docs: human's
block instance preserved while git's block is applied), plus an engine test that
the base is forwarded from showFileAtRef. Existing push assertions updated for the
new base arg. git-sync 589 pass; server merge/datasource/gate 62 pass; typecheck
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:47 +03:00
claude code agent 227
5c1cca4f30 fix(git-sync): merge git body into the live doc block-by-block (no clobber)
Supersedes the active-session "defer" guard with a real merge (review #5 —
"запись делать через мерж", not skip-while-editing).

writeBody no longer does delete-all + re-insert (which discarded a concurrent
editor's in-flight changes on every sync). It now diffs the live body against the
incoming git body at TOP-LEVEL BLOCK granularity (LCS over a canonical structural
serialization) and applies only the minimal inserts/deletes:
- a block a human is editing is left UNTOUCHED when git changed a DIFFERENT block;
- an unchanged resync is a complete 0-op write;
- Yjs CRDT-merges the minimal ops with concurrent edits.

New yjs-body-merge.ts (mergeXmlFragments + cloneXmlNode + diffBlocks) is pure-Yjs
and unit-tested with real Y.Docs (8 tests): identical->0 ops, edit-one-block keeps
the other block instances, append/delete keep neighbours, marks survive the
cross-doc clone. Crash-safety kept: the incoming doc is built before the
connection opens, so a transform failure can't empty the body.

Removed: the ActiveEditSessionError defer path and the now-unused
CollaborationGateway.getActiveEditorCount.

Honest limitation: this is a 2-way merge — for a block BOTH sides changed since the
last sync, git wins (no common ancestor to decide). A full 3-way merge would need
the last-synced base plumbed from the engine; the dominant cases (unchanged
resync, edits to different blocks) are now lossless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:47 +03:00
claude code agent 227
44b902cdfc chore(git-sync): stop committing build/ and node_modules; build in CI/Docker
Review finding #2: packages/git-sync/build/ (the COMPILED engine) and the
package's node_modules/ were committed. Prod executed the committed build/ while
CI/tests ran src/ and never rebuilt it — so a fix in src/ could pass tests while
stale compiled code shipped (a silent src/prod skew). The committed node_modules
were pnpm symlinks with a baked machine-local store path (/home/claude/...),
useless and misleading for everyone else.

- git rm --cached packages/git-sync/{build,node_modules} (42 + 31 files).
- .gitignore: ignore packages/*/node_modules/ and packages/git-sync/build/.
- Build the package where it is actually consumed: apps/server `pretest` now
  builds @docmost/git-sync (its suite imports the built build/index.js), and the
  CI Test workflow gains an explicit "Build git-sync" step. The Dockerfile builder
  already runs `pnpm build` (nx builds the package) and now COPYs the fresh build/.

Verified: wiped build/, rebuilt via `pnpm --filter @docmost/git-sync build`, then
the server converter gate (26/26, imports the rebuilt package) and the git-sync
suite (588 passed) both pass against the freshly-built, non-committed output.

NOTE: packages/mcp/ has the same committed-build/node_modules pattern (pre-existing,
out of this PR's scope) and should get the same treatment in a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
2e7e07bb65 fix(git-sync): don't clobber pages with a live editing session; crash-safe body write
Review finding #5: the git -> page body write (writeBody) did a full-body replace
(delete-all + re-insert) on the shared Yjs doc. Applied while a human is editing
the page, it discarded their in-flight changes; and TiptapTransformer.toYdoc ran
AFTER the fragment was cleared, so a conversion failure could leave the page with
an empty body.

Fixes:
- Active-session guard: CollaborationGateway.getActiveEditorCount(documentName)
  reports live human (websocket) editor sessions for a doc, excluding server-side
  direct connections. writeBody now throws ActiveEditSessionError when an editor
  is connected. The engine's push loop already isolates each importPageMarkdown in
  try/catch and does not advance the loop-guard on failure, so the write is simply
  retried on the next poll once the editor disconnects — never a clobber.
- Crash-safe conversion: build the replacement Yjs update BEFORE opening the
  connection / clearing the fragment, so a transform failure can never leave the
  body empty.

Also updates the server-side converter gate spec to the corrected round-trip
shape: the block-image hoist no longer leaves a leading empty paragraph (the
git-sync converter fix in 7d39c16b, now reaching the built package).

A true merge of git content into a live Yjs session is out of scope (it needs a
real 3-way text merge with no shared update lineage); deferring the write while a
page is being edited is the safe, owner-approved minimum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
981bed63d4 fix(docker): ship packages/git-sync into the runtime image
The server requires @docmost/git-sync (main: ./build/index.js) at runtime, but
the installer stage copied only editor-ext and mcp — so the image built fine and
then crashed on startup with `Cannot find module '@docmost/git-sync'`. Copy the
package's freshly-built build/ + package.json, mirroring the mcp/editor-ext COPY
lines. (Addresses review finding #1 on PR #119.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
d215147d13 test(git-sync): exhaustive converter coverage + fix 3 round-trip data-loss bugs
Coder↔reviewer design loop (9 rounds, reviewer verdict: exhaustive) produced
92 specs; implemented +123 tests (465 -> 588 passing). The new round-trip
coverage exposed three genuine data-loss bugs in the Markdown<->ProseMirror
converter, all now FIXED (round-trip is lossless for these):

1. pageBreak was lost on export (no converter case -> rendered to "" and the
   node vanished). Now emits <div data-type="pageBreak"></div>, which the schema
   parses back -> round-trips.
2. A block image between blocks left an empty <p> artifact after import-hoisting,
   producing a phantom blank-gap diff on every sync. markdownToProseMirror now
   strips content-less paragraphs after generateJSON — with a schema-validity
   guard that keeps the obligatory single empty paragraph in `content: "block+"`
   containers (tableCell/tableHeader/blockquote/column/callout/doc), so empty
   cells/quotes never become an invalid `content: []`.
3. The `code` mark combined with another mark was not byte-stable (emitted nested
   HTML that the schema's `code` `excludes:"_"` collapsed on import). The
   converter now emits code-only when `code` co-occurs, matching the editor.

New coverage spans media/diagram/details/columns/math/mention attribute
round-trips, converter emission branches, git error paths, and engine decision
branches. A dedicated test pins the empty-container schema validity (the review
catch on the bug-2 fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude_code
c7440fe8a4 feat(git-sync): serve spaces over smart-HTTP (gitmost as a two-way git host)
Expose each git-sync-enabled space as a clonable/pushable git repo over HTTP,
so `git clone https://<user>:<pass>@<host>/git/<spaceId>.git` works and external
pushes flow back into Docmost pages — gitmost itself acts as the git host (no
external GitHub/Gitea, no SSH).

Transport: shell out to `git http-backend` (CGI; git is already in the runtime
image) which implements the full smart-HTTP protocol (info/refs, upload-pack,
receive-pack, protocol v2). A raw Fastify route `/git/*` (mounted at the root,
outside the `/api` prefix) bridges the request/response to the CGI; passthrough
content-type parsers for the git media types stream the raw body to stdin.

Reuse the existing engine: clients push the vault's `main` branch, whose commits
beyond `refs/docmost/last-pushed` the engine already reconciles into Docmost.

- http/git-http.service.ts — auth (HTTP Basic -> AuthService.verifyUserCredentials),
  self-resolved workspace (DomainMiddleware does not run for this raw route),
  per-space gating (global + per-space gitSync flags, 404 hides existence),
  CASL authz (Read=fetch, Manage=push), dispatch.
- http/git-http-backend.service.ts — spawn `git http-backend`, binary-safe CGI
  response parsing (Status/headers/body), stream to the socket.
- http/git-http.helpers.ts — pure path parse, service->kind mapping, gate decision
  (unit-tested); rejects literal and percent-encoded path traversal.
- orchestrator: extract reusable withSpaceLock (CAS-guarded lock heartbeat so a
  long push cannot let the lock expire mid-cycle) and add ingestExternalPush
  (receive-pack + Docmost cycle under one lock; 503 on contention).
- vault-registry: ensureServable() — ensureRepo + idempotent receive.denyCurrentBranch
  =updateInstead / denyNonFastForwards / http.receivepack / http.uploadpack.
- env: GIT_SYNC_HTTP_ENABLED (defaults to GIT_SYNC_ENABLED) + validation.
- main.ts: register the /git/* route and the git content-type parsers.

Tests: pure helpers, CGI parsing, and the GitHttpService handler (auth/gate/authz
+ workspace resolution). Server tsc + git-sync/env suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude_code
75fec6444f test(git-sync): add reviewer-requested coverage across engine, server, client
Implements the test cases called out in the PR #119 review threads
(code-review, test-strategy report, red-team) — TESTS ONLY, no production
code changes.

packages/git-sync (vitest):
- lib converter/markdown gaps: pageBreak data-loss (it.fails repro),
  subpages lossy round-trip, nested/fenced callouts, ol->taskList bridge,
  column.width number<->string drift, empty details.
- engine units: parentFolderFile, planReconciliation swap/chained move,
  buildVaultLayout last-resort-by-id, firstDivergence, applyPushActions /
  applyPullActions failure isolation.
- real temp-git integration: diffNameStatus -z rename+add/modify
  alignment, copy-line behavior, per-invocation committer identity (no
  leak into repo/global config).
- ENFORCED type-level GitSyncClient contract via vitest typecheck over a
  *.test-d.ts file (tsconfig.vitest.json; build tsconfig untouched).

apps/server (jest):
- orchestrator: delete-cap neutralization + fail-safe, Redis lock / mutex
  skip ladder + release-on-throw, merge guard, pull/push order, remote
  template substitution, poll lifecycle.
- page-change listener: loop-guard, debounce coalescing, id resolution,
  error swallowing.
- vault registry, controller authz (trigger + status), env
  validation/getters, page.service git-sync provenance stamping,
  persistence precedence (agent > git-sync > user) + no boundary snapshot,
  space.service audit-delta, space.repo jsonb-merge, converter-gate corpus
  extension (mention/math/details/marks).

apps/client (vitest + testing-library):
- history-item git-sync badge: render gating + non-clickable.
- edit-space-form toggle: initial state, optimistic payload, rollback on
  error, disabled states.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
bf23c3c82d fix(git-sync): address review — configurable poll, always-on loop-guard, cleanup
Comprehensive-review follow-ups (APPROVE WITH SUGGESTIONS; no critical issues):
- poll interval is now actually configurable: replaced the hardcoded
  @Interval('git-sync-poll', 15000) with a dynamic SchedulerRegistry interval
  registered in onModuleInit from getGitSyncPollIntervalMs() (cleared in
  onModuleDestroy); /status and the real cadence now share one config source.
  Boots logging 'poll interval registered (Nms)'.
- loop-guard now ALWAYS applies: the lastUpdatedSource==='git-sync' skip was
  nested inside the !spaceId/!workspaceId branch, so structural self-writes
  (CREATE/MOVE/RESTORE/SOFT_DELETE, which carry spaceId+workspaceId) bypassed it
  and re-triggered cycles. Fetch the page row once, guard unconditionally, then
  resolve space/workspace.
- remove the dead PAGE_CONTENT_UPDATED subscription (it's a BullMQ job, never an
  EventEmitter event; body edits arrive via PAGE_UPDATED).
- fix the stale datasource comment (PageService DOES stamp 'git-sync' now).
- env getters: parseInt radix 10 + NaN/<=0 fallback for poll/debounce (+ max
  deletes), with 6 new environment.service.spec tests.

tsc clean; jest 723 pass; live cycle re-verified post-refactor (ran, push
applied, unflagged 92-page space untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
b2f13aea93 feat(git-sync): client 'Git sync' provenance badge + git in runtime image (Phase D)
- page-history history-item: a lastUpdatedSource==='git-sync' version renders a
  neutral gray 'Git sync' badge (git-merge icon), NOT the agent badge/deep-link
  (it is not an agent edit). +2 i18n keys.
- Dockerfile: install git in the installer (runtime) stage — VaultGit shells out
  to git, so assertGitAvailable() needs the binary at runtime.
Client tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
78073e3124 feat(git-sync): per-space 'Enable Git sync' toggle (Phase C, §7.1)
UI opt-in for git-sync, mirroring the existing sharing/comments settings pattern
(no new endpoint, no new mechanism; orchestrator read query untouched):
- UpdateSpaceDto.gitSyncEnabled?: boolean.
- SpaceRepo.updateGitSyncSettings: jsonb-merge into settings.gitSync.<key>
  (COALESCE || jsonb_build_object — never clobbers sibling sharing/comments);
  stored as a real jsonb boolean so the orchestrator's
  settings->'gitSync'->>'enabled' = 'true' matches.
- SpaceService.updateSpace handles the flag (audit diff) via the existing
  CASL-guarded space update path (Manage/Settings).
- client: Switch in edit-space-form (optimistic mutate + revert-on-error,
  readOnly-aware) + space types + 2 i18n keys.
- space.service.spec extended (calls updateGitSyncSettings; no-op when undefined).
tsc clean (server+client); jest src/core/space 4 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
3a03a61060 fix(git-sync): branch choreography + strict scoping + delete cap (Phase B hardening)
Fixes found by the live pull/push e2e:
- CRITICAL: driveCycle never checked out the 'docmost' branch before
  applyPullActions, so Docmost content was written straight onto 'main',
  clobbering local file edits before push could diff them. Now checkout
  'docmost' before pull (applyPullActions commits there then checks out main +
  merges) — mirrors the engine's pull main(). Round-trip now works both ways.
- add an unresolved-merge guard (SPEC §9): skip the cycle if the vault is
  mid-merge instead of failing on checkout.
- SAFETY: enabledSpaces() is now STRICT opt-in — only spaces with
  settings.gitSync.enabled===true; removed the all-spaces fallback that synced
  every space (incl. a 92-page one) the moment GIT_SYNC_ENABLED flipped.
- SAFETY: per-cycle delete cap (GIT_SYNC_MAX_DELETES_PER_CYCLE, default 5):
  dry-run the push, and if planned deletes exceed the cap, run the apply with
  deletePage neutralized — phantom absence-deletions from a non-convergent vault
  can't soft-delete real pages. Fails safe if the dry-run throws.
- fix manual trigger: TriggerGitSyncDto.spaceId needs @IsUUID or the global
  whitelist ValidationPipe strips it (arrived undefined -> vault 'undefined').

Live-verified on an isolated flagged space: push (vault file edit -> Docmost
content, stamped lastUpdatedSource='git-sync') and pull (Docmost rename -> vault
file + meta) both work; an unrelated 92-page space stayed untouched throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
7190eb27f4 feat(git-sync): GitSyncModule orchestrator + config + listener (Phase A.4b/B)
Control plane wiring (plan §5-§11):
- PageService create/update/movePage now honor provenance actor 'git-sync'
  (stamp lastUpdatedSource='git-sync'), closing the A.4a gap.
- EnvironmentService: GIT_SYNC_ENABLED / DATA_DIR / REMOTE_TEMPLATE /
  POLL_INTERVAL_MS / DEBOUNCE_MS / SERVICE_USER_ID (required-if-enabled) /
  SSH_KEY_PATH + validation.
- VaultRegistryService: per-space vault path + cached VaultGit.
- GitSyncOrchestrator: per-space Redis leader-lock (SET NX PX + CAS-Lua release,
  randomUUID instanceId) + in-process mutex; runOnce drives the vendored engine
  PULL (readExisting->computePullActions->applyPullActions) then PUSH (runPush)
  with the bound native GitSyncClient + VaultGit; @Interval poll-safety gated on
  GIT_SYNC_ENABLED; imports plain ScheduleModule (TelemetryModule owns forRoot).
- PageChangeListener: @OnEvent PAGE_* -> per-space debounce -> runOnce, with a
  best-effort lastUpdatedSource==='git-sync' loop-guard.
- GitSyncController: admin POST /api/git-sync/trigger + GET /status (ops/e2e).
- GitSyncModule registered in app.module. Enabled-space enumeration uses
  settings.gitSync.enabled, falling back to all live spaces until Phase C writes
  the flag (master gate = GIT_SYNC_ENABLED).

tsc clean; 713 tests/71 suites pass; dev server hot-reloaded the module (route
live, DI graph boots). Live pull/push round-trip verified next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
32b850b2b2 feat(git-sync): native GitmostDataSource + 'git-sync' provenance (Phase A.4a)
Native data plane for git-sync (plan §3, §8.1):
- provenance: widen actor to 'user'|'agent'|'git-sync' (jwt-payload,
  auth-provenance decorator); PersistenceExtension resolves lastUpdatedSource
  with precedence agent > git-sync > user, debounced history (like a human edit,
  not the agent's immediate snapshot).
- GitmostDataSourceService implements @docmost/git-sync's GitSyncClient natively:
  reads via PageRepo/SpaceRepo (listSpaceTree complete:true, getPageJson), writes
  via PageService (create/removePage soft-delete/movePage with computed fractional
  position/update-rename/restore) + the writeBody linchpin through collab
  openDirectConnection('page.'+id, {actor:'git-sync'}) mirroring
  collaboration.handler withYdocConnection 'replace'. bind({workspaceId,userId})
  returns the context-bound client for the orchestrator.
- 10 unit/contract tests (mapping + soft-delete + move-position), tsc clean.

Known gap (closed in A.4b): PageService.create/update/movePage only branch on
actor==='agent'; git-sync provenance is already passed through so the row source
marker propagates once PageService honors 'git-sync'. Module/orchestrator/config
come next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
88b2477a5a feat(git-sync): vendor IO engine (pull/push/git/settings) with GitSyncClient seam (Phase A.3)
Vendor the IO engine from docmost-sync into packages/git-sync/src/engine:
- git.ts (VaultGit, execFile shell-out — verbatim)
- pull.ts (readExisting, computePullActions, applyPullActions)
- push.ts (classifyRenameMoves, computePushActions, applyPushActions, runPush)
- settings.ts adapted (pure parseSettings + Settings type; no process.env binding
  — the server builds Settings from EnvironmentService later), config-errors.ts.
CLI main()/import.meta entrypoints dropped (server drives in-process).

Client seam: new engine/client.types.ts defines GitSyncClient; pull.ts/push.ts
now use Pick<GitSyncClient, ...> instead of the non-vendored DocmostClient. Engine
logic byte-identical except a zod4-compat fix in config-errors (zod4 dropped the
issue.received==='undefined' signal; match /received undefined/ on the message).

Ported the engine unit tests (compute/apply pull+push actions, classify-rename-
moves, run-push, settings, config-errors) incl. real-git temp-repo tests: 431
pass / 3 expected-fail (was 314/3). REST/CLI-coupled upstream tests skipped
(noted). CJS build clean. No apps/server wiring yet (next step).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
86f02927df feat(git-sync): CommonJS build + §13.1 editor-ext idempotency gate (Phase A.2)
Make @docmost/git-sync natively consumable by the CommonJS server (and jest):
build to CommonJS (tsconfig module CommonJS, drop type:module, strip .js from
relative imports), and lazy-load the only ESM-only dep (marked) via the dynamic
Function('import()') trick (mirrors docmost-client.loader.ts) with a require()
fallback so vitest's evaluator works too. git-sync tests stay green (314 pass,
3 expected fail).

Add the §13.1 idempotency gate (apps/server .../git-sync-converter-gate.spec.ts):
13 editor-ext docs (paragraphs/headings, marks, links, bullet/ordered/task lists,
blockquote, callouts, code block, hr, table, nested mix) round-trip
content(editor-ext) -> convertProseMirrorToMarkdown -> markdownToProseMirror ->
TiptapTransformer.toYdoc/fromYdoc(tiptapExtensions) -> canonicalize and assert
docsCanonicallyEqual. All green => the vendored converter's docmost-schema is
schema-compatible with editor-ext (no node/mark/attr loss), which the plan §13.1
requires before Phase B. The one intrinsic markdown-image lossiness (width/height
/align can't ride plain ![](src)) is isolated in a KNOWN DIVERGENCE block, not
hidden. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
claude code agent 227
87e023b755 feat(git-sync): vendor pure converter + engine into @docmost/git-sync (Phase A.1)
First step of docs/git-sync-plan.md. New workspace package @docmost/git-sync
vendoring the PURE parts from docmost-sync (HEAD b03eb35):
- lib: markdown-converter, markdown-document, canonicalize, docmost-schema,
  node-ops, diff, and an extracted markdown-to-prosemirror (only the pure
  marked->HTML->generateJSON path from upstream collaboration.ts; no websocket).
- engine (pure, no IO): reconcile, layout, sanitize, stabilize, loop-guard.
Ported the upstream pure-module + round-trip corpus tests (vitest): 314 pass,
3 expected upstream known-limitation fails. tsc clean. No server wiring yet.

docmost-schema inlines getStyleProperty (as packages/mcp does — @tiptap/core
3.20.4 doesn't export it). IO engine (pull/push/git/settings) deferred to later
Phase A/B steps; the editor-ext idempotency gate (plan §13.1) is the next step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:38:46 +03:00
538 changed files with 34490 additions and 41986 deletions

View File

@@ -92,19 +92,6 @@ IFRAME_EMBED_ALLOWED=false
# Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS=
# Comma-separated list of additional origins allowed to call the API via CORS.
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
# Leave empty for a same-origin (web-only) deployment.
CORS_ALLOWED_ORIGINS=
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
SWAGGER_ENABLED=false
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
# Leave empty for Android bundled mode / local development.
CAP_SERVER_URL=
# Enable debug logging in production (default: false)
DEBUG_MODE=false
@@ -145,14 +132,6 @@ MCP_DOCMOST_PASSWORD=
# NEVER set is_agent on a human or shared account — every action by that account
# (including normal human edits) would then be mis-attributed as AI.
# Agent-roles catalog source: an http(s):// base URL to the catalog's raw files
# (the server appends /index.json and /bundles/<id>/<lang>.json). This value is
# baked into the Docker image at build time per branch (see the Dockerfile ARG
# AI_AGENT_ROLES_CATALOG_URL and the CI build-args). Set it here only to point a
# local/non-Docker run at a catalog; if unset, the "import role from catalog"
# admin feature is unavailable. Local-filesystem sources are no longer supported.
# AI_AGENT_ROLES_CATALOG_URL=
# Per-embedding-call timeout in milliseconds for the RAG indexer.
# A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000
@@ -216,3 +195,43 @@ MCP_DOCMOST_PASSWORD=
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
# per rolling day).
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
# --- GIT-SYNC (native two-way Docmost <-> git Markdown sync) ---
# Master switch. Off by default. When 'true', GIT_SYNC_SERVICE_USER_ID below is
# REQUIRED (the service account that git-originated create/move/rename/delete are
# attributed to) — the server refuses to boot with sync enabled and no user id.
# GIT_SYNC_ENABLED=false
#
# Serve the per-space vaults over smart-HTTP (the /git host). Defaults to
# GIT_SYNC_ENABLED when unset.
# GIT_SYNC_HTTP_ENABLED=false
#
# REQUIRED when GIT_SYNC_ENABLED=true: id of the user that git-originated page
# operations (create / move / rename / delete) are attributed to.
# GIT_SYNC_SERVICE_USER_ID=
#
# Where the per-space working vaults live (non-bare repos; the engine needs a
# working tree).
# Defaults to "<DATA_DIR or ./data>/git-sync".
# GIT_SYNC_DATA_DIR=
#
# Optional remote URL template to mirror each space's vault to (e.g. a git host).
# Leave unset to keep vaults local-only.
# GIT_SYNC_REMOTE_TEMPLATE=
#
# Path to the SSH private key used when pushing to GIT_SYNC_REMOTE_TEMPLATE.
# GIT_SYNC_SSH_KEY_PATH=
#
# Poll-safety interval in ms — the cadence of the background reconcile cycle
# (default: 15000).
# GIT_SYNC_POLL_INTERVAL_MS=15000
#
# Debounce window in ms for collapsing bursts of page edits into one sync cycle
# (default: 2000).
# GIT_SYNC_DEBOUNCE_MS=2000
#
# Watchdog timeout in ms for the spawned `git http-backend` process serving a
# git smart-HTTP push (default: 120000). A stalled/hung receive-pack is killed
# after this deadline so it cannot hold the per-space lock forever.
# GIT_SYNC_BACKEND_TIMEOUT_MS=120000
#

View File

@@ -52,7 +52,6 @@ jobs:
platforms: linux/amd64
build-args: |
APP_VERSION=${{ steps.version.outputs.value }}
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
push: true
tags: ${{ env.IMAGE }}:develop
cache-from: type=gha,scope=develop-amd64

View File

@@ -17,7 +17,6 @@ permissions:
env:
VERSION: ${{ inputs.version || github.ref_name }}
IMAGE: ghcr.io/vvzvlad/gitmost
AI_AGENT_ROLES_CATALOG_URL: https://raw.githubusercontent.com/vvzvlad/gitmost/main/agent-roles-catalog
jobs:
# Run the reusable test suite first so a failing test blocks the image build.
@@ -58,7 +57,6 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
APP_VERSION=${{ env.VERSION }}
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.suffix }}
cache-to: type=gha,scope=${{ matrix.suffix }},mode=max,ignore-error=true
@@ -87,7 +85,6 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
APP_VERSION=${{ env.VERSION }}
AI_AGENT_ROLES_CATALOG_URL=${{ env.AI_AGENT_ROLES_CATALOG_URL }}
push: false
tags: |
${{ env.IMAGE }}:latest

View File

@@ -68,6 +68,13 @@ jobs:
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
# git-sync and mcp are no longer committed in built form (build/ is
# gitignored), so CI must compile them: the server resolves both via their
# built build/index.js. The server pretest also builds them, but building
# here keeps it explicit and independent of pnpm lifecycle ordering.
- name: Build git-sync and mcp
run: pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
- name: Run unit tests
run: pnpm -r test

11
.gitignore vendored
View File

@@ -5,6 +5,12 @@ data
# compiled output
/dist
/node_modules
# workspace package node_modules (pnpm symlinks — never commit; they bake
# machine-local store paths) and the git-sync compiled output (built in CI/Docker
# via `pnpm build`, never committed, so src/ and prod can never silently diverge).
packages/*/node_modules/
packages/git-sync/build/
packages/mcp/build/
# Logs
logs
@@ -49,8 +55,3 @@ lerna-debug.log*
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
apps/client/public/vad/
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
/ios
/android
.capacitor

View File

@@ -182,7 +182,7 @@ tea issues create --repo vvzvlad/gitmost --labels feature \
## Monorepo layout
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Five workspace packages:
| Path | Name | Stack | Role |
| --- | --- | --- | --- |
@@ -190,6 +190,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
| `packages/git-sync` | `@docmost/git-sync` | Tiptap/ProseMirror, Yjs, git | Pure ProseMirror↔Markdown converter plus the two-way Docmost↔git Markdown sync engine. Bundled into the server (loaded over the ESM bridge), built in CI and the Dockerfile. Does **not** import `editor-ext` — it keeps its own vendored mirror of the document schema (kept in sync with `editor-ext`). |
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
@@ -254,7 +255,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
- **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync.
### The two AI subsystems (the main fork additions)
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (39 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer <MCP_TOKEN>` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry.
1. **Embedded MCP server** (`integrations/mcp/` + `packages/mcp`). The standalone `@docmost/mcp` server (38 agent-native tools: per-block patch/insert/delete by id, scripted `(doc)=>doc` transforms with dry-run diff, table editing, version diff/restore, comments, images, shares) is bundled and served over HTTP at `/mcp`. It writes through Docmost's real-time-collaboration layer so concurrent human edits aren't clobbered. Each request authenticates **per-user** via the `Authorization` header — either HTTP Basic (`base64(email:password)`, the user's own Docmost login, validated through `AuthService`) or a Bearer access JWT (the user's `authToken`) — and the session acts under that user's permissions. `MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD` are an **optional service-account fallback**, used only when a request carries neither Basic nor Bearer credentials (back-compat for CI/scripts). An admin enables MCP with a workspace toggle (Workspace settings → AI). Optionally protected by a shared `MCP_TOKEN`: when set, every `/mcp` request must carry a matching `X-MCP-Token` header (its own header, separate from `Authorization`, which now carries the per-user Basic/Bearer credentials). Note: this changed from the older `Authorization: Bearer <MCP_TOKEN>` scheme — see `.env.example` and the CHANGELOG Breaking Changes entry.
2. **AI agent chat** (`core/ai-chat/` server + `apps/client/src/features/ai-chat/` client). A built-in agent over the wiki using the Vercel **AI SDK** (`ai`, `@ai-sdk/*`) against any OpenAI-compatible provider configured per workspace (`integrations/ai/` — credentials encrypted at rest via `integrations/crypto`, stored in `ai_provider_credentials`). Key pieces:
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
@@ -263,7 +264,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
### Client structure
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note neither `packages/mcp` nor `packages/git-sync` depends on `editor-ext`; each carries its own mirrored copy of the schema. There are now **three** independent copies (`editor-ext` is canonical, plus `packages/mcp` and `packages/git-sync`), so keep all three in sync manually when the document schema changes.
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.

View File

@@ -10,141 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Quick-create regular and temporary notes from the Home and Space screens.**
The Home screen now shows a second action next to "New note" that creates a
*temporary* note (one that auto-moves to Trash after the workspace lifetime),
resolving the target space the same way the regular button does — created
directly when you can write to a single space, or via a space picker when
several. Each space overview screen gains two buttons — "New note" and "New
temporary note" — that create the page directly in that space and open it,
mirroring the existing space-sidebar actions and shown only to members who can
manage pages.
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
message gains a "send now" action that interrupts the streaming turn and
immediately sends that message, keeping the agent's partial output. The
follow-up turn is tagged as an interrupt so the model is told its previous
answer was cut off and builds on it instead of restarting; the rest of the
queue still flushes normally afterward. (#198)
- **Importable multilingual agent-roles catalog.** Admins can browse a curated
catalog of agent roles, grouped into bundles and offered in several languages,
and import the ones they want into the workspace (with skip-or-rename handling
for name collisions); the same role in a different language imports as a
separate install. An imported role remembers its catalog origin and offers a
one-click update when the catalog ships a newer revision. Backed by four new
admin endpoints — `POST /ai-chat/roles/catalog` (browse bundles),
`/catalog/bundle` (read one bundle's roles), `/import`, and
`/update-from-catalog` — and a new `source` column linking a role to its
catalog slug/language/version. The catalog source is configured via the
`AI_AGENT_ROLES_CATALOG_URL` env var — an `http(s)://` base URL to the
catalog's raw files; the image ships a per-branch default baked in CI, and it
can be overridden at runtime via the env var (see `.env.example`). (#222)
- **Author footnotes inline from an agent, and deterministic server-side footnote
canonicalization on every non-editor write path.** A new MCP `insert_footnote`
tool places a footnote at a body anchor by content only — the agent supplies
WHERE (anchor text) and WHAT (markdown); the number and the bottom
`footnotesList` are derived server-side, so an agent can never assign a number,
edit the list, or desync, and a same-content note reuses one definition. Under
the hood, the editor's footnote-integrity invariant (one trailing list,
numbering by first reference, no orphans/duplicates, no raw `[^id]`) is now
enforced as a pure `canonicalizeFootnotes(doc)` on the FULL-document write paths
that bypass the editor's plugins: server markdown/HTML import, `PageService`
create and full-document (`replace`) updates, the client markdown paste, and the
MCP markdown page-import / `update_page` (markdown) / `update_page_json` /
`docmost_transform` / `insert_footnote` / `copy_page_content` paths. It is
idempotent (a no-op once canonical) and is deliberately NOT applied to
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
contain a standalone footnote definition, which canonicalization would drop.
(#228)
### Changed
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
a page "Shared to web" now defaults to the page alone; descendant pages become
public only when you explicitly turn on the dedicated "Include sub-pages"
toggle. Previously the create call defaulted to including sub-pages, silently
exposing every child of a freshly shared page. (#216)
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
children, and comments are cached in IndexedDB (TanStack Query persister plus
`y-indexeddb` for the page's Yjs document), and a PWA service worker
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
offline. The two offline stores (the persisted query cache and the Yjs page
documents) are cleared on logout AND on sign-in so a previous user's private
data does not remain in the browser; the same purge also defensively drops any
legacy service-worker `api-get-cache` left by older clients (current builds
serve `/api` as NetworkOnly, so there is no active service-worker API cache).
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
can request the access JWT in the response body (`data.authToken`) in addition
to the httpOnly cookie (the web client stays cookie-only); an optional
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
### Changed
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
`app.enableCors()`). The same-origin web client is unaffected, but any
separately-hosted cross-domain client must now be listed in
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
allowed automatically). Requests with no `Origin` header (server-to-server)
are still allowed. **Upgrade note:** the old bare `app.enableCors()` reflected
*any* origin (with `credentials:false`), so any previously-working cross-domain
REST/browser client is now rejected until its origin is added to
`CORS_ALLOWED_ORIGINS` (see `.env.example`).
### Fixed
- **Internal links in exported Markdown no longer lose their visible text.** A
link whose target page name had no file extension (e.g. a bare title) was
collapsed to empty text during export, producing an unclickable, label-less
link; the page name is now preserved. (#204)
- **Deep pages no longer render a blank breadcrumb while the sidebar tree loads.**
The breadcrumb now falls back to the page's own ancestor chain (fetched
independently of the lazily-built sidebar tree) so a deep page resolves its
trail immediately; navigating away no longer leaves the previously-viewed
page's breadcrumb showing until the new one resolves. (#206, #218)
- **Pasted GitHub-style callouts (`> [!NOTE]` …) now convert to real callouts.**
GitHub admonition blocks pasted as Markdown are recognized and rendered as
callout blocks instead of plain block-quotes. (#192)
- **The editor stays read-only until collaboration has synced.** While a page is
connecting, the body is shown as a non-editable static view with a
"Connecting… (read-only)" banner, so edits typed before the document finishes
syncing can no longer be silently dropped. (#218)
- **A shared page now keeps EXACTLY ONE custom address (`/l/:alias`).** Editing a
page's vanity slug previously inserted a second `share_aliases` row instead of
renaming the existing one, leaving the old `/l/<old>` link live forever and
making the share modal's lookup nondeterministic. Slug edits and confirmed
reassigns now rename/retarget the single row, and a new partial unique index on
`(workspace_id, page_id)` enforces the invariant in the database. **Upgrade
note:** the accompanying migration `20260627T120000` IRREVERSIBLY deletes the
orphaned duplicate alias rows the old bug created (keeping the newest per
page), so any previously-live duplicate `/l/<old>` link begins returning the
generic 404 after upgrade — intended, but not undoable by `down()`. (#226,
#227)
- **Typing a custom address already used by another page no longer looks like a
dead end.** The share modal previously flagged such a name with a red "This
address is already in use" error, hiding the fact that saving offers to MOVE
the address to the current page. The field now shows an informational hint —
"This address is in use. Saving will move it to this page." — and keeps Save
enabled, so the existing reassign-confirm flow (`409 ALIAS_REASSIGN_REQUIRED`
"Move custom address?") is discoverable instead of reading as terminal. (#227)
### Security
- **The anonymous public-share page payload is trimmed to an explicit allowlist.**
The `/shares/page-info` route (the only unauthenticated path serializing a
page + its share) now returns only the fields the public renderer needs;
internal metadata — creator/last-updater/contributor ids, space/workspace ids,
AI/source bookkeeping, lock/template flags, parent/position and raw timestamps
— is no longer exposed to anonymous viewers. (#218)
- **A forged or mismatched share id can no longer render a page off its slug
alone.** When the public URL carries a share id/key, the page must be reachable
through that exact share (its own share or an ancestor `includeSubPages`
share); any other value now returns the generic "not found" instead of
serving the page. (#218)
## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to
@@ -167,16 +32,6 @@ per-workspace rolling-day token budget.
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
alias any workspace member can reclaim. (#205)
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
be marked temporary so it auto-moves to Trash once a configurable workspace
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
permanent first. The deadline is frozen at creation time, so later changes to
the workspace setting never reschedule existing notes; an hourly background
sweep trashes notes past their deadline (children ride along). An open
temporary note shows a banner with a "Make permanent" rescue action; restoring
a note from Trash disarms the timer so it is not immediately re-trashed.
Operators configure the lifetime per workspace. (#201)
- **Persistent AI-chat history as the source of truth + server-side export.**
An assistant turn is now persisted to the database step by step: the row is
inserted upfront as `streaming` and updated as each agent step finishes, then
@@ -217,31 +72,9 @@ per-workspace rolling-day token budget.
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
- **Generate a page title from its content.** A "sparkles" button in the page
byline reads the live editor content (including unsaved edits), generates a
title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and
applies it through the existing `/pages/update` route — reflecting it in the
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
flag and throttled per user. (#199)
- **AI chat: header button auto-opens the chat bound to the current document.**
Clicking the AI-chat button in the header while viewing a page now reopens the
latest chat tied to that document instead of whatever chat was last active,
reusing the existing `ai_chats.page_id` provenance (no migration). The newest
chat you created on the page wins; with no bound chat — or off a page, or if
the lookup fails — it falls soft to a fresh chat and keeps the current
selection otherwise. (#191)
### Changed
- **AI chat now feeds the model the full stored transcript.** The per-turn model
conversation was rebuilt from a sliding window of the 50 most recent stored
rows, which silently dropped the beginning of any longer chat. It is now
rebuilt from the complete non-deleted transcript in chronological order, so
the model sees every turn (a 5000-row backstop guards process memory — a
safety net far above any realistic chat, not a conversational limit). On a
very long chat this can eventually reach the model's context window; the
client already surfaces that as "start a new chat". (#202)
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
For the `openai` driver the chat provider defaults to the openai-compatible
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the

View File

@@ -17,17 +17,13 @@ RUN pnpm build
FROM base AS installer
# git: required by the git-sync VaultGit (shells out to git)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl bash \
&& apt-get install -y --no-install-recommends curl bash git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Agent-roles catalog base URL: per-branch default set at build time (CI);
# overridable at runtime via the AI_AGENT_ROLES_CATALOG_URL env var.
ARG AI_AGENT_ROLES_CATALOG_URL=""
ENV AI_AGENT_ROLES_CATALOG_URL=$AI_AGENT_ROLES_CATALOG_URL
# Copy apps
COPY --from=builder /app/apps/server/dist /app/apps/server/dist
COPY --from=builder /app/apps/client/dist /app/apps/client/dist
@@ -38,6 +34,11 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
# git-sync: the server requires @docmost/git-sync at runtime; without these the
# image starts and crashes on `require('@docmost/git-sync')`. Built fresh by the
# builder's `pnpm build` (nx builds the package's tsc `build` target).
COPY --from=builder /app/packages/git-sync/build /app/packages/git-sync/build
COPY --from=builder /app/packages/git-sync/package.json /app/packages/git-sync/package.json
# Copy root package files
COPY --from=builder /app/package.json /app/package.json

View File

@@ -34,7 +34,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
| --- | --- |
| **EE code removed** | Stripped all client and server Enterprise-Edition code; ships as a clean community/AGPL build with no license checks. |
| **Comment resolution** | Re-implemented from scratch as a community feature (resolve / re-open with Open/Resolved tabs). No EE code reused, available to anyone who can comment. |
| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 39 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. |
| **Embedded MCP server** | A community MCP server (`@docmost/mcp`, 38 tools) is served over HTTP at `/mcp` — no enterprise license required. Replaces the removed license-gated EE MCP. |
| **AI agent chat** | Built-in AI agent chat over your wiki, written from scratch as a community feature — no enterprise license. The agent reads and edits pages on your behalf (scoped to your permissions), with full-text + vector (RAG) search and optional web access via external MCP servers. |
| **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. |
| **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. |
@@ -44,7 +44,7 @@ The goal of the fork is a **100% open, AGPL-only build with no Enterprise-Editio
### Embedded MCP server
Gitmost has **our own MCP server** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **39
which we wrote — **built directly into the app** and served at `/mcp`. It exposes **38
agent-native tools**: surgical per-block edits (patch / insert / delete by id),
structure-preserving find/replace, scripted `(doc) => doc` transforms with a dry-run diff,
structured table editing, version history with diff / restore, comments, images and share
@@ -60,7 +60,7 @@ every little fix. And it needs no enterprise license.
| | **Gitmost `/mcp` (our docmost-mcp)** | Docmost's built-in MCP |
| --- | :---: | :---: |
| **Enterprise license** | Not required | Required |
| **Tools** | 39, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
| **Tools** | 38, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
| **Per-block edits / find-replace / scripted transforms** | ✅ | — |
| **Structured table editing, version diff / restore** | ✅ | — |
| **Comments, images, share links** | ✅ | — |
@@ -104,7 +104,6 @@ community feature, with no enterprise license. Open it from the page header; the
-**Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
-**Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
-**Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
-**Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
### In progress

View File

@@ -33,7 +33,7 @@
| --- | --- |
| **Удалён EE-код** | Вырезан весь код Enterprise-редакции на клиенте и сервере; это чистая community/AGPL-сборка без лицензионных проверок. |
| **Резолв комментариев** | Переписан с нуля как community-функция (резолв / переоткрытие с вкладками «Открытые» / «Решённые»). EE-код не используется, доступно любому, кто может комментировать. |
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 39 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
| **Встроенный MCP-сервер** | Community MCP-сервер (`@docmost/mcp`, 38 инструментов) отдаётся по HTTP на `/mcp` — без enterprise-лицензии. Заменяет удалённый лицензируемый EE MCP. |
| **Чат с AI-агентом** | Встроенный чат с AI-агентом по содержимому вики, написанный с нуля как community-функция — без enterprise-лицензии. Агент читает и редактирует страницы от вашего имени (в рамках ваших прав), с полнотекстовым + векторным (RAG) поиском и опциональным доступом в интернет через внешние MCP-серверы. |
| **Ребрендинг** | Логотип / название приложения изменены с *Docmost* на *Gitmost*. |
| **Компактное дерево страниц** | Отступ дерева страниц по умолчанию уменьшен с 16px до 8px на уровень вложенности. |
@@ -44,7 +44,7 @@
В Gitmost есть **наш собственный MCP-сервер** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp),
который мы написали сами, — **встроенный прямо в приложение** и доступный на `/mcp`. Он даёт
**39 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
**38 agent-native инструментов**: точечное редактирование по блокам (patch / insert / delete
по id), find/replace с сохранением структуры, скриптовые трансформации `(doc) => doc` с
предпросмотром диффа, структурное редактирование таблиц, история версий с диффом /
восстановлением, комментарии, изображения и ссылки на шаринг — всё применяется через слой
@@ -60,7 +60,7 @@ real-time-коллаборации Docmost, поэтому запись нико
| | **`/mcp` в Gitmost (наш docmost-mcp)** | Родной MCP у Docmost |
| --- | :---: | :---: |
| **Enterprise-лицензия** | Не нужна | Нужна |
| **Инструменты** | 39, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
| **Инструменты** | 38, agent-native | Примитивные (Markdown, CRUD страниц, замена целиком) |
| **Правки по блокам / find-replace / скриптовые трансформации** | ✅ | — |
| **Структурное редактирование таблиц, дифф / восстановление версий** | ✅ | — |
| **Комментарии, изображения, ссылки на шаринг** | ✅ | — |
@@ -105,7 +105,6 @@ real-time-коллаборации Docmost, поэтому запись нико
-**Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
-**AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
-**Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
-**Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
### В процессе

View File

@@ -1,193 +0,0 @@
# Agent roles catalog
This directory is **data, not application code**. It holds the content of an
"agent roles catalog": reusable agent role definitions (system prompts plus a
little metadata), grouped into bundles and translated into one or more
languages. A separate server reads these files and serves them; nothing here is
executable application logic except the validation script.
## File layout
```
agent-roles-catalog/
index.json # the catalog manifest: bundles, languages, role versions
bundles/
<bundle-id>/
<lang>.json # one file per declared language (e.g. ru.json, en.json)
scripts/
check.mjs # validates the catalog (no dependencies)
content-hashes.json # check artifact: per-role content-hash lock (NOT served)
package.json # defines the `check` script
README.md
```
Currently shipped bundles:
- `editorial` — the editorial suite (structural-editor, line-editor,
fact-checker, proofreader, narrator), languages `ru`, `en`.
- `research` — a single `researcher` role, languages `ru`, `en`.
## How it's served
The server does not bundle this data; it reads it at request time from a single
configured location, the `AI_AGENT_ROLES_CATALOG_URL` env var
(`EnvironmentService.getAiAgentRolesCatalogSource()`), an `http(s)://` base URL
to the catalog's raw files. The server fetches `<base>/index.json` for the
manifest and `<base>/bundles/<bundle-id>/<lang>.json` for each opened bundle
file (REMOTE only).
That base URL is provided as a per-branch default in the Docker image (set in
CI: a `develop` build points at the `develop` raw URL, a release build at the
`main` raw URL) and can be overridden at runtime via the
`AI_AGENT_ROLES_CATALOG_URL` env var. Local-filesystem sources are no longer
supported; if the value is unset the catalog is unavailable.
The fetched JSON is re-validated server-side (the catalog is treated as
untrusted input). See `.env.example` for the variable and the CHANGELOG for the
rollout.
## `index.json` schema
```jsonc
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial", // unique bundle id; matches bundles/<id>/
"name": { "ru": "...", "en": "..." }, // localized display name
"description": { "ru": "...", "en": "..." },
"languages": ["ru", "en"], // which <lang>.json files must exist
"roles": [
{ "slug": "structural-editor", "version": 1 }
// ...
]
}
]
}
```
`version` lives **here, in index.json**, per role. Bump it whenever a role's
content (instructions, name, description, etc.) changes, so consumers can detect
updates.
## Bundle (`<lang>.json`) schema
```jsonc
{
"schemaVersion": 1,
"language": "ru",
"roles": [
{
"slug": "structural-editor", // REQUIRED, unique across the whole catalog
"emoji": "🧱",
"name": "...", // REQUIRED, localized
"description": "...", // localized
"instructions": "...", // REQUIRED, the system prompt, localized
"autoStart": true, // whether the role starts working immediately
"launchMessage": "..." // first message sent on launch (or null)
}
]
}
```
Notes:
- `modelConfig` is intentionally absent; the server treats an absent
`modelConfig` as `null`.
- A role's `slug`, `emoji`, and `autoStart` are identical across all language
files of the same bundle. Only `name`, `description`, `instructions`, and
`launchMessage` are translated.
## Slug uniqueness
**Every `slug` must be UNIQUE ACROSS THE WHOLE CATALOG**, not just within a
bundle. A slug appears once per language file of its bundle (same slug in
`ru.json` and `en.json`), but no two different bundles may share a slug.
`scripts/check.mjs` enforces this.
## How to add things
### Add a role to an existing bundle
1. Add an entry to that bundle's `roles[]` in `index.json` with a new unique
`slug` and `version: 1`.
2. Add a role object with the same `slug` to **every** `<lang>.json` of the
bundle, translating `name`, `description`, `instructions`, and
`launchMessage`.
3. Run the check (see below).
### Add a bundle
1. Add a bundle object to `index.json` (`id`, `name`, `description`,
`languages`, `roles`).
2. Create `bundles/<id>/<lang>.json` for each declared language, with one role
object per `roles[]` entry.
3. Run the check.
### Add a language to a bundle
1. Add the language code to that bundle's `languages[]` in `index.json`.
2. Create `bundles/<id>/<lang>.json` containing every role of the bundle,
translated.
3. Run the check.
### Change a role's content
Edit the role in the relevant `<lang>.json` file(s) and **bump that role's
`version`** in `index.json`. Then run `node scripts/check.mjs --update-hashes`
to refresh the content-hash lock (`scripts/content-hashes.json`). `check.mjs`
now **fails if a role's content changed but its `version` was not bumped**, so
this step is mandatory — the lock can only be refreshed after the bump.
## Validating
From this directory:
```sh
node scripts/check.mjs # or: npm run check
```
It fails (exit code 1) if any slug is duplicated across the catalog, if a
bundle's index `roles[]` don't match the slugs present in each language file, if
a declared language file is missing, or if any role is missing a required field
(`slug`, `name`, `instructions`). It prints `OK` on success.
### Content-hash guard
`check.mjs` also guards against changing a role's content without bumping its
`version`. It keeps a lockfile, `scripts/content-hashes.json`, mapping each role
`slug` to `{ version, hash }`, where `hash` is a SHA-256 over the role's
content fields (`emoji`, `autoStart`, `name`, `description`, `instructions`,
`launchMessage`) across all of its language files, in a deterministic canonical
form. This lockfile is a **check artifact only** — the server fetches only
`index.json` and the bundle `<lang>.json` files, never this file, so it has no
effect on the served catalog or its schema.
On a normal run, for every role the check recomputes the hash and compares it
against the lock:
- content unchanged and versions agree → OK;
- content changed but `version` not bumped above the lock → **error** asking you
to bump and refresh;
- content changed and `version` bumped → **error** asking you to record it by
refreshing the lock;
- role missing from the lock, or a lock entry for a role that no longer exists →
**error** asking you to refresh.
Refresh the lock with:
```sh
node scripts/check.mjs --update-hashes # alias: --fix
```
This recomputes the lock from the current catalog, prunes entries for removed
roles, and prints what changed — but it **refuses to write** (exit 1) if any
role's content changed while its `index.json` version was not bumped, so the
version bump is always enforced first. The check also requires every
`index.json` role to carry a finite numeric `version` (the server requires the
same).
Known, accepted limitation: a deliberate prune-then-readd of a slug (remove the
role and run `--update-hashes`, then re-add it with changed content at the same
version) is **not** caught, because a brand-new slug has no lock baseline to
enforce a bump against.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,31 +0,0 @@
{
"schemaVersion": 1,
"bundles": [
{
"id": "editorial",
"name": { "ru": "Редакторский набор", "en": "Editorial suite" },
"description": {
"ru": "Полный цикл редактуры статьи: структура, стиль, корректура, факты и нарратив.",
"en": "The full article-editing cycle: structure, style, copyediting, facts, and narrative."
},
"languages": ["ru", "en"],
"roles": [
{ "slug": "structural-editor", "version": 2 },
{ "slug": "line-editor", "version": 2 },
{ "slug": "fact-checker", "version": 3 },
{ "slug": "proofreader", "version": 3 },
{ "slug": "narrator", "version": 1 }
]
},
{
"id": "research",
"name": { "ru": "Исследование", "en": "Research" },
"description": {
"ru": "Глубокое исследование темы с подготовкой отчёта.",
"en": "Deep research on a topic with a prepared report."
},
"languages": ["ru", "en"],
"roles": [ { "slug": "researcher", "version": 1 } ]
}
]
}

View File

@@ -1,8 +0,0 @@
{
"name": "agent-roles-catalog",
"private": true,
"type": "module",
"scripts": {
"check": "node scripts/check.mjs"
}
}

View File

@@ -1,353 +0,0 @@
#!/usr/bin/env node
// Validates the agent roles catalog.
// Fails (exit 1) on: duplicate slugs across the whole catalog, mismatches
// between a bundle's index roles[] and the slugs present in each language
// file, a missing declared language file, or a role missing required fields.
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const catalogDir = join(__dirname, "..");
// `--update-hashes` (alias `--fix`) recomputes the content-hash lockfile from
// the current catalog instead of just validating against it.
const updateHashes =
process.argv.includes("--update-hashes") || process.argv.includes("--fix");
// The content-hash lockfile lives under scripts/ and is a CHECK ARTIFACT only:
// the server never fetches it, so it has zero impact on the served schema.
const lockPath = join(__dirname, "content-hashes.json");
const errors = [];
function readJson(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (err) {
errors.push(`Cannot read/parse ${path}: ${err.message}`);
return null;
}
}
const indexPath = join(catalogDir, "index.json");
if (!existsSync(indexPath)) {
console.error(`Missing index.json at ${indexPath}`);
process.exit(1);
}
const index = readJson(indexPath);
if (!index) {
for (const e of errors) console.error(e);
process.exit(1);
}
const bundles = Array.isArray(index.bundles) ? index.bundles : [];
if (bundles.length === 0) {
errors.push("index.json has no bundles[]");
}
// Track every slug seen across the whole catalog to detect duplicates.
const slugSeen = new Map(); // slug -> "bundleId/lang"
for (const bundle of bundles) {
const bundleId = bundle.id;
if (!bundleId) {
errors.push("A bundle in index.json is missing an id");
continue;
}
const indexSlugs = (bundle.roles || []).map((r) => r.slug);
// Duplicate slugs inside the bundle index roles[].
const indexSlugSet = new Set(indexSlugs);
if (indexSlugSet.size !== indexSlugs.length) {
errors.push(`Bundle "${bundleId}" index.json roles[] contains duplicate slugs`);
}
// Each index role must carry a finite numeric "version". The server requires
// this (see ai-agent-roles-catalog.provider.ts), and the content-hash guard
// below relies on it for the bump comparison, so enforce it here too.
for (const r of bundle.roles || []) {
if (typeof r.version !== "number" || !Number.isFinite(r.version)) {
errors.push(
`Bundle "${bundleId}" index.json role "${r.slug}" is missing a numeric "version"`
);
}
}
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
if (languages.length === 0) {
errors.push(`Bundle "${bundleId}" declares no languages`);
}
for (const lang of languages) {
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
if (!existsSync(langPath)) {
errors.push(`Bundle "${bundleId}" declares language "${lang}" but ${langPath} is missing`);
continue;
}
const langFile = readJson(langPath);
if (!langFile) continue;
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
const fileSlugs = roles.map((r) => r && r.slug);
// (d) Required fields per role.
for (const role of roles) {
for (const field of ["slug", "name", "instructions"]) {
if (role == null || role[field] == null || role[field] === "") {
errors.push(
`Bundle "${bundleId}/${lang}" has a role missing required field "${field}" (slug=${role && role.slug})`
);
}
}
}
// (b) index roles[] must match the slugs present in each language file.
const fileSlugSet = new Set(fileSlugs);
const missingInFile = indexSlugs.filter((s) => !fileSlugSet.has(s));
const extraInFile = fileSlugs.filter((s) => !indexSlugSet.has(s));
if (missingInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" is missing roles declared in index.json: ${missingInFile.join(", ")}`
);
}
if (extraInFile.length > 0) {
errors.push(
`Bundle "${bundleId}/${lang}" has roles not declared in index.json: ${extraInFile.join(", ")}`
);
}
// (a) Duplicate slugs across the whole catalog.
for (const slug of fileSlugs) {
if (!slug) continue;
const where = `${bundleId}/${lang}`;
// Only flag duplicates across DIFFERENT bundles or files; the same slug
// is expected to appear once per language file of the same bundle.
if (slugSeen.has(slug)) {
const prev = slugSeen.get(slug);
const prevBundle = prev.split("/")[0];
if (prevBundle !== bundleId) {
errors.push(
`Slug "${slug}" is duplicated across the catalog: ${prev} and ${where}`
);
}
} else {
slugSeen.set(slug, where);
}
}
}
}
// ---------------------------------------------------------------------------
// Content-hash guard: detect "content changed without a version bump".
//
// check.mjs cannot use git history, so we maintain a lockfile
// (scripts/content-hashes.json) mapping each role slug to its recorded
// { version, hash }. On every run we recompute each role's content hash and
// compare it against the lock; a content change is only allowed once the role's
// version in index.json has been bumped and the lock refreshed.
//
// Known, accepted limitation: a deliberate prune-then-readd of a slug (remove
// the role and run --update-hashes, then re-add it with changed content at the
// same version) is NOT caught, because a brand-new slug has no lock baseline to
// enforce a bump against. We document this rather than building tombstones.
// ---------------------------------------------------------------------------
// Content fields hashed for each role, in a fixed canonical order. `slug` is
// identity (not content) and `version` lives in index.json, so neither is here.
// `modelConfig` (an OPTIONAL role field the server also serves) is intentionally
// EXCLUDED: no shipped role uses it today, and being an object it would need a
// deterministic deep canonicalization (recursive key sort) before hashing —
// otherwise JSON.stringify key-order would make the hash non-deterministic. If a
// role ever gains a `modelConfig`, add it here WITH such canonicalization so a
// change to it is still caught by the bump guard.
const CONTENT_FIELDS = [
"emoji",
"autoStart",
"name",
"description",
"instructions",
"launchMessage",
];
// Build a map of slug -> { version, langRoles: { lang: roleObject } } from the
// current catalog so we can compute hashes and read index versions.
function collectCatalogRoles() {
const out = new Map(); // slug -> { version, langRoles: Map<lang, role> }
for (const bundle of bundles) {
const bundleId = bundle.id;
if (!bundleId) continue;
const languages = Array.isArray(bundle.languages) ? bundle.languages : [];
for (const r of bundle.roles || []) {
if (!r || !r.slug) continue;
if (!out.has(r.slug)) {
out.set(r.slug, { version: r.version, langRoles: new Map() });
} else {
// Same slug declared twice in index.json roles[]; already flagged above.
out.get(r.slug).version = r.version;
}
}
for (const lang of languages) {
const langPath = join(catalogDir, "bundles", bundleId, `${lang}.json`);
if (!existsSync(langPath)) continue;
const langFile = readJson(langPath);
if (!langFile) continue;
const roles = Array.isArray(langFile.roles) ? langFile.roles : [];
for (const role of roles) {
if (!role || !role.slug) continue;
const entry = out.get(role.slug);
if (!entry) continue; // role not declared in index.json; flagged above.
entry.langRoles.set(lang, role);
}
}
}
return out;
}
// Deterministic content hash for a role: languages sorted ascending, each
// language's content fields taken in CONTENT_FIELDS order (null when absent).
function contentHash(langRoles) {
const langs = [...langRoles.keys()].sort();
const canonical = langs.map((lang) => {
const role = langRoles.get(lang);
const fields = {};
for (const field of CONTENT_FIELDS) {
fields[field] = role && role[field] != null ? role[field] : null;
}
return [lang, fields];
});
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
}
// Compute current { version, hash } for every catalog role.
const catalogRoles = collectCatalogRoles();
const current = new Map(); // slug -> { version, hash }
for (const [slug, entry] of catalogRoles) {
current.set(slug, {
version: entry.version,
hash: contentHash(entry.langRoles),
});
}
// Load the existing lock (may be absent on first run).
let lock = {};
if (existsSync(lockPath)) {
const parsed = readJson(lockPath);
if (parsed && typeof parsed === "object") lock = parsed;
}
if (updateHashes) {
// Refresh the lock from the current catalog, but refuse to write if any role's
// content changed without its version being bumped above the existing lock.
const blockers = [];
for (const [slug, cur] of current) {
const prev = lock[slug];
if (!prev) continue; // new role; nothing to enforce a bump against.
if (cur.hash === prev.hash) continue; // content unchanged.
// Defense-in-depth: a non-numeric version must never pass the bump check via
// `undefined <= N` (which is false). The standard checks already flag a
// missing numeric version, but guard here too before comparing.
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
blockers.push(
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version" before refreshing the lock`
);
} else if (cur.version <= prev.version) {
blockers.push(
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json before refreshing the lock`
);
}
}
// Still honor the standard checks before allowing a write.
if (errors.length > 0) {
console.error("Catalog check FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
if (blockers.length > 0) {
console.error("Refusing to update content-hash lock:");
for (const b of blockers) console.error(` - ${b}`);
process.exit(1);
}
// Compute the change summary relative to the old lock, pruning removed slugs.
const newLock = {};
const added = [];
const changed = [];
const removed = [];
for (const [slug, cur] of [...current].sort((a, b) => a[0].localeCompare(b[0]))) {
newLock[slug] = { version: cur.version, hash: cur.hash };
const prev = lock[slug];
if (!prev) added.push(slug);
else if (prev.hash !== cur.hash || prev.version !== cur.version) changed.push(slug);
}
for (const slug of Object.keys(lock)) {
if (!current.has(slug)) removed.push(slug);
}
writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + "\n");
console.log(`Wrote ${lockPath}`);
if (added.length) console.log(` added: ${added.join(", ")}`);
if (changed.length) console.log(` updated: ${changed.join(", ")}`);
if (removed.length) console.log(` pruned: ${removed.join(", ")}`);
if (!added.length && !changed.length && !removed.length) {
console.log(" (no changes; lock already up to date)");
}
console.log("OK");
process.exit(0);
}
// Normal run: validate current content against the lock.
for (const [slug, cur] of current) {
const prev = lock[slug];
if (!prev) {
errors.push(
`role "${slug}" is not recorded in the content-hash lock; run: node scripts/check.mjs --update-hashes`
);
continue;
}
if (cur.hash === prev.hash) {
// Content unchanged; the lock version must still agree with index.json.
if (cur.version !== prev.version) {
errors.push(
`role "${slug}" content is unchanged but its index.json version (${cur.version}) differs from the lock (${prev.version}); run: node scripts/check.mjs --update-hashes`
);
}
continue;
}
// Content changed.
// Defense-in-depth: treat a non-numeric version as an error before the `<=`
// comparison, so a missing version can never silently pass the bump check
// (and we avoid a misleading "version bumped to undefined" message).
if (typeof cur.version !== "number" || !Number.isFinite(cur.version)) {
errors.push(
`role "${slug}" content changed but its index.json "version" is missing or not numeric; set a numeric "version", then run: node scripts/check.mjs --update-hashes`
);
} else if (cur.version <= prev.version) {
errors.push(
`role "${slug}" content changed but its version was not bumped (still ${prev.version}); bump "version" in index.json, then run: node scripts/check.mjs --update-hashes`
);
} else {
errors.push(
`role "${slug}" content changed and version bumped to ${cur.version}; record it by running: node scripts/check.mjs --update-hashes`
);
}
}
// Lock entries for slugs that no longer exist in the catalog.
for (const slug of Object.keys(lock)) {
if (!current.has(slug)) {
errors.push(
`content-hash lock has entry for unknown role "${slug}" (no longer in the catalog); run: node scripts/check.mjs --update-hashes`
);
}
}
if (errors.length > 0) {
console.error("Catalog check FAILED:");
for (const e of errors) console.error(` - ${e}`);
process.exit(1);
}
console.log("OK");

View File

@@ -1,26 +0,0 @@
{
"fact-checker": {
"version": 3,
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
},
"line-editor": {
"version": 2,
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
},
"narrator": {
"version": 1,
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
},
"proofreader": {
"version": 3,
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
},
"researcher": {
"version": 1,
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
},
"structural-editor": {
"version": 2,
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
}
}

View File

@@ -10,7 +10,6 @@
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/app-icon-192x192.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-title" content="Gitmost" />

View File

@@ -33,9 +33,7 @@
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.17",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-query-persist-client": "5.90.17",
"@tanstack/react-virtual": "3.13.24",
"ai": "6.0.207",
"alfaaz": "1.1.0",
@@ -47,7 +45,6 @@
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"idb-keyval": "6.2.5",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
@@ -98,7 +95,6 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite-plugin-pwa": "1.3.0",
"vitest": "4.1.6"
}
}

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten",
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
"Toggle generative AI": "Generative KI umschalten",
"Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",

View File

@@ -464,18 +464,6 @@
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
"Syncing changes…": "Syncing changes…",
"All changes synced": "All changes synced",
"Update available": "Update available",
"Reload": "Reload",
"Make available offline": "Make available offline",
"Saving page for offline use...": "Saving page for offline use...",
"Page is now available offline": "Page is now available offline",
"Failed to make page available offline": "Failed to make page available offline",
"You're offline": "You're offline",
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
"Retry": "Retry",
"Table of contents": "Table of contents",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share",
@@ -610,17 +598,6 @@
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
"Move to trash": "Move to trash",
"Make temporary": "Make temporary",
"Make permanent": "Make permanent",
"New temporary note": "New temporary note",
"Temporary note": "Temporary note",
"Temporary notes": "Temporary notes",
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
"Note is now permanent": "Note is now permanent",
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Permanently delete": "Permanently delete",
@@ -699,6 +676,9 @@
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search",
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI",
"Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.",
@@ -1200,8 +1180,6 @@
"Send when the agent finishes": "Send when the agent finishes",
"Queue message": "Queue message",
"Remove queued message": "Remove queued message",
"Send now": "Send now",
"Interrupt and send now": "Interrupt and send now",
"Stop": "Stop",
"Response stopped.": "Response stopped.",
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
@@ -1229,6 +1207,8 @@
"Ran tool {{name}}": "Ran tool {{name}}",
"AI-agent": "AI-agent",
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
"Git sync": "Git sync",
"Synced from Git on behalf of {{name}}": "Synced from Git on behalf of {{name}}",
"Endpoints": "Endpoints",
"where we fetch models": "where we fetch models",
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
@@ -1253,6 +1233,8 @@
"MCP server": "MCP server",
"expose the workspace": "expose the workspace",
"Enable MCP server": "Enable MCP server",
"Enable Git sync": "Enable Git sync",
"Sync this space's pages to a Git repository.": "Sync this space's pages to a Git repository.",
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.",
"Resolves to {{url}}": "Resolves to {{url}}",
"Model": "Model",
@@ -1345,37 +1327,10 @@
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
"This address is already in use": "This address is already in use",
"This address is in use. Saving will move it to this page.": "This address is in use. Saving will move it to this page.",
"Move custom address?": "Move custom address?",
"Move here": "Move here",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
"Failed to set custom address": "Failed to set custom address",
"Failed to remove custom address": "Failed to remove custom address",
"Generate title with AI": "Generate title with AI",
"Title generated": "Title generated",
"Failed to generate title": "Failed to generate title",
"The note is empty": "The note is empty",
"Could not generate a title": "Could not generate a title",
"AI title generation is disabled": "AI title generation is disabled",
"AI is not configured": "AI is not configured",
"Too many requests, please try again later": "Too many requests, please try again later",
"Import from catalog": "Import from catalog",
"Browse the catalog": "Browse the catalog",
"Role catalog": "Role catalog",
"On name conflict": "On name conflict",
"Skip": "Skip",
"Import": "Import",
"Installed": "Installed",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
"Failed to import {{count}} role(s)": "Failed to import {{count}} role(s)",
"The role catalog is unavailable": "The role catalog is unavailable",
"Please try again later.": "Please try again later.",
"No bundles available": "No bundles available",
"Already up to date": "Already up to date",
"Updated to the latest version": "Updated to the latest version",
"This role is no longer in the catalog": "This role is no longer in the catalog",
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
"Connecting… (read-only)": "Connecting… (read-only)"
"Failed to remove custom address": "Failed to remove custom address"
}

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA",
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
"Toggle generative AI": "Activar IA generativa",
"Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.",

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA",
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
"Toggle generative AI": "Activer/désactiver l'IA générative",
"Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI",
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
"Toggle generative AI": "Attiva/Disattiva AI generativa",
"Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.",

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え",
"Generative AI (Ask AI)": "生成AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
"Toggle generative AI": "生成AIを切り替える",
"Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환",
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
"Toggle generative AI": "생성 AI 토글",
"Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
"Toggle generative AI": "Generatieve AI schakelen",
"Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.",

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA",
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
"Toggle generative AI": "Alternar IA generativa",
"Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga",
"Upgrade your license tier.": "Faça upgrade do seu nível de licença.",

View File

@@ -474,18 +474,6 @@
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
"Syncing changes…": "Синхронизация изменений…",
"All changes synced": "Все изменения синхронизированы",
"Update available": "Доступно обновление",
"Reload": "Перезагрузить",
"Make available offline": "Сделать доступным офлайн",
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
"Page is now available offline": "Страница доступна офлайн",
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
"You're offline": "Вы офлайн",
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "Эта страница не была сохранена для офлайн-доступа, поэтому её нельзя загрузить сейчас. Подключитесь к интернету и попробуйте снова.",
"Retry": "Повторить",
"Table of contents": "Оглавление",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться",
@@ -619,17 +607,6 @@
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
"Move to trash": "Переместить в корзину",
"Make temporary": "Сделать временной",
"Make permanent": "Сделать постоянной",
"New temporary note": "Новая временная заметка",
"Temporary note": "Временная заметка",
"Temporary notes": "Временные заметки",
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
"Note is now permanent": "Заметка теперь постоянная",
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
"Move this page to trash?": "Переместить эту страницу в корзину?",
"Restore page": "Восстановить страницу",
"Permanently delete": "Удалить навсегда",
@@ -746,8 +723,6 @@
"Send when the agent finishes": "Отправить, когда агент закончит",
"Queue message": "Поставить в очередь",
"Remove queued message": "Убрать из очереди",
"Send now": "Отправить сейчас",
"Interrupt and send now": "Прервать и отправить сейчас",
"Something went wrong": "Что-то пошло не так",
"Stop": "Стоп",
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
@@ -761,6 +736,9 @@
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ",
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
"Toggle generative AI": "Переключить генеративный ИИ",
"Upgrade your plan": "Обновите свой тарифный план",
"Available with a paid license": "Доступно с платной лицензией",
"Upgrade your license tier.": "Обновите уровень вашей лицензии.",
@@ -1202,38 +1180,10 @@
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
"This address is already in use": "Этот адрес уже занят",
"This address is in use. Saving will move it to this page.": "Этот адрес уже используется. При сохранении он будет перемещён на эту страницу.",
"Move custom address?": "Переместить пользовательский адрес?",
"Move here": "Переместить сюда",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
"Failed to set custom address": "Не удалось задать пользовательский адрес",
"Failed to remove custom address": "Не удалось удалить пользовательский адрес",
"Generate title with AI": "Сгенерировать название через AI",
"Title generated": "Название сгенерировано",
"Failed to generate title": "Не удалось сгенерировать название",
"The note is empty": "Заметка пустая",
"Could not generate a title": "Не удалось придумать название",
"AI title generation is disabled": "Генерация названий через AI отключена",
"AI is not configured": "AI не настроен",
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже",
"Import from catalog": "Импорт из каталога",
"Browse the catalog": "Открыть каталог",
"Role catalog": "Каталог ролей",
"On name conflict": "При конфликте имён",
"Skip": "Пропустить",
"Import": "Импортировать",
"Installed": "Установлено",
"v{{from}} → v{{to}}": "v{{from}} → v{{to}}",
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}": "Импортировано: {{created}}, переименовано: {{renamed}}, пропущено: {{skipped}}",
"Failed to import {{count}} role(s)": "Не удалось импортировать ролей: {{count}}",
"The role catalog is unavailable": "Каталог ролей недоступен",
"Please try again later.": "Попробуйте позже.",
"No bundles available": "Наборы недоступны",
"No roles configured": "Роли не настроены",
"Already up to date": "Уже актуальна",
"Updated to the latest version": "Обновлено до последней версии",
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
"Connecting… (read-only)": "Подключение… (только чтение)"
"Failed to remove custom address": "Не удалось удалить пользовательский адрес"
}

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ",
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
"Toggle generative AI": "Переключити генеративний ШІ",
"Upgrade your plan": "Оновіть свій тарифний план",
"Available with a paid license": "Доступно за платною ліцензією",
"Upgrade your license tier.": "Оновіть рівень своєї ліцензії.",

View File

@@ -665,6 +665,9 @@
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索",
"Generative AI (Ask AI)": "生成型AI (询问AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
"Toggle generative AI": "切换生成型AI",
"Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。",

View File

@@ -1,19 +1,30 @@
{
"id": "/",
"name": "Gitmost",
"short_name": "Gitmost",
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
"lang": "en",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0E1117",
"theme_color": "#0E1117",
"icons": [
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
{
"src": "icons/favicon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"src": "icons/favicon-32x32.png",
"type": "image/png",
"sizes": "32x32"
},
{
"src": "icons/app-icon-192x192.png",
"type": "image/png",
"sizes": "180x180 192x192"
},
{
"src": "icons/app-icon-512x512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@@ -10,12 +10,12 @@ import classes from "./app-header.module.css";
import { BrandLogo } from "@/components/ui/brand-logo";
import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom";
import { useAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import {
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { useOpenAiChatForCurrentPage } from "@/features/ai-chat/hooks/use-open-ai-chat.ts";
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
@@ -38,9 +38,7 @@ export function AppHeader() {
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const [workspace] = useAtom(workspaceAtom);
// Opening from the header auto-opens the document's bound chat (last chat
// created on the current page); off a page it keeps the current selection.
const openAiChat = useOpenAiChatForCurrentPage();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
// AI chat entry point: only shown when the workspace enables it (A7 gate).
const aiChatEnabled = workspace?.settings?.ai?.chat === true;
@@ -107,7 +105,7 @@ export function AppHeader() {
color="dark"
size="sm"
aria-label={t("AI chat")}
onClick={openAiChat}
onClick={() => setAiChatWindowOpen((v) => !v)}
>
<IconMessage size={20} />
</ActionIcon>

View File

@@ -0,0 +1,37 @@
import { Badge, Tooltip } from "@mantine/core";
import { IconGitMerge } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
interface GitSyncBadgeProps {
authorName?: string;
}
/**
* Badge marking a version produced by git-sync (provenance §8.1). The history
* version is created on the PUSH path — when an incoming git body is written back
* into the Docmost doc — not by the pull itself. Like {@link AiAgentBadge} it is
* ADDITIVE — shown next to the human author, never replacing them — but a git-sync
* edit is NOT an agent edit and has no chat to deep-link into, so it is a small,
* neutral, non-clickable label.
*/
export function GitSyncBadge({ authorName }: GitSyncBadgeProps) {
const { t } = useTranslation();
const tooltip = t("Synced from Git on behalf of {{name}}", {
name: authorName ?? "",
});
return (
<Tooltip label={tooltip} withArrow>
<Badge
size="sm"
variant="light"
color="gray"
radius="sm"
leftSection={<IconGitMerge size={12} stroke={2} />}
>
{t("Git sync")}
</Badge>
</Tooltip>
);
}

View File

@@ -1,142 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
// above the imports) can expose the captured useChat callbacks / transport and
// the spies back to the test body.
const h = vi.hoisted(() => ({
state: {
status: "streaming" as string,
onFinish: null as null | ((arg: Record<string, unknown>) => void),
sendMessage: vi.fn(),
stop: vi.fn(),
transport: null as null | {
prepareSendMessagesRequest: (arg: {
messages: unknown[];
body: Record<string, unknown>;
}) => { body: Record<string, unknown> };
},
},
}));
// Mock useChat: capture onFinish, return the spies and the controllable status.
vi.mock("@ai-sdk/react", () => ({
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
h.state.onFinish = opts.onFinish ?? null;
return {
messages: [],
sendMessage: h.state.sendMessage,
status: h.state.status,
stop: h.state.stop,
error: null,
};
},
}));
// Mock "ai": deterministic ids + a transport that records its options so the test
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
vi.mock("ai", () => {
let counter = 0;
return {
generateId: () => `gid-${counter++}`,
DefaultChatTransport: class {
constructor(opts: {
prepareSendMessagesRequest: (arg: {
messages: unknown[];
body: Record<string, unknown>;
}) => { body: Record<string, unknown> };
}) {
h.state.transport = opts;
}
},
};
});
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
// composer). The ChatInput stub exposes a button that queues a message, the only
// interaction this test needs to populate the queue while "streaming".
vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
default: () => <div data-testid="message-list" />,
}));
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
queue
</button>
),
}));
import ChatThread from "./chat-thread";
function renderThread() {
const onTurnFinished = vi.fn();
render(
<MantineProvider>
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
</MantineProvider>,
);
return { onTurnFinished };
}
describe("ChatThread — send now (#198)", () => {
beforeEach(() => {
h.state.status = "streaming";
h.state.onFinish = null;
h.state.sendMessage.mockClear();
h.state.stop.mockClear();
h.state.transport = null;
});
it("aborts the current turn and resends the queued message on the abort", () => {
renderThread();
// Queue a message while the turn is streaming.
fireEvent.click(screen.getByTestId("queue-btn"));
const sendNowBtn = screen.getByLabelText("Send now");
expect(sendNowBtn).toBeTruthy();
// "Send now" interrupts the current turn (stop), but does NOT send yet —
// the resend happens once the abort lands in onFinish.
fireEvent.click(sendNowBtn);
expect(h.state.stop).toHaveBeenCalledTimes(1);
expect(h.state.sendMessage).not.toHaveBeenCalled();
// The abort we triggered reaches onFinish: the promoted head is flushed.
act(() => {
h.state.onFinish?.({
message: { id: "a", role: "assistant", parts: [] },
isAbort: true,
isDisconnect: false,
isError: false,
});
});
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
});
it("tags exactly the next send as interrupted (one-shot flag)", () => {
renderThread();
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
const prep = h.state.transport!.prepareSendMessagesRequest;
// The send right after "send now" carries interrupted: true...
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
// ...and only that one (the flag is read-and-cleared).
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
});
it("sends immediately without an interrupt when not streaming", () => {
h.state.status = "ready";
renderThread();
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
// No turn to interrupt: sent straight away, no abort, not flagged.
expect(h.state.stop).not.toHaveBeenCalled();
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
const prep = h.state.transport!.prepareSendMessagesRequest;
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
});
});

View File

@@ -1,11 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { generateId } from "ai";
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import {
IconClockHour4,
IconPlayerPlayFilled,
IconX,
} from "@tabler/icons-react";
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
import { IconClockHour4, IconX } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
@@ -27,7 +23,6 @@ import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import {
dequeue,
enqueueMessage,
promoteToHead,
removeQueuedById,
type QueuedMessage,
} from "@/features/ai-chat/utils/queue-helpers.ts";
@@ -206,25 +201,12 @@ export default function ChatThread({
// helper can call the current instance from the stable `onFinish` callback.
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
// "Send now" single-flight flags. Kept in refs (not state) so they are read
// inside the stable `onFinish` callback and the transport closure WITHOUT a
// re-render or a stale closure. Both are one-shot (read-and-clear).
// - flushOnAbortRef: flush the promoted head on the abort WE triggered, even
// though an aborted turn normally keeps the queue intact.
// - interruptNextSendRef: tag the next send as a user interrupt so the server
// injects the "your previous answer was interrupted" note for that turn only.
const flushOnAbortRef = useRef(false);
const interruptNextSendRef = useRef(false);
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
// Returns whether a message was actually sent, so callers can tell an empty
// dequeue (nothing to flush) from a real send.
const flushNext = useCallback(() => {
const { head, rest } = dequeue(queuedRef.current);
if (!head) return false;
if (!head) return;
setQueue(rest);
sendMessageRef.current?.({ text: head.text });
return true;
}, [setQueue]);
const enqueue = useCallback(
@@ -250,26 +232,17 @@ export default function ChatThread({
// when null) and tell the agent which page "this page" refers to. Both
// are read live from refs so changing chats/pages does NOT recreate the
// transport. `openPage` is null on a non-page route.
prepareSendMessagesRequest: ({ messages, body }) => {
// Read-and-clear the interrupt flag so the "you were interrupted" note
// is carried by ONLY this request (the one resending the promoted
// message right after we aborted the previous turn). The server still
// confirms it against history before acting on it.
const interrupted = interruptNextSendRef.current;
interruptNextSendRef.current = false; // one-shot
return {
body: {
...body,
chatId: chatIdRef.current,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
interrupted,
messages,
},
};
},
prepareSendMessagesRequest: ({ messages, body }) => ({
body: {
...body,
chatId: chatIdRef.current,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
messages,
},
}),
}),
[],
);
@@ -304,21 +277,6 @@ export default function ChatThread({
else if (isAbort) setStopNotice("manual");
else if (isDisconnect) setStopNotice("disconnect");
else setStopNotice(null);
// "Send now": WE triggered this abort to interrupt the current turn and
// immediately send the promoted head. Flush it even though the turn was
// aborted (the normal abort path below keeps the queue intact). The
// interrupt note travels with this send via interruptNextSendRef.
if (flushOnAbortRef.current) {
flushOnAbortRef.current = false;
// Suppress the "Response stopped." flash for an intentional interrupt.
setStopNotice(null);
// If the promoted head vanished (e.g. the user removed it before the
// abort landed) flushNext sends nothing — clear the one-shot interrupt
// tag so it can't leak onto the next unrelated send. On a real send the
// tag is consumed by prepareSendMessagesRequest and stays untouched.
if (!flushNext()) interruptNextSendRef.current = false;
return;
}
if (isAbort || isDisconnect || isError) return;
flushNext();
},
@@ -340,13 +298,6 @@ export default function ChatThread({
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
// Mirror the live turn status in a ref so event handlers (sendNow) branch on the
// CURRENT status rather than a value captured in a stale render closure — a turn
// can finish between render and click, and arming the interrupt refs against a
// no-op stop() would leave them set to leak into a later, unrelated Stop.
const statusRef = useRef(status);
statusRef.current = status;
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
// on the assistant message metadata at the `start` chunk (message.metadata.
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
@@ -378,49 +329,9 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
// "Send now" on a queued message: interrupt the current turn and immediately
// send THIS message, keeping the agent's partial output. Other queued messages
// stay queued and flush normally after the new turn. Reuses the existing
// queue/flush machinery: promote the target to the head, then abort — the
// onFinish flush-on-abort branch sends exactly that head, tagged as an
// interrupt so the server notes the previous answer was cut off.
const sendNow = useCallback(
(id: string) => {
// Branch on the LIVE status (statusRef), NOT the closure-captured isStreaming:
// the turn may have finished between this render and the click, in which case
// stop() is a no-op and arming the interrupt refs would strand them for a
// later, unrelated Stop. Reading the ref always sees the current status.
const liveStreaming =
statusRef.current === "submitted" || statusRef.current === "streaming";
if (liveStreaming) {
// Promote to head so the onFinish -> flushNext path sends exactly it.
setQueue(promoteToHead(queuedRef.current, id));
flushOnAbortRef.current = true;
interruptNextSendRef.current = true;
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
} else {
// Nothing to interrupt: just send it now (no interrupt note).
const msg = queuedRef.current.find((m) => m.id === id);
if (!msg) return;
setQueue(removeQueuedById(queuedRef.current, id));
sendMessageRef.current?.({ text: msg.text });
}
},
[setQueue, stop],
);
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
// this effect runs, so clearing here is a no-op for it; its purpose is to defuse
// the race where a flag was armed but the expected abort never fired (the turn
// finished in the same tick as the click), so it cannot leak into a later turn.
// Clear the stopped marker as soon as a new turn begins streaming.
useEffect(() => {
if (isStreaming) {
setStopNotice(null);
flushOnAbortRef.current = false;
interruptNextSendRef.current = false;
}
if (isStreaming) setStopNotice(null);
}, [isStreaming]);
// Classify the turn error into a heading + detail so the banner names the cause
@@ -512,17 +423,6 @@ export default function ChatThread({
<Text size="xs" lineClamp={2} className={classes.queuedText}>
{m.text}
</Text>
<Tooltip label={t("Interrupt and send now")} withArrow>
<ActionIcon
size="xs"
variant="subtle"
color="blue"
onClick={() => sendNow(m.id)}
aria-label={t("Send now")}
>
<IconPlayerPlayFilled size={12} />
</ActionIcon>
</Tooltip>
<ActionIcon
size="xs"
variant="subtle"

View File

@@ -26,20 +26,16 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
});
import MessageItem from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
// Mirror MessageList: snapshot the signature at (parent) render time and pass it
// as the memo key. The signature must NOT be recomputed inside the memo from the
// live (mutable) message — see message-item.tsx.
const renderRow = (message: UIMessage) =>
render(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
<MessageItem message={message} />
</MantineProvider>,
);
@@ -71,7 +67,7 @@ describe("MessageItem markdown memoization", () => {
]);
rerender(
<MantineProvider>
<MessageItem message={next} signature={messageSignature(next)} />
<MessageItem message={next} />
</MantineProvider>,
);
@@ -82,35 +78,4 @@ describe("MessageItem markdown memoization", () => {
expect(callsFor("beta")).toBe(1);
expect(callsFor("gamm")).toBe(1);
});
// REGRESSION (empty-render bug): the AI SDK streams a turn by MUTATING the same
// `parts` IN PLACE and reusing the message object. A row that mounted empty
// (reasoning-first providers render nothing at first) must still stream its text
// in once the parent hands down a fresh signature snapshot. Before the fix the
// memo recomputed the signature from the (mutated) message — identical on both
// sides — and froze the row at its empty render, so the answer never appeared.
it("streams text in after the row mounted empty and parts mutated in place", () => {
renderChatMarkdownSpy.mockClear();
// Reuse ONE message object across renders (as the SDK does).
const message = msg([{ type: "text", text: "" }]);
const { rerender, queryByText } = render(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
// Empty text part: nothing visible rendered yet.
expect(queryByText("streamed answer")).toBeNull();
// SDK delta: mutate the SAME part in place, then re-render with a NEW snapshot.
(message.parts[0] as { text: string }).text = "streamed answer";
rerender(
<MantineProvider>
<MessageItem message={message} signature={messageSignature(message)} />
</MantineProvider>,
);
// The grown text now renders (the memo did NOT freeze the empty mount).
expect(callsFor("streamed answer")).toBe(1);
expect(queryByText("streamed answer")).not.toBeNull();
});
});

View File

@@ -10,28 +10,21 @@ vi.mock("react-i18next", () => ({
}));
import { arePropsEqual } from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
/**
* Tests for `arePropsEqual`, the `React.memo` comparator for MessageItem. It must
* return false on any visible prop/content change (so the row re-renders) and
* true when nothing visible changed (so a finalized row is skipped). The memo key
* is the `signature` PROP — an immutable snapshot the PARENT (MessageList) takes
* per render via `messageSignature(message)`. A FIXED message id is used so a
* content-identical clone yields an equal signature.
* true when nothing visible changed (so a finalized row is skipped). A FIXED
* message id is used so a content-identical clone yields an equal signature.
*/
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
// Build the props the parent would pass, INCLUDING the snapshot signature it
// computes during its own render (the load-bearing part — see message-item.tsx:
// the signature must never be recomputed inside arePropsEqual).
const props = (
message: UIMessage,
over: Record<string, unknown> = {},
) => ({
message,
signature: messageSignature(message),
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
@@ -60,7 +53,7 @@ describe("arePropsEqual", () => {
).toBe(false);
});
it("returns true for equal snapshot + equal props (finalized row skipped)", () => {
it("returns true on the identity fast path (same message object, equal props)", () => {
const m = msg([{ type: "text", text: "answer" }]);
expect(arePropsEqual(props(m), props(m))).toBe(true);
});
@@ -77,36 +70,4 @@ describe("arePropsEqual", () => {
const b = msg([{ type: "text", text: "answer grown" }]);
expect(arePropsEqual(props(a), props(b))).toBe(false);
});
// REGRESSION (empty-render bug): the AI SDK streams deltas by mutating the SAME
// `parts` in place and handing back a message wrapper that SHARES them. So the
// PREVIOUS and NEXT props can carry the SAME (mutated) message object, and
// recomputing `messageSignature(message)` inside the comparator would read
// identical (latest) content on BOTH sides → always "equal" → the memo skips
// every streamed update and the assistant row freezes at its initial empty
// render. The comparator MUST instead trust the immutable `signature` SNAPSHOT
// the parent captured at each render. This fails against the old implementation
// (a `prev.message === next.message` fast path + a signature recomputed from the
// live objects).
it("re-renders when parts were mutated in place but the snapshot changed", () => {
const message = msg([{ type: "text", text: "" }]); // empty (renders null)
const prevSig = messageSignature(message); // snapshot BEFORE the delta
// SDK streams a delta by mutating the shared part IN PLACE:
(message.parts[0] as { text: string }).text = "hello world";
const nextSig = messageSignature(message); // snapshot AFTER the delta
expect(prevSig).not.toBe(nextSig);
// Same object reference on both sides (the SDK reuses it), differing snapshots.
const base = {
message,
showCitations: true,
neutralizeInternalLinks: false,
assistantName: "AI",
};
expect(
arePropsEqual(
{ ...base, signature: prevSig },
{ ...base, signature: nextSig },
),
).toBe(false);
});
});

View File

@@ -11,30 +11,12 @@ import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/mess
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageItemProps {
message: UIMessage;
/**
* Immutable content signature for `message`, computed by the PARENT
* (MessageList) during its render via `messageSignature(message)`. This is the
* memo key (see `arePropsEqual`): it MUST be a snapshot captured at render time,
* NOT recomputed from `message` inside `arePropsEqual`.
*
* WHY (load-bearing): the AI SDK streams deltas by mutating the SAME `parts`
* array/objects in place and handing back a message wrapper that SHARES those
* mutated parts. So inside `arePropsEqual`, `prev.message` and `next.message`
* both reflect the CURRENT (latest) parts — `messageSignature(prev.message) ===
* messageSignature(next.message)` is therefore ALWAYS true, the memo skips every
* post-mount render, and the assistant row freezes at its initial empty (null)
* render — i.e. the streamed answer + tool cards never appear (reasoning-first
* providers start empty, so NOTHING shows). Snapshotting the signature into this
* immutable string prop in the parent fixes that: `prev.signature` holds the
* value from the previous render (old content) and `next.signature` the new
* content, so they differ as the turn streams in and the row re-renders.
*/
signature: string;
/**
* Forwarded to ToolCallCard: whether tool cards render page citation links.
* Defaults to true (internal chat). The public share passes false.
@@ -106,8 +88,6 @@ function MessageItem({
neutralizeInternalLinks = false,
assistantName,
}: MessageItemProps) {
// `signature` is intentionally not read in the body — it exists solely as the
// memo key (see arePropsEqual). The render reads `message` directly.
const { t } = useTranslation();
const isUser = message.role === "user";
@@ -223,30 +203,24 @@ function MessageItem({
}
/** Skip re-rendering a message whose visible content is unchanged. The streaming
* TAIL message gets a fresh `signature` snapshot each delta (computed by the
* parent), so it still re-renders and streams in; every FINALIZED message keeps
* the same signature and is skipped, turning a per-token whole-transcript
* re-render into a tail-only one.
*
* CRITICAL: compare the `signature` PROP (an immutable snapshot the parent took
* at its own render), NEVER `messageSignature(prev.message)` vs
* `messageSignature(next.message)`. The AI SDK mutates the shared `parts` in
* place, so both `prev.message` and `next.message` reflect the latest content
* here — recomputing the signature from them yields equal strings every time and
* freezes the row at its initial empty render (the bug this guards against). See
* the `signature` prop doc. Likewise there is NO `prev.message === next.message`
* fast path: same-reference-but-mutated must still re-render when the snapshot
* signature changed. */
* TAIL message gets a fresh object whose signature changes each delta, so it
* still re-renders and streams in; every FINALIZED message is skipped, turning a
* per-token whole-transcript re-render into a tail-only one. */
export function arePropsEqual(
prev: MessageItemProps,
next: MessageItemProps,
): boolean {
return (
prev.signature === next.signature &&
prev.showCitations === next.showCitations &&
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
prev.assistantName === next.assistantName
);
if (
prev.showCitations !== next.showCitations ||
prev.neutralizeInternalLinks !== next.neutralizeInternalLinks ||
prev.assistantName !== next.assistantName
) {
return false;
}
// Fast path: identical message object (finalized rows keep their identity
// across deltas) — skip without building signatures.
if (prev.message === next.message) return true;
return messageSignature(prev.message) === messageSignature(next.message);
}
export default memo(MessageItem, arePropsEqual);

View File

@@ -1,119 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
// Stub react-i18next (MessageList and TypingIndicator read `useTranslation`).
// Mirrors the t-mock pattern used by the other component tests in this folder
// (reasoning-block.test.tsx, message-item-memo.test.tsx).
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Spy on `renderChatMarkdown` exactly as message-item-memo.test.tsx does: keep
// every OTHER named export of markdown.ts intact via `importActual`, and override
// only `renderChatMarkdown` with a `vi.fn()` that returns simple HTML. This makes
// assertions synchronous (no async marked + DOMPurify pass) and lets us count
// parses by argument. `vi.hoisted` so the spy exists when the hoisted `vi.mock`
// factory runs.
const { renderChatMarkdownSpy } = vi.hoisted(() => ({
renderChatMarkdownSpy: vi.fn((text: string) => `<p>${text}</p>`),
}));
vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
const actual = await vi.importActual<
typeof import("@/features/ai-chat/utils/markdown.ts")
>("@/features/ai-chat/utils/markdown.ts");
return { ...actual, renderChatMarkdown: renderChatMarkdownSpy };
});
// IMPORTANT: do NOT mock MessageItem and do NOT mock messageSignature — exercising
// the REAL MessageList -> real MessageItem -> real messageSignature wiring is the
// whole point of this file (it closes the parent-side coverage gap left by the
// memo tests, which simulate the parent by hardcoding `signature={...}` in their
// harness). Use the relative import for the component under test, mirroring how
// message-list.tsx itself imports `MessageItem from "./message-item"`.
import MessageList from "./message-list";
// matchMedia / localStorage / sessionStorage (read by MantineProvider and app
// code) are stubbed globally in vitest.setup.ts — do NOT re-stub those here.
//
// MessageList renders Mantine's ScrollArea, which constructs a `ResizeObserver`.
// jsdom does not implement it, so install a minimal no-op stub BEFORE rendering.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
// One assistant message wrapping the given `parts`. Reused across renders in the
// regression test to model how the AI SDK hands back the SAME message object.
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
describe("MessageList", () => {
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
renderChatMarkdownSpy.mockClear();
const { queryByText } = render(
<MantineProvider>
<MessageList
messages={[msg([{ type: "text", text: "hello world" }])]}
isStreaming={false}
/>
</MantineProvider>,
);
// The assistant text renders, which proves MessageList mounted the real
// MessageItem and handed it a valid `signature` prop (computed from the real
// `messageSignature`) — the full parent -> child -> markdown path is live.
expect(queryByText("hello world")).not.toBeNull();
});
// REGRESSION (PR #224, the empty-render freeze). The AI SDK streams a turn by
// MUTATING the same `parts` array IN PLACE and handing back a NEW array each
// delta that REUSES the same message object. The fix moved the content signature
// to the PARENT: MessageList must recompute `messageSignature(message)` FRESH on
// every render and forward it as the immutable `signature` prop, so MessageItem's
// memo (which compares that prop snapshot) sees it change and re-renders the row.
//
// This test exercises the PARENT half that the memo tests only simulate: if
// MessageList ever cached/memoized the signature keyed on the message object's
// identity (which stays stable across deltas while its `parts` mutate in place),
// the snapshot would never change, MessageItem's memo would skip every delta, and
// the row would freeze at its empty mount — exactly the regression class. That
// would make this test fail. See message-item.tsx (`signature` prop +
// `arePropsEqual`) and message-list.tsx (the `signature={messageSignature(...)}`
// snapshot at render time).
it("reflects in-place part mutation of a reused message object across renders", () => {
renderChatMarkdownSpy.mockClear();
// Reuse ONE message object across renders (as the SDK does). The empty text
// part means MessageItem renders nothing visible initially.
const message = msg([{ type: "text", text: "" }]);
const { rerender, queryByText } = render(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// Nothing streamed yet.
expect(queryByText("streamed answer")).toBeNull();
// SDK delta: mutate the SAME part in place on the SAME message object...
(message.parts[0] as { text: string }).text = "streamed answer";
// ...then re-render with a NEW array literal that still holds the SAME mutated
// message object (this mirrors useChat handing back a fresh array of reused
// message objects on each delta).
rerender(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// The grown text now renders: MessageList re-snapshotted the signature, so the
// row re-rendered instead of freezing at its empty mount.
expect(queryByText("streamed answer")).not.toBeNull();
expect(
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
).toBe(true);
});
});

View File

@@ -6,7 +6,6 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
@@ -197,16 +196,9 @@ export default function MessageList({
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs">
{messages.map((message) => (
// `signature` is snapshotted HERE (parent render) into an immutable
// string and handed to MessageItem as its memo key. It must NOT be
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
// shared `parts` in place, so prev/next message objects both read the
// latest content there and the memo would skip every streamed update
// (freezing the row at its empty render). See message-item.tsx.
<MessageItem
key={message.id}
message={message}
signature={messageSignature(message)}
showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName}

View File

@@ -1,135 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { Provider, createStore } from "jotai";
import type { ReactNode } from "react";
import { useOpenAiChatForCurrentPage } from "./use-open-ai-chat";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// useMatch is the only react-router-dom export the hook uses; drive its return
// per test to simulate "on a page" vs "off a page".
const useMatchMock = vi.fn();
vi.mock("react-router-dom", () => ({
useMatch: () => useMatchMock(),
}));
// The bound-chat resolver is the network boundary; stub it per test.
const getBoundChatMock = vi.fn();
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
getBoundChat: (pageId: string) => getBoundChatMock(pageId),
}));
// Put the hook on a page route by default ("doc-p1" -> page id "p1"); individual
// tests override useMatch to go off-page.
function onPage(pageSlug = "doc-p1") {
useMatchMock.mockReturnValue({ params: { pageSlug } });
}
function offPage() {
useMatchMock.mockReturnValue(null);
}
// Render the hook inside an explicit jotai store so atom side effects are
// assertable; the store is returned for setup + assertions.
function setup(seed?: (store: ReturnType<typeof createStore>) => void) {
const store = createStore();
seed?.(store);
const wrapper = ({ children }: { children: ReactNode }) => (
<Provider store={store}>{children}</Provider>
);
const { result } = renderHook(() => useOpenAiChatForCurrentPage(), { wrapper });
return { store, open: () => act(() => result.current()) };
}
describe("useOpenAiChatForCurrentPage", () => {
beforeEach(() => {
vi.clearAllMocks();
onPage();
});
it("on a page: resolves the bound chat, selects it, and opens the window", async () => {
getBoundChatMock.mockResolvedValue("bound-chat-1");
const { store, open } = setup((s) => s.set(aiChatDraftAtom, "stale draft"));
await open();
expect(getBoundChatMock).toHaveBeenCalledWith("p1");
expect(store.get(activeAiChatIdAtom)).toBe("bound-chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // cleared on a real switch
});
it("on a page with no bound chat: opens a fresh chat (null)", async () => {
getBoundChatMock.mockResolvedValue(null);
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
await open();
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("off a page: keeps the current selection and does NOT resolve", async () => {
offPage();
const { store, open } = setup((s) => {
s.set(activeAiChatIdAtom, "keep-me");
s.set(aiChatDraftAtom, "untouched");
});
await open();
expect(getBoundChatMock).not.toHaveBeenCalled();
expect(store.get(activeAiChatIdAtom)).toBe("keep-me");
expect(store.get(aiChatDraftAtom)).toBe("untouched"); // no switch -> kept
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("window already open: re-click does NOT re-resolve or switch chats", async () => {
getBoundChatMock.mockResolvedValue("would-switch");
const { store, open } = setup((s) => {
s.set(aiChatWindowOpenAtom, true);
s.set(activeAiChatIdAtom, "current");
});
await open();
expect(getBoundChatMock).not.toHaveBeenCalled();
expect(store.get(activeAiChatIdAtom)).toBe("current");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("does NOT clear the draft when the resolved chat equals the current one", async () => {
getBoundChatMock.mockResolvedValue("same");
const { store, open } = setup((s) => {
s.set(activeAiChatIdAtom, "same");
s.set(aiChatDraftAtom, "in-progress");
});
await open();
expect(store.get(aiChatDraftAtom)).toBe("in-progress"); // no switch
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("fail-soft: a resolve error opens a fresh chat (null)", async () => {
getBoundChatMock.mockRejectedValue(new Error("network"));
const { store, open } = setup((s) => s.set(activeAiChatIdAtom, "previous"));
await open();
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
});
it("clears the picked role on a real switch", async () => {
getBoundChatMock.mockResolvedValue("bound");
const { store, open } = setup((s) => s.set(selectedAiRoleIdAtom, "role-1"));
await open();
expect(store.get(selectedAiRoleIdAtom)).toBeNull();
});
});

View File

@@ -1,67 +0,0 @@
import { useCallback } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useMatch } from "react-router-dom";
import {
aiChatWindowOpenAtom,
activeAiChatIdAtom,
aiChatDraftAtom,
selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import { getBoundChat } from "@/features/ai-chat/services/ai-chat-service.ts";
import { extractPageSlugId } from "@/lib";
/**
* The generic "open the AI chat" action, WITH document binding: when invoked
* while viewing a page, it resolves that page's bound chat and selects it before
* opening — so the last chat for this document re-opens by itself. With no bound
* chat (or off a page) it keeps the current selection / opens a fresh chat. Used
* by the app-header entry point; NOT by the provenance badge (which deep-links).
*/
export function useOpenAiChatForCurrentPage() {
const [windowOpen, setWindowOpen] = useAtom(aiChatWindowOpenAtom);
const [activeChatId, setActiveChatId] = useAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const setSelectedRoleId = useSetAtom(selectedAiRoleIdAtom);
// Same route-match trick the window uses: read :pageSlug from the pathname.
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
// see :pageSlug — match the full path against the authenticated page route.
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
const pageId = extractPageSlugId(match?.params?.pageSlug);
return useCallback(async () => {
// Re-clicks while the window is already open (incl. minimized) must NOT
// re-resolve and yank the user to another chat: resolve only on a genuine
// closed -> open transition. (`windowOpen` is already true here, so there
// is nothing to set — just bail.)
if (windowOpen) return;
// Open the window FIRST so the control feels instant: the bound-chat
// round-trip below must never gate the window appearing, or on a slow
// connection the first click reads as a hung control until the POST returns.
setWindowOpen(true);
let resolved: string | null = activeChatId; // off-a-page: keep current
if (pageId) {
try {
resolved = await getBoundChat(pageId); // null => fresh chat
} catch {
resolved = null; // fail-soft: a fresh chat is always a safe fallback
}
}
// Clear the composer draft / picked role ONLY on an actual switch, so
// reopening the same chat does not wipe an in-progress draft. Applied after
// the resolve so the window is already visible while the switch settles.
if (resolved !== activeChatId) {
setActiveChatId(resolved);
setDraft("");
setSelectedRoleId(null);
}
}, [
windowOpen,
activeChatId,
pageId,
setWindowOpen,
setActiveChatId,
setDraft,
setSelectedRoleId,
]);
}

View File

@@ -13,40 +13,21 @@ import {
deleteAiRole,
getAiChatMessages,
getAiChats,
getAiRoleCatalog,
getAiRoleCatalogBundle,
getAiRoles,
importAiRolesFromCatalog,
renameAiChat,
updateAiRole,
updateAiRoleFromCatalog,
} from "@/features/ai-chat/services/ai-chat-service.ts";
import {
IAiChat,
IAiChatMessageRow,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"];
// Catalog reads resolve bundle names per language, so the language is part of
// the cache key (a language switch refetches rather than reusing stale names).
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
"ai-role-catalog",
language,
];
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
bundleId: string,
language: string,
) => ["ai-role-catalog-bundle", bundleId, language];
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages",
chatId,
@@ -242,109 +223,3 @@ export function useDeleteAiRoleMutation() {
},
});
}
/**
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
* fetch runs only when the catalog modal is open. The catalog can 502 when the
* curated source is unreachable; callers handle the error state in the UI.
*/
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
return useQuery<IAiRoleCatalog, Error>({
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
queryFn: () => getAiRoleCatalog(language),
enabled,
});
}
/**
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
* fetch only runs when a bundle is actually expanded.
*/
export function useAiRoleCatalogBundleQuery(
bundleId: string,
language: string,
enabled: boolean,
) {
return useQuery<IAiRoleCatalogBundle, Error>({
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
enabled,
});
}
export function useImportAiRolesFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
mutationFn: (payload) => importAiRolesFromCatalog(payload),
onSuccess: (result) => {
notifications.show({
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
created: result.created,
renamed: result.renamed,
skipped: result.skipped,
}),
});
// Surface partial failures (e.g. unique-name races) as a red warning.
if (result.errors.length > 0) {
notifications.show({
color: "red",
message: t("Failed to import {{count}} role(s)", {
count: result.errors.length,
}),
});
}
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// Imported roles can appear in the chat picker / badges.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}
export function useUpdateAiRoleFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
mutationFn: (id) => updateAiRoleFromCatalog(id),
onSuccess: (result) => {
// The server returns updated:false with a reason for a no-op (already
// up to date / removed from catalog / language no longer offered). Map
// each reason to a specific message instead of a generic "up to date".
// Narrow the discriminated union via `"reason" in result` (the `updated`
// boolean discriminant does not narrow under this project's
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
// union, so the comparisons below are compiler-checked.
let message: string;
if (!("reason" in result)) {
message = t("Updated to the latest version");
} else if (result.reason === "not-in-catalog") {
message = t("This role is no longer in the catalog");
} else if (result.reason === "language-unavailable") {
message = t("This language is no longer available in the catalog");
} else {
// "up-to-date" (the only remaining reason).
message = t("Already up to date");
}
notifications.show({ message });
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
// The role badge denormalized onto the chat list may have changed.
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
},
onError: (error) => {
const message = error["response"]?.data?.message;
notifications.show({
message: message ?? t("Failed to update data"),
color: "red",
});
},
});
}

View File

@@ -1,106 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IAiRoleImportResult } from "@/features/ai-chat/types/ai-chat.types.ts";
// `useImportAiRolesFromCatalogMutation` always shows an Imported/renamed/skipped
// summary, and ADDITIONALLY a red "Failed to import N role(s)" notification when
// the result carries partial errors. These tests pin both branches via
// renderHook with a mocked service (twin precedent:
// update-from-catalog-message.test.tsx).
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
// `t` echoes the key with interpolated values so we assert against the exact
// English message strings (mirrors react-i18next's default interpolation).
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars
? key.replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name]))
: key,
}),
}));
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
importAiRolesFromCatalog: vi.fn(),
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
// the module import resolves; they are unused by these tests.
createAiRole: vi.fn(),
deleteAiChat: vi.fn(),
deleteAiRole: vi.fn(),
getAiChatMessages: vi.fn(),
getAiChats: vi.fn(),
getAiRoleCatalog: vi.fn(),
getAiRoleCatalogBundle: vi.fn(),
getAiRoles: vi.fn(),
renameAiChat: vi.fn(),
updateAiRole: vi.fn(),
updateAiRoleFromCatalog: vi.fn(),
}));
import { importAiRolesFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useImportAiRolesFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
async function runMutation(result: IAiRoleImportResult) {
vi.mocked(importAiRolesFromCatalog).mockResolvedValue(result);
const { result: hook } = renderHook(
() => useImportAiRolesFromCatalogMutation(),
{ wrapper: createWrapper() },
);
hook.current.mutate({
bundleId: "general",
language: "en",
conflict: "rename",
});
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
}
describe("useImportAiRolesFromCatalogMutation — success notifications", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Imported 3, renamed 1, skipped 2",
});
});
it("errors.length > 0 -> summary PLUS the red failure notification", async () => {
await runMutation({
created: 1,
renamed: 0,
skipped: 0,
errors: [
{ slug: "a", message: "name taken" },
{ slug: "b", message: "name taken" },
],
});
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
message: "Imported 1, renamed 0, skipped 0",
});
expect(notificationsShowMock).toHaveBeenNthCalledWith(2, {
color: "red",
message: "Failed to import 2 role(s)",
});
});
});

View File

@@ -1,100 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IAiRoleUpdateFromCatalogResult } from "@/features/ai-chat/types/ai-chat.types.ts";
// `useUpdateAiRoleFromCatalogMutation` maps the server's discriminated result to
// a user-facing notification message. These tests pin each of the four branches
// (updated / not-in-catalog / language-unavailable / up-to-date) via renderHook
// with a mocked service (precedent: share-query.null-normalization.test.tsx).
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
// `t` echoes the key so we assert against the exact English message strings.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
updateAiRoleFromCatalog: vi.fn(),
// Other named exports referenced by ai-chat-query.ts must exist on the mock so
// the module import resolves; they are unused by these tests.
createAiRole: vi.fn(),
deleteAiChat: vi.fn(),
deleteAiRole: vi.fn(),
getAiChatMessages: vi.fn(),
getAiChats: vi.fn(),
getAiRoleCatalog: vi.fn(),
getAiRoleCatalogBundle: vi.fn(),
getAiRoles: vi.fn(),
importAiRolesFromCatalog: vi.fn(),
renameAiChat: vi.fn(),
updateAiRole: vi.fn(),
}));
import { updateAiRoleFromCatalog } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useUpdateAiRoleFromCatalogMutation } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
async function runMutation(result: IAiRoleUpdateFromCatalogResult) {
vi.mocked(updateAiRoleFromCatalog).mockResolvedValue(result);
const { result: hook } = renderHook(
() => useUpdateAiRoleFromCatalogMutation(),
{ wrapper: createWrapper() },
);
hook.current.mutate("role-1");
await waitFor(() => expect(hook.current.isSuccess).toBe(true));
}
describe("useUpdateAiRoleFromCatalogMutation — reason → message", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("updated:true -> 'Updated to the latest version'", async () => {
await runMutation({
updated: true,
fromVersion: 1,
toVersion: 2,
role: { id: "role-1" } as never,
});
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Updated to the latest version",
});
});
it("not-in-catalog -> 'This role is no longer in the catalog'", async () => {
await runMutation({ updated: false, reason: "not-in-catalog" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "This role is no longer in the catalog",
});
});
it("language-unavailable -> 'This language is no longer available in the catalog'", async () => {
await runMutation({ updated: false, reason: "language-unavailable" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "This language is no longer available in the catalog",
});
});
it("up-to-date -> 'Already up to date'", async () => {
await runMutation({ updated: false, reason: "up-to-date" });
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Already up to date",
});
});
});

View File

@@ -6,13 +6,8 @@ import {
IAiChatMessageRow,
IAiChatMessagesParams,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
@@ -42,17 +37,6 @@ export async function getAiChatMessages(
return req.data;
}
/**
* Resolve the chat bound to a document (the current user's most-recent chat
* created on that page), or null when there is none. Drives auto-open-on-page.
*/
export async function getBoundChat(pageId: string): Promise<string | null> {
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
pageId,
});
return req.data.chatId;
}
/** Rename a chat. */
export async function renameAiChat(data: {
chatId: string;
@@ -84,19 +68,6 @@ export async function exportAiChat(
return req.data.markdown;
}
/**
* Generate a page title from note content (markdown). One-shot, non-streaming
* (#199): the server only summarizes the supplied text and returns a suggestion;
* it never writes the page. The caller applies the title via /pages/update.
*/
export async function generatePageTitle(content: string): Promise<string> {
const req = await api.post<{ title: string }>(
"/ai-chat/generate-page-title",
{ content },
);
return req.data.title;
}
/**
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
* member (for the chat-creation picker); create/update/delete are admin-only
@@ -128,54 +99,3 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
});
return req.data;
}
/**
* Role catalog API (`/ai-chat/roles/*`, admin-only — the server enforces this).
* Browse a curated catalog, import roles/bundles into the workspace, and update
* an imported role when the catalog ships a newer version. Same `{ data }`
* unwrap convention as above.
*/
/** Browse the catalog, optionally localized to `language`. */
export async function getAiRoleCatalog(
language?: string,
): Promise<IAiRoleCatalog> {
const req = await api.post<IAiRoleCatalog>("/ai-chat/roles/catalog", {
language,
});
return req.data;
}
/** Open one catalog bundle in a language (role content + versions). */
export async function getAiRoleCatalogBundle(
bundleId: string,
language: string,
): Promise<IAiRoleCatalogBundle> {
const req = await api.post<IAiRoleCatalogBundle>(
"/ai-chat/roles/catalog/bundle",
{ bundleId, language },
);
return req.data;
}
/** Import roles from a catalog bundle into the workspace (admin). */
export async function importAiRolesFromCatalog(
payload: IAiRoleImportPayload,
): Promise<IAiRoleImportResult> {
const req = await api.post<IAiRoleImportResult>(
"/ai-chat/roles/import",
payload,
);
return req.data;
}
/** Update an already-imported role from its catalog source (admin). */
export async function updateAiRoleFromCatalog(
id: string,
): Promise<IAiRoleUpdateFromCatalogResult> {
const req = await api.post<IAiRoleUpdateFromCatalogResult>(
"/ai-chat/roles/update-from-catalog",
{ id },
);
return req.data;
}

View File

@@ -57,79 +57,10 @@ export interface IAiRole {
autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null;
// Catalog origin of an imported role, or null for a manually-created one.
// Admin-only (present only in the admin list view); the picker view omits it.
// The admin UI compares `version` against the catalog to offer an update.
source?: { slug: string; language: string; version: number } | null;
createdAt?: string;
updatedAt?: string;
}
/** One bundle's summary in the catalog index (mirrors `getCatalog().bundles[]`). */
export interface IAiRoleCatalogBundleSummary {
id: string;
name: string;
description: string | null;
languages: string[];
roles: { slug: string; version: number }[];
}
/** The browsable catalog index (mirrors `getCatalog()`). */
export interface IAiRoleCatalog {
languages: string[];
bundles: IAiRoleCatalogBundleSummary[];
}
/** A single role inside an opened catalog bundle (localized content + version). */
export interface IAiRoleCatalogRole {
slug: string;
emoji: string | null;
name: string;
description: string | null;
instructions: string;
autoStart: boolean;
launchMessage: string | null;
version: number;
}
/** An opened catalog bundle (mirrors `getCatalogBundle()`). */
export interface IAiRoleCatalogBundle {
bundleId: string;
language: string;
roles: IAiRoleCatalogRole[];
}
/** Import payload (mirrors the server `ImportFromCatalogDto`). */
export interface IAiRoleImportPayload {
bundleId: string;
language: string;
// Omitted => import the whole bundle; otherwise only these slugs.
slugs?: string[];
conflict: "skip" | "rename";
}
/** Import result counts (mirrors `importFromCatalog()`). */
export interface IAiRoleImportResult {
created: number;
skipped: number;
renamed: number;
errors: { slug: string; message: string }[];
}
/**
* Update-from-catalog result (mirrors the server `updateFromCatalog()`). A
* discriminated union on `updated`: a no-op carries a typed `reason` the UI maps
* to a specific message; a successful update carries the version bump + new role.
* Keeping the union (not a widened `reason?: string`) lets the consumer's literal
* comparisons be compiler-checked.
*/
export type IAiRoleUpdateFromCatalogResult =
| {
updated: false;
reason: "not-in-catalog" | "up-to-date" | "language-unavailable";
}
| { updated: true; fromVersion: number; toVersion: number; role: IAiRole };
/** Admin create payload for a role. */
export interface IAiRoleCreate {
name: string;

View File

@@ -1,107 +0,0 @@
import { describe, it, expect } from "vitest";
import { catalogRoleInstallState } from "./catalog-role-install-state.ts";
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
// Build a workspace role with a catalog source. Fields irrelevant to the
// install-state decision are filled with harmless defaults.
function installedRole(
source: { slug: string; language: string; version: number },
overrides: Partial<IAiRole> = {},
): IAiRole {
return {
id: `role-${source.slug}-${source.language}`,
name: source.slug,
emoji: null,
description: null,
enabled: true,
autoStart: true,
launchMessage: null,
source,
...overrides,
};
}
const catalogRole = { slug: "writer", version: 3 };
// Mirrors the role-launch.ts precedent: the modal's role-state computation is a
// pure function so the import/installed/update decision is testable directly.
describe("catalogRoleInstallState", () => {
it("no matching installed role -> import", () => {
const result = catalogRoleInstallState(catalogRole, [], "en");
expect(result).toEqual({ state: "import" });
});
it("same slug + language, installed version > catalog -> installed", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 5,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "installed", installed });
});
it("same slug + language, installed version == catalog -> installed", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 3,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "installed", installed });
});
it("same slug + language, installed version < catalog -> update (from/to)", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 1,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({
state: "update",
installed,
fromVersion: 1,
toVersion: 3,
});
});
it("same slug but DIFFERENT language -> import (a separate install)", () => {
// 'writer' is installed in 'ru'; browsing the 'en' catalog must offer it as a
// fresh import, not treat the ru copy as already installed.
const installed = installedRole({
slug: "writer",
language: "ru",
version: 5,
});
const result = catalogRoleInstallState(catalogRole, [installed], "en");
expect(result).toEqual({ state: "import" });
});
it("matches the right language when the same slug is installed in several", () => {
const ru = installedRole(
{ slug: "writer", language: "ru", version: 5 },
{ id: "ru-role" },
);
const en = installedRole(
{ slug: "writer", language: "en", version: 1 },
{ id: "en-role" },
);
const result = catalogRoleInstallState(catalogRole, [ru, en], "en");
expect(result).toEqual({
state: "update",
installed: en,
fromVersion: 1,
toVersion: 3,
});
});
it("ignores manually-created roles (no source) sharing the name", () => {
const manual = installedRole(
{ slug: "writer", language: "en", version: 9 },
{ source: null },
);
const result = catalogRoleInstallState(catalogRole, [manual], "en");
expect(result).toEqual({ state: "import" });
});
});

View File

@@ -1,49 +0,0 @@
import type {
IAiRole,
IAiRoleCatalogRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* The install state of a single catalog role relative to the workspace's
* existing roles. Extracted as a pure function so the catalog modal's role-state
* computation is unit-testable without mounting the component (mirrors the
* `roleLaunchMessage` precedent in role-launch.ts).
*
* A catalog role is matched to an installed role by BOTH `source.slug` and
* `source.language`: the same slug in a different language is a separate install
* (so it shows as "import", not "installed"). When matched, the installed source
* version decides the state:
* - no match -> "import"
* - matched & installed version >= catalog version -> "installed"
* - matched & installed version < catalog version -> "update" (from -> to)
*/
export type CatalogRoleInstallState =
| { state: "import" }
| { state: "installed"; installed: IAiRole }
| {
state: "update";
installed: IAiRole;
fromVersion: number;
toVersion: number;
};
export function catalogRoleInstallState(
role: Pick<IAiRoleCatalogRole, "slug" | "version">,
workspaceRoles: IAiRole[],
language: string,
): CatalogRoleInstallState {
const installed = workspaceRoles.find(
(r) => r.source?.slug === role.slug && r.source?.language === language,
);
if (!installed) return { state: "import" };
const fromVersion = installed.source?.version ?? 0;
if (fromVersion >= role.version) {
return { state: "installed", installed };
}
return {
state: "update",
installed,
fromVersion,
toVersion: role.version,
};
}

View File

@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
import {
enqueueMessage,
dequeue,
promoteToHead,
removeQueuedById,
type QueuedMessage,
} from "./queue-helpers";
@@ -90,52 +89,6 @@ describe("removeQueuedById", () => {
});
});
describe("promoteToHead", () => {
it("moves the matching id to the front, preserving the rest's order", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
{ id: "c", text: "third" },
];
expect(promoteToHead(queue, "c")).toEqual([
{ id: "c", text: "third" },
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
it("is a no-op order-wise when the id is already the head", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
expect(promoteToHead(queue, "a")).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
it("returns an equivalent list when the id is not present", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
expect(promoteToHead(queue, "missing")).toEqual(queue);
});
it("does not mutate the input queue", () => {
const queue: QueuedMessage[] = [
{ id: "a", text: "first" },
{ id: "b", text: "second" },
];
promoteToHead(queue, "b");
expect(queue).toEqual([
{ id: "a", text: "first" },
{ id: "b", text: "second" },
]);
});
});
describe("FIFO order", () => {
it("preserves order across enqueue -> dequeue", () => {
let queue: QueuedMessage[] = [];

View File

@@ -32,16 +32,3 @@ export function removeQueuedById(
): QueuedMessage[] {
return queue.filter((m) => m.id !== id);
}
/** Move the queued message with the given id to the FRONT (returns a new array).
* No-op (returns an equivalent array) when the id is absent. Pure — backs the
* "send now" action: promoting a message to the head lets the existing
* onFinish -> flushNext path send exactly that message on the abort we trigger. */
export function promoteToHead(
queue: QueuedMessage[],
id: string,
): QueuedMessage[] {
const target = queue.find((m) => m.id === id);
if (!target) return queue;
return [target, ...queue.filter((m) => m.id !== id)];
}

View File

@@ -1,154 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// react-i18next: identity t() so the hook renders without an i18n provider.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// react-router-dom: only useNavigate is used by the hook.
const navigateMock = vi.fn();
vi.mock("react-router-dom", () => ({
useNavigate: () => navigateMock,
}));
// The auth service is the network boundary; stub login/logout per test.
const loginMock = vi.fn();
const logoutMock = vi.fn();
vi.mock("@/features/auth/services/auth-service", () => ({
login: (...args: unknown[]) => loginMock(...args),
logout: (...args: unknown[]) => logoutMock(...args),
forgotPassword: vi.fn(),
passwordReset: vi.fn(),
setupWorkspace: vi.fn(),
verifyUserToken: vi.fn(),
}));
vi.mock("@/features/workspace/services/workspace-service.ts", () => ({
acceptInvitation: vi.fn(),
}));
// The offline cache purge is the unit under test — assert it is invoked.
const clearOfflineCacheMock = vi.fn();
vi.mock("@/features/offline/clear-offline-cache", () => ({
clearOfflineCache: () => clearOfflineCacheMock(),
}));
// app-route helpers are pure config; provide deterministic values.
vi.mock("@/lib/app-route.ts", () => ({
default: { AUTH: { LOGIN: "/login" }, HOME: "/home" },
getPostLoginRedirect: () => "/home",
}));
// Mantine notifications: avoid touching the DOM-bound notification system.
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
import useAuth from "./use-auth";
beforeEach(() => {
navigateMock.mockReset();
loginMock.mockReset();
loginMock.mockResolvedValue(undefined);
logoutMock.mockReset();
logoutMock.mockResolvedValue(undefined);
clearOfflineCacheMock.mockReset();
clearOfflineCacheMock.mockResolvedValue(undefined);
});
describe("useAuth.handleSignIn", () => {
it("clears the offline cache BEFORE logging in (cross-user leak guard)", async () => {
const order: string[] = [];
clearOfflineCacheMock.mockImplementation(async () => {
order.push("clear");
});
loginMock.mockImplementation(async () => {
order.push("login");
});
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.signIn({ email: "b@x", password: "pw" } as any);
});
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
expect(loginMock).toHaveBeenCalledTimes(1);
// The purge must run before the new session's login resolves.
expect(order).toEqual(["clear", "login"]);
expect(navigateMock).toHaveBeenCalledWith("/home");
});
it("does not block sign-in when the cache purge throws (best-effort)", async () => {
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.signIn({ email: "b@x", password: "pw" } as any);
});
// Login still proceeds despite the cleanup failure.
expect(loginMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith("/home");
});
});
describe("useAuth.handleLogout", () => {
const replaceMock = vi.fn();
let originalLocation: Location;
beforeEach(() => {
replaceMock.mockReset();
// window.location.replace is the post-logout redirect. jsdom's real `replace`
// is a non-configurable method that warns "not implemented", so swap the
// whole location object for one whose `replace` we can capture.
originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
writable: true,
value: { replace: replaceMock },
});
});
afterEach(() => {
Object.defineProperty(window, "location", {
configurable: true,
writable: true,
value: originalLocation,
});
});
it("purges the offline cache exactly once BEFORE redirecting (cross-user leak guard)", async () => {
const order: string[] = [];
clearOfflineCacheMock.mockImplementation(async () => {
order.push("clear");
});
replaceMock.mockImplementation((url: string) => {
order.push(`replace:${url}`);
});
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.logout();
});
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
// Purge must complete before the redirect (which would otherwise interrupt
// the async cleanup).
expect(order).toEqual(["clear", "replace:/login?logout=1"]);
});
it("still redirects when the cache purge throws (best-effort, never blocks logout)", async () => {
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.logout();
});
// The thrown purge error is swallowed and the redirect still fires.
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
expect(replaceMock).toHaveBeenCalledTimes(1);
expect(replaceMock).toHaveBeenCalledWith("/login?logout=1");
});
});

View File

@@ -23,7 +23,6 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
export default function useAuth() {
const { t } = useTranslation();
@@ -34,20 +33,6 @@ export default function useAuth() {
const handleSignIn = async (data: ILogin) => {
setIsLoading(true);
// Purge any previous user's offline data BEFORE signing in (mirrors logout).
// On a shared/kiosk device the prior session may have ended WITHOUT an
// explicit logout (cookie/JWT expiry, tab close, force-quit), leaving user
// A's persisted query cache (gitmost-rq-cache) and Yjs page bodies
// (page.<id>) in IndexedDB. Without this purge user B would briefly read A's
// cached currentUser/pages/comments on first render (UserProvider serves the
// cached user) and A's page bodies would stay readable offline. Best-effort:
// never block sign-in on cache cleanup.
try {
await clearOfflineCache();
} catch {
// best-effort: never block sign-in on cache cleanup
}
try {
await login(data);
setIsLoading(false);
@@ -138,13 +123,6 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await logout();
// Purge the previous user's offline data while the page is still alive —
// window.location.replace below would otherwise interrupt async cleanup.
try {
await clearOfflineCache();
} catch {
// best-effort: never block logout on cache cleanup
}
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};

View File

@@ -1,43 +0,0 @@
import { describe, it, expect } from "vitest";
import { AxiosError } from "axios";
import { collabTokenRetry } from "./auth-query";
// Regression for the offline white-screen (#237/#238): offline the collab-token
// POST rejects as an axios NETWORK error (isAxiosError === true but
// error.response === undefined). The old predicate read `error.response.status`
// without a guard and threw an uncaught TypeError inside the React Query retryer
// BEFORE React mounted, blanking the whole app. The predicate must stay total.
describe("collabTokenRetry", () => {
it("does NOT throw and returns a retryable value for a network error with no response (offline)", () => {
// An axios error with no `response` is exactly the offline/network-failure shape.
const networkError = new AxiosError("Network Error");
expect(networkError.response).toBeUndefined();
let result: boolean | number = false;
expect(() => {
result = collabTokenRetry(0, networkError);
}).not.toThrow();
// Network failures stay retryable (truthy), matching the original intent.
expect(result).toBe(true);
});
it("returns false (no retry) for a real 404 response", () => {
const notFound = new AxiosError("Not Found");
notFound.response = { status: 404 } as AxiosError["response"];
expect(collabTokenRetry(0, notFound)).toBe(false);
});
it("retries for a non-404 response (e.g. 500)", () => {
const serverError = new AxiosError("Server Error");
serverError.response = { status: 500 } as AxiosError["response"];
expect(collabTokenRetry(0, serverError)).toBe(true);
});
it("does not throw and retries for a non-axios error", () => {
let result: boolean | number = false;
expect(() => {
result = collabTokenRetry(0, new Error("boom"));
}).not.toThrow();
expect(result).toBe(true);
});
});

View File

@@ -3,27 +3,6 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service";
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
import { isAxiosError } from "axios";
/**
* Retry predicate for the collab-token query.
*
* Offline (or any network failure) the POST rejects as an axios NETWORK error:
* `isAxiosError(error) === true` but `error.response === undefined`. Reading
* `error.response.status` without a guard threw an uncaught TypeError inside the
* React Query retryer BEFORE React mounted, white-screening the whole app on an
* offline cold boot (#237/#238). Optional-chaining `error.response?.status`
* keeps the predicate total: a network error (no response) is retryable, a real
* 404 is not. Extracted (and exported) so it can be unit-tested in isolation.
*/
export function collabTokenRetry(
_failureCount: number,
error: Error,
): boolean {
if (isAxiosError(error) && error.response?.status === 404) {
return false;
}
return true;
}
export function useVerifyUserTokenQuery(
verify: IVerifyUserToken,
): UseQueryResult<any, Error> {
@@ -43,7 +22,13 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
//refetchIntervalInBackground: true,
refetchOnMount: true,
retry: collabTokenRetry,
//@ts-ignore
retry: (failureCount, error) => {
if (isAxiosError(error) && error.response.status === 404) {
return false;
}
return 10;
},
retryDelay: (retryAttempt) => {
// Exponential backoff: 5s, 10s, 20s, etc.
return 5000 * Math.pow(2, retryAttempt - 1);

View File

@@ -20,7 +20,6 @@ import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react";
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
@@ -61,9 +60,6 @@ export function useCreateCommentMutation() {
const { t } = useTranslation();
return useMutation<IComment, Error, Partial<IComment>>({
// Stable key so a paused comment-create restored from IndexedDB after an
// offline reload finds its default mutationFn and is replayed on reconnect.
mutationKey: offlineMutationKeys.createComment,
mutationFn: (data) => createComment(data),
onSuccess: (newComment) => {
const cache = queryClient.getQueryData(

View File

@@ -10,11 +10,7 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>("");
// Local (IndexedDB) persistence sync state for the current page's Y.Doc.
export const isLocalSyncedAtom = atom<boolean>(false);
// Remote (Hocuspocus) sync state for the current page's Y.Doc.
export const isRemoteSyncedAtom = atom<boolean>(false);
export const showAiMenuAtom = atom(false);
export const showLinkMenuAtom = atom(false);

View File

@@ -9,10 +9,11 @@ import {
IconStrikethrough,
IconUnderline,
IconMessage,
IconSparkles,
} from "@tabler/icons-react";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
import { ActionIcon, rem, Tooltip } from "@mantine/core";
import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -25,8 +26,8 @@ import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next";
import { showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom } from "@/features/user/atoms/current-user-atom";
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem {
name: string;
@@ -43,12 +44,16 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { templateMode = false } = props;
const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const user = useAtomValue(userAtom);
const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
const [showLinkMenu] = useAtom(showLinkMenuAtom);
const showLinkMenuRef = useRef(showLinkMenu);
@@ -56,6 +61,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
useEffect(() => {
showLinkMenuRef.current = showLinkMenu;
}, [showLinkMenu]);
@@ -136,6 +145,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty ||
isNodeSelection(selection) ||
isCellSelection(selection) ||
showAiMenuRef.current ||
showLinkMenuRef.current ||
showCommentPopupRef?.current
) {
@@ -158,8 +168,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when the link menu is shown
if (showLinkMenu) return;
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu || showLinkMenu) return;
return (
<BubbleMenu
@@ -167,6 +177,22 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
style={{ zIndex: 199, position: "relative" }}
>
<div className={classes.bubbleMenu}>
{isGenerativeAiEnabled && (
<>
<Button
variant="default"
className={clsx(classes.buttonRoot)}
radius="0"
leftSection={<IconSparkles size={16} />}
onClick={() => {
setShowAiMenu(true);
}}
>
{t("Ask AI")}
</Button>
<div className={classes.divider} />
</>
)}
{!editorToolbarEnabled && (
<>
<NodeSelector

View File

@@ -1,100 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
sortFrequentlyUsedEmoji,
getFrequentlyUsedEmoji,
LOCAL_STORAGE_FREQUENT_KEY,
} from "./utils";
describe("sortFrequentlyUsedEmoji", () => {
it("orders known emoji by descending usage count", async () => {
const result = await sortFrequentlyUsedEmoji({
rocket: 1,
joy: 9,
heart_eyes: 5,
});
expect(result.map((e) => e.id)).toEqual(["joy", "heart_eyes", "rocket"]);
});
it("caps the result at the top 5 most frequent", async () => {
const result = await sortFrequentlyUsedEmoji({
rocket: 1,
joy: 2,
heart_eyes: 3,
grinning: 4,
laughing: 5,
scream: 6,
sweat_smile: 7,
});
expect(result).toHaveLength(5);
// Highest counts retained, lowest (rocket:1, joy:2) dropped.
expect(result.map((e) => e.id)).toEqual([
"sweat_smile",
"scream",
"laughing",
"grinning",
"heart_eyes",
]);
});
it("drops ids that have no matching emoji in the index", async () => {
const result = await sortFrequentlyUsedEmoji({
__definitely_not_a_real_emoji_id__: 100,
rocket: 1,
});
expect(result.map((e) => e.id)).toEqual(["rocket"]);
});
it("maps each entry to its native glyph and a command", async () => {
const [entry] = await sortFrequentlyUsedEmoji({ rocket: 5 });
expect(entry.id).toBe("rocket");
expect(typeof entry.emoji).toBe("string");
expect(entry.emoji.length).toBeGreaterThan(0);
expect(typeof entry.command).toBe("function");
});
it("returns an empty list for empty input", async () => {
expect(await sortFrequentlyUsedEmoji({})).toEqual([]);
});
});
describe("getFrequentlyUsedEmoji", () => {
beforeEach(() => {
localStorage.clear();
});
it("falls back to the default map when nothing is stored", () => {
const result = getFrequentlyUsedEmoji();
expect(result["+1"]).toBe(10);
expect(result["rocket"]).toBe(1);
});
it("parses a valid stored JSON map", () => {
localStorage.setItem(
LOCAL_STORAGE_FREQUENT_KEY,
JSON.stringify({ rocket: 42 }),
);
expect(getFrequentlyUsedEmoji()).toEqual({ rocket: 42 });
});
// BUG (issue #204, Phase 2): getFrequentlyUsedEmoji() does an unprotected
// JSON.parse() of the raw localStorage value. A corrupt value (e.g. truncated
// by a crash, or written by another tab/extension) makes the emoji menu throw
// on open instead of degrading gracefully to the default set.
//
// Documented with it.fails: this asserts the DESIRED behavior (return a sane
// default, never throw). It currently FAILS because the function throws —
// flip to `it()` once utils.ts guards the JSON.parse.
it.fails(
"should degrade to a sane default on corrupt localStorage (currently throws)",
() => {
localStorage.setItem(LOCAL_STORAGE_FREQUENT_KEY, "{not valid json");
let result: Record<string, number> | undefined;
expect(() => {
result = getFrequentlyUsedEmoji();
}).not.toThrow();
// Should hand back a usable, non-empty map rather than nothing.
expect(result).toBeTruthy();
expect(Object.keys(result ?? {}).length).toBeGreaterThan(0);
},
);
});

View File

@@ -12,6 +12,8 @@ import { MediaGroup } from "./groups/media-group";
import { QuickInsertsGroup } from "./groups/quick-inserts-group";
import { MoreInsertsGroup } from "./groups/more-inserts-group";
import { HistoryGroup } from "./groups/history-group";
import { AskAiGroup } from "./groups/ask-ai-group";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import classes from "./fixed-toolbar.module.css";
type FixedToolbarProps = {
@@ -26,6 +28,8 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
const editorFromAtom = useAtomValue(pageEditorAtom);
const editor = editorProp ?? editorFromAtom;
const state = useToolbarState(editor);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
if (!editor || !state) return null;
@@ -39,6 +43,12 @@ export const FixedToolbar: FC<FixedToolbarProps> = ({
onMouseDown={(e) => e.preventDefault()}
>
<div className={classes.inner}>
{/* {isGenerativeAiEnabled && (
<>
<AskAiGroup />
<div className={classes.divider} />
</>
)} */}
<BlockTypeGroup editor={editor} />
<div className={classes.divider} />
<InlineMarksGroup editor={editor} state={state} />

View File

@@ -0,0 +1,23 @@
import { FC } from "react";
import { Button } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
export const AskAiGroup: FC = () => {
const { t } = useTranslation();
const setShowAiMenu = useSetAtom(showAiMenuAtom);
return (
<Button
variant="subtle"
color="dark"
size="xs"
leftSection={<IconSparkles size={14} />}
onClick={() => setShowAiMenu(true)}
>
{t("Ask AI")}
</Button>
);
};

View File

@@ -1,39 +0,0 @@
import { FC } from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useGeneratePageTitle } from "@/features/editor/hooks/use-generate-page-title.ts";
interface Props {
pageId: string;
color?: string;
iconSize?: number;
}
/**
* AI "generate title" button (#199). Reads the live editor content and applies a
* model-suggested title immediately. Rendered in the page byline, only in edit
* mode and when the workspace's AI chat flag is on.
*/
export const GenerateTitleGroup: FC<Props> = ({
pageId,
color = "gray",
iconSize = 20,
}) => {
const { t } = useTranslation();
const gen = useGeneratePageTitle(pageId);
return (
<Tooltip label={t("Generate title with AI")} withArrow openDelay={250}>
<ActionIcon
variant="subtle"
color={color}
aria-label={t("Generate title with AI")}
loading={gen.isPending}
onClick={() => gen.mutate()}
>
<IconSparkles size={iconSize} stroke={1.5} />
</ActionIcon>
</Tooltip>
);
};

View File

@@ -1,163 +0,0 @@
import { describe, it, expect } from "vitest";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import {
isHeaderCell,
sortItems,
weaveItems,
type SortableItem,
} from "./sort-cells";
// isHeaderCell only reads node.type.name and node.attrs?.header, so a minimal
// duck-typed node is sufficient (no real ProseMirror schema needed).
function fakeNode(typeName: string, attrs: Record<string, unknown> = {}) {
return { type: { name: typeName }, attrs } as unknown as ProseMirrorNode;
}
function item<T>(
payload: T,
text: string,
originalOrder: number,
opts: { isHeader?: boolean; isEmpty?: boolean } = {},
): SortableItem<T> {
return {
payload,
text,
originalOrder,
isHeader: opts.isHeader ?? false,
isEmpty: opts.isEmpty ?? text.trim() === "",
};
}
describe("isHeaderCell", () => {
it("recognizes the tableHeader node type", () => {
expect(isHeaderCell(fakeNode("tableHeader"))).toBe(true);
});
it("recognizes the snake_case table_header node type", () => {
expect(isHeaderCell(fakeNode("table_header"))).toBe(true);
});
it("treats a plain cell with header:true attr as a header", () => {
expect(isHeaderCell(fakeNode("tableCell", { header: true }))).toBe(true);
});
it("returns false for a regular body cell", () => {
expect(isHeaderCell(fakeNode("tableCell", { header: false }))).toBe(false);
expect(isHeaderCell(fakeNode("tableCell"))).toBe(false);
});
});
describe("sortItems", () => {
it("sorts non-empty rows ascending using a base/numeric collator", () => {
const data = [
item("c", "cherry", 0),
item("a", "Apple", 1),
item("b", "banana", 2),
];
expect(sortItems(data, "asc").map((i) => i.payload)).toEqual([
"a",
"b",
"c",
]);
});
it("sorts descending when direction is desc", () => {
const data = [
item("a", "apple", 0),
item("b", "banana", 1),
item("c", "cherry", 2),
];
expect(sortItems(data, "desc").map((i) => i.payload)).toEqual([
"c",
"b",
"a",
]);
});
it("orders numerically, not lexically (numeric collator)", () => {
const data = [
item("ten", "10", 0),
item("two", "2", 1),
item("one", "1", 2),
];
expect(sortItems(data, "asc").map((i) => i.payload)).toEqual([
"one",
"two",
"ten",
]);
});
it("always pushes empty cells to the bottom regardless of direction", () => {
const data = [
item("empty", "", 0, { isEmpty: true }),
item("b", "banana", 1),
item("a", "apple", 2),
];
const asc = sortItems(data, "asc");
expect(asc.map((i) => i.payload)).toEqual(["a", "b", "empty"]);
const desc = sortItems(data, "desc");
// Empty stays last even when the rest is reversed.
expect(desc[desc.length - 1].payload).toBe("empty");
});
it("keeps empty cells in their original relative order (stable)", () => {
const data = [
item("e1", "", 5, { isEmpty: true }),
item("e2", "", 2, { isEmpty: true }),
item("a", "apple", 9),
];
const sorted = sortItems(data, "asc");
// e2 (originalOrder 2) before e1 (originalOrder 5).
expect(sorted.map((i) => i.payload)).toEqual(["a", "e2", "e1"]);
});
it("does not mutate the input array", () => {
const data = [item("b", "banana", 0), item("a", "apple", 1)];
const snapshot = data.map((i) => i.payload);
sortItems(data, "asc");
expect(data.map((i) => i.payload)).toEqual(snapshot);
});
});
describe("weaveItems", () => {
it("keeps header rows pinned in place and fills body slots from sorted data", () => {
const header = item("H", "Name", 0, { isHeader: true });
const all = [
header,
item("orig-b", "b", 1),
item("orig-a", "a", 2),
];
const sortedBody = [item("orig-a", "a", 2), item("orig-b", "b", 1)];
const woven = weaveItems(all, sortedBody);
// Header never moves out of row 0...
expect(woven[0]).toBe(header);
// ...and the body positions are filled in sorted order.
expect(woven.slice(1).map((i) => i.payload)).toEqual(["orig-a", "orig-b"]);
});
it("does not consume body data for header positions (header stays at top)", () => {
const header = item("H", "head", 0, { isHeader: true });
const all = [header, item("x", "x", 1), item("y", "y", 2)];
const sortedBody = [item("y", "y", 2), item("x", "x", 1)];
const woven = weaveItems(all, sortedBody);
expect(woven[0].isHeader).toBe(true);
expect(woven.filter((i) => !i.isHeader).map((i) => i.payload)).toEqual([
"y",
"x",
]);
});
it("interleaves correctly when a header sits between body rows", () => {
const header = item("H", "head", 1, { isHeader: true });
const all = [
item("b1", "b1", 0),
header,
item("b2", "b2", 2),
];
const sortedBody = [item("b2", "b2", 2), item("b1", "b1", 0)];
const woven = weaveItems(all, sortedBody);
expect(woven.map((i) => i.payload)).toEqual(["b2", "H", "b1"]);
expect(woven[1]).toBe(header);
});
});

View File

@@ -1,21 +0,0 @@
import { createContext, useContext } from "react";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type * as Y from "yjs";
// Shared collaboration providers lifted above the title/body editors so that
// both siblings bind to the SAME Y.Doc and HocuspocusProvider. The title lives
// in a dedicated 'title' fragment of the same doc as the body.
export interface EditorProvidersContextValue {
ydoc: Y.Doc;
remote: HocuspocusProvider;
providersReady: boolean;
}
export const EditorProvidersContext =
createContext<EditorProvidersContextValue | null>(null);
// Returns the shared providers, or null when rendered outside of a provider.
// Consumers must be null-safe (the body editor falls back to a non-collab mode).
export function useEditorProviders(): EditorProvidersContextValue | null {
return useContext(EditorProvidersContext);
}

View File

@@ -1,32 +0,0 @@
import { describe, it, expect } from "vitest";
import { WebSocketStatus } from "@hocuspocus/provider";
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
describe("isCollabSynced", () => {
it("is true only when Connected and synced", () => {
expect(isCollabSynced(WebSocketStatus.Connected, true)).toBe(true);
});
it("is false while connecting or not yet synced", () => {
expect(isCollabSynced(WebSocketStatus.Connecting, true)).toBe(false);
expect(isCollabSynced(WebSocketStatus.Connected, false)).toBe(false);
expect(isCollabSynced(WebSocketStatus.Disconnected, true)).toBe(false);
});
});
describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
const base = { editable: true, inEditMode: true, showStatic: false };
it("allows editing only after the static (pre-sync) phase ends", () => {
expect(isBodyEditable(base)).toBe(true);
});
it("never editable while the static read-only editor is shown", () => {
expect(isBodyEditable({ ...base, showStatic: true })).toBe(false);
});
it("honors read-only and view mode", () => {
expect(isBodyEditable({ ...base, editable: false })).toBe(false);
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
});
});

View File

@@ -1,32 +0,0 @@
import { WebSocketStatus } from "@hocuspocus/provider";
/**
* The collab document is usable only once the provider is Connected AND has
* synced (both the local IndexedDB replica and the remote room). Until then the
* in-browser Y.Doc is empty/stale, so edits would either be dropped or clobber
* the server's authoritative doc when it finally arrives.
*/
export function isCollabSynced(
status: WebSocketStatus | string,
isSynced: boolean,
): boolean {
return status === WebSocketStatus.Connected && isSynced;
}
/**
* Whether the page BODY editor may accept edits.
*
* `showStatic` is true during the pre-sync window (a read-only static editor is
* shown). Gating editability on `!showStatic` guarantees the body never becomes
* editable before the collab doc is synced, so early keystrokes on a freshly
* created page can't land only in local ProseMirror and then be lost when the
* server's initial empty doc syncs in (#218). Read-only and view modes are
* still honored via `editable`/`inEditMode`.
*/
export function isBodyEditable(opts: {
editable: boolean;
inEditMode: boolean;
showStatic: boolean;
}): boolean {
return opts.editable && opts.inEditMode && !opts.showStatic;
}

View File

@@ -1,168 +0,0 @@
import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Node as PMNode, Fragment, Slice } from "@tiptap/pm/model";
import {
FootnoteReference,
FootnotesList,
FootnoteDefinition,
FOOTNOTE_REFERENCE_NAME,
FOOTNOTE_DEFINITION_NAME,
FOOTNOTES_LIST_NAME,
} from "@docmost/editor-ext";
import { canonicalizePastedFootnotes } from "./markdown-clipboard";
/**
* A markdown paste builds its ProseMirror fragment via DOM -> parseSlice and is
* applied with a manual transaction (handlePaste returns true), so it bypasses
* the editor's footnoteSyncPlugin — which never reorders an existing list. These
* tests pin canonicalizePastedFootnotes, the focused hook that makes a pasted
* out-of-order markdown footnote block come out canonical (issue #228).
*/
const extensions = [
Document,
Paragraph,
Text,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
];
function makeSchema() {
const editor = new Editor({ extensions, content: { type: "doc", content: [] } });
const { schema } = editor;
return { editor, schema };
}
/** List footnote def ids of the (single) footnotesList in a slice, in order. */
function listIds(slice: Slice): string[] {
const out: string[] = [];
slice.content.forEach((node: PMNode) => {
if (node.type.name === FOOTNOTES_LIST_NAME) {
node.content.forEach((def: PMNode) => {
if (def.type.name === FOOTNOTE_DEFINITION_NAME) out.push(def.attrs.id);
});
}
});
return out;
}
function hasList(slice: Slice): boolean {
let found = false;
slice.content.forEach((n: PMNode) => {
if (n.type.name === FOOTNOTES_LIST_NAME) found = true;
});
return found;
}
describe("canonicalizePastedFootnotes", () => {
it("reorders a pasted block to reference order, dedups reuse, drops orphans", () => {
const { editor, schema } = makeSchema();
// Body references c, a, b (and again a => reuse); definitions a, b, c, z
// (z is an orphan) — the exact shape a markdown paste produces.
const slice = new Slice(
Fragment.fromArray([
schema.nodes.paragraph.create(null, [
schema.text("body "),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "c" }),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
]),
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
schema.nodes.paragraph.create(null, [schema.text("note A")]),
]),
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
schema.nodes.paragraph.create(null, [schema.text("note B")]),
]),
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "c" }, [
schema.nodes.paragraph.create(null, [schema.text("note C")]),
]),
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "z" }, [
schema.nodes.paragraph.create(null, [schema.text("orphan")]),
]),
]),
]),
0,
0,
);
const out = canonicalizePastedFootnotes(slice, schema);
// Reference order, orphan z dropped, reused a appears once.
expect(listIds(out)).toEqual(["c", "a", "b"]);
editor.destroy();
});
it("leaves a reference-ONLY paste untouched (no synthesized definitions)", () => {
// A paste that reuses an id defined in the TARGET doc must NOT gain a
// synthesized empty definition here — it carries no footnotesList of its own.
const { editor, schema } = makeSchema();
const slice = new Slice(
Fragment.from(
schema.nodes.paragraph.create(null, [
schema.text("see "),
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
]),
),
0,
0,
);
const out = canonicalizePastedFootnotes(slice, schema);
expect(hasList(out)).toBe(false);
expect(out).toBe(slice); // returned unchanged (same reference)
editor.destroy();
});
it("leaves a definitions-ONLY paste untouched (no references -> no empty paste)", () => {
// A whole-block paste of ONLY definitions (a footnotesList with no matching
// footnoteReference anywhere in the selection). Canonicalizing it would strip
// the reference-less list -> an EMPTY paste, losing the pasted text. The hook
// must leave such a block untouched.
const { editor, schema } = makeSchema();
const slice = new Slice(
Fragment.fromArray([
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
schema.nodes.paragraph.create(null, [schema.text("note A")]),
]),
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
schema.nodes.paragraph.create(null, [schema.text("note B")]),
]),
]),
]),
0,
0,
);
const out = canonicalizePastedFootnotes(slice, schema);
expect(out).toBe(slice); // returned unchanged (same reference, content kept)
expect(listIds(out)).toEqual(["a", "b"]);
editor.destroy();
});
it("leaves an open (partial) slice untouched even if it carries a list", () => {
// An open slice (openStart/openEnd > 0) is a partial selection, not a
// standalone block, so it is returned as-is BEFORE any footnote handling.
const { editor, schema } = makeSchema();
const slice = new Slice(
Fragment.fromArray([
schema.nodes.paragraph.create(null, [
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
]),
schema.nodes[FOOTNOTES_LIST_NAME].create(null, [
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [
schema.nodes.paragraph.create(null, [schema.text("A")]),
]),
]),
]),
1,
1,
);
const out = canonicalizePastedFootnotes(slice, schema);
expect(out).toBe(slice);
editor.destroy();
});
});

View File

@@ -1,126 +0,0 @@
import { describe, it, expect } from "vitest";
import { normalizeTableColumnWidths } from "./markdown-clipboard";
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
function root(html: string): HTMLElement {
const div = document.createElement("div");
div.innerHTML = html;
return div;
}
function firstRowColWidths(container: HTMLElement): (string | null)[] {
const row = container.querySelector("tr");
return Array.from(row?.children ?? []).map((c) =>
c.getAttribute("colwidth"),
);
}
describe("normalizeTableColumnWidths", () => {
// The core "squash столбцов вставленной таблицы" concern: markdown has no
// widths, so every pasted table would otherwise render at table-layout:fixed
// / 100% and squash columns. This stamps an explicit per-column px width.
it("stamps the default px width on every column when no widths are present", () => {
const container = root(
"<table><tbody><tr><td>a</td><td>b</td><td>c</td></tr></tbody></table>",
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["150", "150", "150"]);
});
it("derives column widths from a colgroup", () => {
const container = root(
"<table>" +
'<colgroup><col style="width:200px"><col style="width:80px"></colgroup>' +
"<tbody><tr><td>a</td><td>b</td></tr></tbody>" +
"</table>",
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["200", "80"]);
});
it("derives column widths from per-cell width attributes", () => {
const container = root(
'<table><tbody><tr><td width="120">a</td><td width="90">b</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["120", "90"]);
});
it("derives column widths from a cell style:width:px", () => {
const container = root(
'<table><tbody><tr><td style="width:140px">a</td><td>b</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
// First cell width parsed; a fully-unmeasured column is left untouched
// (the 100 fallback only fills in NULL gaps inside an otherwise-measured
// multi-column slice, e.g. a colspan).
expect(firstRowColWidths(container)).toEqual(["140", null]);
});
it("fills a null gap inside a measured colspanned slice with 100", () => {
// colgroup gives [200, null]; the single colspan=2 cell spans both, so its
// slice is [200, null] -> the null is backfilled to 100 => "200,100".
const container = root(
"<table>" +
'<colgroup><col style="width:200px"><col></colgroup>' +
'<tbody><tr><td colspan="2">merged</td></tr></tbody>' +
"</table>",
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["200,100"]);
});
it("splits a measured width across a colspanned cell", () => {
const container = root(
'<table><tbody><tr><td colspan="2" width="300">merged</td><td width="100">x</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
// 300 / colspan(2) = 150 per underlying column => "150,150" on the merged cell.
expect(firstRowColWidths(container)).toEqual(["150,150", "100"]);
});
it("falls back to the default width per spanned column when nothing is measurable", () => {
const container = root(
'<table><tbody><tr><td colspan="2">merged</td><td>x</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["150,150", "150"]);
});
it("leaves cells that already have a colwidth untouched", () => {
const container = root(
'<table><tbody><tr><td colwidth="42">a</td><td>b</td></tr></tbody></table>',
);
normalizeTableColumnWidths(container);
expect(firstRowColWidths(container)).toEqual(["42", "150"]);
});
it("normalizes every table in the subtree", () => {
const container = root(
"<table><tbody><tr><td>a</td></tr></tbody></table>" +
"<table><tbody><tr><td>b</td><td>c</td></tr></tbody></table>",
);
normalizeTableColumnWidths(container);
const tables = container.querySelectorAll("table");
const widths = Array.from(tables).map((t) =>
Array.from(t.querySelector("tr")!.children).map((c) =>
c.getAttribute("colwidth"),
),
);
expect(widths).toEqual([["150"], ["150", "150"]]);
});
it("only annotates the first row (column widths are defined once)", () => {
const container = root(
"<table><tbody>" +
"<tr><td>a</td><td>b</td></tr>" +
"<tr><td>c</td><td>d</td></tr>" +
"</tbody></table>",
);
normalizeTableColumnWidths(container);
const rows = container.querySelectorAll("tr");
expect(
Array.from(rows[1].children).map((c) => c.getAttribute("colwidth")),
).toEqual([null, null]);
});
});

View File

@@ -3,14 +3,7 @@ import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs";
import {
markdownToHtml,
htmlToMarkdown,
canonicalizeFootnotes,
FOOTNOTES_LIST_NAME,
FOOTNOTE_REFERENCE_NAME,
} from "@docmost/editor-ext";
import type { Schema } from "@tiptap/pm/model";
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
@@ -90,25 +83,12 @@ export const MarkdownClipboard = Extension.create({
const body = elementFromString(parsed);
normalizeTableColumnWidths(body);
const parsedSlice = DOMParser.fromSchema(
const contentNodes = DOMParser.fromSchema(
this.editor.schema,
).parseSlice(body, {
preserveWhitespace: true,
});
// A markdown paste builds its ProseMirror fragment directly (DOM ->
// parseSlice), bypassing the editor's footnoteSyncPlugin, which never
// reorders an existing list. So a pasted markdown block whose footnote
// definitions are out of order (or contains orphan defs) would be
// stored out of order. Canonicalize the self-contained pasted block so
// its footnotes come out reference-ordered, deduped and orphan-free
// (issue #228). See canonicalizePastedFootnotes for why this is scoped
// to whole-block pastes that carry their own footnotesList.
const contentNodes = canonicalizePastedFootnotes(
parsedSlice,
this.editor.schema,
);
tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
@@ -153,54 +133,6 @@ export const MarkdownClipboard = Extension.create({
},
});
/**
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
* list, so an out-of-order pasted block would otherwise persist out of order).
*
* Scoped deliberately to whole-block pastes (openStart/openEnd === 0) that carry
* their OWN footnotesList: canonicalizeFootnotes would synthesize empty
* definitions for any reference lacking a definition, which is correct for a
* standalone block but would be wrong for a reference-only paste that REUSES a
* footnote already defined in the target document — so those are left untouched
* for the paste/sync plugins to merge. Residual: when the pasted block is merged
* into a doc that already has footnotes, ordering RELATIVE to the pre-existing
* footnotes is still governed by the sync plugin (which does not reorder).
*
* Also requires at least one footnoteReference in the selection: a definitions-ONLY
* paste (`[^a]: …` with no `[^a]` reference in the same block) has no references,
* so canonicalizeFootnotes would drop the whole list and the paste would come out
* EMPTY — losing the pasted text. Such a block is left as-is for the sync plugin.
*/
export function canonicalizePastedFootnotes(slice: Slice, schema: Schema): Slice {
if (slice.openStart !== 0 || slice.openEnd !== 0) return slice;
let hasFootnotesList = false;
let hasReference = false;
slice.content.forEach((node) => {
if (node.type.name === FOOTNOTES_LIST_NAME) hasFootnotesList = true;
// footnoteReference is an inline atom, never a top-level slice child here
// (this function early-returns for open slices, so children are whole
// blocks), so it is only reachable by descending.
node.descendants((child) => {
if (child.type.name === FOOTNOTE_REFERENCE_NAME) hasReference = true;
});
});
if (!hasFootnotesList) return slice;
// No reference anywhere -> a definitions-only paste; canonicalizing would strip
// the reference-less list (empty paste). Leave it untouched.
if (!hasReference) return slice;
const content = slice.content.toJSON();
if (!Array.isArray(content)) return slice;
const canonical = canonicalizeFootnotes({ type: "doc", content }) as {
content?: unknown[];
};
const fragment = Fragment.fromJSON(schema, canonical.content ?? []);
return new Slice(fragment, 0, 0);
}
function elementFromString(value) {
// add a wrapper to preserve leading and trailing whitespace
const wrappedValue = `<body>${value}</body>`;

View File

@@ -26,22 +26,17 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { useAsideTriggerProps } from "@/hooks/use-toggle-aside.tsx";
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
import { TemporaryNoteBanner } from "@/features/page/components/temporary-note-banner.tsx";
import clsx from "clsx";
import {
currentPageEditModeAtom,
pageEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers";
import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context";
const MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor);
const MemoizedFixedToolbar = React.memo(FixedToolbar);
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
const MemoizedTemporaryNoteBanner = React.memo(TemporaryNoteBanner);
type PageUser = {
id: string;
@@ -79,27 +74,16 @@ export function FullEditor({
const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
// AI title generation is gated by the general AI chat flag (the same toggle
// that enables the chat agent); the server enforces it too (#199).
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
// `user` can momentarily be null during logout teardown (the currentUser atom
// is reset before this subtree unmounts). Optional-chain every access so the
// teardown render does not throw "Cannot read properties of null (reading
// 'settings')".
const fullPageWidth = user?.settings?.preferences?.fullPageWidth;
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user?.settings?.preferences?.editorToolbar ?? false;
user.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
const userPageEditMode =
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Single shared Y.Doc + HocuspocusProvider for both the title and body
// editors (title lives in the 'title' fragment of the same doc).
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
@@ -119,55 +103,44 @@ export function FullEditor({
<MemoizedFixedToolbar />
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTemporaryNoteBanner slugId={slugId} />
<EditorProvidersContext.Provider
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
>
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
pageId={pageId}
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
isTitleGenEnabled={isTitleGenEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</EditorProvidersContext.Provider>
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</Container>
);
}
type PageBylineProps = {
pageId: string;
creator?: PageUser;
contributors?: IContributor[];
editable?: boolean;
isEditMode?: boolean;
isDictationEnabled?: boolean;
isTitleGenEnabled?: boolean;
};
function PageByline({
pageId,
creator,
contributors,
editable,
isEditMode,
isDictationEnabled,
isTitleGenEnabled,
}: PageBylineProps) {
const { t } = useTranslation();
const detailsTriggerProps = useAsideTriggerProps("details");
@@ -175,9 +148,6 @@ function PageByline({
const showDictation = Boolean(
isDictationEnabled && editable && isEditMode && editor,
);
const showTitleGen = Boolean(
isTitleGenEnabled && editable && isEditMode && editor,
);
const otherContributors = (contributors ?? []).filter(
(c) => c.id !== creator?.id,
@@ -268,11 +238,6 @@ function PageByline({
{showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} />
)}
{/* Shown only in edit mode when the workspace's AI chat flag is on,
so AI title generation stays reachable from the byline (#199). */}
{showTitleGen && (
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />
)}
</Group>
</Group>
);

View File

@@ -1,48 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// jwt-decode is mocked so we can drive the four token states deterministically
// (decode success with a chosen exp, or a thrown decode error).
const decodeMock = vi.hoisted(() => vi.fn());
vi.mock("jwt-decode", () => ({
jwtDecode: decodeMock,
}));
import { collabTokenNeedsRefresh } from "./collab-token";
const NOW_MS = 1_000_000_000; // fixed "now" in ms (so NOW_MS/1000 seconds)
beforeEach(() => {
decodeMock.mockReset();
});
describe("collabTokenNeedsRefresh", () => {
it("returns true when there is no token (fetch a fresh one)", () => {
expect(collabTokenNeedsRefresh(undefined, NOW_MS)).toBe(true);
// jwtDecode must not even be called for a missing token.
expect(decodeMock).not.toHaveBeenCalled();
});
it("returns true when the token is malformed (jwtDecode throws)", () => {
decodeMock.mockImplementation(() => {
throw new Error("invalid token");
});
expect(collabTokenNeedsRefresh("garbage", NOW_MS)).toBe(true);
});
it("returns false for a valid, not-yet-expired token (no reconnect)", () => {
// exp is in the future relative to NOW.
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 + 60 });
expect(collabTokenNeedsRefresh("good", NOW_MS)).toBe(false);
});
it("returns true for a valid but expired token (refresh + reconnect)", () => {
// exp is in the past relative to NOW.
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 - 60 });
expect(collabTokenNeedsRefresh("expired", NOW_MS)).toBe(true);
});
it("treats exp exactly equal to now as expired (>= boundary)", () => {
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 });
expect(collabTokenNeedsRefresh("boundary", NOW_MS)).toBe(true);
});
});

View File

@@ -1,26 +0,0 @@
import { jwtDecode } from "jwt-decode";
/**
* Decide whether a collab token must be refreshed before reconnecting after an
* onAuthenticationFailed event. Pure and side-effect free so the four token
* states can be unit-tested directly:
* - no token -> true (fetch a fresh one and reconnect)
* - undecodable/malformed -> true (jwtDecode throws -> refresh)
* - valid, not expired -> false (token is still good; do NOT reconnect)
* - valid, expired -> true (refresh + reconnect)
*
* `nowMs` is injectable for deterministic tests; it defaults to `Date.now()`.
*/
export function collabTokenNeedsRefresh(
token: string | undefined,
nowMs: number = Date.now(),
): boolean {
if (!token) return true;
try {
const payload = jwtDecode<{ exp: number }>(token);
return nowMs / 1000 >= payload.exp;
} catch {
// malformed/undecodable token -> refresh
return true;
}
}

View File

@@ -1,250 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider, createStore } from "jotai";
import type { Editor } from "@tiptap/core";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
// --- Mocks for the hook's collaborators ---------------------------------------
const generatePageTitleMock = vi.fn();
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
generatePageTitle: (content: string) => generatePageTitleMock(content),
}));
const updateTitleMock = vi.fn();
const updatePageDataMock = vi.fn();
vi.mock("@/features/page/queries/page-query.ts", () => ({
useUpdateTitlePageMutation: () => ({ mutateAsync: updateTitleMock }),
updatePageData: (page: unknown) => updatePageDataMock(page),
}));
const emitMock = vi.fn();
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
useQueryEmit: () => emitMock,
}));
const localEmitMock = vi.fn();
vi.mock("@/lib/local-emitter.ts", () => ({
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
}));
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
// purely via the fake page editor's getHTML().
vi.mock("@docmost/editor-ext", () => ({
htmlToMarkdown: (html: string) => html,
}));
const notificationsShowMock = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (opts: unknown) => notificationsShowMock(opts) },
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Import after mocks are registered.
import { useGeneratePageTitle } from "./use-generate-page-title.ts";
// --- Test helpers -------------------------------------------------------------
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
return {
isDestroyed: false,
getHTML: () => html,
storage: { pageId },
} as unknown as Editor;
}
function makeTitleEditor(): Editor & {
commands: { setContent: ReturnType<typeof vi.fn> };
} {
return {
isDestroyed: false,
isFocused: false,
commands: { setContent: vi.fn() },
} as unknown as Editor & {
commands: { setContent: ReturnType<typeof vi.fn> };
};
}
function setup(pageId: string, store = createStore()) {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<Provider store={store}>{children}</Provider>
</QueryClientProvider>
);
const { result } = renderHook(() => useGeneratePageTitle(pageId), {
wrapper,
});
return { result, store };
}
const PAGE_A = {
id: "pageA",
title: "Generated Title",
spaceId: "space1",
slugId: "slugA",
parentPageId: null,
icon: null,
} as any;
beforeEach(() => {
vi.clearAllMocks();
});
describe("useGeneratePageTitle", () => {
it("shows a notice and bails when the editor content is empty", async () => {
const store = createStore();
store.set(pageEditorAtom as never, makePageEditor("pageA", " "));
store.set(titleEditorAtom as never, makeTitleEditor());
const { result } = setup("pageA", store);
await act(async () => {
await result.current.mutateAsync();
});
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "The note is empty", color: "yellow" }),
);
expect(generatePageTitleMock).not.toHaveBeenCalled();
expect(updateTitleMock).not.toHaveBeenCalled();
});
it("leaves the title untouched when the model returns nothing usable", async () => {
const store = createStore();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, makeTitleEditor());
generatePageTitleMock.mockResolvedValue(" ");
const { result } = setup("pageA", store);
await act(async () => {
await result.current.mutateAsync();
});
expect(updateTitleMock).not.toHaveBeenCalled();
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({
message: "Could not generate a title",
color: "yellow",
}),
);
});
it("happy path: applies the title, refreshes cache, broadcasts, and does NOT write the editor", async () => {
const store = createStore();
const titleEditor = makeTitleEditor();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, titleEditor);
generatePageTitleMock.mockResolvedValue("Generated Title");
updateTitleMock.mockResolvedValue(PAGE_A);
const { result } = setup("pageA", store);
await act(async () => {
await result.current.mutateAsync();
});
expect(updateTitleMock).toHaveBeenCalledWith({
pageId: "pageA",
title: "Generated Title",
});
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
// The title editor is bound to the Yjs `title` fragment; the server REST
// update reseeds that fragment and the reseed reaches the bound editor on
// its own. Writing here too would double/garble the title, so the hook must
// NOT touch the editor (regression guard for the Yjs duplication trap).
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
expect(localEmitMock).toHaveBeenCalled();
expect(emitMock).toHaveBeenCalled();
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({ message: "Title generated" }),
);
});
it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
const store = createStore();
const titleEditor = makeTitleEditor(); // persistent across navigation
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, titleEditor);
// Control when generation resolves so we can navigate mid-flight.
let resolveTitle!: (t: string) => void;
generatePageTitleMock.mockReturnValue(
new Promise<string>((res) => {
resolveTitle = res;
}),
);
updateTitleMock.mockResolvedValue(PAGE_A);
const { result } = setup("pageA", store);
let pending!: Promise<void>;
act(() => {
pending = result.current.mutateAsync();
});
// User navigates to page B: the live page editor now belongs to pageB.
act(() => {
store.set(pageEditorAtom as never, makePageEditor("pageB"));
});
await act(async () => {
resolveTitle("Generated Title");
await pending;
});
// DB write is still correct (keyed by the captured pageId)...
expect(updateTitleMock).toHaveBeenCalledWith({
pageId: "pageA",
title: "Generated Title",
});
// ...the hook never writes the editor regardless of navigation...
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
// ...and the change is still broadcast to other clients.
expect(emitMock).toHaveBeenCalled();
});
it("bails before calling the model when the page editor is destroyed", async () => {
const store = createStore();
const pageEditor = makePageEditor("pageA");
(pageEditor as { isDestroyed: boolean }).isDestroyed = true;
store.set(pageEditorAtom as never, pageEditor);
store.set(titleEditorAtom as never, makeTitleEditor());
const { result } = setup("pageA", store);
await act(async () => {
await result.current.mutateAsync();
});
expect(generatePageTitleMock).not.toHaveBeenCalled();
expect(updateTitleMock).not.toHaveBeenCalled();
});
it.each([
[403, "AI title generation is disabled"],
[503, "AI is not configured"],
[429, "Too many requests, please try again later"],
[500, "Failed to generate title"],
])("maps HTTP %s onError to a friendly message", async (status, message) => {
const store = createStore();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, makeTitleEditor());
generatePageTitleMock.mockRejectedValue({ response: { status } });
const { result } = setup("pageA", store);
await act(async () => {
await expect(result.current.mutateAsync()).rejects.toBeTruthy();
});
expect(notificationsShowMock).toHaveBeenCalledWith(
expect.objectContaining({ message, color: "red" }),
);
});
});

View File

@@ -1,103 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query.ts";
import { generatePageTitle } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
// Maximum length we send to the model. The server truncates again; this is a
// cheap client-side bound so we never ship a huge body over the wire.
const MAX_CONTENT_CHARS = 20000;
/**
* Generate a title for the given page from the LIVE editor content (#199),
* including unsaved edits, then apply it IMMEDIATELY (per product decision). The
* server endpoint only summarizes the supplied markdown — it never writes the
* page; the actual title write goes through the existing /pages/update mutation
* (which enforces edit permission), and is mirrored to the title field + other
* clients exactly like TitleEditor.saveTitle. Returns a mutation-like API so the
* button can show a loading state via `isPending`.
*/
export function useGeneratePageTitle(pageId: string) {
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
const emit = useQueryEmit();
return useMutation<void, Error, void>({
mutationFn: async () => {
if (!pageEditor || pageEditor.isDestroyed) return;
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
if (!markdown) {
notifications.show({ message: t("The note is empty"), color: "yellow" });
return;
}
const title = (
await generatePageTitle(markdown.slice(0, MAX_CONTENT_CHARS))
).trim();
if (!title) {
// The model returned nothing usable — keep the existing title untouched.
notifications.show({
message: t("Could not generate a title"),
color: "yellow",
});
return;
}
const page = await updateTitle({ pageId, title }); // POST /pages/update
updatePageData(page); // refresh the react-query cache
// Do NOT write the title into the editor here. The title editor is bound to
// the Yjs `title` fragment and Yjs is the source of truth. The server REST
// /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
// a full clear+replace) and the reseed reaches the bound title editor on
// its own as a remote provider update. The old REST-era setContent here
// would race that reseed and double/garble the title (the "Yjs duplication
// trap"), so it is intentionally omitted. The DB write above is keyed by
// the captured `pageId`, so it stays correct even if the user navigated
// away during generation.
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: page.title,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
localEmitter.emit("message", event);
emit(event);
notifications.show({ message: t("Title generated") });
},
onError: (err) => {
// Map known HTTP statuses to friendly messages, falling back to generic.
const status = (err as { response?: { status?: number } })?.response
?.status;
const message =
status === 403
? t("AI title generation is disabled")
: status === 503
? t("AI is not configured")
: status === 429
? t("Too many requests, please try again later")
: t("Failed to generate title");
notifications.show({ message, color: "red" });
},
});
}

View File

@@ -1,189 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import { useAtom, useSetAtom } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import {
isLocalSyncedAtom,
isRemoteSyncedAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { collabTokenNeedsRefresh } from "@/features/editor/hooks/collab-token";
import { pageYdocName } from "@/features/editor/page-ydoc-name";
import { pageKeys } from "@/features/page/queries/page-query";
export interface PageCollabProviders {
ydoc: Y.Doc | null;
remote: HocuspocusProvider | null;
socket: HocuspocusProviderWebsocket | null;
providersReady: boolean;
}
/**
* Owns the full collaboration provider lifecycle for a page so that the title
* and body editors can share a single Y.Doc + HocuspocusProvider. The behavior
* is relocated verbatim from page-editor.tsx: it creates the providers once per
* pageId, connects/disconnects on idle/visibility, attaches each render,
* destroys on unmount, refreshes the collab token on auth failure, and applies
* the onStateless 'page.updated' cache update.
*/
export function usePageCollabProviders(pageId: string): PageCollabProviders {
const collaborationURL = useCollaborationUrl();
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
// The provider-creating effect runs only once per pageId, so any token read
// inside its handlers would be captured STALE (the old token at first render).
// Mirror the latest token into a ref the auth-failure handler can read live.
const collabTokenRef = useRef<string | undefined>(undefined);
useEffect(() => {
collabTokenRef.current = collabQuery?.token;
}, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
// Providers only created once per pageId
const providersRef = useRef<{
ydoc: Y.Doc;
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
useEffect(() => {
if (!providersRef.current) {
const documentName = pageYdocName(pageId);
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSyncedAtom(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSyncedAtom(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(
pageKeys.detail(slugId),
);
if (pageData) {
queryClient.setQueryData(pageKeys.detail(slugId), {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
// Read the token from the ref, not the closed-over `collabQuery`: this
// handler is created once and would otherwise decode a stale token after
// a refetch. A missing/malformed token must NOT crash the handler —
// jwtDecode(undefined) throws — so treat any decode failure as "needs
// refresh" and proceed to refetch + reconnect instead of getting stuck.
if (!collabTokenNeedsRefresh(collabTokenRef.current)) return;
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { ydoc, socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
// Reset shared sync state on page change/unmount.
setIsLocalSyncedAtom(false);
setIsRemoteSyncedAtom(false);
};
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
) {
socket.disconnect();
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
return {
ydoc: providersRef.current?.ydoc ?? null,
remote: providersRef.current?.remote ?? null,
socket: providersRef.current?.socket ?? null,
providersReady,
};
}

View File

@@ -6,7 +6,16 @@ import React, {
useRef,
useState,
} from "react";
import { WebSocketStatus } from "@hocuspocus/provider";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import {
Editor,
EditorContent,
@@ -19,15 +28,13 @@ import {
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
@@ -51,8 +58,10 @@ import {
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback } from "@mantine/hooks";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
@@ -63,7 +72,9 @@ import {
GitmostInsertRecordingResult,
gitmostInsertRecordingIntoEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
@@ -73,10 +84,6 @@ import { PageEmbedLookupProvider } from "@/features/editor/components/page-embed
import { PageEmbedAncestryProvider } from "@/features/editor/components/page-embed/page-embed-ancestry-context";
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
import { useTranslation } from "react-i18next";
import {
isBodyEditable,
isCollabSynced,
} from "@/features/editor/editor-sync-state";
interface PageEditorProps {
pageId: string;
@@ -92,6 +99,7 @@ export default function PageEditor({
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
@@ -105,10 +113,22 @@ export default function PageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
// Always holds the latest collab token. The provider effect below runs once
// per pageId, so a handler created inside it would otherwise close over a
// stale `collabQuery`. Reading the ref gives the current token instead.
const collabTokenRef = useRef<string | undefined>(undefined);
useEffect(() => {
collabTokenRef.current = collabQuery?.token;
}, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
@@ -117,27 +137,141 @@ export default function PageEditor({
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
// Shared providers + Y.Doc lifted into full-editor via context. The provider
// lifecycle (creation, idle/visibility connect, attach, destroy, token
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
// the context (defensive) — in practice full-editor always provides it.
const editorProviders = useEditorProviders();
const remote = editorProviders?.remote ?? null;
const providersReady = editorProviders?.providersReady ?? false;
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
// Read the latest token via the ref (the closure-captured `collabQuery`
// may be stale). Guard the decode: a missing or unparseable token must
// not throw "Invalid token specified" and should trigger a refresh so
// the editor reconnects even when the initial token fetch failed.
const token = collabTokenRef.current;
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
if (token) {
try {
// A token that decodes but lacks a numeric `exp` must be treated as
// expired (`Date.now()/1000 >= undefined` is `false`, which would
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
const exp = jwtDecode<{ exp?: number }>(token).exp;
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
} catch {
needsRefresh = true;
}
}
if (!needsRefresh) return;
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
) {
socket.disconnect();
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
const extensions = useMemo(() => {
if (!providersReady || !remote || !currentUser?.user) {
if (!providersReady || !providersRef.current || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [
...mainExtensions,
...collabExtensions(remote, currentUser?.user),
...collabExtensions(remoteProvider, currentUser?.user),
];
}, [providersReady, remote, currentUser?.user]);
}, [providersReady, currentUser?.user]);
const editor = useEditor(
{
@@ -306,9 +440,6 @@ export default function PageEditor({
const isSynced = isLocalSynced && isRemoteSynced;
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useEffect(() => {
const timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
@@ -320,21 +451,17 @@ export default function PageEditor({
}, [yjsConnectionStatus, isSynced]);
useEffect(() => {
if (!editor) return;
// Keep the body read-only until the collab doc has synced (showStatic), so
// early keystrokes on a freshly created page can't be lost (#218).
editor.setEditable(
isBodyEditable({
editable,
inEditMode: currentPageEditMode === PageEditMode.Edit,
showStatic,
}),
);
}, [currentPageEditMode, editor, editable, showStatic]);
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
}, [currentPageEditMode, editor, editable]);
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useEffect(() => {
if (
!hasConnectedOnceRef.current &&
isCollabSynced(yjsConnectionStatus, isSynced)
yjsConnectionStatus === WebSocketStatus.Connected &&
isSynced
) {
hasConnectedOnceRef.current = true;
setShowStatic(false);
@@ -346,43 +473,17 @@ export default function PageEditor({
<PageEmbedLookupProvider>
<PageEmbedAncestryProvider hostPageId={pageId}>
{showStatic ? (
<div style={{ position: "relative" }}>
{/* Surface the pre-sync read-only window so edits typed before the
collab provider connects aren't silently swallowed (#218). Shown
only when the user is otherwise allowed to edit. */}
{editable && currentPageEditMode === PageEditMode.Edit && (
<div
role="status"
aria-live="polite"
className="print-hide"
style={{
position: "absolute",
top: 0,
right: 0,
zIndex: 2,
padding: "2px 8px",
fontSize: "12px",
borderRadius: "4px",
background: "var(--mantine-color-gray-light)",
color: "var(--mantine-color-dimmed)",
pointerEvents: "none",
}}
>
{t("Connecting… (read-only)")}
</div>
)}
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": t("Page content"),
},
}}
/>
</div>
<EditorProvider
editable={false}
immediatelyRender={true}
extensions={mainExtensions}
content={content}
editorProps={{
attributes: {
"aria-label": t("Page content"),
},
}}
/>
) : (
<div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}>
@@ -412,7 +513,7 @@ export default function PageEditor({
{editor &&
!editorIsEditable &&
(editable || canComment) &&
remote && <ReadonlyBubbleMenu editor={editor} />}
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}

View File

@@ -1,14 +0,0 @@
/**
* Single source of truth for the IndexedDB / Hocuspocus document name of a
* page's collaborative Yjs doc.
*
* The `page.<id>` convention is shared knowledge across three call sites: the
* live editor providers (`use-page-collab-providers`), the offline warm path
* (`make-offline`), and the offline purge (`clear-offline-cache`, which matches
* the databases to delete by this prefix). Centralizing it here stops those
* sites from silently drifting apart.
*/
export const PAGE_YDOC_NAME_PREFIX = "page.";
export const pageYdocName = (pageId: string): string =>
`${PAGE_YDOC_NAME_PREFIX}${pageId}`;

View File

@@ -1,33 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// isChangeOrigin is mocked so we can simulate local vs remote/collab-origin
// transactions without constructing a real ProseMirror/Yjs transaction.
const isChangeOriginMock = vi.hoisted(() => vi.fn());
vi.mock("@tiptap/extension-collaboration", () => ({
isChangeOrigin: isChangeOriginMock,
}));
import { shouldPropagateTitleChange } from "./title-collab";
beforeEach(() => {
isChangeOriginMock.mockReset();
});
describe("shouldPropagateTitleChange", () => {
it("propagates a genuine local edit (isChangeOrigin false)", () => {
isChangeOriginMock.mockReturnValue(false);
expect(shouldPropagateTitleChange({ local: true })).toBe(true);
expect(isChangeOriginMock).toHaveBeenCalledWith({ local: true });
});
it("skips a remote/collab-origin update (isChangeOrigin true)", () => {
isChangeOriginMock.mockReturnValue(true);
expect(shouldPropagateTitleChange({ remote: true })).toBe(false);
});
it("propagates when there is no transaction (treated as local)", () => {
expect(shouldPropagateTitleChange(undefined)).toBe(true);
// isChangeOrigin must not be called for a missing transaction.
expect(isChangeOriginMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,19 +0,0 @@
import { isChangeOrigin } from "@tiptap/extension-collaboration";
/**
* Whether a TitleEditor `onUpdate` should drive URL + tree propagation.
*
* Only genuine LOCAL edits propagate. Remote/collab-origin Yjs updates
* (detected via `isChangeOrigin`) are skipped so a remote title change is not
* re-broadcast back, which would create a feedback loop. A missing transaction
* is treated as a local edit (propagate).
*
* Extracted as a pure helper so the skip decision is unit-testable without
* mounting the full collaborative editor.
*/
export function shouldPropagateTitleChange(transaction: unknown): boolean {
return !(
transaction &&
isChangeOrigin(transaction as Parameters<typeof isChangeOrigin>[0])
);
}

View File

@@ -1,87 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
// Drive the fallback-vs-collaborative switch (titleReady = providersReady &&
// !!ydoc) by controlling what the editor-providers context returns.
const editorProvidersValue: { ydoc: unknown; providersReady: boolean } = {
ydoc: null,
providersReady: false,
};
vi.mock("@/features/editor/contexts/editor-providers-context", () => ({
useEditorProviders: () => editorProvidersValue,
}));
// Mock the tiptap React bindings so the test does not mount a real editor:
// useEditor returns a minimal stub and EditorContent renders a marker.
vi.mock("@tiptap/react", () => ({
useEditor: () => ({
isInitialized: true,
commands: { focus: vi.fn() },
setEditable: vi.fn(),
getText: () => "",
}),
EditorContent: () => <div data-testid="collab-editor" />,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
const navigateMock = vi.fn();
vi.mock("react-router-dom", () => ({
useNavigate: () => navigateMock,
}));
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
useQueryEmit: () => vi.fn(),
}));
// page-query transitively imports @/main.tsx; mock it to a pure stub.
vi.mock("@/features/page/queries/page-query", () => ({
updatePageData: vi.fn(),
}));
vi.mock("@/main.tsx", () => ({
queryClient: { getQueryData: vi.fn(), setQueryData: vi.fn() },
}));
import { TitleEditor } from "./title-editor";
const baseProps = {
pageId: "p1",
slugId: "slug-1",
title: "My Page Title",
spaceSlug: "space",
editable: true,
};
beforeEach(() => {
navigateMock.mockReset();
editorProvidersValue.ydoc = null;
editorProvidersValue.providersReady = false;
});
describe("TitleEditor fallback vs collaborative switch", () => {
it("renders a static <h1> with the title before the shared doc is ready", () => {
editorProvidersValue.ydoc = null;
editorProvidersValue.providersReady = false;
render(<TitleEditor {...baseProps} />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.textContent).toBe("My Page Title");
// The collaborative editor must NOT mount until the doc is ready.
expect(screen.queryByTestId("collab-editor")).toBeNull();
});
it("renders the collaborative editor once the shared doc is ready", () => {
editorProvidersValue.ydoc = {}; // truthy shared doc
editorProvidersValue.providersReady = true;
render(<TitleEditor {...baseProps} />);
expect(screen.getByTestId("collab-editor")).toBeDefined();
// The static fallback <h1> is gone — Yjs is the single source of truth and
// the prop is never seeded into the collaborative editor.
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
});
});

View File

@@ -1,5 +1,5 @@
import "@/features/editor/styles/index.css";
import { useEffect } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
@@ -11,11 +11,14 @@ import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import { pageKeys, updatePageData } from "@/features/page/queries/page-query";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query";
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
import { useAtom } from "jotai";
import { Collaboration } from "@tiptap/extension-collaboration";
import { shouldPropagateTitleChange } from "@/features/editor/title-collab";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
@@ -25,9 +28,6 @@ import localEmitter from "@/lib/local-emitter.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
export interface TitleEditorProps {
pageId: string;
@@ -45,82 +45,65 @@ export function TitleEditor({
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } =
useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
// the body). Yjs is the source of truth for the title content.
const editorProviders = useEditorProviders();
const ydoc = editorProviders?.ydoc ?? null;
const providersReady = editorProviders?.providersReady ?? false;
// Until the shared doc is ready, the collaborative editor binds nothing and
// would render an empty heading until the Yjs 'title' fragment hydrates. Show
// a non-editable static <h1> with the `title` prop in the meantime. The prop
// is NEVER fed into the collaborative editor (Yjs stays the single source of
// truth — seeding it would duplicate the title).
const titleReady = providersReady && !!ydoc;
const titleEditor = useEditor(
{
extensions: [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
// Bind the title to the dedicated 'title' fragment of the shared doc.
// Collaboration also manages undo/redo, so the History extension is
// intentionally omitted (it would conflict with Yjs). When the doc is
// not ready yet the editor renders empty until the doc arrives.
...(ydoc
? [Collaboration.configure({ document: ydoc, field: "title" })]
: []),
EmojiCommand,
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
}
const titleEditor = useEditor({
extensions: [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
History.configure({
depth: 20,
}),
EmojiCommand,
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
setActivePageId(pageId);
}
},
onUpdate({ editor }) {
debounceUpdate();
},
editable: editable,
content: title,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": t("Page title"),
},
onUpdate({ editor, transaction }) {
// Drive URL + tree propagation only on genuine local edits; skip
// remote/collab-origin Yjs updates to avoid feedback loops.
if (!shouldPropagateTitleChange(transaction)) return;
debouncedPropagateTitle(editor.getText());
},
editable: editable,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": t("Page title"),
},
handleDOMEvents: {
keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
},
handleDOMEvents: {
keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
},
},
},
[pageId, ydoc],
);
});
useEffect(() => {
const anchorId = window.location.hash
@@ -130,45 +113,59 @@ export function TitleEditor({
navigate(pageSlug, { replace: true });
}, [title]);
// On a local title change: update the URL slug and propagate the change to
// the live tree/breadcrumbs for online users. No REST round-trip — the title
// itself is persisted through Yjs. Offline this simply no-ops the socket
// emit and the title syncs on reconnect.
const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => {
const anchorId = window.location.hash
? window.location.hash.substring(1)
: undefined;
navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), {
replace: true,
const saveTitle = useCallback(() => {
if (!titleEditor || activePageId !== pageId) return;
if (
titleEditor.getText() === title ||
(titleEditor.getText() === "" && title === null)
) {
return;
}
updateTitlePageMutationAsync({
pageId: pageId,
title: titleEditor.getText(),
}).then((page) => {
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: page.title,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
if (page.title !== titleEditor.getText()) return;
updatePageData(page);
localEmitter.emit("message", event);
emit(event);
});
}, [pageId, title, titleEditor]);
const page =
queryClient.getQueryData<IPage>(pageKeys.detail(slugId)) ??
queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
if (!page) return;
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
const updatedPage: IPage = { ...page, title: titleText };
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: titleText,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
updatePageData(updatedPage);
// Drive the local (same-tab) tree/breadcrumb update. The cross-user tree
// refresh is handled server-side: the collab process extracts the renamed
// 'title' Yjs fragment and broadcasts a treeUpdate. The previous socket
// `emit(event)` here was a no-op (the gateway ignores it) and was removed.
localEmitter.emit("message", event);
}, 500);
useEffect(() => {
// Do not overwrite the title while the user is actively editing it. The
// server rebroadcasts PAGE_UPDATED to the author too, and that echo can
// carry a title that lags behind what the user has just typed; resetting
// content from it here would drop in-progress characters and jump the
// cursor. Apply external title changes only when the field is not focused.
if (
titleEditor &&
!titleEditor.isDestroyed &&
!titleEditor.isFocused &&
title !== titleEditor.getText()
) {
titleEditor.commands.setContent(title);
}
}, [pageId, title, titleEditor]);
useEffect(() => {
setTimeout(() => {
@@ -178,6 +175,13 @@ export function TitleEditor({
}, 300);
}, [titleEditor]);
useEffect(() => {
return () => {
// force-save title on navigation
saveTitle();
};
}, [pageId]);
useEffect(() => {
if (!titleEditor) return;
titleEditor.setEditable(editable && currentPageEditMode === PageEditMode.Edit);
@@ -244,22 +248,16 @@ export function TitleEditor({
return (
<div className="page-title">
{titleReady ? (
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
) : (
// Static, non-editable fallback so the title is visible before Yjs
// hydrates the 'title' fragment. Not wired into the collaborative editor.
<h1>{title}</h1>
)}
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
</div>
);
}

View File

@@ -1,10 +1,7 @@
import { Button, Menu, Stack, Text } from "@mantine/core";
import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { ReactNode } from "react";
import { Button, Menu, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { onlineManager } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -13,78 +10,49 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { canCreatePage } from "./can-create-page.ts";
// A single create-note action, parametrized by `temporary`. Self-contained: it
// owns its own create mutation so the regular and temporary buttons show
// independent loading state, while the list of writable spaces is resolved once
// by the parent and passed in. With exactly one writable space it creates
// directly; with several it shows a target-space picker.
function CreateNoteButton({
writableSpaces,
temporary,
label,
icon,
color,
}: {
writableSpaces: ISpace[];
temporary: boolean;
label: string;
icon: ReactNode;
// Mantine color token; lets the temporary action tint toward the warm
// orange/amber used by the clock marker + banner while "New note" stays neutral.
color: string;
}) {
// Prominent home-screen action to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several.
export default function NewNoteButton() {
const { t } = useTranslation();
const navigate = useNavigate();
const createPageMutation = useCreatePageMutation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
const createNote = async (space: ISpace) => {
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
// not part of the shared `IPageInput` type; cast to satisfy the mutation
// signature.
const variables = {
spaceId: space.id,
...(temporary ? { temporary: true } : {}),
} as any;
if (!onlineManager.isOnline()) {
// Offline: the create is PAUSED and queued — its promise will not resolve
// until we are back online, so awaiting it here would spin the button
// forever. Fire it without awaiting (it persists and replays on reconnect)
// and tell the user it was saved offline instead of leaving a dead spinner.
createPageMutation.mutate(variables);
notifications.show({
color: "blue",
message: t("You're offline. This note will be created once you reconnect."),
});
return;
}
try {
const createdPage = await createPageMutation.mutateAsync(variables);
// `spaceId` is accepted by the create-page endpoint but is not part of
// the shared `IPageInput` type; cast to satisfy the mutation signature.
const createdPage = await createPageMutation.mutateAsync({
spaceId: space.id,
} as any);
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch {
// useCreatePageMutation already surfaces a red notification on error.
}
};
// A paused (offline) mutation stays `isPending`, so gate the spinner on it NOT
// being paused — otherwise the button would spin forever after an offline
// create. The offline path above gives its own "saved offline" feedback.
const isPending = createPageMutation.isPending && !createPageMutation.isPaused;
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
const isPending = createPageMutation.isPending;
// Exactly one writable space → create directly, no picker needed.
if (writableSpaces.length === 1) {
return (
<Button
fullWidth
size="md"
variant="light"
color={color}
fullWidth
leftSection={icon}
color="gray"
leftSection={<IconPlus size={18} />}
loading={isPending}
onClick={() => createNote(writableSpaces[0])}
>
{label}
{t("New note")}
</Button>
);
}
@@ -94,14 +62,14 @@ function CreateNoteButton({
<Menu shadow="md" width="target" position="bottom-start">
<Menu.Target>
<Button
fullWidth
size="md"
variant="light"
color={color}
fullWidth
leftSection={icon}
color="gray"
leftSection={<IconPlus size={18} />}
loading={isPending}
>
{label}
{t("New note")}
</Button>
</Menu.Target>
<Menu.Dropdown>
@@ -131,39 +99,3 @@ function CreateNoteButton({
</Menu>
);
}
// Prominent home-screen actions to create a new note (page). Because the home
// screen has no active space, the target space is resolved from the user's
// writable spaces: created directly when there is one, picked from a dropdown
// when there are several. Renders two full-width, vertically stacked buttons: a
// neutral regular note and an orange-tinted temporary note (which auto-moves to
// Trash after the workspace lifetime). Stacking full-width keeps the longer
// "New temporary note" label from clipping on narrow mobile widths.
export default function NewNoteButton() {
const { t } = useTranslation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
// No writable space → nothing to create in; render nothing.
if (writableSpaces.length === 0) return null;
return (
<Stack gap="sm">
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={false}
label={t("New note")}
icon={<IconPlus size={18} />}
color="gray"
/>
<CreateNoteButton
writableSpaces={writableSpaces}
temporary={true}
label={t("New temporary note")}
icon={<IconHourglass size={18} />}
color="orange"
/>
</Stack>
);
}

View File

@@ -1,107 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// vi.mock factories are hoisted above imports, so the spies they reference must
// be declared via vi.hoisted (also hoisted). These are inspected by assertions.
const h = vi.hoisted(() => ({
clear: vi.fn(),
del: vi.fn(),
}));
// The module under test imports the app entry at load time — it must be mocked.
vi.mock("@/main.tsx", () => ({
queryClient: { clear: h.clear },
}));
vi.mock("idb-keyval", () => ({
del: h.del,
}));
import { clearOfflineCache } from "./clear-offline-cache";
import { OFFLINE_CACHE_KEY } from "./query-persister";
// jsdom does not provide indexedDB.databases() or Cache Storage, so the browser
// globals are stubbed per-test. We restore them afterwards.
const originalIndexedDB = (globalThis as any).indexedDB;
const originalCaches = (globalThis as any).caches;
beforeEach(() => {
h.clear.mockClear();
h.del.mockClear();
});
afterEach(() => {
(globalThis as any).indexedDB = originalIndexedDB;
(globalThis as any).caches = originalCaches;
vi.restoreAllMocks();
});
describe("clearOfflineCache", () => {
it("resolves without throwing when the browser globals are absent", async () => {
(globalThis as any).indexedDB = undefined;
delete (globalThis as any).caches;
await expect(clearOfflineCache()).resolves.toBeUndefined();
// The two store-agnostic steps still run.
expect(h.clear).toHaveBeenCalledTimes(1);
expect(h.del).toHaveBeenCalledWith(OFFLINE_CACHE_KEY);
});
it("deletes only `page.*` IndexedDB databases and only `api-get-cache` caches", async () => {
const deleteDatabase = vi.fn((_name: string) => {
const request: any = {};
// Resolve the deletion on the next microtask, like a real IDBRequest.
queueMicrotask(() => request.onsuccess && request.onsuccess());
return request;
});
(globalThis as any).indexedDB = {
databases: vi
.fn()
.mockResolvedValue([
{ name: "page.aaa" },
{ name: "page.bbb" },
{ name: "keyval-store" },
{ name: undefined },
]),
deleteDatabase,
};
const cacheDelete = vi.fn().mockResolvedValue(true);
(globalThis as any).caches = {
keys: vi
.fn()
.mockResolvedValue([
"workbox-runtime-https://app/api-get-cache",
"other-cache",
]),
delete: cacheDelete,
};
await expect(clearOfflineCache()).resolves.toBeUndefined();
// Only the two page.* databases are deleted.
expect(deleteDatabase).toHaveBeenCalledTimes(2);
expect(deleteDatabase).toHaveBeenCalledWith("page.aaa");
expect(deleteDatabase).toHaveBeenCalledWith("page.bbb");
// Only the api-get-cache entry is deleted.
expect(cacheDelete).toHaveBeenCalledTimes(1);
expect(cacheDelete).toHaveBeenCalledWith(
"workbox-runtime-https://app/api-get-cache",
);
});
it("never throws even if a step rejects (best-effort)", async () => {
h.del.mockRejectedValueOnce(new Error("idb boom"));
(globalThis as any).indexedDB = {
databases: vi.fn().mockRejectedValue(new Error("databases boom")),
deleteDatabase: vi.fn(),
};
(globalThis as any).caches = {
keys: vi.fn().mockRejectedValue(new Error("caches boom")),
delete: vi.fn(),
};
await expect(clearOfflineCache()).resolves.toBeUndefined();
expect(h.clear).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,112 +0,0 @@
import { del } from "idb-keyval";
import { queryClient } from "@/main.tsx";
import {
OFFLINE_CACHE_KEY,
freezeOfflinePersistence,
unfreezeOfflinePersistence,
} from "./query-persister";
import { PAGE_YDOC_NAME_PREFIX } from "@/features/editor/page-ydoc-name";
/**
* Best-effort purge of all of the current user's offline data from the browser.
*
* On logout the previous user's private data would otherwise linger locally and
* be readable by the next person on the device. This clears the three offline
* stores the app writes:
* 1. the in-memory + IndexedDB-persisted TanStack Query cache (idb-keyval key
* `OFFLINE_CACHE_KEY`),
* 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by
* y-indexeddb in make-offline.ts), and
* 3. any legacy service worker `api-get-cache` Cache Storage entry. The
* Workbox runtime no longer creates this cache (the GET /api NetworkFirst
* rule was removed — offline reads come from the persisted RQ cache), so
* this is now a defensive cleanup for caches left by older app versions.
*
* Fully best-effort: every step is isolated so a single failure neither blocks
* the remaining steps nor throws to the caller (logout must never be blocked on
* cache cleanup). Callers may ignore the resolved value.
*
* Limitations:
* - Deleting the Yjs page databases relies on `indexedDB.databases()`, which
* is unavailable in some browsers (notably Firefox). There we skip silently;
* those `page.<id>` databases are then left in place.
* - Cache Storage clearing only runs where `caches` exists (secure contexts /
* service-worker-capable browsers).
*/
export async function clearOfflineCache(): Promise<void> {
// Freeze the throttled persister BEFORE touching the cache so the
// queryClient.clear() below cannot trigger a late re-write of the (still
// nearly-full) dehydrated snapshot after we del() the key — which would
// otherwise resurrect the previous user's persisted data in IndexedDB.
// Re-enabled in `finally` so the next (sign-in) session persists normally.
freezeOfflinePersistence();
try {
// 1a. Drop the in-memory query cache immediately.
try {
queryClient.clear();
} catch {
// best-effort: ignore in-memory cache reset failures
}
// 1b. Delete the persisted RQ cache from IndexedDB.
try {
await del(OFFLINE_CACHE_KEY);
} catch {
// best-effort: ignore persisted-cache deletion failures
}
// 2. Delete the Yjs page IndexedDB databases (`page.<id>`).
// `indexedDB.databases()` is not implemented everywhere (e.g. Firefox); when
// it is missing we cannot enumerate the page databases, so we skip silently.
try {
if (
typeof indexedDB !== "undefined" &&
typeof indexedDB.databases === "function"
) {
const dbs = await indexedDB.databases();
for (const db of dbs) {
const name = db?.name;
if (typeof name !== "string" || !name.startsWith(PAGE_YDOC_NAME_PREFIX))
continue;
try {
// Fire-and-forget delete; await a thin wrapper so a slow delete does
// not race the page teardown, but never reject on it.
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase(name);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
});
} catch {
// best-effort per database
}
}
}
} catch {
// best-effort: ignore enumeration/deletion failures
}
// 3. Clear any legacy service worker API cache. Current builds no longer
// create it, but an older client may have left an "api-get-cache" entry
// (Workbox may prefix the name), so match by substring rather than exact name.
try {
if ("caches" in window) {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.includes("api-get-cache"))
.map((key) => caches.delete(key)),
);
}
} catch {
// best-effort: ignore Cache Storage failures
}
} finally {
// Re-enable persistence for the next session (sign-in continues running in
// the same tab; logout reloads via window.location.replace, so this is a
// harmless no-op there).
unfreezeOfflinePersistence();
}
}

View File

@@ -1,475 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// vi.mock factories are hoisted above imports, so any spy they reference must be
// declared with vi.hoisted (which is hoisted as well). These shared spies are
// inspected by the assertions below.
const h = vi.hoisted(() => ({
ydocDestroy: vi.fn(),
idbDestroy: vi.fn(),
providerOn: vi.fn(),
providerOff: vi.fn(),
providerDestroy: vi.fn(),
}));
// The module under test imports the app entry at load time — it must be mocked.
vi.mock("@/main.tsx", () => ({
queryClient: { setQueryData: vi.fn(), prefetchQuery: vi.fn() },
}));
vi.mock("@/features/page/services/page-service", () => ({
getPageById: vi.fn(),
getPageBreadcrumbs: vi.fn(),
getSidebarPages: vi.fn(),
getAllSidebarPages: vi.fn(),
}));
vi.mock("@/features/space/services/space-service.ts", () => ({
getSpaceById: vi.fn(),
}));
vi.mock("@/features/comment/services/comment-service", () => ({
getPageComments: vi.fn(),
}));
// Use the `function` form (not an arrow) so Vitest binds the constructor return
// value when the module under test calls `new Y.Doc()` etc.
vi.mock("yjs", () => ({
Doc: vi.fn(function () {
return { destroy: h.ydocDestroy };
}),
}));
vi.mock("y-indexeddb", () => ({
IndexeddbPersistence: vi.fn(function () {
return { destroy: h.idbDestroy };
}),
}));
vi.mock("@hocuspocus/provider", () => ({
HocuspocusProvider: vi.fn(function () {
return { on: h.providerOn, off: h.providerOff, destroy: h.providerDestroy };
}),
}));
import {
warmInfiniteAll,
warmPageYdoc,
makePageAvailableOffline,
} from "./make-offline";
import { queryClient } from "@/main.tsx";
import {
getPageById,
getPageBreadcrumbs,
getSidebarPages,
} from "@/features/page/services/page-service";
import { getPageComments } from "@/features/comment/services/comment-service";
const setQueryData = (queryClient as any).setQueryData as ReturnType<
typeof vi.fn
>;
const prefetchQuery = (queryClient as any).prefetchQuery as ReturnType<
typeof vi.fn
>;
beforeEach(() => {
// Clear call history WITHOUT wiping the mock implementations the vi.mock
// factories installed (vi.clearAllMocks would drop the constructor return
// objects and break the provider/idb/yjs spies).
setQueryData.mockClear();
prefetchQuery.mockReset();
prefetchQuery.mockResolvedValue(undefined);
(getPageById as ReturnType<typeof vi.fn>).mockReset();
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockReset();
(getSidebarPages as ReturnType<typeof vi.fn>).mockReset();
(getPageComments as ReturnType<typeof vi.fn>).mockReset();
h.ydocDestroy.mockClear();
h.idbDestroy.mockClear();
h.providerOn.mockClear();
h.providerOff.mockClear();
h.providerDestroy.mockClear();
});
describe("warmInfiniteAll", () => {
it("warms a single page and writes the InfiniteData cache shape", async () => {
const res = { items: [{ id: 1 }], meta: { nextCursor: null } };
const fetchPage = vi.fn().mockResolvedValue(res);
await warmInfiniteAll(["comments", "p1"], fetchPage);
expect(fetchPage).toHaveBeenCalledTimes(1);
expect(fetchPage).toHaveBeenCalledWith(undefined);
expect(setQueryData).toHaveBeenCalledTimes(1);
expect(setQueryData).toHaveBeenCalledWith(["comments", "p1"], {
pages: [res],
pageParams: [undefined],
});
});
it("walks the cursor chain across multiple pages", async () => {
const r0 = { items: [], meta: { nextCursor: "c1" } };
const r1 = { items: [], meta: { nextCursor: "c2" } };
const r2 = { items: [], meta: { nextCursor: null } };
const fetchPage = vi
.fn()
.mockResolvedValueOnce(r0)
.mockResolvedValueOnce(r1)
.mockResolvedValueOnce(r2);
await warmInfiniteAll(["comments", "p1"], fetchPage);
expect(fetchPage).toHaveBeenCalledTimes(3);
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
undefined,
"c1",
"c2",
]);
const payload = setQueryData.mock.calls[0][1];
expect(payload.pages).toEqual([r0, r1, r2]);
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
});
it("caps pagination at maxPages and reports the truncation (returns false)", async () => {
// Always returns a non-null cursor — the cap is the only thing that stops it.
const fetchPage = vi
.fn()
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Hitting maxPages with a cursor still pending is a truncated warm: the
// (partial) cache is still written, but the result is reported as false.
await expect(
warmInfiniteAll(["comments", "p1"], fetchPage, 2),
).resolves.toBe(false);
expect(fetchPage).toHaveBeenCalledTimes(2);
const payload = setQueryData.mock.calls[0][1];
expect(payload.pages).toHaveLength(2);
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it("returns true on success", async () => {
const fetchPage = vi
.fn()
.mockResolvedValue({ items: [], meta: { nextCursor: null } });
await expect(
warmInfiniteAll(["comments", "p1"], fetchPage),
).resolves.toBe(true);
});
it("reports errors (returns false) and never writes the cache on failure", async () => {
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await expect(
warmInfiniteAll(["comments", "p1"], fetchPage),
).resolves.toBe(false);
expect(setQueryData).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
});
describe("makePageAvailableOffline", () => {
const okPage = {
id: "uuid-1",
slugId: "slug-1",
space: { slug: "space-slug" },
};
it("returns ok:true with no failures when every step succeeds", async () => {
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result).toEqual({ ok: true, failed: [] });
});
it("returns ok:false with the failed step label when a warm step fails", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
// Comments warm fails -> labeled "comments".
(getPageComments as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("network"),
);
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result.ok).toBe(false);
expect(result.failed).toContain("comments");
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
// Helper: the page-ids passed to the sidebar-children warm (its query key is
// ["sidebar-pages", { pageId, spaceId }]) — i.e. which nodes were prefetched.
const warmedSidebarIds = () =>
prefetchQuery.mock.calls
.map((c) => c[0])
.filter((opts: any) => opts?.queryKey?.[0] === "sidebar-pages")
.map((opts: any) => opts.queryKey[1]?.pageId);
it("warms the page + every ancestor's children once and skips the self-ancestor guard", async () => {
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
// Breadcrumbs include two real ancestors, the page's OWN id (must be skipped
// by the ancestorId === pageId guard so it is not warmed twice), and a
// malformed entry with no id (also skipped).
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "anc-1" },
{ id: "uuid-1" }, // === pageId -> guard
{ id: "anc-2" },
{}, // no id -> skipped
]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
const ids = warmedSidebarIds();
// The page's own children (warmSidebarChildren(pageId)) plus each real
// ancestor — exactly once each. The self-ancestor (uuid-1 in breadcrumbs) is
// NOT a second warm: uuid-1 appears once (from the page's own children call).
expect(ids).toEqual(["uuid-1", "anc-1", "anc-2"]);
expect(ids.filter((id: string) => id === "uuid-1")).toHaveLength(1);
expect(result).toEqual({ ok: true, failed: [] });
});
it("dedupes repeated tree failures into a single 'tree' label", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: "anc-1" },
{ id: "anc-2" },
]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
// Fail ONLY the sidebar-children prefetches (page-own + both ancestors = 3
// failures); the currentUser/space prefetches still resolve.
prefetchQuery.mockImplementation(async (opts: any) => {
if (opts?.queryKey?.[0] === "sidebar-pages") throw new Error("network");
return undefined;
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
// Three node warms failed but the contract collapses them to one "tree".
expect(result.ok).toBe(false);
expect(result.failed).toEqual(["tree"]);
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it("records 'breadcrumbs' (not 'tree') when the breadcrumbs lookup rejects", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
// Ancestor discovery fails -> the ancestor-walk is recorded as "breadcrumbs".
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("network"),
);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
// The page's own children still warmed fine (prefetch resolves), so the only
// failure is the breadcrumbs lookup.
expect(result.ok).toBe(false);
expect(result.failed).toEqual(["breadcrumbs"]);
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it("records 'page' when the central document fetch (getPageById) rejects", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// The central page document fetch fails (the most realistic failure).
(getPageById as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("network"),
);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
// With no page document, the space step is skipped (no slug), so the only
// failure label is "page".
expect(result.ok).toBe(false);
expect(result.failed).toContain("page");
expect(result.failed).not.toContain("space");
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it("records 'space' when ONLY the space prefetch rejects", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
// Fail ONLY the space prefetch (queryKey ["space", slug]); the currentUser
// and sidebar-children prefetches still resolve.
prefetchQuery.mockImplementation(async (opts: any) => {
if (opts?.queryKey?.[0] === "space") throw new Error("network");
return undefined;
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result.ok).toBe(false);
expect(result.failed).toContain("space");
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
it("records 'currentUser' when ONLY the currentUser prefetch rejects", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
meta: { nextCursor: null },
});
// Fail ONLY the currentUser prefetch (queryKey ["currentUser"]); the space
// and sidebar-children prefetches still resolve.
prefetchQuery.mockImplementation(async (opts: any) => {
if (opts?.queryKey?.[0] === "currentUser") throw new Error("network");
return undefined;
});
const result = await makePageAvailableOffline({
pageId: "uuid-1",
spaceId: "space-uuid",
});
expect(result.ok).toBe(false);
expect(result.failed).toContain("currentUser");
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
});
describe("warmPageYdoc", () => {
afterEach(() => {
vi.useRealTimers();
});
it("resolves on synced, detaches the listener once, and tears everything down (settle-once)", async () => {
const promise = warmPageYdoc("p1", "ws://x");
// Grab the synced handler the provider registered.
expect(h.providerOn).toHaveBeenCalledWith("synced", expect.any(Function));
const handler = h.providerOn.mock.calls.find(
(c) => c[0] === "synced",
)![1] as () => void;
handler();
// Returns true because the real "synced" event fired.
await expect(promise).resolves.toBe(true);
// Listener detached and everything cleaned up.
expect(h.providerOff).toHaveBeenCalledWith("synced", expect.any(Function));
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
// Firing the handler again must NOT re-run cleanup (settled guard).
handler();
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
});
it("resolves false and cleans up after the timeout when synced never fires", async () => {
vi.useFakeTimers();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const promise = warmPageYdoc("p1", "ws://x");
// Do not fire "synced"; let the 8s safety timeout settle it.
await vi.advanceTimersByTimeAsync(8000);
// Returns false (the doc never synced) and logs the timeout with the pageId.
await expect(promise).resolves.toBe(false);
expect(errorSpy).toHaveBeenCalledWith(
"warmPageYdoc: timed out before sync",
{ pageId: "p1" },
);
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
});
});

View File

@@ -1,337 +0,0 @@
import * as Y from "yjs";
import { IndexeddbPersistence } from "y-indexeddb";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { queryClient } from "@/main.tsx";
import {
getPageById,
getPageBreadcrumbs,
getSidebarPages,
} from "@/features/page/services/page-service";
import {
pageKeys,
sidebarPagesQueryOptions,
} from "@/features/page/queries/page-query";
import { spaceByIdQueryOptions } from "@/features/space/queries/space-query";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
import { getPageComments } from "@/features/comment/services/comment-service";
import { getMyInfo } from "@/features/user/services/user-service";
import { userKeys } from "@/features/user/hooks/use-current-user";
import { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts";
import { pageYdocName } from "@/features/editor/page-ydoc-name";
/**
* Fully paginate an infinite query and write the @tanstack InfiniteData cache
* shape ({ pages, pageParams }) that the matching useInfiniteQuery hook reads.
*
* The default prefetchInfiniteQuery only warms the FIRST page, which leaves
* hooks that treat hasNextPage as still-loading (e.g. the comments panel)
* spinning forever offline, and silently truncates large lists. This walks the
* cursor chain until it runs out (or hits maxPages) so the whole list is cached.
*
* Best-effort: a failure does not throw (a partial/failed warm is still useful),
* but it is reported — the error is logged with context and `false` is returned
* so the caller can record the failed step instead of silently succeeding.
*
* Returns true ONLY if the cursor chain was fully exhausted and written. If the
* walk stops because it hit `maxPages` while a `nextCursor` is still pending,
* the cached list is truncated AND its last page keeps a nextCursor that cannot
* be re-fetched offline (hooks that gate on hasNextPage would spin forever), so
* that case is logged and returns false too — the caller records it as a failed
* warm instead of a silent truncated success. The (partial) cache is still
* written so what we did fetch is usable.
*
* Exported for unit testing of the cursor-walk / cache-write behavior.
*/
export async function warmInfiniteAll<T>(
queryKey: readonly unknown[],
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
maxPages = 50,
): Promise<boolean> {
try {
const pages: IPagination<T>[] = [];
const pageParams: (string | undefined)[] = [];
let cursor: string | undefined = undefined;
let exhausted = false;
for (let i = 0; i < maxPages; i++) {
const res = await fetchPage(cursor);
pages.push(res);
pageParams.push(cursor);
cursor = res?.meta?.nextCursor ?? undefined;
if (!cursor) {
exhausted = true;
break;
}
}
queryClient.setQueryData(queryKey, { pages, pageParams });
if (!exhausted) {
// Stopped at maxPages with a cursor still pending: the list is truncated
// and the last cached page's nextCursor is un-fetchable offline. Report it
// as a failed warm rather than a silent truncated success.
console.error("warmInfiniteAll truncated at maxPages", {
queryKey,
maxPages,
});
return false;
}
return true;
} catch (error) {
console.error("warmInfiniteAll failed", { queryKey, error });
return false;
}
}
export interface MakePageAvailableOfflineParams {
pageId: string;
spaceId?: string;
}
/**
* Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm
* step succeeded; `failed` lists the labels of the steps that failed (a subset
* of: "currentUser", "page", "space", "tree", "breadcrumbs", "comments").
*/
export interface MakePageAvailableOfflineResult {
ok: boolean;
failed: string[];
}
/**
* Best-effort prefetch of a page's read queries so they get persisted to
* IndexedDB and become readable offline.
*
* Each step is isolated and this function does NOT throw — a partial warm is
* still useful. Instead of silently succeeding, every failed step is logged
* with a label and recorded in the returned result: `{ ok, failed }` where
* `ok` is true only if no step failed and `failed` lists the failed step
* labels. Only meaningful while online (the underlying requests must succeed).
*/
export async function makePageAvailableOffline({
pageId,
spaceId,
}: MakePageAvailableOfflineParams): Promise<MakePageAvailableOfflineResult> {
const failed: string[] = [];
// Warm the current user (['currentUser']) so the auth-gated <Layout> can
// hydrate offline. UserProvider blanks the whole app while useCurrentUser has
// no data, and the offline POST /api/users/me fails as a network error, so
// without a persisted user a pinned page still white-screens after relaunch
// (#238). Persisted via OFFLINE_PERSIST_ROOTS; warmed here so the persisted
// cache actually has an entry to restore.
try {
await queryClient.prefetchQuery({
queryKey: userKeys.currentUser(),
queryFn: () => getMyInfo(),
});
} catch (error) {
console.error("makePageAvailableOffline: currentUser step failed", {
pageId,
error,
});
failed.push("currentUser");
}
// Fetch the page document ONCE and write it under BOTH cache keys, exactly
// like usePageQuery's onData effect. Every page consumer reads
// pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads),
// so warming only the uuid key would leave the offline page blank.
let page: IPage | undefined;
try {
page = await getPageById({ pageId });
queryClient.setQueryData(pageKeys.detail(page.slugId), page);
queryClient.setQueryData(pageKeys.detail(page.id), page);
} catch (error) {
console.error("makePageAvailableOffline: page step failed", {
pageId,
error,
});
failed.push("page");
}
// Warm the space — page.tsx renders nothing until the space query resolves
// (useGetSpaceBySlugQuery). Awaited (not the fire-and-forget prefetchSpace) so
// the space is actually persisted before the caller fires its toast. Shares
// spaceByIdQueryOptions so the key/fn cannot drift from the hook.
try {
const spaceSlug = page?.space?.slug;
if (spaceSlug) {
await queryClient.prefetchQuery(spaceByIdQueryOptions(spaceSlug));
}
} catch (error) {
console.error("makePageAvailableOffline: space step failed", {
pageId,
error,
});
failed.push("space");
}
// Warm the sidebar tree root so the WHOLE root level renders offline (matches
// useGetRootSidebarPagesQuery's pageKeys.rootSidebar(spaceId) infinite cache).
// Fully paginated so large root levels are not truncated at 100.
if (spaceId) {
const ok = await warmInfiniteAll(pageKeys.rootSidebar(spaceId), (cursor) =>
getSidebarPages({ spaceId, cursor, limit: 100 }),
);
if (!ok) failed.push("tree");
}
// Warm the children of the page and of every ancestor so the path to this
// page is expandable offline. We MIRROR fetchAllAncestorChildren exactly via
// sidebarPagesQueryOptions — same pageKeys.sidebar({ pageId, spaceId }) key,
// same getAllSidebarPages fn (which aggregates ALL children pages, so nothing
// is truncated at 100), same 30min staleTime — otherwise the warmed cache
// would never be read by the offline tree.
const warmSidebarChildren = async (id: string): Promise<boolean> => {
try {
// Keep EXACTLY { pageId, spaceId } so the key hashes identically to
// fetchAllAncestorChildren's (no parentPageId, no extra fields).
const params = { pageId: id, spaceId };
await queryClient.prefetchQuery(sidebarPagesQueryOptions(params));
return true;
} catch (error) {
console.error("makePageAvailableOffline: tree node step failed", {
pageId: id,
error,
});
return false;
}
};
// The page's own children.
if (!(await warmSidebarChildren(pageId))) failed.push("tree");
// Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the
// ancestor ids — we intentionally do NOT cache the breadcrumbs themselves
// (the UI derives the path from the tree).
try {
const ancestors = (await getPageBreadcrumbs(pageId)) as
| Array<{ id?: string }>
| undefined;
for (const ancestor of ancestors ?? []) {
const ancestorId = ancestor?.id;
if (!ancestorId || ancestorId === pageId) continue;
if (!(await warmSidebarChildren(ancestorId))) failed.push("tree");
}
} catch (error) {
console.error("makePageAvailableOffline: breadcrumbs step failed", {
pageId,
error,
});
failed.push("breadcrumbs");
}
// Comments (matches useCommentsQuery's RQ_KEY(pageId) infinite cache).
// useCommentsQuery reports isLoading while hasNextPage is true, so warming
// only the first page leaves the offline comments panel spinning forever on
// pages with >100 comments. Fully paginate so the last cached page has no
// nextCursor and the panel settles offline.
const commentsOk = await warmInfiniteAll(RQ_KEY(pageId), (cursor) =>
getPageComments({ pageId, cursor, limit: 100 }),
);
if (!commentsOk) failed.push("comments");
// Dedupe — the tree label can be recorded once per failed node/ancestor.
const uniqueFailed = [...new Set(failed)];
return { ok: uniqueFailed.length === 0, failed: uniqueFailed };
}
/**
* Best-effort warm-up of the page's Yjs document into IndexedDB so the editor
* can open offline.
*
* Opens a local IndexeddbPersistence plus a transient HocuspocusProvider to
* pull the server state into IndexedDB, then tears both down once synced (or
* after a timeout). Entirely wrapped in try/catch — NEVER throws.
*
* Returns true ONLY when the provider's real "synced" event fired — i.e. the
* server state actually landed in IndexedDB. The timeout and failure paths
* return false (and log with the pageId) so the caller does not report a page
* as offline-available when its editor body never warmed. For a wiki the editor
* body IS the page, so a silent timeout here is a real misreport.
*
* Only meaningful when online at warm time; offline it is a no-op that resolves.
*/
export async function warmPageYdoc(
pageId: string,
collabUrl: string,
token?: string,
): Promise<boolean> {
let ydoc: Y.Doc | null = null;
let local: IndexeddbPersistence | null = null;
let remote: HocuspocusProvider | null = null;
// Flipped to true ONLY inside the real "synced" handler; the timeout/failure
// paths leave it false. Returned so the caller can record a failed editor warm.
let didSync = false;
try {
const documentName = pageYdocName(pageId);
ydoc = new Y.Doc();
local = new IndexeddbPersistence(documentName, ydoc);
remote = new HocuspocusProvider({
url: collabUrl,
name: documentName,
document: ydoc,
token,
});
const provider = remote;
await new Promise<void>((resolve) => {
let settled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
// `synced` is true only when called from the real "synced" handler; the
// timeout path passes false so didSync stays false on a give-up.
const finish = (synced: boolean) => {
if (settled) return;
settled = true;
didSync = synced;
// Clear the pending timeout and detach the listener so neither leaks
// after we resolve.
if (timeoutId !== undefined) clearTimeout(timeoutId);
try {
provider.off("synced", onSynced);
} catch {
// best-effort
}
if (!synced) {
// Gave up before the server synced: the page body never landed in
// IndexedDB. Log with the pageId (parity with the other warm steps)
// so the caller can report the editor step as failed.
console.error("warmPageYdoc: timed out before sync", { pageId });
}
resolve();
};
const onSynced = () => finish(true);
// Resolve once the server state has synced into the local doc...
provider.on("synced", onSynced);
// ...or give up after a short timeout so we never hang.
timeoutId = setTimeout(() => finish(false), 8000);
});
} catch (error) {
console.error("warmPageYdoc: warm failed", { pageId, error });
} finally {
try {
remote?.destroy();
} catch {
// best-effort
}
try {
local?.destroy();
} catch {
// best-effort
}
try {
ydoc?.destroy();
} catch {
// best-effort
}
}
return didSync;
}

View File

@@ -1,45 +0,0 @@
import { Button, Container, Group, Stack, Text, Title } from "@mantine/core";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { getAppName } from "@/lib/config";
/**
* Shown when the authenticated app shell cannot hydrate because the current
* user is unavailable AND there is no cached user to fall back on (e.g. an
* offline cold boot of a page that was never warmed for offline).
*
* Previously UserProvider returned a bare `<></>` in this situation, which
* white-screened the whole app on any offline reload (#237/#238). Rendering an
* explicit "you're offline" state with a retry instead gives the user a clear,
* non-blank fallback and a way to recover once the network returns.
*/
export function OfflineFallback() {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>
{t("You're offline")} - {getAppName()}
</title>
</Helmet>
<Container size="sm" py={80}>
<Stack align="center" gap="md">
<Title order={2} ta="center">
{t("You're offline")}
</Title>
<Text c="dimmed" size="lg" ta="center">
{t(
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
)}
</Text>
<Group justify="center">
<Button onClick={() => window.location.reload()} variant="subtle">
{t("Retry")}
</Button>
</Group>
</Stack>
</Container>
</>
);
}

View File

@@ -1,114 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, hydrate, dehydrate } from "@tanstack/react-query";
// Stub the network services so a replayed mutation hits a spy, not the network.
const h = vi.hoisted(() => ({
createPage: vi.fn(),
movePage: vi.fn(),
createComment: vi.fn(),
}));
vi.mock("@/features/page/services/page-service", () => ({
createPage: h.createPage,
movePage: h.movePage,
}));
vi.mock("@/features/comment/services/comment-service", () => ({
createComment: h.createComment,
}));
// page-query pulls in the app entry (queryClient) and a lot of UI deps via its
// cache helpers; we only need invalidateOnCreatePage to be a no-op here.
vi.mock("@/features/page/queries/page-query", () => ({
invalidateOnCreatePage: vi.fn(),
}));
import {
offlineMutationKeys,
registerOfflineMutationDefaults,
} from "./offline-mutations";
beforeEach(() => {
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
h.movePage.mockReset().mockResolvedValue(undefined);
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
});
describe("registerOfflineMutationDefaults", () => {
it("registers a default mutationFn for every offline mutation key", () => {
const qc = new QueryClient();
registerOfflineMutationDefaults(qc);
for (const key of Object.values(offlineMutationKeys)) {
const defaults = qc.getMutationDefaults(key);
expect(typeof defaults?.mutationFn).toBe("function");
}
});
// The headline durability guarantee: a paused mutation dehydrated into
// IndexedDB while offline must, after a reload, have a mutationFn so
// resumePausedMutations() actually replays the write on reconnect.
it("makes a rehydrated paused create replayable by resumePausedMutations", async () => {
// 1) Simulate the offline tab: a paused create mutation gets dehydrated.
const offlineClient = new QueryClient();
const observer = offlineClient.getMutationCache().build(offlineClient, {
mutationKey: offlineMutationKeys.createPage,
});
// Force the dehydrate-worthy paused state (offline = isPaused) with the
// payload the user submitted before losing connectivity.
observer.state.isPaused = true;
observer.state.status = "pending";
observer.state.variables = { spaceId: "s1", title: "Offline page" };
const dehydrated = dehydrate(offlineClient, {
shouldDehydrateMutation: () => true,
});
expect(dehydrated.mutations).toHaveLength(1);
// The dehydrated mutation carries NO mutationFn (functions aren't
// serializable) — only its key + variables survive the reload.
expect((dehydrated.mutations[0] as any).mutationFn).toBeUndefined();
// 2) Simulate the fresh page after reload: register defaults, then hydrate
// the persisted paused mutation back in.
const freshClient = new QueryClient();
registerOfflineMutationDefaults(freshClient);
hydrate(freshClient, dehydrated);
expect(freshClient.getMutationCache().getAll()).toHaveLength(1);
// 3) Reconnect: replay the paused mutations.
await freshClient.resumePausedMutations();
// The default mutationFn ran with the persisted variables — the write is
// NOT silently dropped.
expect(h.createPage).toHaveBeenCalledTimes(1);
expect(h.createPage).toHaveBeenCalledWith({
spaceId: "s1",
title: "Offline page",
});
});
it("makes a rehydrated paused move replayable by resumePausedMutations", async () => {
const offlineClient = new QueryClient();
const observer = offlineClient.getMutationCache().build(offlineClient, {
mutationKey: offlineMutationKeys.movePage,
});
observer.state.isPaused = true;
observer.state.status = "pending";
observer.state.variables = { pageId: "p1", parentPageId: null, position: "a" };
const dehydrated = dehydrate(offlineClient, {
shouldDehydrateMutation: () => true,
});
const freshClient = new QueryClient();
registerOfflineMutationDefaults(freshClient);
hydrate(freshClient, dehydrated);
await freshClient.resumePausedMutations();
expect(h.movePage).toHaveBeenCalledTimes(1);
expect(h.movePage).toHaveBeenCalledWith({
pageId: "p1",
parentPageId: null,
position: "a",
});
});
});

View File

@@ -1,64 +0,0 @@
import type { QueryClient } from "@tanstack/react-query";
import { createPage, movePage } from "@/features/page/services/page-service";
import { createComment } from "@/features/comment/services/comment-service";
import { invalidateOnCreatePage } from "@/features/page/queries/page-query";
import type {
IMovePage,
IPage,
IPageInput,
} from "@/features/page/types/page.types";
import type { IComment } from "@/features/comment/types/comment.types";
/**
* Stable mutation keys for the offline-relevant structural mutations.
*
* When the browser goes offline, React Query PAUSES these mutations and the
* PersistQueryClientProvider dehydrates the paused mutation into IndexedDB. On a
* reload-while-offline the mutation is restored, but a restored mutation has NO
* observer (no component is mounted) — so its replay relies entirely on the
* `mutationFn` registered via `setMutationDefaults` for its `mutationKey`.
* Without that, `resumePausedMutations()` finds a paused mutation with no
* `mutationFn` and silently no-ops, dropping the offline create/move/comment
* (#237/#238). Each offline mutation hook tags itself with the matching key so
* the rehydrated paused mutation can find its default `mutationFn` and replay.
*/
export const offlineMutationKeys = {
createPage: ["create-page"] as const,
movePage: ["move-page"] as const,
createComment: ["create-comment"] as const,
};
/**
* Register default `mutationFn`s (and the minimal success side effects safe to
* run without a mounted component) for the offline-relevant mutation keys, so a
* paused mutation restored from IndexedDB after an offline reload is replayable
* by `resumePausedMutations()` on reconnect.
*
* Called once when the QueryClient is created (see main.tsx). The hooks still
* carry their own inline `mutationFn`/`onSuccess` for the live in-session path;
* these defaults only take over for a rehydrated paused mutation that lost its
* observer across the reload.
*/
export function registerOfflineMutationDefaults(queryClient: QueryClient): void {
queryClient.setMutationDefaults(offlineMutationKeys.createPage, {
mutationFn: (data: Partial<IPageInput>) => createPage(data),
// Re-converge the sidebar tree / recent-changes from the authoritative
// create response. Pure cache writes — safe with no component mounted.
onSuccess: (data: IPage) => {
invalidateOnCreatePage(data);
},
});
queryClient.setMutationDefaults(offlineMutationKeys.movePage, {
// Replay the server-side move. The tree re-converges from the next online
// sidebar fetch / websocket `moveTreeNode` echo, so no cache write is
// needed here (the optimistic tree state was local-only anyway).
mutationFn: (data: IMovePage) => movePage(data),
});
queryClient.setMutationDefaults(offlineMutationKeys.createComment, {
// Replay the server-side comment create. The comments list refetches on the
// online reload, so the replay only needs to persist the write.
mutationFn: (data: Partial<IComment>) => createComment(data),
});
}

View File

@@ -1,128 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { QueryClient, onlineManager } from "@tanstack/react-query";
import {
persistQueryClientRestore,
persistQueryClientSave,
} from "@tanstack/react-query-persist-client";
// Stub the network services so a replayed mutation hits a spy, not the network.
const h = vi.hoisted(() => ({
createPage: vi.fn(),
movePage: vi.fn(),
createComment: vi.fn(),
}));
vi.mock("@/features/page/services/page-service", () => ({
createPage: h.createPage,
movePage: h.movePage,
}));
vi.mock("@/features/comment/services/comment-service", () => ({
createComment: h.createComment,
}));
vi.mock("@/features/page/queries/page-query", () => ({
invalidateOnCreatePage: vi.fn(),
}));
// In-memory idb-keyval so the REAL queryPersister round-trips through a fake
// store (the actual persist -> reload -> restore path, not a hand-built blob).
const store = new Map<string, string>();
vi.mock("idb-keyval", () => ({
get: vi.fn((k: string) => Promise.resolve(store.get(k) ?? undefined)),
set: vi.fn((k: string, v: string) => {
store.set(k, v);
return Promise.resolve();
}),
del: vi.fn((k: string) => {
store.delete(k);
return Promise.resolve();
}),
}));
import { queryPersister } from "./query-persister";
import {
offlineMutationKeys,
registerOfflineMutationDefaults,
} from "./offline-mutations";
const BUSTER = "test-buster";
beforeEach(() => {
store.clear();
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
h.movePage.mockReset().mockResolvedValue(undefined);
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
});
afterEach(() => {
// onlineManager is a global singleton; leave it in the default online state.
onlineManager.setOnline(true);
});
describe("offline paused-mutation resume across a reload", () => {
// This is the #120 silent-data-loss reproduction: a paused mutation persisted
// to IndexedDB while offline, then the tab RELOADS while still offline, must
// resume on reconnect. It exercises the real persister round-trip plus the two
// boot-time fixes the app wiring relies on:
// (a) onlineManager seeded to the real offline state so the later reconnect
// is a true offline->online transition that auto-resumes, and
// (b) resumePausedMutations() called after the persister restores (what the
// PersistQueryClientProvider onSuccess does), with mutation defaults
// registered BEFORE the resume so the rehydrated mutation has a fn.
it("replays a rehydrated paused create on reconnect (mutationFn fires)", async () => {
// --- Tab 1, OFFLINE: user creates a page; it pauses and gets persisted. ---
onlineManager.setOnline(false); // (a) boot seeded offline
const client1 = new QueryClient();
registerOfflineMutationDefaults(client1);
const observer = client1.getMutationCache().build(client1, {
mutationKey: offlineMutationKeys.createPage,
});
observer.state.isPaused = true;
observer.state.status = "pending";
observer.state.variables = { spaceId: "s1", title: "Offline page" };
await persistQueryClientSave({
// Cast: persist-client-core and react-query may resolve to different
// @tanstack/query-core copies whose QueryClient brands are nominally
// incompatible (see query-persister.ts). Structurally identical at runtime.
queryClient: client1 as any,
persister: queryPersister,
buster: BUSTER,
dehydrateOptions: { shouldDehydrateMutation: () => true },
});
// The paused mutation is now in the persisted store.
expect(store.size).toBe(1);
// --- RELOAD while still offline: fresh client restores from the SAME
// persister. Defaults are registered BEFORE restore/resume. ---
const client2 = new QueryClient();
registerOfflineMutationDefaults(client2);
client2.mount(); // subscribes to onlineManager (auto-resume on reconnect)
await persistQueryClientRestore({
queryClient: client2 as any,
persister: queryPersister,
buster: BUSTER,
});
expect(client2.getMutationCache().getAll()).toHaveLength(1);
// (b) onSuccess wiring resumes after restore — but we are still OFFLINE, so
// the mutation must stay paused and NOT fire yet.
await client2.resumePausedMutations();
expect(h.createPage).not.toHaveBeenCalled();
// --- RECONNECT: the offline->online transition auto-resumes the paused
// mutation and its registered default mutationFn finally fires. ---
onlineManager.setOnline(true);
await vi.waitFor(() => {
expect(h.createPage).toHaveBeenCalledTimes(1);
});
expect(h.createPage).toHaveBeenCalledWith({
spaceId: "s1",
title: "Offline page",
});
client2.unmount();
});
});

View File

@@ -1,48 +0,0 @@
import { describe, it, expect } from "vitest";
// The query modules transitively import the app entry (@/main.tsx) for the
// shared queryClient; mock it so importing the key factories has no side effects.
import { vi } from "vitest";
vi.mock("@/main.tsx", () => ({
queryClient: { setQueryData: vi.fn(), getQueryData: vi.fn() },
}));
import { OFFLINE_PERSIST_ROOTS } from "./query-persister";
import { pageKeys } from "@/features/page/queries/page-query";
import { spaceKeys } from "@/features/space/queries/space-query";
import { RQ_KEY } from "@/features/comment/queries/comment-query";
import { userKeys } from "@/features/user/hooks/use-current-user";
/**
* Architecture guard (#13): every string persisted via OFFLINE_PERSIST_ROOTS
* must be the ROOT (queryKey[0]) of some exported query-key factory. If a
* factory's root is renamed without updating the persist registry — or vice
* versa — offline persist/warm silently breaks (persisted keys never match the
* live queries). This turns that silent regression into a red build.
*
* Each factory is invoked with throwaway args; only queryKey[0] is inspected.
*/
function rootOf(key: readonly unknown[]): string {
return String(key[0]);
}
const FACTORY_ROOTS = new Set<string>([
rootOf(pageKeys.detail("x")),
rootOf(pageKeys.sidebar({})),
rootOf(pageKeys.rootSidebar("x")),
rootOf(pageKeys.breadcrumbs("x")),
rootOf(pageKeys.recentChanges("x")),
rootOf(spaceKeys.detail("x")),
rootOf(spaceKeys.list()),
rootOf(RQ_KEY("x")),
rootOf(userKeys.currentUser()),
]);
describe("OFFLINE_PERSIST_ROOTS is backed by real query-key factories", () => {
it("maps every persisted root to an exported factory root", () => {
const unbacked = [...OFFLINE_PERSIST_ROOTS].filter(
(root) => !FACTORY_ROOTS.has(root),
);
expect(unbacked).toEqual([]);
});
});

View File

@@ -1,128 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
// In-memory idb-keyval so we can observe whether the persister actually writes.
const h = vi.hoisted(() => ({
get: vi.fn(() => Promise.resolve(undefined)),
set: vi.fn(() => Promise.resolve()),
del: vi.fn(() => Promise.resolve()),
}));
vi.mock("idb-keyval", () => h);
import {
shouldDehydrateOfflineQuery,
OFFLINE_PERSIST_ROOTS,
queryPersister,
freezeOfflinePersistence,
unfreezeOfflinePersistence,
} from "./query-persister";
// Small helper to build the structural query shape the predicate reads.
const makeQuery = (status: string, queryKey: readonly unknown[]) =>
({ state: { status }, queryKey }) as any;
describe("shouldDehydrateOfflineQuery", () => {
it("returns true for a successful query whose root is in the allowlist", () => {
expect(shouldDehydrateOfflineQuery(makeQuery("success", ["pages", "abc"]))).toBe(
true,
);
expect(
shouldDehydrateOfflineQuery(
makeQuery("success", ["sidebar-pages", { pageId: "p", spaceId: "s" }]),
),
).toBe(true);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["comments", "p1"])),
).toBe(true);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["space", "s"])),
).toBe(true);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])),
).toBe(true);
// currentUser is persisted so the auth-gated Layout can hydrate offline.
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["currentUser"])),
).toBe(true);
});
it("returns false when the status is not success (status gate)", () => {
expect(
shouldDehydrateOfflineQuery(makeQuery("pending", ["pages", "abc"])),
).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("error", ["pages", "abc"])),
).toBe(false);
});
it("returns false for a successful query whose root is NOT in the allowlist (privacy gate)", () => {
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["collab-token", "ws"])),
).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["trash", "s"])),
).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", ["unknown"])),
).toBe(false);
});
it("returns false for an empty/undefined queryKey", () => {
// String(undefined) is not a member of the allowlist.
expect(shouldDehydrateOfflineQuery(makeQuery("success", []))).toBe(false);
expect(
shouldDehydrateOfflineQuery(makeQuery("success", undefined as any)),
).toBe(false);
});
});
describe("OFFLINE_PERSIST_ROOTS", () => {
it("contains exactly the expected 9 navigation/read roots", () => {
const expected = [
"pages",
"sidebar-pages",
"root-sidebar-pages",
"breadcrumbs",
"comments",
"space",
"spaces",
"recent-changes",
"currentUser",
];
expect(OFFLINE_PERSIST_ROOTS.size).toBe(9);
for (const root of expected) {
expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true);
}
});
it("does NOT contain volatile/auth keys", () => {
expect(OFFLINE_PERSIST_ROOTS.has("collab-token")).toBe(false);
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
});
});
describe("freeze/unfreeze persistence (logout no-late-write guard)", () => {
const dummyClient = {
timestamp: Date.now(),
buster: "",
clientState: { mutations: [], queries: [] },
} as any;
afterEach(() => {
// Always leave persistence enabled so other tests/sessions persist normally.
unfreezeOfflinePersistence();
h.set.mockClear();
});
it("does NOT write to storage while frozen", async () => {
freezeOfflinePersistence();
await queryPersister.persistClient(dummyClient);
expect(h.set).not.toHaveBeenCalled();
});
it("resumes writing to storage once unfrozen", async () => {
freezeOfflinePersistence();
unfreezeOfflinePersistence();
await queryPersister.persistClient(dummyClient);
expect(h.set).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,84 +0,0 @@
import { get, set, del } from "idb-keyval";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
// Structural subset of a TanStack Query we read when deciding what to persist.
// We avoid importing the branded `Query` class because the persist-client and
// react-query may resolve to different `@tanstack/query-core` copies, whose
// `Query` types are nominally incompatible (private brand). This structural
// shape stays assignable to whichever copy the persister expects.
type DehydratableQuery = {
state: { status: string };
queryKey: readonly unknown[];
};
// idb-keyval key under which TanStack Query persists its dehydrated cache.
// Exported so the logout cache-clear logic deletes the exact same key (no
// magic-string drift between persist and purge).
export const OFFLINE_CACHE_KEY = "gitmost-rq-cache";
// IndexedDB-backed storage adapter for TanStack Query's async persister.
const idbStorage = {
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
setItem: (key: string, value: string) => set(key, value),
removeItem: (key: string) => del(key),
};
const basePersister = createAsyncStoragePersister({
storage: idbStorage,
key: OFFLINE_CACHE_KEY,
throttleTime: 1000,
});
// When frozen, persistClient becomes a no-op so no new dehydrated snapshot is
// written to IndexedDB. This closes a logout data-leak race: clearing the cache
// (queryClient.clear()) fires `removed` cache events, each of which the persist
// subscription turns into a throttled persistClient call. The FIRST such call
// dehydrates a still-nearly-full snapshot and its async write can land AFTER the
// del() that clears the key, resurrecting the previous user's data (~180KB) in
// IndexedDB. Freezing before clear()/del() prevents any such rewrite. Re-enabled
// afterwards so the next (sign-in) session persists normally. See
// clear-offline-cache.ts.
let persistFrozen = false;
export function freezeOfflinePersistence(): void {
persistFrozen = true;
}
export function unfreezeOfflinePersistence(): void {
persistFrozen = false;
}
export const queryPersister = {
persistClient: (persistedClient: Parameters<typeof basePersister.persistClient>[0]) =>
persistFrozen ? Promise.resolve() : basePersister.persistClient(persistedClient),
restoreClient: () => basePersister.restoreClient(),
removeClient: () => basePersister.removeClient(),
};
// Only navigation/read query roots are persisted for offline reading.
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
//
// `currentUser` IS persisted: UserProvider gates the entire <Layout> subtree on
// useCurrentUser(), and offline the POST /api/users/me fails as a no-response
// network error. Without the persisted/hydrated user the gate blanked every
// authenticated route on an offline cold boot (#237/#238). It is the logged-in
// user's own profile (already mirrored to localStorage["currentUser"]), so
// persisting it to IndexedDB leaks nothing new while unlocking offline reads.
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
"pages",
"sidebar-pages",
"root-sidebar-pages",
"breadcrumbs",
"comments",
"space",
"spaces",
"recent-changes",
"currentUser",
]);
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
return (
query.state.status === "success" &&
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
);
}

View File

@@ -1,69 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getDefaultStore } from "jotai";
// Mock the app entry so importing the query module doesn't boot the whole app
// (it only needs queryClient's cache methods, which we stub here). The spies are
// declared via vi.hoisted so they exist before the hoisted vi.mock factory runs.
const { setQueryData, getQueryData, invalidateQueries } = vi.hoisted(() => ({
setQueryData: vi.fn(),
getQueryData: vi.fn(() => undefined as unknown),
invalidateQueries: vi.fn(),
}));
vi.mock("@/main.tsx", () => ({
queryClient: { setQueryData, getQueryData, invalidateQueries },
}));
import { syncTemporaryExpiresInCache } from "./page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
const mkNode = (id: string, slugId: string): SpaceTreeNode =>
({
id,
slugId,
name: id,
position: "a0",
spaceId: "space-1",
parentPageId: null,
hasChildren: false,
children: [],
}) as unknown as SpaceTreeNode;
describe("syncTemporaryExpiresInCache — treeDataAtom patch", () => {
beforeEach(() => {
vi.clearAllMocks();
getQueryData.mockReturnValue(undefined);
});
it("patches the in-tree node's temporaryExpiresAt (sidebar marker updates without reload)", () => {
const store = getDefaultStore();
const tree = [mkNode("p1", "slug-1"), mkNode("p2", "slug-2")];
store.set(treeDataAtom, tree);
const deadline = "2026-07-01T00:00:00.000Z";
syncTemporaryExpiresInCache({ id: "p1", slugId: "slug-1" }, deadline);
const next = store.get(treeDataAtom);
// A new atom value was written...
expect(next).not.toBe(tree);
// ...the matching node gained the deadline...
expect(next.find((n) => n.id === "p1")?.temporaryExpiresAt).toBe(deadline);
// ...and the untouched sibling is unchanged.
expect(next.find((n) => n.id === "p2")?.temporaryExpiresAt).toBeUndefined();
});
it("leaves the atom value at the SAME reference when the id is absent from the tree (no write)", () => {
const store = getDefaultStore();
const tree = [mkNode("p1", "slug-1")];
store.set(treeDataAtom, tree);
syncTemporaryExpiresInCache(
{ id: "not-in-tree", slugId: "missing" },
"2026-07-01T00:00:00.000Z",
);
// treeModel.update is a no-op (same reference) for an unknown id, so the
// guard skips the store write entirely — same reference back.
expect(store.get(treeDataAtom)).toBe(tree);
});
});

View File

@@ -1,57 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { getDefaultStore } from "jotai";
import {
toggleTemplate,
toggleTemporary,
} from "@/features/page-embed/services/page-embed-api";
import type {
ToggleTemplateResponse,
ToggleTemporaryResponse,
} from "@/features/page-embed/types/page-embed.types";
import { queryClient } from "@/main.tsx";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
/**
* After toggling a note's temporary state, mirror the new deadline into the
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
* menu label, the in-page banner, and the tree icon all reflect the change.
* Centralised here so the header menu and the banner can't drift apart on the
* cache-key plumbing.
*/
export function syncTemporaryExpiresInCache(
page: { id: string; slugId: string },
temporaryExpiresAt: string | null,
) {
for (const key of [page.slugId, page.id]) {
const cached = queryClient.getQueryData<any>(["pages", key]);
if (cached) {
queryClient.setQueryData(["pages", key], {
...cached,
temporaryExpiresAt,
});
}
}
// Patch the in-memory sidebar tree node so its temporary clock marker
// appears/disappears immediately — WITHOUT a reload. The page cache update
// above only drives the in-page banner/menu; the sidebar reads
// `temporaryExpiresAt` straight off the `treeDataAtom` node. The app uses
// jotai's default store (no <Provider>), so `getDefaultStore()` is the same
// store the sidebar's hooks read from. `treeModel.update` returns the same
// reference (a no-op) when the page isn't in the currently loaded tree.
const store = getDefaultStore();
const prevTree = store.get(treeDataAtom);
const nextTree = treeModel.update(prevTree, page.id, {
temporaryExpiresAt,
} as Partial<SpaceTreeNode>);
if (nextTree !== prevTree) store.set(treeDataAtom, nextTree);
queryClient.invalidateQueries({
predicate: (item) =>
["sidebar-pages"].includes(item.queryKey[0] as string),
});
}
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
export function useToggleTemplateMutation() {
return useMutation<
@@ -68,20 +18,3 @@ export function useToggleTemplateMutation() {
},
});
}
export function useToggleTemporaryMutation() {
return useMutation<
ToggleTemporaryResponse,
Error,
{ pageId: string; temporary?: boolean }
>({
mutationFn: (data) => toggleTemporary(data),
onError: (err: any) => {
notifications.show({
message:
err?.response?.data?.message || "Failed to update temporary note",
color: "red",
});
},
});
}

View File

@@ -2,7 +2,6 @@ import api from "@/lib/api-client";
import type {
PageTemplateLookup,
ToggleTemplateResponse,
ToggleTemporaryResponse,
} from "../types/page-embed.types";
export async function lookupTemplate(params: {
@@ -19,11 +18,3 @@ export async function toggleTemplate(params: {
const r = await api.post("/pages/toggle-template", params);
return r.data;
}
export async function toggleTemporary(params: {
pageId: string;
temporary?: boolean;
}): Promise<ToggleTemporaryResponse> {
const r = await api.post("/pages/toggle-temporary", params);
return r.data;
}

View File

@@ -14,9 +14,3 @@ export type ToggleTemplateResponse = {
pageId: string;
isTemplate: boolean;
};
export type ToggleTemporaryResponse = {
pageId: string;
// null => the note was made permanent; ISO string => armed deadline.
temporaryExpiresAt: string | null;
};

Some files were not shown because too many files have changed in this diff Show More