Compare commits

...

65 Commits

Author SHA1 Message Date
claude code agent 227
411c05a9d6 fix(client): add /l vanity route to SW denylist; name failed offline steps (F2, F3)
F2: navigateFallbackDenylist was missing the server's `l/:alias` vanity
short-link, so a top-nav to /l/<alias> after SW registration got the
index.html app shell (which has no /l route) and dead-ended on Error404
instead of the server's 302 redirect. Add /^\/l(\/|$)/ mirroring main.ts.

F3: the partial-failure branch of "make page available offline" showed a
bare generic toast; include result.failed step labels in the message per
AGENTS.md (errors must be specific), matching the catch-branch below it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:40:42 +03:00
claude code agent 227
e8805b39c8 fix(collab): persist renamed title fragment to page.ydoc (F1, variant C)
A REST/MCP/agent rename (no live editor) wrote the new title to the
page.title column, then writePageTitle loaded the ydoc (fragment still
OLD) and set it to NEW only in memory. On disconnect onStoreDocument saw
titleText(NEW) === column(NEW), took the no-op fast-path, and never
persisted the in-memory fragment — so page.ydoc kept the OLD title and a
later body edit silently reverted the column back to OLD.

writePageTitle now persists the 'title' fragment to page.ydoc DIRECTLY
(PersistenceExtension.persistTitleFragmentYdoc) after the transact,
bypassing the no-op onStoreDocument. The write carries no treeUpdate, so
the tree WS/redis listeners do not re-broadcast (no double broadcast),
and it is idempotent/lock-free so it is safe whether or not a live editor
is connected. Adds a persist-then-reload-then-edit-body regression test
that fails on the old no-op behaviour and passes after the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:40:34 +03:00
claude code agent 227
67a3663fc5 fix(offline): resume rehydrated paused mutations, stop logout cache leak, offline affordances (PR #120 QA)
Address six QA findings on the offline-sync feature:

1. HIGH — silent data loss: a paused mutation persisted to IndexedDB and
   reloaded while still offline never resumed on reconnect. Seed TanStack
   onlineManager from navigator.onLine at boot (it defaults to online:true and
   only flips on events, so a cold-boot-offline tab wrongly believed it was
   online and never got a true online transition), and call
   resumePausedMutations() in PersistQueryClientProvider onSuccess after the
   persister rehydrates (defaults are registered before, so the restored
   mutation has a mutationFn). New offline-resume.test.ts reproduces the full
   persist -> reload -> reconnect path.

2. MEDIUM (security) — logout did not durably clear gitmost-rq-cache: the
   throttled persister re-wrote the key ~1s after del() with the still-in-memory
   snapshot, resurrecting the previous user's data. Freeze the persister
   (persistClient becomes a no-op) before clearing/deleting so neither the
   clear()-triggered nor any in-flight write can repopulate the key; re-enable
   afterwards for the next sign-in session.

3. MEDIUM (UX) — offline create spun forever: the create-note button awaited a
   mutateAsync that stays pending while paused. Detect offline, fire-and-forget
   the (queued) mutation, show a "saved offline" notice, and gate the spinner on
   !isPaused so it no longer hangs.

4. LOW — an uncached page opened offline showed the generic "Error fetching page
   data." instead of the offline fallback (offline fetch yields no HTTP status).
   Render OfflineFallback when navigator is offline or the error has no status.

5. LOW — logout teardown threw "Cannot read properties of null (reading
   'settings')" in full-editor.tsx: optional-chain the (transiently null) user.

6. Tab title "Untitled": investigated — the tab-title derivation in page.tsx is
   byte-identical to develop and already reads page.title from REST/cache (the
   recommended source); live edits keep it in sync via updatePageData. Not a
   tab-title-derivation regression introduced by this PR; no change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:51:01 +03:00
claude code agent 227
2cf30c7690 fix(offline): address PR #120 review (comment 2513)
CHANGELOG: stop presenting the service-worker API cache as an active offline
store (/api is NetworkOnly) — describe it as a defensive purge of the legacy
api-get-cache from older clients; add an explicit upgrade note that the new CORS
allowlist rejects previously-allowed cross-domain REST clients until their origin
is added to CORS_ALLOWED_ORIGINS.

test(offline): cover make-offline ancestor-walk + dedup — a real-ancestor case
exercising the ancestorId===pageId guard (page warmed once), the dedup of
repeated tree failures into a single "tree" label, and the "breadcrumbs" label
when the breadcrumbs lookup rejects.

test(auth): cover clearOfflineCache in handleLogout — purged exactly once before
window.location.replace, and a thrown purge error does not block the redirect.

conventions: use pageKeys.detail() instead of raw ["pages", …] literals in
title-editor and use-page-collab-providers.

cleanup: remove the dead emit() in title-editor (the gateway ignores it; the
cross-user tree refresh is server-side via the Yjs title fragment); drop the
trivial Array.isArray(tiptapExtensions) test (schema is exercised transitively).

refactor: extract the shared page.<id> Yjs doc-name convention into
pageYdocName()/PAGE_YDOC_NAME_PREFIX so the editor providers, offline warm, and
offline purge can no longer drift apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:26:24 +03:00
a
ca26af9e9d fix(offline): address PR #120 review (cross-user leak, Yjs title dup, i18n, docs, guards)
Security:
- Clear the offline IndexedDB cache on sign-in (not only logout) so a previous
  user's persisted query cache and Yjs page bodies cannot leak to the next user
  on a shared device when the prior session ended without an explicit logout.

Regressions:
- Remove the double Yjs title write from the AI title-generation path: the title
  editor is bound to the Yjs `title` fragment and the server REST update reseeds
  it, so the local setContent raced that reseed and doubled/garbled the title.

Conventions / i18n / docs:
- Remove the unused showAiMenuAtom.
- Register the 3 offline-fallback strings in en-US and ru-RU.
- Fix the 5 broken links to the nonexistent docs/offline-sync-plan.md.

Stability / simplification:
- warmInfiniteAll now reports truncation (returns false) when it hits maxPages
  with a cursor still pending instead of silently succeeding.
- space-tree make-offline catch logs the raw error and surfaces the real cause.
- Move the Offline/Mobile/CORS CHANGELOG entries from the released 0.93.0 section
  into [Unreleased] (CORS is a documented breaking change).
- Drop the pass-through sync-flag forwarders in use-page-collab-providers; set the
  atoms directly.
- Collapse the three isSwaggerEnabled true-cases into it.each.

Tests / architecture:
- Extract collabTokenNeedsRefresh (pure) and cover all four token states.
- Extract shouldPropagateTitleChange and cover the collab-origin skip; add a
  TitleEditor render test for the static-h1 vs collaborative-editor switch.
- Add a use-auth test asserting the sign-in cache purge runs before login.
- Add an OFFLINE_PERSIST_ROOTS guard test asserting every persisted root maps to
  an exported query-key factory; route make-offline's currentUser warm through a
  new userKeys factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
a
3d6f48c3bd fix(offline): stop offline white-screen and replay paused structural mutations
Fixes the offline-sync defects QA found on PR #120 (#237/#238/#220).

Blank-shell / white-screen on offline reload (HIGH):
- auth-query.tsx: the useCollabToken retry predicate read
  `error.response.status` unguarded. Offline the collab-token POST rejects as
  an axios NETWORK error (isAxiosError true, response undefined), so `.status`
  threw an uncaught TypeError in the React Query retryer BEFORE React mounted,
  white-screening every route. Extracted the predicate as `collabTokenRetry`
  and guarded it with optional chaining (`error.response?.status === 404`).
- user-provider.tsx: gated the whole <Layout> on useCurrentUser() and returned
  a bare `<></>` on any error, blanking every authenticated route offline even
  when cached data existed. Now renders the cached app when a (stale) user is
  present and an explicit OfflineFallback when there is no user to fall back on.
- query-persister.ts / make-offline.ts: persist and warm the ['currentUser']
  query so the auth gate can hydrate offline (pinned pages now survive relaunch).

Offline structural create/move/comment silently lost on reload (HIGH):
- offline-mutations.ts: register setMutationDefaults (default mutationFns) for
  stable mutation keys and tag useCreatePageMutation / useMovePageMutation /
  useCreateCommentMutation with those keys. A paused mutation dehydrated to
  IndexedDB while offline now has a mutationFn after reload, so
  resumePausedMutations() replays it on reconnect instead of no-op'ing.

Tests (client vitest): collabTokenRetry no longer throws on a no-response
network error; UserProvider renders cached children / the offline fallback (not
a blank fragment) on a network error; a rehydrated paused create/move is
replayable via resumePausedMutations; currentUser persist-root coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227
2f5b520af2 chore(offline-sync): tighten SW denylist, drop dead /api cache + http localhost CORS
- Service worker (vite-plugin-pwa/Workbox): add /share/, /mcp, and /robots.txt
  to navigateFallbackDenylist so the SPA app-shell never shadows those
  server-rendered routes (they mirror the server static-serve exclude list — the
  share SEO/OG HTML, the MCP endpoint, and robots.txt must come from the server).
- Remove the dead /api GET NetworkFirst Workbox rule (api-get-cache): offline
  reads are served by the persisted TanStack Query cache (IndexedDB) + y-indexeddb,
  never by an SW HTTP cache, so caching GET /api only risked stale responses. All
  /api is now NetworkOnly. clearOfflineCache still deletes any legacy api-get-cache
  defensively (comment updated to note it is no longer created).
- CORS: drop the cleartext 'http://localhost' native-WebView origin. The Capacitor
  shell uses the secure scheme (capacitor.config cleartext:false, default Android
  scheme https, iOS hosted via CAP_SERVER_URL), so no native client uses it;
  allowing it only widened the credentialed-CORS surface. Keeps capacitor://,
  ionic://, and https://localhost.
- docs/mobile-bootstrap.md: replace the inaccurate 'hand-rolled service worker'
  description with the real Workbox generateSW setup (prompt registration via
  virtual:pwa-register, production-only, denylist, NetworkOnly, RQ/y-indexeddb
  offline reads) and drop http://localhost from the CORS origins list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227
655970dd49 refactor(offline-sync): share query keys/options between hooks and offline warm
The 'Make available offline' warm path re-typed React Query key literals and
re-declared queryKey+queryFn pairs that the feature hooks already owned, so the
two could silently drift (a hook key change would leave the warm cache under a
stale key). Centralize them so there is one source:

- Add pageKeys (page-query.ts) and spaceKeys (space-query.ts) key factories and
  route the inline key literals through them. Partial-match keys and 2-element
  spaceMembers invalidations are deliberately left inline so their effective key
  VALUE (and invalidation breadth) is unchanged.
- Add queryOptions factories sidebarPagesQueryOptions and spaceByIdQueryOptions,
  consumed by both the hooks (fetchAllAncestorChildren, useGetSpaceBySlugQuery)
  and the warm path. Comments reuse the existing RQ_KEY factory.

The warm path also stops silently succeeding: warmInfiniteAll returns a boolean
and logs failures; makePageAvailableOffline is best-effort (never throws) and
returns { ok, failed[] }, recording each failed step by label; the tree menu
caller now shows a success or error toast from result.ok. Removed the unused
slugId/parentPageId params from the offline params type.

This is a behavior-preserving centralization: effective query keys, queryFns,
staleTime and enabled are unchanged for every hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227
7ceef2bae6 fix(offline-sync): harden collab auth-failure handler, drop dead sync state
The onAuthenticationFailed handler is created once per page (effect keyed on
pageId), so it closed over the initial collab token and decoded a STALE value
after a refetch. Worse, jwtDecode(undefined) throws, so when the token had not
loaded (or the request failed) the handler crashed before it could refetch and
reconnect — leaving the editor stuck disconnected.

Mirror the latest token into a ref the handler reads live, and guard the decode:
a missing or malformed token is treated as 'needs refresh' so it refetches and
reconnects instead of throwing. A valid, unexpired token still early-returns.

Also remove two local useState sync flags (isLocalSynced/isRemoteSynced) that
were set but never read — the header indicator consumes the Jotai atoms, and the
hook's return values were never destructured by any caller. The setter wrappers
now drive only the atoms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227
77aa9443e9 fix(offline-sync): bridge collaborative tree updates across processes via Redis
In 2-process deployments (COLLAB_URL set) the standalone collab process runs
Hocuspocus onStoreDocument, which emits PAGE_UPDATED with a treeUpdate snapshot
on a collaborative rename. But CollabAppModule has no WsModule, so PageWsListener
(the broadcaster) only exists in the API process — the collab-originated tree
update never reached clients, and other users' sidebars/breadcrumbs went stale.

Bridge it over Redis pub/sub with the API process as the single broadcast
authority:

- PageTreeBridgePublisher (registered ONLY in CollabAppModule) listens for
  PAGE_UPDATED and, when a treeUpdate snapshot is present, publishes it to the
  collab:tree-update channel. Gated exactly like PageWsListener so content-only
  saves never publish noise.
- PageTreeBridgeSubscriber (registered in WsModule, API process) subscribes on a
  dedicated duplicated connection and re-broadcasts each snapshot through
  WsTreeService.broadcastPageUpdated — the same restriction-aware emitTreeEvent
  path, so authorization is preserved.

Double-broadcast is prevented by module placement: the publisher lives only in
the standalone collab process's root module, so in single-process mode it is
never loaded and the local PageWsListener stays the sole broadcaster.

The bridge is optional and fail-safe: publish errors, malformed payloads,
broadcast rejections, an unlistened 'error' on the subscriber connection, and a
subscribe() failure at boot are all caught and logged, never crashing or blocking
the process. NOTE: assumes a single API broadcaster; horizontal API scaling would
need a consumer-group/leader-election instead of fan-out pub/sub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227
1ac9a8df98 fix(offline-sync): make legacy ydoc self-heal atomic and crash-safe
onLoadDocument rebuilds a legacy page (page.content, no page.ydoc) into a Yjs
doc and seeds its 'title' fragment from the page.title column. Both
TiptapTransformer.toYdoc and buildTitleSeedYdoc mint fresh Yjs client-ids on
every call, so the heal must run exactly once per page. Three holes let it run
twice (or lose a write):

- Duplication trap: the initial page read took no row lock, so two processes
  (the API process via openDirectConnection and the standalone collab process)
  could both observe ydoc IS NULL and each rebuild with different client-ids; a
  long-offline client merging an earlier rebuild then duplicates all content.
- Lost-update: persistYdoc wrote updatePage({ydoc}) outside any transaction, so
  it could clobber a concurrent onStoreDocument write (which does take a lock).
- Swallowed write errors: a failed heal-persist was logged but the unpersisted
  fresh-client-id doc was returned anyway, silently re-arming the trap.

Fix: the heal now runs in healUnderLock, which re-reads the row FOR UPDATE inside
one transaction and re-validates under the lock — if ydoc is now present it
adopts it (no rebuild, no write), otherwise it rebuilds, seeds, and persists the
ydoc in the SAME transaction. The healthy hot path still loads with no lock and
no write. Failure handling surfaces instead of hiding: a rebuild-persist failure
refuses the load (re-throw + error log) so an unpersisted rebuild is never handed
out, while a seed-only persist failure serves the existing healthy ydoc without
the unpersisted seed (non-fatal). Removed the non-transactional persistYdoc.

Deliberately does NOT use a fixed clientID: identical client-ids across docs
built from differing content violate Yjs per-actor uniqueness and corrupt worse
than the trap; serialization under the row lock is the correct fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227
8cfc4c3c40 fix(offline-sync): keep page titles in sync between REST and Yjs
Title now lives in the page's Yjs 'title' fragment, but two paths corrupted it:

- Rename-revert: a REST/MCP title change wrote only the page.title column,
  never the Yjs fragment, so the next editor open replayed the stale Yjs title
  and reverted the rename. PageService.update now mirrors the new title into the
  Yjs 'title' fragment via CollaborationGateway.writePageTitle, which goes
  through openDirectConnection directly (Redis-independent: works with
  COLLAB_DISABLE_REDIS and in single-process deployments, unlike the
  Redis-routed handleYjsEvent path). The write is best-effort: a Yjs failure is
  logged and never rolls back the committed column write. Agent provenance
  (actor/aiChatId) is threaded into the store context.

- Untitled-on-open: an empty/just-initialized 'title' fragment clobbered a
  non-empty page.title to '' on open. onStoreDocument now treats the title as
  changed only when the extracted text is non-empty, covering both the
  title-only and body+title save branches. Empty-retitling via collab is
  intentionally impossible; the REST DTO is the place to enforce non-empty.

writeTitleFragment does a full clear+seed of the 'title' fragment (no
duplication/concatenation) and leaves the body fragment intact. Removed the dead
useTreeMutation.handleRename path. Adds unit tests for writeTitleFragment, the
gateway write, the anti-empty-clobber guard, and agent provenance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code
85ad697cd4 fix(offline,server,docs): apply PR #116 review findings to offline-sync
Carries the still-applicable findings from the PR #116 review into PR #120,
since #120 includes the mobile-bootstrap commit. CORS hardening (removing the
unconditional localhost/capacitor origins) is intentionally left out of scope.

Service worker routing (latent bug fix + testability):
- vite.config.ts: anchor Workbox path matching to a segment boundary
  (^/<seg>(/|$)) instead of startsWith, so siblings like /apidocs,
  /collaborators, /socket.iox are no longer mis-routed as API/realtime and
  forced NetworkOnly; align navigateFallbackDenylist with the same anchors.
- new apps/client/src/pwa/sw-strategy.ts holds the canonical predicates
  (isApiPath, isCollabOrSocketPath) + unit tests; the vite.config regexes
  mirror it inline (Workbox generateSW serializes urlPattern fns standalone,
  so they cannot import the module).

Server CORS (R1 extraction + coverage):
- extract buildCorsAllowlist / isOriginAllowed into cors.util.ts with unit
  tests (evil-origin rejected, WebView/no-Origin allowed); main.ts rewired to
  use them with byte-for-byte identical behavior.

Privacy — clear offline cache on logout:
- new clear-offline-cache.ts purges the persisted query cache
  (idb-keyval gitmost-rq-cache), the Yjs page.* IndexedDB databases, and the
  service-worker api-get-cache; wired into handleLogout (best-effort, before
  the redirect) so a previous user's private data does not linger locally.

Conventions & docs:
- prettier fixes on main.ts and login.dto.ts.
- CHANGELOG: document offline reading, returnToken opt-in, optional Swagger,
  new env vars, logout cache-clear, and the CORS open->allowlist breaking
  change.
- docs/mobile-app-plan.md: correct the now-false §2.4 claims and update the
  §12 checklist (native cap add ios left unchecked — generated locally,
  gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code
ccc5e97000 test(server): port missing returnToken/env edge cases from #116
PR #120 rewrote auth.controller.spec.ts and environment.service.spec.ts in a
leaner style but dropped several edge cases that PR #116 covered. Port the
gaps so the server coverage matches the original review intent:

- auth.controller: returnToken=false must behave like the omitted case
  (no token in the response body, cookie still set) — guards an
  `!== undefined`-style regression.
- environment.getCorsAllowedOrigins: empty string -> [], single origin,
  and leading/trailing/duplicate commas with spaces -> trimmed list.
- environment.isSwaggerEnabled: mixed-case "True" -> true; "false"/""/"1"
  -> false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code
df02f2d672 test(offline): add reviewer-requested coverage for offline-sync core logic
Adds the unit tests called out in the PR #120 review (test-coverage
aspect). No production logic changes — the only non-test edit is exporting
the already-injectable warmInfiniteAll helper so it can be unit tested.

Server (Jest):
- persistence.extension.spec.ts: onStoreDocument classification matrix
  (no-op / title-only / body+title / body-only), onLoadDocument seed +
  persist gating (early-return, page-null, ydoc seed, already-seeded
  no-persist, legacy content->ydoc), and seedTitleFragment 4-branch guard.
- collaboration.util.spec.ts: buildTitleSeedYdoc round-trip.
- environment.service.spec.ts: getCorsAllowedOrigins / isSwaggerEnabled.
- auth.controller.spec.ts: login returnToken opt-in branch.

Client (Vitest):
- query-persister.test.ts: shouldDehydrateOfflineQuery status + allowlist
  gates and OFFLINE_PERSIST_ROOTS membership.
- is-capacitor.test.ts: isCapacitorNativePlatform platform detection.
- make-offline.test.ts: warmInfiniteAll cursor walk / maxPages / error
  swallow, and warmPageYdoc settle-once + timeout + teardown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code
7ac7fcba2d chore(pwa): reconcile dual service worker after mobile-app-bootstrap merge
The mobile bootstrap shipped a hand-written public/sw.js plus a manual
navigator.serviceWorker.register('/sw.js') in main.tsx. The offline-sync
Workbox SW (vite-plugin-pwa, generateSW) functionally supersedes it
(NetworkOnly for /api,/collab,/socket.io, navigateFallback to the app shell,
runtime caching) and adds precache + prompt-based updates, so:

- Remove the hand-written apps/client/public/sw.js.
- Remove the manual SW registration block from main.tsx; registration is now
  owned by <PwaUpdatePrompt/> via useRegisterSW (skipped in Capacitor native).
- Regenerate pnpm-lock.yaml for the merged Capacitor + @nestjs/swagger deps.

Kept from mobile-app-bootstrap: the richer manifest.json (offline-sync uses
manifest:false), capacitor.config.ts, the apple-touch-icon, and all server
mobile-auth/CORS/Swagger changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code
caeb555039 feat(mobile): bootstrap mobile app (PWA + Capacitor + backend auth/CORS)
Implements the §12 bootstrap from docs/mobile-app-plan.md.

Backend (§6):
- auth: optional returnToken flag on login returns the JWT in the body
  (data.authToken) for native Keychain/Keystore + Bearer; web cookie flow
  unchanged.
- main.ts: explicit CORS allowlist (APP_URL + CORS_ALLOWED_ORIGINS env +
  Capacitor WebView origins), credentials enabled, replaces open enableCors().
- optional OpenAPI/Swagger at /api/docs behind SWAGGER_ENABLED.
- env: CORS_ALLOWED_ORIGINS, SWAGGER_ENABLED, CAP_SERVER_URL.

PWA:
- manifest metadata, hand-rolled service worker (network-first nav, SWR
  assets, never intercepts /api,/socket.io,/collab), prod-only registration,
  apple-touch-icon.

Capacitor:
- capacitor.config.ts (webDir apps/client/dist; iOS via CAP_SERVER_URL to
  avoid bundling the AGPL client in the .ipa, see plan §9), cap:* scripts,
  deps, .gitignore for native dirs.
- docs/mobile-bootstrap.md documenting what is done and the remaining manual
  steps (cap add ios/android, APNs/FCM, stores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code
e05495ba4f feat(offline): PWA shell, Yjs-backed titles, and offline read cache (M0–M2)
Implements docs/offline-sync-plan.md milestones M0–M2.

M0 (PWA shell):
- Add vite-plugin-pwa (generateSW, registerType: 'prompt', manifest:false);
  NetworkOnly for /api,/collab,/socket.io, NetworkFirst for GET /api,
  navigateFallback to index.html.
- Register SW via useRegisterSW with a Mantine update prompt; skip
  registration inside Capacitor native WebView (is-capacitor guard).

M1 (harden CRDT body + title into Yjs):
- Lift the per-page Y.Doc/Hocuspocus providers into a shared hook+context so
  body and title editors share one doc.
- Move the page title into a dedicated 'title' Yjs fragment (CRDT, offline-
  tolerant); drop the REST title save. Server persists the title fragment to
  page.title and seeds it for legacy pages (empty-fragment guard); a collab
  rename emits a treeUpdate so other users' tree/breadcrumbs refresh.
- Persist the rebuilt ydoc on the content->ydoc path to neutralize the Yjs
  duplication trap. Add a 3-state sync indicator.

M2 (offline read/navigation):
- Persist React Query to IndexedDB (idb-keyval persister, version buster,
  selected roots only).
- "Make available offline" action warms page, space, tree (root+ancestors+
  children) and comments under exact hook keys, plus the page ydoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:49 +03:00
claude_code
106df7c907 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-28 02:28:02 +03:00
claude_code
89edddc5a1 feat(agent-roles): fact-checker flags errors instead of confirming facts
Rework the fact-checker editorial role prompt so it stops commenting on
correct facts and only flags problems (errors, doubtful, unverifiable).

- Add the directive "don't write/comment that a fact is right or confirmed:
  your job is to find errors, not confirm facts" to both RU and EN bundles.
- Remove the [Подтверждено]/[Verified] verdict; reframe the verdict list as
  "for problem claims only".
- Reword the role description (no longer "confirms") and the
  comment-on-every-claim rule to "problem claims only".
- Bump fact-checker role version 2 -> 3 and refresh the content-hash lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 02:27:53 +03:00
c5109aa2a3 Merge pull request 'feat(footnotes): author-inline footnotes + deterministic server canonicalization (#228)' (#232) from feat/228-inline-footnotes into develop
Reviewed-on: #232
2026-06-28 02:23:27 +03:00
a
c4ed4a4855 fix(footnotes): strip bare definitions on rebuild; MCP full-doc + zip-import canonicalize tests (#228)
Review #6 (approve-with-comments) follow-ups:
1. canonicalize step 7 now strips bare footnoteDefinitions at ANY depth
   (stripFootnoteDefinitionsDeep), not just footnotesList, in BOTH copies. A
   definition hand-authored outside a list (e.g. nested in a callout via a
   raw-JSON write path) was left in place while a copy was also added to the
   rebuilt list -> duplicate, idempotent, self-perpetuating. Runs only in the
   rebuild path (after the lists are stripped); the fast-path / placement-keep
   branch is untouched. Added a shared-corpus case (bare def nested in a callout)
   to pin it in both mirrors.
2. markdown-clipboard: removed the dead top-level footnoteReference check in
   canonicalizePastedFootnotes (an inline atom is never a top-level slice child;
   only the descendants scan can find it).

Test coverage:
4. New MCP binding tests (full-doc-write-canonicalize.test.mjs): update_page_json
   and copy_page_content canonicalize the persisted full doc, asserted via a new
   `replacePage` seam (symmetric to the existing `mutatePage` seam) so no live
   collab socket is needed. Routed both writers through the seam.
5. New server spec (file-import-task.service.footnote-canonicalize.spec.ts): the
   zip-import path (processGenericImport) canonicalizes footnotes — real
   markdown->HTML->JSON via a real ImportService over a temp-dir .md file, DB trx
   stubbed to capture the persisted page content. FileImportTaskService had no
   spec before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 01:39:25 +03:00
a
9c1f952b2f fix(footnotes): guard insert against nested/bare definitions, skip definitions-only paste, doc + reorder fixes (#228)
Must-fix:
- insertInlineFootnote could glue a footnoteReference inside an EXISTING
  definition (nested footnotesList, or a bare footnoteDefinition with no list
  wrapper), which canonicalize then dropped as an orphan — silently losing the
  definition's prose. Now: (a) the body/notes boundary is computed from the first
  top-level block that IS or CONTAINS (recursively) a footnotesList/
  footnoteDefinition, not just a top-level list; and (b) the insertNodesAfterAnchor
  core skips footnotesList/footnoteDefinition subtrees entirely (skipSubtreeTypes),
  so an anchor whose only match is inside a definition -> inserted:false (clean
  abort, no write). Added tests: nested-definition, bare-definition, and
  body-before-nested-list-still-inserts.
- editor-ext footnote-canonicalize header listed `markdownToProseMirror` among the
  canonicalizing MCP paths; it is the NON-canonicalizing primitive. Replaced with
  `markdownToProseMirrorCanonical` (+ note that the plain primitive is for comment
  bodies) and added copy_page_content.
- Client paste: canonicalizePastedFootnotes now skips a definitions-ONLY paste
  (no footnoteReference anywhere) — canonicalizing it would strip the
  reference-less list and yield an EMPTY paste. Added a test.

Suggestions:
- docmost_transform now runs validateDocStructure/validateDocUrls on the RAW
  transform output BEFORE canonicalizeFootnotes (mirrors updatePageJson), so a
  too-deep doc gives the intended max-depth error instead of a stack overflow.
- docmost_transform tool description now states the RESULT is footnote-canonical
  (dryRun diff may show tidy-ups; idempotent after first run).
- insertFootnote: dropped the dead `result ? … : undefined` ternaries and the
  `as any` casts (result is always set by the time we return; the not-found path
  throws and aborts mutatePage). `const r = result!;`.

Tests / architecture:
- Added a LIVE-plugin golden case: the real footnoteSyncPlugin leaves a list with
  non-empty content after it in place, and canonicalize agrees (placement parity
  is now a driven property, not a hand-set expected).
- Added generateFootnoteId uuidv7 shape + uniqueness test.
- Item 9: added the ENFORCEMENT-RULE comments at the server parseProsemirrorContent
  and the MCP canonicalizer header (any NEW full-doc persist path MUST canonicalize;
  fragments/append/prepend and comment bodies MUST NOT). Kept per-call-site over a
  brittle grep CI test (the replace-vs-fragment + comment-vs-page nuance makes a
  single wrapper unsafe).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:40:28 +03:00
c6ffdb6536 Merge pull request 'fix(ui)+test: QA UI bugs (#216 #218) + test coverage (#206 #204 #192)' (#230) from fix/qa-ui-bugs-216-218 into develop
Reviewed-on: #230
2026-06-27 22:50:19 +03:00
a
3fd66b4245 fix(footnotes): don't canonicalize comment bodies (data loss); canonicalize only page write paths (#228)
Must-fix (REAL DATA LOSS):
- markdownToProseMirror is reused for COMMENT bodies (createComment/updateComment).
  It unconditionally canonicalized, so a comment carrying a standalone footnote
  definition ([^1]: text with no matching reference) had its whole footnotesList
  stripped (referenceIds.length===0 -> stripFootnotesListsDeep) — the text
  vanished. Fix: markdownToProseMirror no longer canonicalizes (content-preserving
  primitive); a new markdownToProseMirrorCanonical wraps it for the PAGE write
  paths (markdown import via importPageMarkdown, update_page markdown via
  updatePageContentRealtime). Comment callers keep the non-canonicalizing
  primitive. Updated the now-false header comment and added create/update-comment
  inline notes. Added collaboration tests: comment path PRESERVES a reference-less
  definition; page path still drops it AND still reorders real footnotes. Updated
  the page-import canonicalization test to use the canonical variant.

Suggestions / architecture:
- #2: collapsed transforms.footnoteDefinition onto the shared
  makeFootnoteDefinition factory (adds only the inner paragraph block id); kept
  the dependency direction transforms -> footnote-authoring (no circular import,
  mirror stays pure).
- #3: confirmed docmost_transform auto-canonicalization is documented (inline
  comment, tool description, CHANGELOG) — no code change.
- #4: copyPageContent is a FULL-document write (replacePageContent of a
  type:"doc"); added a defensive canonicalizeFootnotes pass (no-op on
  already-canonical source).
- CHANGELOG entry refined to list the FULL-document write paths (incl.
  copy_page_content) and to state canonicalization is NOT applied to comment
  bodies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:17:15 +03:00
a
40d1cdfc77 refactor(review): address #230 third review — callout dedup, ticket/type tidy
Approve-with-comments follow-ups (no blockers):

- callout: unify the GitHub-callout feature ticket on #192 (the callout-paste
  feature the CHANGELOG already tracks); #218 is the public-share security work.
  Fixed the code comment and test reference.
- export/utils.spec: pin current behavior of a leading-dot name (".gitignore" ->
  "") — same bug class as #204 but unreachable via the sole caller, so document
  not change.
- share.types: narrow ISharedPage to the actual /shares/page-info allowlist
  (page -> Pick of id/slugId/title/icon/content; trimmed share; dropped the
  spurious `extends IShare`). Verified all three consumers (shared-page,
  link-view, mention-view) read only allowlist fields.
- editor-ext: extract shared CALLOUT_TYPES / normalizeCalloutType /
  renderCalloutHtml into callout-common.marked.ts; both tokenizers
  (`:::type` and `> [!type]`) now share the renderer + type dict while staying
  separate. Eliminates the byte-identical renderer + duplicated type list.
- share.service: extract named predicate shareIdGrantsAccess(requestedShareId,
  resolvedShare) for the id-or-key fast path (naming only, no control-flow
  change); kept narrower than resolveReadableSharePage's id-only gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 22:11:16 +03:00
a
a77a0bc92b fix(footnotes): re-review #232 — refuse footnoteRef into codeBlock/definition, deep-strip nested lists, docs + cross-copy guard (#228)
Must-fix:
- REAL BUG: insertInlineFootnote could splice a footnoteReference (inline atom)
  into a codeBlock or an existing footnoteDefinition, persisting a schema-invalid
  doc (insert_footnote skips validateDocStructure). Now the search is bounded to
  the BODY (before the first footnotesList) and the insertNodesAfterAnchor core
  refuses textblocks that can't hold the atom (codeBlock); when the only match is
  in such a place the insert returns inserted:false and the write aborts cleanly.
  Reachable via docmost_transform too. Added codeBlock / definition / fall-through
  tests.
- Fixed the deepEqualJson doc comment in both copies: arrays are order-SENSITIVE
  (correctness depends on it), only object keys are order-insensitive.
- README.ru.md MCP tool count 38 -> 39 (lines 36/47/63), matching README.md/AGENTS.
- CHANGELOG [Unreleased] Added entry for insert_footnote + server-side footnote
  canonicalization on non-editor write paths (#228).

Suggestions:
- canonicalize step 5/7 now strips footnotesList at ANY depth (both copies), so a
  schema-valid list nested in a callout/blockquote can't leave duplicate defs.
- Exclude the test-only footnote-corpus.ts fixture from the editor-ext build
  (tsconfig), so it no longer ships in dist/.
- Removed the duplicate manual canonicalize cases from the MCP unit test (the
  shared corpus covers them via full deepEqual); kept idempotence + immutability.
- insertInlineFootnote dedup key now keys off the inline array directly
  (footnoteContentKey({ content: inline })) instead of a throwaway node.

Tests / architecture:
- New client-wrapper test (#9): overrides a small mutatePage seam to assert the
  not-found path throws and persists NOTHING, and the success path shapes
  footnoteId/reused/message/verify and writes the right content. Fixed the
  misleading comment in footnote-write.test.mjs.
- B: cross-copy corpus parity guard test (loads both corpora, asserts deep-equal)
  so a typo in one copy can't pass both suites green.
- A: declined — the full-vs-fragment decision lives at the call site, so a
  prepareDocForPersist wrapper would be a bare alias for canonicalizeFootnotes;
  kept the existing per-call-site comments instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:41:10 +03:00
a
525172104a fix(review): address #230 re-review — stale breadcrumb, swallowed error, i18n, docs
Approve-with-comments follow-ups:

- breadcrumb: fix the reverse regression where navigating A->B to a page absent
  from the lazily-built tree (before its ancestors load) left the previous
  page's clickable chain on screen. New pure computeBreadcrumbState clears a
  stale chain that doesn't end at the current page, while keeping one that does
  (no blank flash for an already-resolved page); unit-tested for the
  navigated-to-absent-page case.
- share.service: getShareAncestorPage no longer swallows DB errors silently —
  now a live public-share path (isPageReachableThroughShare), so a transient
  error is logged with ancestor/child ids and still fails closed (caller 404s)
  instead of becoming a traceless misleading "not found".
- i18n: register the new "Connecting… (read-only)" key (U+2026 ellipsis) in
  en-US (source of truth) and ru-RU (Подключение… (только чтение)).
- share.service: correct the FUTURE note — 3 callers pass no shareId
  (share-alias.controller/.service, share-seo.controller); the two ai-chat
  callers already pass a real shareId.
- CHANGELOG: add Unreleased Changed/Fixed/Security entries for #216 opt-in
  sub-pages default, #218 trimmed page-info payload + forged-shareId 404, #204
  export internal-link name, #206/#218 breadcrumb, #192 callout paste, #218
  editor pre-sync read-only gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:31:49 +03:00
a
07ebd8c63e fix(footnotes): address PR #232 review — fragment-safe canonicalization, plugin placement parity, dead-code removal (#228)
Must-fix:
- Move canonicalizeFootnotes OUT of parseProsemirrorContent. It now runs only
  on FULL writes (createPage, updatePageContent operation==='replace'), never on
  an append/prepend fragment (a fragment would lose definition-only footnotes or
  synthesize a bogus empty list). Add a server binding spec.
- Match the live plugin's list PLACEMENT: a single already-canonical
  footnotesList is left exactly where it sits (the plugin never repositions a
  sole correct list), so the first write no longer reorders content that follows
  the list. Applied to BOTH the editor-ext copy and the MCP mirror; pinned by a
  shared golden corpus case with content after the list.
- Fix MCP tool count 38 -> 39 (README x3, AGENTS.md) and the transformJs param
  help (add canonicalizeFootnotes/insertInlineFootnote).

Simplifications:
- Remove the dead duplicate re-id mechanism (deriveFootnoteId/suffix/occurrence)
  from the PURE canonicalizer in both copies — references are never renamed, so
  the derived ids were never requested; first-wins-drop is the real behaviour.
  This also makes the editor-ext footnote-util note about "no cross-package copy"
  true again.
- Remove the sentinel round-trip in insertInlineFootnote: a generalized
  insertNodesAfterAnchor core inserts the footnoteReference node directly.
- Drop the redundant per-definition deep clone in step 4 (shallow id-normalizing
  copy; out is already deep-cloned).

Docs / architecture:
- Correct the editor-ext copy's "It exists because…" header to its real
  consumers (server import, page.service create/update, client paste).
- Note markdownToProseMirror reuse for create/update comment in collaboration.ts.
- A: shared golden JSON corpus exercised by BOTH the editor-ext copy and the MCP
  mirror (footnote-corpus.ts / .mjs) so "the two copies behave identically" is
  checkable.
- C: split the MCP canonicalizer into a pure mirror + footnote-authoring.ts.
- B: import services persist via a different path, so left one-line consolidation
  comments at the call sites rather than folding (does not fall out cleanly).

Tests: insertFootnote wrapper guards + docmost_transform dryRun auto-canonicalize
(MCP mock), page.service create/update + append/prepend binding (server jest),
shared corpus incl. nested-container reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:23:16 +03:00
a
c9d252cf2a fix(review): address PR #230 review — payload type, breadcrumb helper, tests
Review follow-ups for the combined QA-UI fixes (#216/#206/#204/#218/#192):

- export/utils: correct the misleading getInternalLinkPageName comment — a
  bare `v1.2` loses its last dot-segment (`v1`); dots survive only in
  multi-segment names like `v1.2.md` -> `v1.2`.
- share: extract toPublicSharePayload(page, share): PublicSharePayload, an
  explicit allowlist type+mapper replacing the inline literal in the
  /shares/page-info anonymous path (#218). Add share.controller.spec.ts that
  stubs getSharedPage returning internal fields and asserts the response key
  set EXACTLY equals the whitelist (page + share), so any `...shareData`
  regression or new leaking field fails. Also key-tests the extracted mapper.
- breadcrumb: extract pure resolveBreadcrumbNodes(treeData, ancestors, pageId)
  (tree-hit -> tree; tree-miss -> map ancestors via canonical pageToTreeNode,
  dropping the as-any casts; else null) and unit-test all three branches.
- share-modal: RTL test asserting enabling a share calls mutateAsync with
  includeSubPages: false (#216 security default).
- share.service: one-line note at getSharedPage on the deferred consolidation
  of the ancestor-aware match into resolveReadableSharePage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:09:48 +03:00
a
fa929c9e86 fix(footnotes): canonicalize footnotes on server import + markdown paste (#228)
The footnote canonicalizer was wired into the MCP and editor-ext write paths
but NOT into the server's user-facing markdown/HTML import paths, so importing
or pasting markdown with out-of-order, reused, or orphan footnotes did not
canonicalize -- the exact trigger bug #228 fixes was still reproduced on
import. markdownToHtml -> htmlToJson builds ProseMirror JSON directly and never
runs the editor's footnoteSyncPlugin, and that plugin does not reorder an
existing list, so the stored footnotes kept the source's physical definition
order, retained orphans, and did not collapse reused references.

Wire canonicalizeFootnotes (already exported from @docmost/editor-ext) into
every server markdown/HTML -> page-JSON seam, before persisting:
  - ImportService.importPage (REST single-file .md/.html import)
  - FileImportTaskService (zip import worker)
  - PageService.parseProsemirrorContent (API createPage / updatePageContent)

Also hook the client markdown paste: handlePaste applies a manual transaction
(returns true), bypassing transformPasted/footnoteSyncPlugin, so a pasted
out-of-order markdown footnote block would persist out of order.
canonicalizePastedFootnotes reorders a self-contained pasted block (one that
carries its own footnotesList) to reference order, deduped and orphan-free; it
is deliberately scoped to whole-block pastes so a reference-only paste that
reuses a footnote already defined in the target doc is left untouched.

canonicalizeFootnotes is pure, idempotent and shape-safe (a doc with no
footnotes is unchanged), so it is safe on every write path.

Residual: when a pasted block merges into a doc that already has footnotes,
ordering relative to the pre-existing footnotes is still governed by the live
sync plugin (which does not reorder across the boundary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:10:41 +03:00
claude code agent 227
30cb9d293c feat(footnotes): inline authoring + deterministic server-side canonicalization
Make footnotes author-inline: the agent/tool inserts a footnote at its point
of use (anchor + text) and the numbering plus the bottom list are DERIVED
deterministically server-side. The agent has no access to footnotesList and
cannot desync — out-of-order lists, orphan definitions, and raw trailing
[^id] blocks become structurally impossible.

editor-ext:
- canonicalizeFootnotes(docJSON) -> docJSON: a pure, EditorView-free port of
  footnoteSyncPlugin's end-state. Distinct reference ids in document order are
  the source of truth; exactly one trailing footnotesList holds one definition
  per referenced id in reference order (reusing the existing node or
  synthesizing an empty one); orphans dropped; duplicate definitions resolved
  deterministically (first wins, never lost); idempotent.
- Unit tests + a golden parity suite: on every editor-reachable steady state
  the live footnoteSyncPlugin's JSON is a canonicalize no-op (byte-for-byte
  parity), and the canonicalizer additionally repairs the out-of-order list a
  non-editor write produces.

mcp:
- footnote-canonicalize.ts: behavioural mirror of the editor-ext canonicalizer
  (the MCP package is intentionally decoupled from the editor barrel, like
  footnote-lex/docmost-schema), plus footnoteContentKey for content dedup.
- Auto-canonicalize on EVERY write path: markdownToProseMirror (fixes import
  ordering), update_page_json, and after every docmost_transform. Idempotent,
  so it is a no-op when footnotes are already canonical.
- insert_footnote tool + insertInlineFootnote: anchor + markdown text -> a
  mark-safe footnoteReference and a content-dedup'd definition; the list and
  numbering are derived. Same-content footnotes reuse one number/definition.
- canonicalizeFootnotes + insertInlineFootnote exposed as docmost_transform
  sandbox helpers.

Tests: editor-ext 157 green; MCP 325 green; server + client tsc clean.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:15:55 +03:00
claude code agent 227
22852be2e2 fix(qa): resolve UI bugs from #216 and #218
Public sharing (#218):
- Bind public-share content to the requested shareId. getSharedPage now
  enforces dto.shareId (forwarded from /share/:shareId/p/:slug): the page must
  be reachable THROUGH that exact share (its own share, or an includeSubPages
  ancestor that contains it). A forged/mismatched shareId 404s instead of
  rendering off the slug alone and no longer leaks the real canonical key via
  redirect. A request with no shareId keeps the legacy slug-capability path.
- Trim /shares/page-info: drop internal metadata (creatorId, spaceId,
  workspaceId, contributorIds, lastUpdated*, parent/position, lock/template
  flags, timestamps) from the anonymous payload.
- Default share-to-web includeSubPages to false (opt-in), so enabling a share
  no longer silently exposes the whole sub-tree (#216).

Editor (#218):
- Harden the new-page pre-sync window: the body editor is kept read-only until
  the collab provider is Connected and synced, so early keystrokes can't land
  only in local ProseMirror and then be clobbered by the server's empty doc.
- Surface a "Connecting… (read-only)" affordance during the static phase so
  input isn't silently swallowed.

Other:
- Breadcrumb: resolve from the page's own ancestor data (/pages/breadcrumbs)
  instead of waiting for the lazily-built sidebar tree, so deep pages don't
  render a blank breadcrumb for seconds.
- Pasting GitHub `> [!type]` callouts now converts to a callout node instead of
  a literal blockquote (new marked extension wired into markdownToHtml).

Tests: editor-sync-state gate (client), getSharedPage share-binding (server),
github-callout markdown conversion (editor-ext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:54:06 +03:00
claude_code
904f7b4303 fix(agent-roles): bump proofreader v3 + guard against content edits without a version bump
The proofreader role content was changed (STYLE SHEET block removed) without
bumping its catalog version, so clients never saw an update. Bump proofreader
2 -> 3, and add a content-hash guard so this can't happen silently again.

- index.json: proofreader version 2 -> 3
- scripts/check.mjs: new content-hash guard. A scripts/content-hashes.json lock
  maps slug -> { version, hash } (sha256 over emoji/autoStart/name/description/
  instructions/launchMessage across all languages). check.mjs now fails when a
  role's content changed without bumping its version; the new --update-hashes
  (alias --fix) refreshes the lock but refuses to write when a bump is missing.
- check.mjs: also require every index.json role to carry a finite numeric
  version (matches the server's catalog validation), with defense-in-depth so a
  missing version can't bypass the bump guard.
- scripts/content-hashes.json: new lock artifact (not part of the served catalog).
- README.md: document the guard, the lockfile, --update-hashes, and the
  prune-then-readd limitation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 05:18:39 +03:00
claude_code
cac84dec9b refactor(ai-roles): make catalog URL a per-branch image default, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no longer
supports a local filesystem directory. The provider fetches only from an
http(s):// base URL read at runtime from AI_AGENT_ROLES_CATALOG_URL; an empty or
non-http value yields a 502 (catalog unavailable). The image ships a per-branch
default for that URL (set in CI), still overridable at runtime via the env var.

- provider: drop readLocal + node:fs/node:path; readRelative requires http(s)
  and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged.
- environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment
  reflects the per-branch build-time default that is runtime-overridable.
- Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage as
  the image default.
- CI: develop.yml builds with the develop raw URL; release.yml defines the main
  raw URL once in workflow env and references it from both build steps.
- tests: replace local-fixture tests with remote-mock happy/malformed bundle
  tests and a non-http => 502 case; path-traversal block uses an https source.
- docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:54:43 +03:00
claude_code
90dd8f1481 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-27 03:54:24 +03:00
39113c9dbf Merge pull request 'fix(share): custom address edit renames in place instead of duplicating (#226)' (#227) from fix/share-alias-rename into develop
Reviewed-on: #227
2026-06-27 03:53:31 +03:00
claude_code
1367070468 refactor(agent-roles): drop style-sheet duties from copyeditor role
Remove the STYLE SHEET / СТАЙЛ-ШИТ section from the copyeditor
(proofreader) role and clean up all dangling references to it in both
the ru and en editorial bundles:
- description: drop "maintains a style sheet" / "ведёт стайл-шит"
- instructions: remove the STYLE SHEET block
- instructions: drop "record it in the style sheet" mentions in the
  WHAT YOU DO and WHEN UNSURE sections

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:46:03 +03:00
claude code agent 227
767ac9e7e2 fix(share): guard alias swap/rename against concurrent-delete race; share unique-violation helpers
Address PR #227 re-review (comment 2193).

- Stability: `updatePageId`/`updateAlias` now `executeTakeFirstOrThrow`, so a row
  reaped by a concurrent `removeAlias` between the read and the UPDATE (READ
  COMMITTED) raises `NoResultError` instead of returning `undefined`. The service
  maps that to a retryable `ConflictException` (`ALIAS_PAGE_RACE`) rather than a
  200-without-alias (swap) or a generic 400 from `undefined.id` (rename). Tests
  cover both branches.
- Simplification: drop the redundant secondary "unexpected unique index" warn and
  the now-unused `UNIQUE_ALIAS_INDEX` const (the constraint name is already logged
  unconditionally; both index branches still distinguish "Alias already taken" vs
  ALIAS_PAGE_RACE).
- Architecture: extract `isUniqueViolation`/`violatedConstraint` into
  database/utils.ts; adopt them in the share-alias service and favorite.repo
  (the bare `23505` check). ai-agent-roles (#222) is on a separate unmerged branch
  and should adopt them after #227 merges (noted at the helpers). Helper unit test
  added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:33:33 +03:00
claude_code
2a4ef9267e refactor(ai-roles): bake catalog URL at image build, drop local-fs source
The agent-roles catalog source is no longer hardcoded in app code and no
longer supports a local filesystem directory. The provider now fetches only
from an http(s):// base URL read from AI_AGENT_ROLES_CATALOG_URL; an empty or
non-http value yields a 502 (catalog unavailable). The default URL is baked
into the Docker image at build time and set per branch in CI.

- provider: drop readLocal + node:fs/node:path; readRelative requires http(s)
  and 502s otherwise; remote fetch/streaming-cap/SSRF guards unchanged.
- environment.service: keep AI_AGENT_ROLES_CATALOG_URL (default ''); comment
  updated to reflect build-time injection, remote-only.
- Dockerfile: add ARG+ENV AI_AGENT_ROLES_CATALOG_URL in the installer stage.
- CI: develop.yml builds with the develop raw URL; release.yml (both build
  steps) with the main raw URL.
- tests: replace local-fixture tests with remote-mock happy/malformed bundle
  tests and a non-http => 502 case; path-traversal block uses an https source.
- docs: update .env.example, CHANGELOG (#222), agent-roles-catalog/README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:32:48 +03:00
claude code agent 227
309719abc6 fix(share): show reassign hint instead of dead-end error for a taken custom address
The share modal flagged a custom address already owned by another page with a
red "This address is already in use" error driven by the availability probe.
That reads as terminal even though Save actually triggers the server's
409 `ALIAS_REASSIGN_REQUIRED` and opens the "Move custom address?" confirm
modal that retargets the address to the current page — so the reassign path was
hidden behind what looked like a hard stop.

Replace the red error with an informational description hint ("This address is
in use. Saving will move it to this page.") and keep Save enabled, so the
existing confirm-reassign flow is discoverable. Renaming to a FREE name was
already correct (the probe returns available -> no error -> server renames the
single row in place); this only changes the taken-name presentation.

Verified end-to-end in a real browser against a live stand on this branch:
- A (free rename `test`->`test2`): 200, same alias row renamed in place, link
  becomes `/l/test2`, no error, exactly one DB row for the page.
- B (`test2` owned by another page): hint shown (no dead-end error), Save ->
  409 ALIAS_REASSIGN_REQUIRED -> "Move custom address?" modal -> confirm -> 200,
  the single row retargets, one row each.
- C (same-name re-save): Save disabled (no-op); first-time set inserts.

Add a client component test covering both branches (taken name -> hint not
error + Save enabled; 409 -> reassign modal -> confirm sends confirmReassign).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:24:00 +03:00
claude_code
3511301331 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-27 03:12:27 +03:00
claude_code
b65ca6d7dd chore(agent-roles-catalog): merge copy-editor into proofreader, refresh editorial roles
Merge the copy-editor (📐) and proofreader (🧹 "Корректор") editorial roles
into a single role. Keep slug `proofreader`, drop slug `copy-editor`, and set
the merged role's emoji to 📐.

- index.json: remove copy-editor; bump structural-editor, line-editor,
  fact-checker, proofreader to version 2 (narrator unchanged); update editorial
  bundle description (ru/en).
- bundles/editorial/{ru,en}.json: delete copy-editor; refresh emoji/name/
  description/instructions of structural-editor, line-editor, fact-checker and
  the merged proofreader verbatim from gitmost-agenty-ru.md / gitmost-agents-en.md;
  preserve autoStart and launchMessage; leave narrator untouched.
- README.md: drop copy-editor from the editorial role list.

Validated with scripts/check.mjs (OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:12:14 +03:00
4a3819373d Merge pull request 'feat(ai-chat): auto-open last chat bound to the document (#191)' (#209) from feat/191-chat-doc-binding into develop
Reviewed-on: #209
2026-06-27 02:56:31 +03:00
claude code agent 227
e682bbccd1 fix(share): order swap delete-before-update and distinguish unique violations
Addresses review on PR #227.

- setAlias confirmed-reassign branch: DELETE the target page's existing
  alias row(s) BEFORE retargeting `byName` onto the page, instead of after.
  The new partial unique index `(workspace_id, page_id)` is non-deferrable
  and checked at each statement, so retargeting first momentarily left two
  rows for the page -> immediate 23505 -> rolled-back tx surfaced as a
  misleading "Alias already taken" (regressing a previously-working swap onto
  a page that already had its own alias). The reordered branch needs no
  trailing self-heal. JSDoc updated to describe the real ordering.

- catch block: the postgres@3.x driver exposes the violated index as
  `err.constraint_name` (with `.constraint` as a fallback). Map
  `share_aliases_workspace_id_alias_unique` -> "Alias already taken" and the
  new `share_aliases_workspace_id_page_id_unique` -> a distinct ALIAS_PAGE_RACE
  outcome (a concurrent same-page write, not a name clash). Always log the
  constraint name on any 23505 so the race is diagnosable.

- migration 20260627T120000: document that the dedup DELETE is intended,
  irreversible data loss (old duplicate `/l/<old>` links start 404ing after
  upgrade; `down()` cannot restore the rows). Same note added to CHANGELOG
  [Unreleased] Fixed.

Tests:
- integration: confirmed reassign onto a page that ALREADY has its own alias
  (RED before the reorder); migration up() dedup scoping across pages and a
  second workspace; mid-transaction error -> BadRequest with clean rollback.
- unit: constraint_name distinguishing (alias index, page_id index, fallback
  `.constraint`, no-info default) and non-unique error -> BadRequest; retarget
  test now asserts delete-before-update order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:52:33 +03:00
claude code agent 227
9d2bec8eb8 fix(share): keep exactly one custom address per page on alias edit (#226)
Editing an existing share alias (e.g. slug `te` -> `ted`) failed to update
the displayed `/l/<alias>` link: `setAlias()` looked the requested slug up by
name and, if free, INSERTed a brand-new row, leaving the page with multiple
alias rows. The modal then read via `findByPageId().executeTakeFirst()` with no
`ORDER BY`, so Postgres returned an arbitrary (in practice the oldest, stale)
row. Every edit also spawned an orphan row that kept a live `/l/<old>` link
forever. Regression of #205.

Enforce the invariant "a page has EXACTLY ONE custom address":
- `setAlias()` now resolves the page's current alias row and RENAMES it in
  place when the requested name is free (insert only when the page has none),
  keeps the same-name no-op and the cross-page 409 `ALIAS_REASSIGN_REQUIRED`
  + confirmed-retarget flow, and after any successful write DELETEs all other
  alias rows for the page (self-heal). Runs in one transaction so the page is
  never transiently empty or duplicated.
- repo: add `updateAlias` (rename) and `deleteOthersForPage`; make
  `findByPageId` deterministic with `ORDER BY created_at DESC, id DESC`.
- migration: dedup existing rows (keep newest per page) + a PARTIAL unique
  index `(workspace_id, page_id) WHERE page_id IS NOT NULL` so dangling
  aliases still coexist while live ones are one-per-page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:51:51 +03:00
b6630deb32 Merge pull request 'feat(ai-roles): импортируемый мультиязычный каталог ролей агента' (#222) from feature/agent-roles-catalog into develop
Reviewed-on: #222
2026-06-27 02:39:27 +03:00
claude code agent 227
7ef98a663b Address PR #222 review: import-mutation notification tests + redirect-SSRF hardening
ITEM 1: cover useImportAiRolesFromCatalogMutation onSuccess notifications.
Add import-from-catalog-message.test.tsx (twin of update-from-catalog-message)
asserting the always-shown summary (errors:[]) and the additional red
"Failed to import N role(s)" notification when result.errors is non-empty.

ITEM 2: pass redirect:'error' to the remote catalog fetch in fetchRemote so a
compromised-but-trusted upstream cannot 3xx the fetch into the internal network
(redirect-SSRF). Add provider specs asserting the option is passed and that a
redirect rejection maps to BadGatewayException.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:36:28 +03:00
109ab10fc5 Merge pull request 'fix(temporary-notes): tree clock marker updates without reload + mobile-friendly full-width create buttons' (#225) from fix/temporary-notes-ui into develop
Reviewed-on: #225
2026-06-27 01:39:10 +03:00
claude code agent 227
2b7c861f78 Address PR #222 re-review: fix source-uniqueness detection + coverage/cleanups
MUST-FIX
- isSourceUniqueViolation read the wrong error field: kysely-postgres-js
  (postgres@3.4.8) puts the violated constraint on `constraint_name`, not
  node-postgres' `.constraint`, so a concurrent same-slug+language import's
  23505 was never recognized as a source-collision and surfaced a false
  "name already exists" error. Now read `constraint_name` (with `.constraint`
  as a fallback for other drivers). Fix the faked test fixture (it built the
  error with the same wrong `.constraint` field, masking the bug): it now
  uses `constraint_name`, so the test genuinely exercises the skip path and
  FAILS against the unfixed code.
- Extract the catalog modal's role-state computation into a pure
  `catalogRoleInstallState(role, workspaceRoles, language)` helper (mirrors
  role-launch.ts) and cover it with vitest: import / installed / update /
  same-slug-different-language.

SUGGESTIONS
- Restore IAiRoleUpdateFromCatalogResult as a discriminated union mirroring
  the server; narrow the consumer via `"reason" in result` (the boolean
  discriminant does not narrow under strictNullChecks:false).
- README: add a "How it's served" section documenting AI_AGENT_ROLES_CATALOG_URL
  (remote http(s) base / local path / empty => in-repo folder).
- check.mjs: drop the redundant `const key = slug` alias.
- Cover the reason->message mapping in useUpdateAiRoleFromCatalogMutation
  (4 branches) via renderHook with a mocked service.
- Cover importFromCatalog "bundle not in index" => BadGateway.
- Cover updateFromCatalog "slug in index but missing in bundle file" =>
  not-in-catalog.

ARCHITECTURE
- Extract the shared catalog read prefix: a private `loadBundleById`
  (fetchIndex -> meta -> fetchBundle -> versionMap) reused by getCatalogBundle
  and importFromCatalog, and a `catalogRoleContentFields` mapper shared by the
  import insert and update patch. The three orchestrations and their distinct
  write paths stay separate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:01:29 +03:00
claude code agent 227
d181b5c4ff test(temporary-notes): cover the create race-guard, broadcast deadline + cache patch; unify page->tree-node mappers
Address review comment 2159 on the temporary-notes UI work.

Tests:
- tree-model: cover handleCreate's race-guard temporaryExpiresAt patch — (a)
  server node inserted WITHOUT a deadline + create response carries one => node
  gains the deadline; (b) node already has a deadline => not overwritten, prev
  returned by reference.
- ws-tree.service.spec: broadcastPageCreated now asserts the deadline is carried
  when present and pinned to null (`?? null`) when absent.
- page-embed-query (new spec): syncTemporaryExpiresInCache patches the in-tree
  node's temporaryExpiresAt, and leaves the atom value at the same reference when
  the id is absent from the loaded tree (no write).

Refactor (closes the drift bug-class at the root):
- Client: extract one canonical pageToTreeNode(page, overrides) mapper in
  tree/utils and route buildTree, handleCreate's optimistic insert, the restore
  mutation and the duplicate handler through it. Restore stays permanent (server
  nulls temporaryExpiresAt) and duplicate stays permanent (server arms no timer)
  — both now reflect the server without a reload, where before they dropped the
  field entirely.
- Server: extract one toTreeNodeSnapshot(page) helper called by both the
  PAGE_CREATED event enrichment (page.repo) and the addTreeNode broadcast
  (ws-tree.service), so the optional temporaryExpiresAt can't drift between the
  two literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:58:40 +03:00
claude code agent 227
12ff76fb89 fix(temporary-notes): live sidebar clock marker + stacked mobile create buttons
Issue 1 — the sidebar tree's temporary-note clock marker did not appear/
disappear until a page reload when a note's temporary state changed.

- Make/unmake permanent from the page header menu and the in-page banner went
  through syncTemporaryExpiresInCache(), which patched the page query cache but
  never touched treeDataAtom, so the sidebar node kept its stale
  temporaryExpiresAt. Patch the tree node there too (via jotai's default store),
  so the marker updates without a reload.
- Creating a note as temporary showed no marker until reload: the create flow's
  cache write (invalidateOnCreatePage) omitted temporaryExpiresAt, so the tree
  rebuild (buildTree -> mergeRootTrees) overwrote the optimistic/socket node's
  marker with undefined. Carry temporaryExpiresAt in that cached entry.
- Thread temporaryExpiresAt through the server addTreeNode broadcast (PAGE_CREATED
  snapshot -> TreeNodeSnapshot -> broadcastPageCreated) so OTHER clients watching
  the space also render the marker immediately, and harden handleCreate's
  idempotency guard to patch the deadline if the broadcast won the insert race.

Issue 2 — the home and space-overview "New note" / "New temporary note" buttons
sat side-by-side and the temporary label clipped on narrow mobile widths. Lay
them out full-width, stacked vertically, and tint the temporary button orange
(matching the clock marker + banner) while the regular one stays neutral gray.

Tests: extend tree-socket-reducers.test.ts (addTreeNode carries
temporaryExpiresAt). Verified live with Playwright: marker appears on create and
toggles both ways with no reload; mobile buttons are stacked, full-width,
unclipped, and differently colored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:29:19 +03:00
claude code agent 227
26ca19f89e agent-roles: concurrency-safe catalog import + unified source validator
Item 1 (concurrency-safe import): add a partial UNIQUE index on
(workspace_id, source->>'slug', source->>'language') WHERE source IS NOT NULL
AND deleted_at IS NULL, so two concurrent imports of the same bundle can no
longer create duplicate roles for one catalog slug+language. The in-memory
installedKeys snapshot cannot see a sibling request's writes; the index is the
backstop. importFromCatalog now catches the 23505 from THIS index (keyed off
the constraint name) and treats it as "already installed" -> skip, batch
continues. A 23505 from the name-uniqueness index keeps its existing friendly
per-role error behavior (distinguished by constraint name; an indeterminate
23505 falls back to that path, so no regression).

Item 2 (single source validator): strengthen parseSource into THE single form
validator for the source jsonb column -> returns a fully-valid RoleSource | null
(slug/language non-empty strings, version a number). The service's weaker
roleSource is removed and both layers share the RoleSource type (defined in the
db entity.types module both already import AiAgentRole from, so no import
cycle). normalizeRow / the read path now only ever yield a valid RoleSource or
null; a malformed stored source normalizes to null (tolerated by the service).

Tests: parseSource null for {} / {slug:123} / {slug:'a'} / empty-string keys /
string version, typed value for a full valid shape; service test that a
source-uniqueness 23505 is skipped (not errored) and the batch continues.
Verified the partial index rejects a duplicate source-not-null row but allows
two source-NULL rows, and the migration up/down run cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:40:25 +03:00
claude code agent 227
50e79275e1 Address review on agent-roles catalog: changelog, docs, BadGateway on body-read abort
- CHANGELOG: document the importable multilingual agent-roles catalog under
  [Unreleased] (browse/import/update, 4 new endpoints, source column, the new
  AI_AGENT_ROLES_CATALOG_URL env var) (#222).
- Fix importFromCatalog docstring: a role is skipped only on source.slug AND
  source.language; another language of the same slug still imports.
- Provider: map a timeout/abort (or any failure) during the response-BODY read
  to a logged BadGatewayException, so a slow/dripping source yields a 502, not a
  generic 500. Existing too-large BadGateway cases are rethrown as-is.
- Service: inject a Nest Logger and log the root cause (with workspaceId/
  bundleId/slug) on a non-23505 insert error during import.
- Modal: hoist the duplicated i18n base-subtag into a single baseLang const.
- Tests: AbortError body-read -> BadGateway; null-body text() fallback (under
  and over cap); invalid-JSON and malformed-index BadGateway; non-23505 import
  error -> generic message + logged root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:15:45 +03:00
claude code agent 227
8be8279809 Address PR #222 review: migration order, provider logging, catalog tests
- Rename catalog-source migration 20260626T120000 -> T150000 so it sorts
  after develop's latest migration (T140000-page-temporary-notes); the old
  timestamp predated ai-chat-message-status/share-aliases and tripped
  Kysely's #ensureMigrationsInOrder, aborting server boot.
- Provider: inject a Nest Logger and log the real cause (incl. response
  status) in the parseJson / readLocal / fetchRemote catch blocks, and
  propagate a useful cause into the BadGatewayException message; add a
  shortError helper (robust to jest's realm-shifted Error-likes).
- Provider: replace the manual Uint8Array assembly with
  Buffer.concat(chunks).toString('utf8'); keep the streaming size cap.
- Controller spec: add admin-gate coverage for the 4 catalog routes
  (catalog/catalogBundle/import/updateFromCatalog) - non-admin Forbidden +
  service untouched, admin delegates with the right args.
- Service spec: add getCatalog/getCatalogBundle tests covering the
  localized() three-tier fallback, the sorted language union, the
  missing-bundle BadGateway, and the role-version default.
- Provider spec: add remote fetch-rejects and non-ok (503) error branches.
- Service: drop the dead Date.now() tail in freeName (now an explicit
  unreachable throw) and extract a shared isUniqueViolation() predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:36:26 +03:00
claude_code
19f84ca0e7 feat(ai-roles): add importable, multilingual agent roles catalog
Admins can browse a curated catalog of agent roles, import roles/bundles
into a workspace, and update an imported role when the catalog ships a
newer version.

Catalog: a set of JSON files (index.json manifest + bundles/<id>/<lang>.json)
served from a local folder (dev) or a remote http(s) base URL via
AI_AGENT_ROLES_CATALOG_URL. Seeded with the existing 7 RU roles (editorial +
research bundles) plus EN translations.

Server:
- migration: nullable jsonb `source` column on ai_agent_roles
  ({ slug, language, version }; null => manually created)
- catalog provider: remote fetch with timeout + streaming size cap, or local
  read; ^[a-z0-9-]+$ segment guard against path-traversal/SSRF
- admin endpoints: catalog, catalog/bundle, import, update-from-catalog
- import/update match by slug+language; update preserves `enabled`

Client:
- catalog modal with language selector and Import/Installed/Update states
- "Import from catalog" button + empty-state CTA in the roles settings panel
- en-US/ru-RU strings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:36:26 +03:00
claude_code
e9409e245b style(share): drop divider line from custom-address prefix
The right border on the address prefix read as a stray vertical line
between the domain and the slug. Remove it and rely on the subtle
prefix background alone to separate the two parts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:08 +03:00
claude_code
fa6a87e22d test(ai-chat): cover MessageList parent-side signature snapshot (#224)
PR #224 fixed an AI-chat streaming-render regression by moving the React.memo
content signature into the parent: MessageList now snapshots
messageSignature(message) per render and passes it to MessageItem as the
immutable `signature` prop. The existing memo tests only SIMULATED that
parent half by hardcoding `signature={messageSignature(message)}` in their
harness; the real MessageList was never exercised (chat-thread.test.tsx mocks
it out, and there was no message-list.test).

Add message-list.test.tsx that mounts the REAL MessageList (without mocking
MessageItem or messageSignature) and asserts that an in-place mutation of a
reused message object surfaces on re-render. This guards the parent-side
contract: re-caching the signature on message identity (stable across deltas
while parts mutate) would refreeze the row, and this test would fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:33:01 +03:00
claude_code
0fc9c4a998 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 22:09:22 +03:00
claude_code
40b8f7922a feat(client): quick-create regular and temporary notes from Home and Space screens
Add fast note-creation entry points alongside the existing space-sidebar
actions.

- Home: refactor new-note-button.tsx into a reusable inner CreateNoteButton
  (parametrized by `temporary`/label/icon, keeps the 0/1/many writable-space
  resolution and space-picker dropdown) and render two equal-width buttons via
  `Group grow` — a regular note and a temporary note (IconHourglass).
- Space overview: new SpaceCreateNoteButtons component with two buttons that
  create a regular/temporary note directly in the current space and open it,
  reusing useTreeMutation.handleCreate (optimistic sidebar-tree insert +
  navigation). Permission-gated to members who can manage pages; a local
  pending state shows a per-button spinner and disables both to prevent a
  double-create. Wired into space-home.tsx above the tabs.
- Reuse existing i18n keys (no new strings): "New note", "New temporary note",
  "Create in space".
- Docs: add a CHANGELOG [Unreleased] entry and a "Temporary notes" roadmap
  bullet to README.md and README.ru.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:09 +03:00
08c70cf550 Merge pull request 'fix(ai-chat): assistant turn renders nothing — memo signature defeated by AI-SDK in-place part mutation (#182 regression)' (#224) from fix/ai-chat-empty-render into develop
Reviewed-on: #224
2026-06-26 22:09:05 +03:00
claude_code
276ccc0783 refactor(ai): drop Generative AI flag, gate title generation on AI chat
Remove the separate, un-toggleable `settings.ai.generative` workspace flag
(and its write-side alias `generativeAi`) along with the dead "Ask AI"
generative editor menu, and re-gate the AI page-title generation on the
general AI chat flag (`settings.ai.chat`) — the same toggle that enables
the chat agent and the chat stream endpoint.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:35:30 +03:00
claude code agent 227
c64d7f315e fix(ai-chat): open chat window before resolving the bound chat (#191)
Address PR #209 review.

- use-open-ai-chat.ts: call setWindowOpen(true) before awaiting
  getBoundChat so the header button feels instant on slow connections;
  the chat switch (setActiveChatId/setDraft/setSelectedRoleId) is applied
  after the round-trip resolves. Also drop the redundant no-op
  setWindowOpen(true) in the already-open branch (bare early return).
- CHANGELOG.md: document the header AI-chat button auto-opening the
  latest chat bound to the current document under [Unreleased]/Added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:02:15 +03:00
claude code agent 227
7a7aa79eab feat(ai-chat): auto-open last chat bound to the document (#191)
On opening the floating AI-chat window from the header on a document page,
auto-open the LAST chat bound to that document. Binding reuses the existing
ai_chats.page_id (no migration): the bound chat is the requesting user's
most-recent non-deleted chat created on that page, so a new chat on the page
becomes the bound one for free. Resolution happens only on a genuine
closed -> open transition; the provenance badge deep-link is untouched.

Server: AiChatRepo.findLatestByPage + POST /ai-chat/bound-chat (BoundChatDto),
both read-only and owner/workspace-scoped.
Client: getBoundChat service + useOpenAiChatForCurrentPage hook wired into the
app-header entry point (fail-soft to a fresh chat; draft/role cleared only on a
real switch).
Tests: repo scoping/ordering, controller wiring, and hook behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:01:38 +03:00
240 changed files with 22104 additions and 1028 deletions

View File

@@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false
# Example: https://intranet.example.com,https://portal.example.com # Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS= 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) # Enable debug logging in production (default: false)
DEBUG_MODE=false DEBUG_MODE=false
@@ -132,6 +145,14 @@ MCP_DOCMOST_PASSWORD=
# NEVER set is_agent on a human or shared account — every action by that account # 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. # (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. # Per-embedding-call timeout in milliseconds for the RAG indexer.
# A slow/hung embeddings endpoint fails after this and the batch continues. # A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000 # AI_EMBEDDING_TIMEOUT_MS=120000

View File

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

View File

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

5
.gitignore vendored
View File

@@ -49,3 +49,8 @@ lerna-debug.log*
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time) # Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
apps/client/public/vad/ apps/client/public/vad/
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
/ios
/android
.capacitor

View File

@@ -254,7 +254,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. - **Redis** backs caching, the BullMQ queues, the WebSocket Socket.IO adapter, and collaboration sync.
### The two AI subsystems (the main fork additions) ### The two AI subsystems (the main fork additions)
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. 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.
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: 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/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. - `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.

View File

@@ -12,6 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 - **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 message gains a "send now" action that interrupts the streaming turn and
immediately sends that message, keeping the agent's partial output. The immediately sends that message, keeping the agent's partial output. The
@@ -19,6 +28,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
answer was cut off and builds on it instead of restarting; the rest of the answer was cut off and builds on it instead of restarting; the rest of the
queue still flushes normally afterward. (#198) 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 ## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to This release makes AI chat durable and fast: assistant turns are persisted to
@@ -97,6 +223,13 @@ per-workspace rolling-day token budget.
applies it through the existing `/pages/update` route — reflecting it in the 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` title field and broadcasting to other clients. Gated by the `settings.ai.generative`
flag and throttled per user. (#199) 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 ### Changed

View File

@@ -23,6 +23,11 @@ RUN apt-get update \
WORKDIR /app 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 apps
COPY --from=builder /app/apps/server/dist /app/apps/server/dist COPY --from=builder /app/apps/server/dist /app/apps/server/dist
COPY --from=builder /app/apps/client/dist /app/apps/client/dist COPY --from=builder /app/apps/client/dist /app/apps/client/dist

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. | | **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. | | **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`, 38 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`, 39 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. | | **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*. | | **Rebranding** | App logo / name changed from *Docmost* to *Gitmost*. |
| **Compact page tree** | Default page-tree indentation reduced from 16px to 8px per nesting level. | | **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 ### Embedded MCP server
Gitmost has **our own MCP server** — [docmost-mcp](https://github.com/vvzvlad/docmost-mcp), 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 **38 which we wrote — **built directly into the app** and served at `/mcp`. It exposes **39
agent-native tools**: surgical per-block edits (patch / insert / delete by id), 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, 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 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 | | | **Gitmost `/mcp` (our docmost-mcp)** | Docmost's built-in MCP |
| --- | :---: | :---: | | --- | :---: | :---: |
| **Enterprise license** | Not required | Required | | **Enterprise license** | Not required | Required |
| **Tools** | 38, agent-native | Coarse (read Markdown, page CRUD, replace whole page) | | **Tools** | 39, agent-native | Coarse (read Markdown, page CRUD, replace whole page) |
| **Per-block edits / find-replace / scripted transforms** | ✅ | — | | **Per-block edits / find-replace / scripted transforms** | ✅ | — |
| **Structured table editing, version diff / restore** | ✅ | — | | **Structured table editing, version diff / restore** | ✅ | — |
| **Comments, images, share links** | ✅ | — | | **Comments, images, share links** | ✅ | — |
@@ -104,6 +104,7 @@ 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). -**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. -**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. -**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 ### In progress

View File

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

View File

@@ -0,0 +1,193 @@
# 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

@@ -0,0 +1,31 @@
{
"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

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

View File

@@ -0,0 +1,353 @@
#!/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

@@ -0,0 +1,26 @@
{
"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,6 +10,7 @@
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.json" /> <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="mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" /> <meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-title" content="Gitmost" /> <meta name="apple-mobile-web-app-title" content="Gitmost" />

View File

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

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)", "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.", "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", "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", "Upgrade your plan": "Upgrade Ihres Plans",
"Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz", "Available with a paid license": "Verfügbar mit einer kostenpflichtigen Lizenz",
"Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.", "Upgrade your license tier.": "Stufen Sie Ihre Lizenz hoch.",

View File

@@ -464,6 +464,18 @@
"Move page": "Move page", "Move page": "Move page",
"Move page to a different space.": "Move page to a different space.", "Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...", "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", "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.", "Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share", "Share": "Share",
@@ -687,9 +699,6 @@
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)", "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.", "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", "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", "Upgrade your plan": "Upgrade your plan",
"Available with a paid license": "Available with a paid license", "Available with a paid license": "Available with a paid license",
"Upgrade your license tier.": "Upgrade your license tier.", "Upgrade your license tier.": "Upgrade your license tier.",
@@ -1336,6 +1345,7 @@
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.", "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", "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 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 custom address?": "Move custom address?",
"Move here": "Move here", "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}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
@@ -1349,5 +1359,23 @@
"Could not generate a title": "Could not generate a title", "Could not generate a title": "Could not generate a title",
"AI title generation is disabled": "AI title generation is disabled", "AI title generation is disabled": "AI title generation is disabled",
"AI is not configured": "AI is not configured", "AI is not configured": "AI is not configured",
"Too many requests, please try again later": "Too many requests, please try again later" "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)"
} }

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)", "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.", "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", "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", "Upgrade your plan": "Mejora tu plan",
"Available with a paid license": "Disponible con una licencia de pago", "Available with a paid license": "Disponible con una licencia de pago",
"Upgrade your license tier.": "Mejora el nivel de tu licencia.", "Upgrade your license tier.": "Mejora el nivel de tu licencia.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)", "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.", "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", "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", "Upgrade your plan": "Mettez à niveau votre forfait",
"Available with a paid license": "Disponible avec une licence payante", "Available with a paid license": "Disponible avec une licence payante",
"Upgrade your license tier.": "Mettez à niveau votre niveau de licence.", "Upgrade your license tier.": "Mettez à niveau votre niveau de licence.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)", "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.", "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", "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", "Upgrade your plan": "Aggiorna il tuo piano",
"Available with a paid license": "Disponibile con una licenza a pagamento", "Available with a paid license": "Disponibile con una licenza a pagamento",
"Upgrade your license tier.": "Aggiorna il livello della tua licenza.", "Upgrade your license tier.": "Aggiorna il livello della tua licenza.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)", "AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "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": "プランをアップグレードする", "Upgrade your plan": "プランをアップグレードする",
"Available with a paid license": "有料ライセンスで利用可能", "Available with a paid license": "有料ライセンスで利用可能",
"Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。", "Upgrade your license tier.": "ライセンスタイアをアップグレードしてください。",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)", "AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "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": "요금제를 업그레이드하세요", "Upgrade your plan": "요금제를 업그레이드하세요",
"Available with a paid license": "유료 라이선스에서만 사용 가능합니다", "Available with a paid license": "유료 라이선스에서만 사용 가능합니다",
"Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.", "Upgrade your license tier.": "라이선스 등급을 업그레이드하세요.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)", "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.", "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", "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", "Upgrade your plan": "Upgrade je abonnement",
"Available with a paid license": "Beschikbaar met een betaalde licentie", "Available with a paid license": "Beschikbaar met een betaalde licentie",
"Upgrade your license tier.": "Upgrade je licentieniveau.", "Upgrade your license tier.": "Upgrade je licentieniveau.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)", "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.", "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", "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", "Upgrade your plan": "Faça upgrade do seu plano",
"Available with a paid license": "Disponível com uma licença paga", "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.", "Upgrade your license tier.": "Faça upgrade do seu nível de licença.",

View File

@@ -474,6 +474,18 @@
"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": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
"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": "Оглавление", "Table of contents": "Оглавление",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.", "Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться", "Share": "Поделиться",
@@ -749,9 +761,6 @@
"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)": "Генеративный ИИ (Спросить ИИ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
"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.": "Обновите уровень вашей лицензии.",
@@ -1193,6 +1202,7 @@
"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": "Используйте 2–60 строчных букв, цифр и дефисов", "Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
"This address is already in use": "Этот адрес уже занят", "This address is already in use": "Этот адрес уже занят",
"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?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?", "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
@@ -1206,5 +1216,24 @@
"Could not generate a title": "Не удалось придумать название", "Could not generate a title": "Не удалось придумать название",
"AI title generation is disabled": "Генерация названий через AI отключена", "AI title generation is disabled": "Генерация названий через AI отключена",
"AI is not configured": "AI не настроен", "AI is not configured": "AI не настроен",
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже" "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)": "Подключение… (только чтение)"
} }

View File

@@ -665,9 +665,6 @@
"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)": "Генеративний ШІ (Запитати ШІ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
"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.": "Оновіть рівень своєї ліцензії.",

View File

@@ -665,9 +665,6 @@
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)", "AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换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": "升级您的方案", "Upgrade your plan": "升级您的方案",
"Available with a paid license": "需付费许可才可用", "Available with a paid license": "需付费许可才可用",
"Upgrade your license tier.": "升级您的许可等级。", "Upgrade your license tier.": "升级您的许可等级。",

View File

@@ -1,30 +1,19 @@
{ {
"id": "/",
"name": "Gitmost", "name": "Gitmost",
"short_name": "Gitmost", "short_name": "Gitmost",
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
"lang": "en",
"start_url": "/", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any",
"background_color": "#0E1117", "background_color": "#0E1117",
"theme_color": "#0E1117", "theme_color": "#0E1117",
"icons": [ "icons": [
{ { "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
"src": "icons/favicon-16x16.png", { "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
"type": "image/png", { "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
"sizes": "16x16" { "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
},
{
"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 { BrandLogo } from "@/components/ui/brand-logo";
import TopMenu from "@/components/layouts/global/top-menu.tsx"; import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useAtom, useSetAtom } from "jotai"; import { useAtom } from "jotai";
import { import {
desktopSidebarAtom, desktopSidebarAtom,
mobileSidebarAtom, mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts"; } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; import { useOpenAiChatForCurrentPage } from "@/features/ai-chat/hooks/use-open-ai-chat.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-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 { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx"; import SidebarToggle from "@/components/ui/sidebar-toggle-button.tsx";
@@ -38,7 +38,9 @@ export function AppHeader() {
const toggleDesktop = useToggleSidebar(desktopSidebarAtom); const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom); // 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();
// AI chat entry point: only shown when the workspace enables it (A7 gate). // AI chat entry point: only shown when the workspace enables it (A7 gate).
const aiChatEnabled = workspace?.settings?.ai?.chat === true; const aiChatEnabled = workspace?.settings?.ai?.chat === true;
@@ -105,7 +107,7 @@ export function AppHeader() {
color="dark" color="dark"
size="sm" size="sm"
aria-label={t("AI chat")} aria-label={t("AI chat")}
onClick={() => setAiChatWindowOpen((v) => !v)} onClick={openAiChat}
> >
<IconMessage size={20} /> <IconMessage size={20} />
</ActionIcon> </ActionIcon>

View File

@@ -0,0 +1,119 @@
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

@@ -0,0 +1,135 @@
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

@@ -0,0 +1,67 @@
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,21 +13,40 @@ import {
deleteAiRole, deleteAiRole,
getAiChatMessages, getAiChatMessages,
getAiChats, getAiChats,
getAiRoleCatalog,
getAiRoleCatalogBundle,
getAiRoles, getAiRoles,
importAiRolesFromCatalog,
renameAiChat, renameAiChat,
updateAiRole, updateAiRole,
updateAiRoleFromCatalog,
} from "@/features/ai-chat/services/ai-chat-service.ts"; } from "@/features/ai-chat/services/ai-chat-service.ts";
import { import {
IAiChat, IAiChat,
IAiChatMessageRow, IAiChatMessageRow,
IAiRole, IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate, IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate, IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts"; } from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"]; export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"]; 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) => [ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages", "ai-chat-messages",
chatId, chatId,
@@ -223,3 +242,109 @@ 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

@@ -0,0 +1,106 @@
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

@@ -0,0 +1,100 @@
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,8 +6,13 @@ import {
IAiChatMessageRow, IAiChatMessageRow,
IAiChatMessagesParams, IAiChatMessagesParams,
IAiRole, IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
IAiRoleCreate, IAiRoleCreate,
IAiRoleImportPayload,
IAiRoleImportResult,
IAiRoleUpdate, IAiRoleUpdate,
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts"; } from "@/features/ai-chat/types/ai-chat.types.ts";
/** /**
@@ -37,6 +42,17 @@ export async function getAiChatMessages(
return req.data; 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. */ /** Rename a chat. */
export async function renameAiChat(data: { export async function renameAiChat(data: {
chatId: string; chatId: string;
@@ -112,3 +128,54 @@ export async function deleteAiRole(id: string): Promise<{ success: true }> {
}); });
return req.data; 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,10 +57,79 @@ export interface IAiRole {
autoStart: boolean; autoStart: boolean;
// Custom auto-start text; null/empty => the default launch message is sent. // Custom auto-start text; null/empty => the default launch message is sent.
launchMessage: string | null; 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; createdAt?: string;
updatedAt?: 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. */ /** Admin create payload for a role. */
export interface IAiRoleCreate { export interface IAiRoleCreate {
name: string; name: string;

View File

@@ -0,0 +1,107 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,154 @@
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,6 +23,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils"; import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
export default function useAuth() { export default function useAuth() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -33,6 +34,20 @@ export default function useAuth() {
const handleSignIn = async (data: ILogin) => { const handleSignIn = async (data: ILogin) => {
setIsLoading(true); 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 { try {
await login(data); await login(data);
setIsLoading(false); setIsLoading(false);
@@ -123,6 +138,13 @@ export default function useAuth() {
const handleLogout = async () => { const handleLogout = async () => {
setCurrentUser(RESET); setCurrentUser(RESET);
await logout(); 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`); window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
}; };

View File

@@ -0,0 +1,43 @@
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,6 +3,27 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service";
import { ICollabToken, IVerifyUserToken } from "../types/auth.types"; import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
import { isAxiosError } from "axios"; 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( export function useVerifyUserTokenQuery(
verify: IVerifyUserToken, verify: IVerifyUserToken,
): UseQueryResult<any, Error> { ): UseQueryResult<any, Error> {
@@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs //refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
//refetchIntervalInBackground: true, //refetchIntervalInBackground: true,
refetchOnMount: true, refetchOnMount: true,
//@ts-ignore retry: collabTokenRetry,
retry: (failureCount, error) => {
if (isAxiosError(error) && error.response.status === 404) {
return false;
}
return 10;
},
retryDelay: (retryAttempt) => { retryDelay: (retryAttempt) => {
// Exponential backoff: 5s, 10s, 20s, etc. // Exponential backoff: 5s, 10s, 20s, etc.
return 5000 * Math.pow(2, retryAttempt - 1); return 5000 * Math.pow(2, retryAttempt - 1);

View File

@@ -20,6 +20,7 @@ import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts"; import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
export const RQ_KEY = (pageId: string) => ["comments", pageId]; export const RQ_KEY = (pageId: string) => ["comments", pageId];
@@ -60,6 +61,9 @@ export function useCreateCommentMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
return useMutation<IComment, Error, Partial<IComment>>({ 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), mutationFn: (data) => createComment(data),
onSuccess: (newComment) => { onSuccess: (newComment) => {
const cache = queryClient.getQueryData( const cache = queryClient.getQueryData(

View File

@@ -10,7 +10,11 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>(""); export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false); // 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 showLinkMenuAtom = atom(false); export const showLinkMenuAtom = atom(false);

View File

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

View File

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

View File

@@ -1,23 +0,0 @@
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

@@ -13,7 +13,7 @@ interface Props {
/** /**
* AI "generate title" button (#199). Reads the live editor content and applies a * 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 * model-suggested title immediately. Rendered in the page byline, only in edit
* mode and when the workspace's generative AI flag is on. * mode and when the workspace's AI chat flag is on.
*/ */
export const GenerateTitleGroup: FC<Props> = ({ export const GenerateTitleGroup: FC<Props> = ({
pageId, pageId,

View File

@@ -0,0 +1,163 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,168 @@
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

@@ -0,0 +1,126 @@
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,7 +3,14 @@ import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model"; import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs"; import { find } from "linkifyjs";
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext"; import {
markdownToHtml,
htmlToMarkdown,
canonicalizeFootnotes,
FOOTNOTES_LIST_NAME,
FOOTNOTE_REFERENCE_NAME,
} from "@docmost/editor-ext";
import type { Schema } from "@tiptap/pm/model";
export const MarkdownClipboard = Extension.create({ export const MarkdownClipboard = Extension.create({
name: "markdownClipboard", name: "markdownClipboard",
@@ -83,12 +90,25 @@ export const MarkdownClipboard = Extension.create({
const body = elementFromString(parsed); const body = elementFromString(parsed);
normalizeTableColumnWidths(body); normalizeTableColumnWidths(body);
const contentNodes = DOMParser.fromSchema( const parsedSlice = DOMParser.fromSchema(
this.editor.schema, this.editor.schema,
).parseSlice(body, { ).parseSlice(body, {
preserveWhitespace: true, 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); tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1); const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1)); tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
@@ -133,6 +153,54 @@ 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) { function elementFromString(value) {
// add a wrapper to preserve leading and trailing whitespace // add a wrapper to preserve leading and trailing whitespace
const wrappedValue = `<body>${value}</body>`; const wrappedValue = `<body>${value}</body>`;

View File

@@ -34,6 +34,8 @@ import {
} from "@/features/editor/atoms/editor-atoms.ts"; } from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group"; import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-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 MemoizedTitleEditor = React.memo(TitleEditor);
const MemoizedPageEditor = React.memo(PageEditor); const MemoizedPageEditor = React.memo(PageEditor);
@@ -77,19 +79,27 @@ export function FullEditor({
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const workspace = useAtomValue(workspaceAtom); const workspace = useAtomValue(workspaceAtom);
const isDictationEnabled = workspace?.settings?.ai?.dictation === true; const isDictationEnabled = workspace?.settings?.ai?.dictation === true;
// AI title generation reuses the generative AI flag (same gate as the on-page // AI title generation is gated by the general AI chat flag (the same toggle
// generative menu); the server enforces it too (#199). // that enables the chat agent); the server enforces it too (#199).
const isTitleGenEnabled = workspace?.settings?.ai?.generative === true; const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth; // `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 editorToolbarEnabled = const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false; user?.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom( const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom, currentPageEditModeAtom,
); );
const userPageEditMode = const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit; user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = currentPageEditMode === 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 // 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. // page navigation — so the mode sticks across navigations within a session.
useEffect(() => { useEffect(() => {
@@ -110,28 +120,32 @@ export function FullEditor({
)} )}
<MemoizedDeletedPageBanner slugId={slugId} /> <MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTemporaryNoteBanner slugId={slugId} /> <MemoizedTemporaryNoteBanner slugId={slugId} />
<MemoizedTitleEditor <EditorProvidersContext.Provider
pageId={pageId} value={ydoc && remote ? { ydoc, remote, providersReady } : null}
slugId={slugId} >
title={title} <MemoizedTitleEditor
spaceSlug={spaceSlug} pageId={pageId}
editable={editable} slugId={slugId}
/> title={title}
<PageByline spaceSlug={spaceSlug}
pageId={pageId} editable={editable}
creator={creator} />
contributors={contributors} <PageByline
editable={editable} pageId={pageId}
isEditMode={isEditMode} creator={creator}
isDictationEnabled={isDictationEnabled} contributors={contributors}
isTitleGenEnabled={isTitleGenEnabled} editable={editable}
/> isEditMode={isEditMode}
<MemoizedPageEditor isDictationEnabled={isDictationEnabled}
pageId={pageId} isTitleGenEnabled={isTitleGenEnabled}
editable={editable} />
content={content} <MemoizedPageEditor
canComment={canComment} pageId={pageId}
/> editable={editable}
content={content}
canComment={canComment}
/>
</EditorProvidersContext.Provider>
</Container> </Container>
); );
} }
@@ -254,7 +268,7 @@ function PageByline({
{showDictation && editor && ( {showDictation && editor && (
<DictationGroup editor={editor} color="gray" iconSize={20} /> <DictationGroup editor={editor} color="gray" iconSize={20} />
)} )}
{/* Shown only in edit mode when the workspace's generative AI flag is on, {/* Shown only in edit mode when the workspace's AI chat flag is on,
so AI title generation stays reachable from the byline (#199). */} so AI title generation stays reachable from the byline (#199). */}
{showTitleGen && ( {showTitleGen && (
<GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} /> <GenerateTitleGroup pageId={pageId} color="gray" iconSize={20} />

View File

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,26 @@
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

@@ -139,7 +139,7 @@ describe("useGeneratePageTitle", () => {
); );
}); });
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => { it("happy path: applies the title, refreshes cache, broadcasts, and does NOT write the editor", async () => {
const store = createStore(); const store = createStore();
const titleEditor = makeTitleEditor(); const titleEditor = makeTitleEditor();
store.set(pageEditorAtom as never, makePageEditor("pageA")); store.set(pageEditorAtom as never, makePageEditor("pageA"));
@@ -157,9 +157,11 @@ describe("useGeneratePageTitle", () => {
title: "Generated Title", title: "Generated Title",
}); });
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A); expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
expect(titleEditor.commands.setContent).toHaveBeenCalledWith( // The title editor is bound to the Yjs `title` fragment; the server REST
"Generated Title", // 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(localEmitMock).toHaveBeenCalled();
expect(emitMock).toHaveBeenCalled(); expect(emitMock).toHaveBeenCalled();
expect(notificationsShowMock).toHaveBeenCalledWith( expect(notificationsShowMock).toHaveBeenCalledWith(
@@ -167,7 +169,7 @@ describe("useGeneratePageTitle", () => {
); );
}); });
it("does NOT write the visible title field when the user navigated away during generation", async () => { it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
const store = createStore(); const store = createStore();
const titleEditor = makeTitleEditor(); // persistent across navigation const titleEditor = makeTitleEditor(); // persistent across navigation
store.set(pageEditorAtom as never, makePageEditor("pageA")); store.set(pageEditorAtom as never, makePageEditor("pageA"));
@@ -203,55 +205,9 @@ describe("useGeneratePageTitle", () => {
pageId: "pageA", pageId: "pageA",
title: "Generated Title", title: "Generated Title",
}); });
// ...but we must NOT stamp page A's title into page B's visible field. // ...the hook never writes the editor regardless of navigation...
expect(titleEditor.commands.setContent).not.toHaveBeenCalled(); expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
// The change is still broadcast to other clients. // ...and the change is still broadcast to other clients.
expect(emitMock).toHaveBeenCalled();
});
it("does NOT write the visible title field when the title editor is focused", async () => {
const store = createStore();
const titleEditor = makeTitleEditor();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
store.set(titleEditorAtom as never, titleEditor);
// Resolve generation under our control so we can mark the live title editor
// as focused before the post-generation write runs.
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();
});
// The user clicked into the title field while the model ran — overwriting it
// now would clobber what they are actively typing.
act(() => {
(titleEditor as { isFocused: boolean }).isFocused = true;
});
await act(async () => {
resolveTitle("Generated Title");
await pending;
});
// The DB write still persists the value...
expect(updateTitleMock).toHaveBeenCalledWith({
pageId: "pageA",
title: "Generated Title",
});
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
// ...but the visible field is left alone while it is focused.
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
// The change is still broadcast to other clients.
expect(localEmitMock).toHaveBeenCalled();
expect(emitMock).toHaveBeenCalled(); expect(emitMock).toHaveBeenCalled();
}); });

View File

@@ -1,13 +1,9 @@
import { useRef } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { htmlToMarkdown } from "@docmost/editor-ext"; import { htmlToMarkdown } from "@docmost/editor-ext";
import { import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { import {
updatePageData, updatePageData,
useUpdateTitlePageMutation, useUpdateTitlePageMutation,
@@ -33,18 +29,9 @@ const MAX_CONTENT_CHARS = 20000;
export function useGeneratePageTitle(pageId: string) { export function useGeneratePageTitle(pageId: string) {
const { t } = useTranslation(); const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom); const pageEditor = useAtomValue(pageEditorAtom);
const titleEditor = useAtomValue(titleEditorAtom);
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation(); const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
const emit = useQueryEmit(); const emit = useQueryEmit();
// The page/title editors come from GLOBAL atoms that re-point when the user
// navigates to another page. The mutation below awaits the model for 1-3s, and
// its closure captures the editors from the render that started it. Keep a live
// reference so the post-generation write targets whatever page is on screen
// *now*, not the page the generation was started from.
const editorsRef = useRef({ pageEditor, titleEditor });
editorsRef.current = { pageEditor, titleEditor };
return useMutation<void, Error, void>({ return useMutation<void, Error, void>({
mutationFn: async () => { mutationFn: async () => {
if (!pageEditor || pageEditor.isDestroyed) return; if (!pageEditor || pageEditor.isDestroyed) return;
@@ -70,33 +57,15 @@ export function useGeneratePageTitle(pageId: string) {
const page = await updateTitle({ pageId, title }); // POST /pages/update const page = await updateTitle({ pageId, title }); // POST /pages/update
updatePageData(page); // refresh the react-query cache updatePageData(page); // refresh the react-query cache
// Reflect the new title in the field immediately. The button lives in the // Do NOT write the title into the editor here. The title editor is bound to
// byline, so the title editor is not focused — setContent is safe and stays // the Yjs `title` fragment and Yjs is the source of truth. The server REST
// undoable through its History extension (Ctrl/Cmd+Z reverts the change). // /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
// // a full clear+replace) and the reseed reaches the bound title editor on
// Guard against navigation during generation: if the user switched pages // its own as a remote provider update. The old REST-era setContent here
// while the model ran, the (persistent) title editor now shows ANOTHER // would race that reseed and double/garble the title (the "Yjs duplication
// page, so writing here would drop page A's title into page B's visible // trap"), so it is intentionally omitted. The DB write above is keyed by
// field. page-editor.tsx stamps the live page editor with its pageId // the captured `pageId`, so it stays correct even if the user navigated
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !== // away during generation.
// pageId` guard — bail the visible write unless that live editor still
// belongs to the page this title was generated for. The DB write above is
// already correct (keyed by the captured `pageId`), and the broadcast below
// still propagates page A's change to other clients.
const livePageEditor = editorsRef.current.pageEditor;
const liveTitleEditor = editorsRef.current.titleEditor;
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
const livePageId = (livePageEditor?.storage as { pageId?: string })
?.pageId;
const stillOnPage = livePageId === pageId;
if (
stillOnPage &&
liveTitleEditor &&
!liveTitleEditor.isDestroyed &&
!liveTitleEditor.isFocused
) {
liveTitleEditor.commands.setContent(page.title);
}
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape. // Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
const event: UpdateEvent = { const event: UpdateEvent = {

View File

@@ -0,0 +1,189 @@
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,16 +6,7 @@ import React, {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { IndexeddbPersistence } from "y-indexeddb"; import { WebSocketStatus } from "@hocuspocus/provider";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import { import {
Editor, Editor,
EditorContent, EditorContent,
@@ -28,13 +19,15 @@ import {
mainExtensions, mainExtensions,
} from "@/features/editor/extensions/extensions"; } from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { import {
currentPageEditModeAtom, currentPageEditModeAtom,
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms"; } 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 { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import { import {
activeCommentIdAtom, activeCommentIdAtom,
@@ -58,10 +51,8 @@ import {
} from "@/features/editor/components/common/editor-paste-handler.tsx"; } from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu"; 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 SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks"; import { useDebouncedCallback } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
@@ -72,9 +63,7 @@ import {
GitmostInsertRecordingResult, GitmostInsertRecordingResult,
gitmostInsertRecordingIntoEditor, gitmostInsertRecordingIntoEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts"; } from "@/features/editor/gitmost/gitmost-recording.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts"; import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll"; import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu"; import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
@@ -84,6 +73,10 @@ import { PageEmbedLookupProvider } from "@/features/editor/components/page-embed
import { PageEmbedAncestryProvider } from "@/features/editor/components/page-embed/page-embed-ancestry-context"; import { PageEmbedAncestryProvider } from "@/features/editor/components/page-embed/page-embed-ancestry-context";
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker"; import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
isBodyEditable,
isCollabSynced,
} from "@/features/editor/editor-sync-state";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -99,7 +92,6 @@ export default function PageEditor({
canComment, canComment,
}: PageEditorProps) { }: PageEditorProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false); const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null); const editorRef = useRef<Editor | null>(null);
@@ -113,22 +105,10 @@ export default function PageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom); const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom); const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom( const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
); );
const menuContainerRef = useRef(null); 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 { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug); const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom); const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
@@ -137,141 +117,27 @@ export default function PageEditor({
[isComponentMounted], [isComponentMounted],
); );
const { handleScrollTo } = useEditorScroll({ canScroll }); 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);
useEffect(() => { // Shared providers + Y.Doc lifted into full-editor via context. The provider
if (!providersRef.current) { // lifecycle (creation, idle/visibility connect, attach, destroy, token
const documentName = `page.${pageId}`; // refresh) lives in usePageCollabProviders. Null-safe when rendered without
const ydoc = new Y.Doc(); // the context (defensive) — in practice full-editor always provides it.
const local = new IndexeddbPersistence(documentName, ydoc); const editorProviders = useEditorProviders();
const socket = new HocuspocusProviderWebsocket({ const remote = editorProviders?.remote ?? null;
url: collaborationURL, const providersReady = editorProviders?.providersReady ?? false;
}); const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const onLocalSyncedHandler = () => { const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
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(() => { const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) { if (!providersReady || !remote || !currentUser?.user) {
return mainExtensions; return mainExtensions;
} }
const remoteProvider = providersRef.current.remote;
return [ return [
...mainExtensions, ...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user), ...collabExtensions(remote, currentUser?.user),
]; ];
}, [providersReady, currentUser?.user]); }, [providersReady, remote, currentUser?.user]);
const editor = useEditor( const editor = useEditor(
{ {
@@ -440,6 +306,9 @@ export default function PageEditor({
const isSynced = isLocalSynced && isRemoteSynced; const isSynced = isLocalSynced && isRemoteSynced;
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
useEffect(() => { useEffect(() => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) { if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
@@ -451,17 +320,21 @@ export default function PageEditor({
}, [yjsConnectionStatus, isSynced]); }, [yjsConnectionStatus, isSynced]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
editor.setEditable(editable && currentPageEditMode === PageEditMode.Edit); // Keep the body read-only until the collab doc has synced (showStatic), so
}, [currentPageEditMode, editor, editable]); // early keystrokes on a freshly created page can't be lost (#218).
editor.setEditable(
const hasConnectedOnceRef = useRef(false); isBodyEditable({
const [showStatic, setShowStatic] = useState(true); editable,
inEditMode: currentPageEditMode === PageEditMode.Edit,
showStatic,
}),
);
}, [currentPageEditMode, editor, editable, showStatic]);
useEffect(() => { useEffect(() => {
if ( if (
!hasConnectedOnceRef.current && !hasConnectedOnceRef.current &&
yjsConnectionStatus === WebSocketStatus.Connected && isCollabSynced(yjsConnectionStatus, isSynced)
isSynced
) { ) {
hasConnectedOnceRef.current = true; hasConnectedOnceRef.current = true;
setShowStatic(false); setShowStatic(false);
@@ -473,17 +346,43 @@ export default function PageEditor({
<PageEmbedLookupProvider> <PageEmbedLookupProvider>
<PageEmbedAncestryProvider hostPageId={pageId}> <PageEmbedAncestryProvider hostPageId={pageId}>
{showStatic ? ( {showStatic ? (
<EditorProvider <div style={{ position: "relative" }}>
editable={false} {/* Surface the pre-sync read-only window so edits typed before the
immediatelyRender={true} collab provider connects aren't silently swallowed (#218). Shown
extensions={mainExtensions} only when the user is otherwise allowed to edit. */}
content={content} {editable && currentPageEditMode === PageEditMode.Edit && (
editorProps={{ <div
attributes: { role="status"
"aria-label": t("Page content"), 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>
) : ( ) : (
<div className="editor-container" style={{ position: "relative" }}> <div className="editor-container" style={{ position: "relative" }}>
<div ref={menuContainerRef}> <div ref={menuContainerRef}>
@@ -513,7 +412,7 @@ export default function PageEditor({
{editor && {editor &&
!editorIsEditable && !editorIsEditable &&
(editable || canComment) && (editable || canComment) &&
providersRef.current && <ReadonlyBubbleMenu editor={editor} />} remote && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && ( {showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} /> <CommentDialog editor={editor} pageId={pageId} />
)} )}

View File

@@ -0,0 +1,14 @@
/**
* 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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,19 @@
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

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

View File

@@ -1,7 +1,10 @@
import { Button, Menu, Text } from "@mantine/core"; import { Button, Menu, Stack, Text } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react"; import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next"; 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 { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts"; import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -10,49 +13,78 @@ import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { canCreatePage } from "./can-create-page.ts"; import { canCreatePage } from "./can-create-page.ts";
// Prominent home-screen action to create a new note (page). Because the home // A single create-note action, parametrized by `temporary`. Self-contained: it
// screen has no active space, the target space is resolved from the user's // owns its own create mutation so the regular and temporary buttons show
// writable spaces: created directly when there is one, picked from a dropdown // independent loading state, while the list of writable spaces is resolved once
// when there are several. // by the parent and passed in. With exactly one writable space it creates
export default function NewNoteButton() { // 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;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const createPageMutation = useCreatePageMutation(); const createPageMutation = useCreatePageMutation();
const { data } = useGetSpacesQuery({ limit: 100 });
const writableSpaces = (data?.items ?? []).filter(canCreatePage);
const createNote = async (space: ISpace) => { 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 { try {
// `spaceId` is accepted by the create-page endpoint but is not part of const createdPage = await createPageMutation.mutateAsync(variables);
// 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)); navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch { } catch {
// useCreatePageMutation already surfaces a red notification on error. // useCreatePageMutation already surfaces a red notification on error.
} }
}; };
// No writable space → nothing to create in; render nothing. // A paused (offline) mutation stays `isPending`, so gate the spinner on it NOT
if (writableSpaces.length === 0) return null; // 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; const isPending = createPageMutation.isPending && !createPageMutation.isPaused;
// Exactly one writable space → create directly, no picker needed. // Exactly one writable space → create directly, no picker needed.
if (writableSpaces.length === 1) { if (writableSpaces.length === 1) {
return ( return (
<Button <Button
fullWidth
size="md" size="md"
variant="light" variant="light"
color="gray" color={color}
leftSection={<IconPlus size={18} />} fullWidth
leftSection={icon}
loading={isPending} loading={isPending}
onClick={() => createNote(writableSpaces[0])} onClick={() => createNote(writableSpaces[0])}
> >
{t("New note")} {label}
</Button> </Button>
); );
} }
@@ -62,14 +94,14 @@ export default function NewNoteButton() {
<Menu shadow="md" width="target" position="bottom-start"> <Menu shadow="md" width="target" position="bottom-start">
<Menu.Target> <Menu.Target>
<Button <Button
fullWidth
size="md" size="md"
variant="light" variant="light"
color="gray" color={color}
leftSection={<IconPlus size={18} />} fullWidth
leftSection={icon}
loading={isPending} loading={isPending}
> >
{t("New note")} {label}
</Button> </Button>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
@@ -99,3 +131,39 @@ export default function NewNoteButton() {
</Menu> </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

@@ -0,0 +1,107 @@
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

@@ -0,0 +1,112 @@
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

@@ -0,0 +1,373 @@
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();
});
});
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();
await expect(promise).resolves.toBeUndefined();
// 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 and cleans up after the timeout when synced never fires", async () => {
vi.useFakeTimers();
const promise = warmPageYdoc("p1", "ws://x");
// Do not fire "synced"; let the 8s safety timeout settle it.
await vi.advanceTimersByTimeAsync(8000);
await expect(promise).resolves.toBeUndefined();
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,315 @@
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.
*
* 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<void> {
let ydoc: Y.Doc | null = null;
let local: IndexeddbPersistence | null = null;
let remote: HocuspocusProvider | null = null;
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;
const finish = () => {
if (settled) return;
settled = true;
// Clear the pending timeout and detach the listener so neither leaks
// after we resolve.
if (timeoutId !== undefined) clearTimeout(timeoutId);
try {
provider.off("synced", finish);
} catch {
// best-effort
}
resolve();
};
// Resolve once the server state has synced into the local doc...
provider.on("synced", finish);
// ...or give up after a short timeout so we never hang.
timeoutId = setTimeout(finish, 8000);
});
} catch {
// best-effort
} finally {
try {
remote?.destroy();
} catch {
// best-effort
}
try {
local?.destroy();
} catch {
// best-effort
}
try {
ydoc?.destroy();
} catch {
// best-effort
}
}
}

View File

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,114 @@
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

@@ -0,0 +1,64 @@
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

@@ -0,0 +1,128 @@
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

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,128 @@
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

@@ -0,0 +1,84 @@
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

@@ -0,0 +1,69 @@
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,5 +1,6 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { getDefaultStore } from "jotai";
import { import {
toggleTemplate, toggleTemplate,
toggleTemporary, toggleTemporary,
@@ -9,6 +10,9 @@ import type {
ToggleTemporaryResponse, ToggleTemporaryResponse,
} from "@/features/page-embed/types/page-embed.types"; } from "@/features/page-embed/types/page-embed.types";
import { queryClient } from "@/main.tsx"; 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 * After toggling a note's temporary state, mirror the new deadline into the
@@ -30,6 +34,19 @@ export function syncTemporaryExpiresInCache(
}); });
} }
} }
// 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({ queryClient.invalidateQueries({
predicate: (item) => predicate: (item) =>
["sidebar-pages"].includes(item.queryKey[0] as string), ["sidebar-pages"].includes(item.queryKey[0] as string),

View File

@@ -1,7 +1,7 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { findBreadcrumbPath } from "@/features/page/tree/utils"; import { computeBreadcrumbState } from "./breadcrumb.utils";
import { import {
Button, Button,
Anchor, Anchor,
@@ -15,8 +15,12 @@ import { IconCornerDownRightDouble, IconDots } from "@tabler/icons-react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import classes from "./breadcrumb.module.css"; import classes from "./breadcrumb.module.css";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import {
usePageQuery,
usePageBreadcrumbsQuery,
} from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib"; import { extractPageSlugId } from "@/lib";
import { useMediaQuery } from "@mantine/hooks"; import { useMediaQuery } from "@mantine/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -38,14 +42,29 @@ export default function Breadcrumb() {
const { data: currentPage } = usePageQuery({ const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug), pageId: extractPageSlugId(pageSlug),
}); });
// The page's own ancestor chain, fetched independently of the lazily-built
// sidebar tree so a deep page doesn't render a blank breadcrumb for seconds
// while the tree backfills (#218).
const { data: ancestors } = usePageBreadcrumbsQuery(currentPage?.id);
const isMobile = useMediaQuery("(max-width: 48em)"); const isMobile = useMediaQuery("(max-width: 48em)");
useEffect(() => { useEffect(() => {
if (treeData?.length > 0 && currentPage) { if (!currentPage) return;
const breadcrumb = findBreadcrumbPath(treeData, currentPage.id);
setBreadcrumbNodes(breadcrumb || null); // Selection/mapping + stale-clearing live in a pure, unit-tested helper
} // (#218). It resolves the correct chain when possible and, on a transient
}, [currentPage?.id, treeData]); // miss, clears a chain left over from a previously-viewed page instead of
// showing the wrong trail — while keeping a chain already resolved for THIS
// page to avoid a blank flash.
setBreadcrumbNodes((previous) =>
computeBreadcrumbState(
treeData,
ancestors as IPage[] | undefined,
currentPage.id,
previous,
),
);
}, [currentPage?.id, treeData, ancestors]);
const HiddenNodesTooltipContent = () => const HiddenNodesTooltipContent = () =>
breadcrumbNodes?.slice(1, -1).map((node) => ( breadcrumbNodes?.slice(1, -1).map((node) => (

View File

@@ -0,0 +1,114 @@
import { describe, it, expect } from "vitest";
import {
computeBreadcrumbState,
resolveBreadcrumbNodes,
} from "./breadcrumb.utils";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types.ts";
// Pure selection/mapping behind the breadcrumb (#218): tree-hit prefers the live
// sidebar tree, tree-miss maps the page's own ancestors, and "no data" returns
// null so the component keeps its prior state.
function treeNode(id: string, over?: Partial<SpaceTreeNode>): SpaceTreeNode {
return {
id,
slugId: `slug-${id}`,
name: `node-${id}`,
icon: null,
position: "a",
hasChildren: false,
spaceId: "space-1",
parentPageId: null,
children: [],
...over,
} as SpaceTreeNode;
}
function ancestorPage(id: string, over?: Partial<IPage>): IPage {
return {
id,
slugId: `slug-${id}`,
title: `title-${id}`,
icon: "📄",
position: "m",
spaceId: "space-1",
parentPageId: null,
hasChildren: true,
...over,
} as IPage;
}
describe("resolveBreadcrumbNodes", () => {
it("tree-hit: returns the path found in the live sidebar tree", () => {
const child = treeNode("child");
const root = treeNode("root", { hasChildren: true, children: [child] });
// findBreadcrumbPath walks the tree; the chain ends at the target page.
const result = resolveBreadcrumbNodes([root], [ancestorPage("child")], "child");
expect(result).not.toBeNull();
expect(result!.map((n) => n.id)).toEqual(["root", "child"]);
// Came from the tree, NOT the ancestor mapping (icon stays the tree's null).
expect(result![result!.length - 1].icon).toBeNull();
});
it("tree-miss: maps the page's own ancestors (title->name, hasChildren default)", () => {
// Tree has no node for the target page -> findBreadcrumbPath misses.
const unrelated = treeNode("unrelated");
const ancestors = [
ancestorPage("a", { hasChildren: true }),
ancestorPage("b", { hasChildren: undefined as any }),
];
const result = resolveBreadcrumbNodes([unrelated], ancestors, "missing-page");
expect(result).not.toBeNull();
expect(result!.map((n) => n.id)).toEqual(["a", "b"]);
// Non-trivial field transform: title -> name.
expect(result![0].name).toBe("title-a");
// hasChildren defaults to false when the ancestor row omits it.
expect(result![1].hasChildren).toBe(false);
expect(result![0].hasChildren).toBe(true);
});
it("falls back to ancestors when the tree is empty", () => {
const result = resolveBreadcrumbNodes([], [ancestorPage("a")], "a");
expect(result!.map((n) => n.id)).toEqual(["a"]);
});
it("returns null when there is no tree hit and no ancestor data", () => {
expect(resolveBreadcrumbNodes([], [], "x")).toBeNull();
expect(resolveBreadcrumbNodes(undefined, undefined, "x")).toBeNull();
expect(resolveBreadcrumbNodes(null, null, "x")).toBeNull();
});
});
describe("computeBreadcrumbState (stale-chain clearing on navigation)", () => {
it("uses a freshly resolved chain when available", () => {
const child = treeNode("B");
const root = treeNode("root", { hasChildren: true, children: [child] });
const next = computeBreadcrumbState([root], null, "B", null);
expect(next!.map((n) => n.id)).toEqual(["root", "B"]);
});
it("navigating A->B to a page absent from treeData clears the previous A chain (no stale trail)", () => {
// Previous chain ends at page A; we are now on page B, which is not yet in
// the lazily-built tree and whose ancestors have not loaded.
const previous = [treeNode("rootA"), treeNode("A")];
const next = computeBreadcrumbState([treeNode("unrelated")], undefined, "B", previous);
// Must NOT keep showing A's (clickable) chain.
expect(next).toBeNull();
});
it("keeps a chain that already ends at the current page through a transient miss", () => {
// We already resolved B once (chain ends at B); a transient miss must not
// blank it.
const previous = [treeNode("rootB"), treeNode("B")];
const next = computeBreadcrumbState([], undefined, "B", previous);
expect(next).toBe(previous);
});
it("returns null when nothing resolves and there is no previous chain", () => {
expect(computeBreadcrumbState([], undefined, "B", null)).toBeNull();
});
});

View File

@@ -0,0 +1,61 @@
import { IPage } from "@/features/page/types/page.types.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { findBreadcrumbPath, pageToTreeNode } from "@/features/page/tree/utils";
/**
* Pure selection/mapping for the breadcrumb nodes (#218). Three branches:
* 1. tree-hit — the lazily-built sidebar tree already contains this page's
* ancestor chain, so prefer it (stays live with sidebar renames/moves).
* 2. tree-miss — fall back to the page's own ancestor data so a deep page
* resolves immediately instead of rendering a blank breadcrumb for seconds
* while the tree backfills. Mapped through the canonical `pageToTreeNode`
* (title -> name, hasChildren defaulted to false).
* 3. neither — no data yet, return null (the caller decides whether to keep
* a prior chain via computeBreadcrumbState).
*/
export function resolveBreadcrumbNodes(
treeData: SpaceTreeNode[] | null | undefined,
ancestors: IPage[] | null | undefined,
pageId: string,
): SpaceTreeNode[] | null {
if (treeData && treeData.length > 0) {
const breadcrumb = findBreadcrumbPath(treeData, pageId);
if (breadcrumb) {
return breadcrumb;
}
}
if (ancestors && ancestors.length > 0) {
return ancestors.map((page) =>
pageToTreeNode(page, { hasChildren: page.hasChildren ?? false }),
);
}
return null;
}
/**
* Decide the next breadcrumb state, given the previous one. When a chain
* resolves (#218) it always wins. When nothing resolves yet, a stale chain from
* a previously-viewed page must be CLEARED rather than left showing the wrong,
* clickable trail (the reverse regression of the original blank-breadcrumb fix
* when navigating A -> B to a deep page not yet in the lazily-built tree). The
* one chain we keep through a transient miss is one that already ends at the
* current page — that means we already resolved THIS page, so keeping it avoids
* a needless blank flash without ever showing the previous page's chain.
*/
export function computeBreadcrumbState(
treeData: SpaceTreeNode[] | null | undefined,
ancestors: IPage[] | null | undefined,
pageId: string,
previous: SpaceTreeNode[] | null,
): SpaceTreeNode[] | null {
const resolved = resolveBreadcrumbNodes(treeData, ancestors, pageId);
if (resolved) {
return resolved;
}
const previousEndsAtCurrentPage =
previous != null && previous[previous.length - 1]?.id === pageId;
return previousEndsAtCurrentPage ? previous : null;
}

View File

@@ -12,6 +12,8 @@ import {
IconList, IconList,
IconMarkdown, IconMarkdown,
IconPrinter, IconPrinter,
IconCloud,
IconCloudCheck,
IconStar, IconStar,
IconStarFilled, IconStarFilled,
IconTrash, IconTrash,
@@ -39,6 +41,8 @@ import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal"; import ExportModal from "@/components/common/export-modal";
import { htmlToMarkdown } from "@docmost/editor-ext"; import { htmlToMarkdown } from "@docmost/editor-ext";
import { import {
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom, pageEditorAtom,
yjsConnectionStatusAtom, yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts"; } from "@/features/editor/atoms/editor-atoms.ts";
@@ -176,8 +180,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
pageId: page.id, pageId: page.id,
temporary: next, temporary: next,
}); });
// Reflect the new deadline in the page cache so the menu label flips and // Reflect the new deadline in the page cache (menu label + banner) AND in
// any banner updates. The sidebar icon refreshes via its own query. // the sidebar tree node so its clock marker updates immediately, no reload.
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt); syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
notifications.show({ notifications.show({
message: next message: next
@@ -411,14 +415,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
function ConnectionWarning() { function ConnectionWarning() {
const { t } = useTranslation(); const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom); const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
const [showWarning, setShowWarning] = useState(false); const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { const isDisconnected = ["disconnected", "connecting"].includes(
const isDisconnected = ["disconnected", "connecting"].includes( yjsConnectionStatus,
yjsConnectionStatus, );
);
useEffect(() => {
if (isDisconnected) { if (isDisconnected) {
if (!timeoutRef.current) { if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000); timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
@@ -430,7 +436,7 @@ function ConnectionWarning() {
} }
setShowWarning(false); setShowWarning(false);
} }
}, [yjsConnectionStatus]); }, [isDisconnected]);
// Cleanup only on unmount // Cleanup only on unmount
useEffect(() => { useEffect(() => {
@@ -441,22 +447,59 @@ function ConnectionWarning() {
}; };
}, []); }, []);
if (!showWarning) return null; // State (1): offline/disconnected — changes are kept locally. Preserve the
// existing >5s debounce before surfacing this state.
if (isDisconnected) {
if (!showWarning) return null;
const offlineLabel = t(
"Offline — changes are saved locally and will sync when you reconnect",
);
return (
<Tooltip label={offlineLabel} openDelay={250} withArrow>
<ThemeIcon
variant="default"
c="red"
role="status"
aria-label={offlineLabel}
style={{ border: "none" }}
>
<IconWifiOff size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);
}
// State (2): connected but the remote replica is not fully caught up yet.
if (!isRemoteSynced || !isLocalSynced) {
const syncingLabel = t("Syncing changes…");
return (
<Tooltip label={syncingLabel} openDelay={250} withArrow>
<ThemeIcon
variant="default"
c="dimmed"
role="status"
aria-label={syncingLabel}
style={{ border: "none" }}
>
<IconCloud size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);
}
// State (3): fully synced — subtle confirmation indicator.
const syncedLabel = t("All changes synced");
return ( return (
<Tooltip <Tooltip label={syncedLabel} openDelay={250} withArrow>
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<ThemeIcon <ThemeIcon
variant="default" variant="default"
c="red" c="dimmed"
role="status" role="status"
aria-label={t("Real-time editor connection lost. Retrying...")} aria-label={syncedLabel}
style={{ border: "none" }} style={{ border: "none" }}
> >
<IconWifiOff size={20} stroke={2} /> <IconCloudCheck size={20} stroke={2} />
</ThemeIcon> </ThemeIcon>
</Tooltip> </Tooltip>
); );

View File

@@ -1,6 +1,7 @@
import { import {
InfiniteData, InfiniteData,
QueryKey, QueryKey,
queryOptions,
useInfiniteQuery, useInfiniteQuery,
UseInfiniteQueryResult, UseInfiniteQueryResult,
useMutation, useMutation,
@@ -32,7 +33,7 @@ import {
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils"; import { buildTree, pageToTreeNode } from "@/features/page/tree/utils";
import { useEffect } from "react"; import { useEffect } from "react";
import { validate as isValidUuid } from "uuid"; import { validate as isValidUuid } from "uuid";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -42,12 +43,38 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types"; import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification"; import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
/**
* Centralized React Query key factories for page queries. The hooks below and
* the offline warm path (features/offline/make-offline.ts) share these so the
* runtime keys can never silently drift apart.
*/
export const pageKeys = {
detail: (idOrSlug: string) => ["pages", idOrSlug] as const,
sidebar: (data: unknown) => ["sidebar-pages", data] as const,
rootSidebar: (spaceId: string) => ["root-sidebar-pages", spaceId] as const,
breadcrumbs: (pageId: string) => ["breadcrumbs", pageId] as const,
recentChanges: (spaceId?: string) => ["recent-changes", spaceId] as const,
};
/**
* Shared queryOptions for the sidebar-pages (ancestor children) query. Both
* fetchAllAncestorChildren and the offline warm path consume this so the key,
* queryFn and staleTime stay identical.
*/
export const sidebarPagesQueryOptions = (params: SidebarPagesParams) =>
queryOptions({
queryKey: pageKeys.sidebar(params),
queryFn: () => getAllSidebarPages(params),
staleTime: 30 * 60 * 1000,
});
export function usePageQuery( export function usePageQuery(
pageInput: Partial<IPageInput>, pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> { ): UseQueryResult<IPage, Error> {
const query = useQuery({ const query = useQuery({
queryKey: ["pages", pageInput.pageId], queryKey: pageKeys.detail(pageInput.pageId),
queryFn: () => getPageById(pageInput), queryFn: () => getPageById(pageInput),
enabled: !!pageInput.pageId, enabled: !!pageInput.pageId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@@ -56,9 +83,9 @@ export function usePageQuery(
useEffect(() => { useEffect(() => {
if (query.data) { if (query.data) {
if (isValidUuid(pageInput.pageId)) { if (isValidUuid(pageInput.pageId)) {
queryClient.setQueryData(["pages", query.data.slugId], query.data); queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
} else { } else {
queryClient.setQueryData(["pages", query.data.id], query.data); queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
} }
} }
}, [query.data]); }, [query.data]);
@@ -69,6 +96,10 @@ export function usePageQuery(
export function useCreatePageMutation() { export function useCreatePageMutation() {
const { t } = useTranslation(); const { t } = useTranslation();
return useMutation<IPage, Error, Partial<IPageInput>>({ return useMutation<IPage, Error, Partial<IPageInput>>({
// Stable key so a paused create restored from IndexedDB after an offline
// reload finds its default mutationFn (registerOfflineMutationDefaults) and
// is replayed by resumePausedMutations() on reconnect instead of being lost.
mutationKey: offlineMutationKeys.createPage,
mutationFn: (data) => createPage(data), mutationFn: (data) => createPage(data),
onSuccess: (data) => { onSuccess: (data) => {
invalidateOnCreatePage(data); invalidateOnCreatePage(data);
@@ -80,18 +111,20 @@ export function useCreatePageMutation() {
} }
export function updatePageData(data: IPage) { export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]); const pageBySlug = queryClient.getQueryData<IPage>(
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]); pageKeys.detail(data.slugId),
);
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
if (pageBySlug) { if (pageBySlug) {
queryClient.setQueryData(["pages", data.slugId], { queryClient.setQueryData(pageKeys.detail(data.slugId), {
...pageBySlug, ...pageBySlug,
...data, ...data,
}); });
} }
if (pageById) { if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data }); queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
} }
invalidateOnUpdatePage( invalidateOnUpdatePage(
@@ -145,11 +178,11 @@ export function useRemovePageMutation() {
}); });
// Stamp deletedAt so a re-visit shows the trash banner, not stale state. // Stamp deletedAt so a re-visit shows the trash banner, not stale state.
const cached = queryClient.getQueryData<IPage>(["pages", pageId]); const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
if (cached) { if (cached) {
const stamped = { ...cached, deletedAt: new Date() }; const stamped = { ...cached, deletedAt: new Date() };
queryClient.setQueryData(["pages", cached.id], stamped); queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
queryClient.setQueryData(["pages", cached.slugId], stamped); queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
} }
invalidateOnDeletePage(pageId); invalidateOnDeletePage(pageId);
@@ -188,6 +221,9 @@ export function useDeletePageMutation() {
export function useMovePageMutation() { export function useMovePageMutation() {
return useMutation<void, Error, IMovePage>({ return useMutation<void, Error, IMovePage>({
// Stable key so a paused move restored from IndexedDB after an offline
// reload finds its default mutationFn and is replayed on reconnect.
mutationKey: offlineMutationKeys.movePage,
mutationFn: (data) => movePage(data), mutationFn: (data) => movePage(data),
}); });
} }
@@ -210,18 +246,15 @@ export function useRestorePageMutation() {
// Check if the page already exists in the tree (it shouldn't) // Check if the page already exists in the tree (it shouldn't)
if (!treeModel.find(currentTree, restoredPage.id)) { if (!treeModel.find(currentTree, restoredPage.id)) {
// Create the tree node data with hasChildren from backend // Create the tree node data with hasChildren from backend. Routed
const nodeData: SpaceTreeNode = { // through the canonical mapper so the field copy stays in lockstep with
id: restoredPage.id, // buildTree. The server NULLS `temporaryExpiresAt` on restore (a restored
slugId: restoredPage.slugId, // page is made permanent), so the mapper carries that null through and
// the node correctly shows no clock marker.
const nodeData: SpaceTreeNode = pageToTreeNode(restoredPage, {
name: restoredPage.title || "Untitled", name: restoredPage.title || "Untitled",
icon: restoredPage.icon,
position: restoredPage.position,
spaceId: restoredPage.spaceId,
parentPageId: restoredPage.parentPageId,
hasChildren: restoredPage.hasChildren || false, hasChildren: restoredPage.hasChildren || false,
children: [], });
};
// Determine the parent and index // Determine the parent and index
const parentId = restoredPage.parentPageId || null; const parentId = restoredPage.parentPageId || null;
@@ -270,8 +303,11 @@ export function useRestorePageMutation() {
// Replace would strip space/permissions/content and break the editor. // Replace would strip space/permissions/content and break the editor.
const merge = (cached: IPage | undefined) => const merge = (cached: IPage | undefined) =>
cached ? { ...cached, ...restoredPage } : cached; cached ? { ...cached, ...restoredPage } : cached;
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge); queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge); queryClient.setQueryData<IPage>(
pageKeys.detail(restoredPage.slugId),
merge,
);
}, },
onError: (error) => { onError: (error) => {
notifications.show({ notifications.show({
@@ -286,7 +322,7 @@ export function useGetSidebarPagesQuery(
data: SidebarPagesParams | null, data: SidebarPagesParams | null,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> { ): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["sidebar-pages", data], queryKey: pageKeys.sidebar(data),
enabled: !!data?.pageId || !!data?.spaceId, enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }), getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
@@ -297,7 +333,7 @@ export function useGetSidebarPagesQuery(
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) { export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId], queryKey: pageKeys.rootSidebar(data.spaceId),
queryFn: async ({ pageParam }) => { queryFn: async ({ pageParam }) => {
return getSidebarPages({ return getSidebarPages({
spaceId: data.spaceId, spaceId: data.spaceId,
@@ -323,7 +359,7 @@ export function usePageBreadcrumbsQuery(
pageId: string, pageId: string,
): UseQueryResult<Partial<IPage[]>, Error> { ): UseQueryResult<Partial<IPage[]>, Error> {
return useQuery({ return useQuery({
queryKey: ["breadcrumbs", pageId], queryKey: pageKeys.breadcrumbs(pageId),
queryFn: () => getPageBreadcrumbs(pageId), queryFn: () => getPageBreadcrumbs(pageId),
enabled: !!pageId, enabled: !!pageId,
}); });
@@ -335,10 +371,12 @@ export async function fetchAllAncestorChildren(
// refresh (#159 #8), which must NOT receive the 30-min-cached children. // refresh (#159 #8), which must NOT receive the 30-min-cached children.
opts?: { fresh?: boolean }, opts?: { fresh?: boolean },
) { ) {
// not using a hook here, so we can call it inside a useEffect hook // not using a hook here, so we can call it inside a useEffect hook. Reuse the
// shared sidebarPagesQueryOptions (key + queryFn) so the offline warm path and
// this fetch never drift, but override staleTime for the `fresh` reconnect
// refresh (#159 #8), which must force a server refetch (staleTime 0).
const response = await queryClient.fetchQuery({ const response = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params], ...sidebarPagesQueryOptions(params),
queryFn: () => getAllSidebarPages(params),
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000, staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
}); });
@@ -348,7 +386,7 @@ export async function fetchAllAncestorChildren(
export function useRecentChangesQuery(spaceId?: string) { export function useRecentChangesQuery(spaceId?: string) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["recent-changes", spaceId], queryKey: pageKeys.recentChanges(spaceId),
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }), getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
@@ -410,16 +448,21 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
slugId: data.slugId, slugId: data.slugId,
spaceId: data.spaceId, spaceId: data.spaceId,
title: data.title, title: data.title,
// Carry the death-timer deadline so a note created as temporary keeps its
// sidebar clock marker when the tree is rebuilt from this cached entry
// (buildTree → mergeRootTrees). Omitting it overwrote the optimistic/socket
// node's marker with `undefined`, hiding it until a reload.
temporaryExpiresAt: data.temporaryExpiresAt,
}; };
let queryKey: QueryKey = null; let queryKey: QueryKey = null;
if (data.parentPageId === null) { if (data.parentPageId === null) {
queryKey = ["root-sidebar-pages", data.spaceId]; queryKey = pageKeys.rootSidebar(data.spaceId);
} else { } else {
queryKey = [ queryKey = pageKeys.sidebar({
"sidebar-pages", pageId: data.parentPageId,
{ pageId: data.parentPageId, spaceId: data.spaceId }, spaceId: data.spaceId,
]; });
} }
//update all sidebar pages //update all sidebar pages
@@ -479,7 +522,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update root sidebar pages haschildern //update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({ const rootSideBarMatches = queryClient.getQueriesData({
queryKey: ["root-sidebar-pages", data.spaceId], queryKey: pageKeys.rootSidebar(data.spaceId),
exact: false, exact: false,
}); });
@@ -503,7 +546,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update recent changes //update recent changes
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["recent-changes", data.spaceId], queryKey: pageKeys.recentChanges(data.spaceId),
}); });
} }
@@ -517,9 +560,9 @@ export function invalidateOnUpdatePage(
invalidatePageTree(); invalidatePageTree();
let queryKey: QueryKey = null; let queryKey: QueryKey = null;
if (parentPageId === null) { if (parentPageId === null) {
queryKey = ["root-sidebar-pages", spaceId]; queryKey = pageKeys.rootSidebar(spaceId);
} else { } else {
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }]; queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
} }
//update all sidebar pages //update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>( queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
@@ -542,7 +585,7 @@ export function invalidateOnUpdatePage(
//update recent changes //update recent changes
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["recent-changes", spaceId], queryKey: pageKeys.recentChanges(spaceId),
}); });
} }
@@ -557,8 +600,8 @@ export function updateCacheOnMovePage(
// Remove page from old parent's cache // Remove page from old parent's cache
const oldQueryKey = const oldQueryKey =
oldParentId === null oldParentId === null
? ["root-sidebar-pages", spaceId] ? pageKeys.rootSidebar(spaceId)
: ["sidebar-pages", { pageId: oldParentId, spaceId }]; : pageKeys.sidebar({ pageId: oldParentId, spaceId });
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>( queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey, oldQueryKey,
@@ -578,7 +621,7 @@ export function updateCacheOnMovePage(
if (oldParentId !== null) { if (oldParentId !== null) {
const oldParentCache = queryClient.getQueryData< const oldParentCache = queryClient.getQueryData<
InfiniteData<IPagination<IPage>> InfiniteData<IPagination<IPage>>
>(["sidebar-pages", { pageId: oldParentId, spaceId }]); >(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
const remainingChildren = const remainingChildren =
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0; oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
@@ -616,8 +659,8 @@ export function updateCacheOnMovePage(
// Add page to new parent's cache // Add page to new parent's cache
const newQueryKey = const newQueryKey =
newParentId === null newParentId === null
? ["root-sidebar-pages", spaceId] ? pageKeys.rootSidebar(spaceId)
: ["sidebar-pages", { pageId: newParentId, spaceId }]; : pageKeys.sidebar({ pageId: newParentId, spaceId });
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>( queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey, newQueryKey,

View File

@@ -7,6 +7,7 @@ import { notifications } from "@mantine/notifications";
import { import {
IconArrowRight, IconArrowRight,
IconClockHour4, IconClockHour4,
IconCloudDownload,
IconCopy, IconCopy,
IconDotsVertical, IconDotsVertical,
IconFileExport, IconFileExport,
@@ -35,8 +36,15 @@ import {
useToggleTemplateMutation, useToggleTemplateMutation,
useToggleTemporaryMutation, useToggleTemporaryMutation,
} from "@/features/page-embed/queries/page-embed-query"; } from "@/features/page-embed/queries/page-embed-query";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { getCollaborationUrl } from "@/lib/config.ts";
import {
makePageAvailableOffline,
warmPageYdoc,
} from "@/features/offline/make-offline";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model"; import { treeModel } from "@/features/page/tree/model/tree-model";
import { pageToTreeNode } from "@/features/page/tree/utils";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts"; import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
import classes from "@/features/page/tree/styles/tree.module.css"; import classes from "@/features/page/tree/styles/tree.module.css";
@@ -71,6 +79,46 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const isTemplate = !!node.isTemplate; const isTemplate = !!node.isTemplate;
const toggleTemporary = useToggleTemporaryMutation(); const toggleTemporary = useToggleTemporaryMutation();
const isTemporary = !!node.temporaryExpiresAt; const isTemporary = !!node.temporaryExpiresAt;
const { data: collabQuery } = useCollabToken();
const handleMakeAvailableOffline = async () => {
notifications.show({ message: t("Saving page for offline use...") });
try {
// Prefetch read queries so they get persisted to IndexedDB. The result
// reports whether every warm step succeeded.
const result = await makePageAvailableOffline({
pageId: node.id,
spaceId: node.spaceId,
});
// Best-effort: warm the page's Yjs document into IndexedDB.
await warmPageYdoc(node.id, getCollaborationUrl(), collabQuery?.token);
if (result.ok) {
notifications.show({ message: t("Page is now available offline") });
} else {
// Partial warm — the page may still be partly usable offline, but some
// queries failed to cache, so surface it as an error rather than a
// silent success. Name the failed step(s) (AGENTS.md: errors must be
// specific, never a bare generic string); `result.failed` carries them.
notifications.show({
message: `${t("Failed to make page available offline")}: ${result.failed.join(", ")}`,
color: "red",
});
}
} catch (err) {
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
// unexpected failures stay guarded here. Log the raw error and surface the
// real cause to the user instead of a bare generic string (AGENTS.md).
console.error("handleMakeAvailableOffline failed", err);
const reason =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? (err instanceof Error ? err.message : String(err));
notifications.show({
message: `${t("Failed to make page available offline")}: ${reason}`,
color: "red",
});
}
};
const handleToggleTemplate = async () => { const handleToggleTemplate = async () => {
const next = !isTemplate; const next = !isTemplate;
@@ -130,18 +178,14 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const currentIndex = siblings?.index ?? 0; const currentIndex = siblings?.index ?? 0;
const newIndex = currentIndex + 1; const newIndex = currentIndex + 1;
const treeNodeData: SpaceTreeNode = { // Routed through the canonical mapper so the field copy stays in lockstep
id: duplicatedPage.id, // with buildTree. The server does NOT arm a death timer on duplicate (the
slugId: duplicatedPage.slugId, // copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
name: duplicatedPage.title, // carries that null through and the duplicated node correctly shows no
position: duplicatedPage.position, // clock marker — matching the server without a reload.
spaceId: duplicatedPage.spaceId, const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
parentPageId: duplicatedPage.parentPageId,
icon: duplicatedPage.icon,
hasChildren: duplicatedPage.hasChildren,
canEdit: true, canEdit: true,
children: [], });
};
setData((prev) => setData((prev) =>
treeModel.insert(prev, parentId, treeNodeData, newIndex), treeModel.insert(prev, parentId, treeNodeData, newIndex),
@@ -231,6 +275,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
{t("Export")} {t("Export")}
</Menu.Item> </Menu.Item>
<Menu.Item
leftSection={<IconCloudDownload size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleMakeAvailableOffline();
}}
>
{t("Make available offline")}
</Menu.Item>
{canEdit && ( {canEdit && (
<> <>
<Menu.Item <Menu.Item

View File

@@ -9,12 +9,12 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
import type { DropOp } from "@/features/page/tree/model/tree-model.types"; import type { DropOp } from "@/features/page/tree/model/tree-model.types";
import { dropOpToMovePayload } from "./drop-op-to-move-payload"; import { dropOpToMovePayload } from "./drop-op-to-move-payload";
import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { pageToTreeNode } from "@/features/page/tree/utils";
import { IPage } from "@/features/page/types/page.types.ts"; import { IPage } from "@/features/page/types/page.types.ts";
import { import {
useCreatePageMutation, useCreatePageMutation,
useRemovePageMutation, useRemovePageMutation,
useMovePageMutation, useMovePageMutation,
useUpdatePageMutation,
updateCacheOnMovePage, updateCacheOnMovePage,
} from "@/features/page/queries/page-query.ts"; } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts"; import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -26,7 +26,6 @@ export type UseTreeMutation = {
parentId: string | null, parentId: string | null,
opts?: { temporary?: boolean }, opts?: { temporary?: boolean },
) => Promise<void>; ) => Promise<void>;
handleRename: (id: string, name: string) => Promise<void>;
handleDelete: (id: string) => Promise<void>; handleDelete: (id: string) => Promise<void>;
}; };
@@ -38,7 +37,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// children) and then immediately invokes a handler. // children) and then immediately invokes a handler.
const store = useStore(); const store = useStore();
const createPageMutation = useCreatePageMutation(); const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
const removePageMutation = useRemovePageMutation(); const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation(); const movePageMutation = useMovePageMutation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -139,18 +137,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
throw new Error("Failed to create page"); throw new Error("Failed to create page");
} }
const newNode: SpaceTreeNode = { // Route through the canonical mapper so the field copy (esp.
id: createdPage.id, // `temporaryExpiresAt`, which shows the temporary-note clock marker on
slugId: createdPage.slugId, // optimistic insert) can't drift from buildTree. `name: ""` because a
// freshly created page is untitled; `hasChildren: false` because it has no
// children yet.
const newNode: SpaceTreeNode = pageToTreeNode(createdPage, {
name: "", name: "",
position: createdPage.position,
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false, hasChildren: false,
// Show the temporary-note icon immediately on optimistic insert. });
temporaryExpiresAt: createdPage.temporaryExpiresAt,
children: [],
};
// Read latest tree at call time. Without this, callers that mutate the // Read latest tree at call time. Without this, callers that mutate the
// tree (e.g. lazy-load children on expand) immediately before calling // tree (e.g. lazy-load children on expand) immediately before calling
@@ -173,7 +168,22 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// optimistic node's id IS the real created page id (createdPage.id), so // optimistic node's id IS the real created page id (createdPage.id), so
// the ids match exactly regardless of which path runs first. // the ids match exactly regardless of which path runs first.
setData((prev) => { setData((prev) => {
if (treeModel.find(prev, newNode.id)) return prev; const existing = treeModel.find(prev, newNode.id);
if (existing) {
// The server `addTreeNode` broadcast won the race and already inserted
// this node. Older broadcasts could omit `temporaryExpiresAt`, leaving
// a temporary note WITHOUT its clock marker until reload; patch it on
// from the authoritative create response so the marker shows now.
if (
newNode.temporaryExpiresAt &&
!(existing as SpaceTreeNode).temporaryExpiresAt
) {
return treeModel.update(prev, newNode.id, {
temporaryExpiresAt: newNode.temporaryExpiresAt,
} as Partial<SpaceTreeNode>);
}
return prev;
}
return treeModel.insert(prev, parentId, newNode, lastIndex); return treeModel.insert(prev, parentId, newNode, lastIndex);
}); });
@@ -192,20 +202,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[spaceId, createPageMutation, setData, store, navigate, spaceSlug], [spaceId, createPageMutation, setData, store, navigate, spaceSlug],
); );
const handleRename = useCallback(
async (id: string, name: string) => {
setData((prev) =>
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
);
try {
await updatePageMutation.mutateAsync({ pageId: id, title: name });
} catch (error) {
console.error("Error updating page title:", error);
}
},
[updatePageMutation, setData],
);
const handleDelete = useCallback( const handleDelete = useCallback(
async (id: string) => { async (id: string) => {
const node = treeModel.find( const node = treeModel.find(
@@ -251,7 +247,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug], [removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
); );
return { handleMove, handleCreate, handleRename, handleDelete }; return { handleMove, handleCreate, handleDelete };
} }
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean { function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {

View File

@@ -393,6 +393,101 @@ describe("handleCreate optimistic-insert idempotency (find-then-skip)", () => {
}); });
}); });
// handleCreate race-guard temporaryExpiresAt patch: when the server's
// addTreeNode broadcast wins the race and inserts the node BEFORE the optimistic
// updater runs, the updater must not re-insert. Two sub-branches:
// (a) the node the broadcast inserted carries NO deadline (an older broadcast
// omitted it) while the authoritative create response DOES → patch the
// deadline on so the clock marker shows now, without a reload.
// (b) the existing node ALREADY has a deadline → do NOT overwrite it; return
// `prev` by reference (a no-op write).
describe("handleCreate race-guard temporaryExpiresAt patch", () => {
type TN = TreeNode<{ name: string; temporaryExpiresAt?: string | null }>;
// Mirrors the setData updater in use-tree-mutation handleCreate.
const applyOptimisticInsert = (
tree: TN[],
parentId: string | null,
node: TN,
index: number,
): TN[] => {
const existing = treeModel.find(tree, node.id) as TN | null;
if (existing) {
if (node.temporaryExpiresAt && !existing.temporaryExpiresAt) {
return treeModel.update(tree, node.id, {
temporaryExpiresAt: node.temporaryExpiresAt,
});
}
return tree;
}
return treeModel.insert(tree, parentId, node, index);
};
const fixtureTN: TN[] = [
{ id: "a", name: "A" },
{ id: "b", name: "B" },
];
const deadline = "2026-07-01T00:00:00.000Z";
it("(a) patches temporaryExpiresAt when the existing node has none + the response carries a deadline", () => {
// Server broadcast won the race and inserted the node WITHOUT a deadline.
const afterServer = treeModel.insert(fixtureTN, null, {
id: "new",
name: "",
});
expect((treeModel.find(afterServer, "new") as TN).temporaryExpiresAt).toBe(
undefined,
);
// The authoritative create response carries the deadline.
const created: TN = { id: "new", name: "", temporaryExpiresAt: deadline };
const patched = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
// A new reference (the patch wrote) and the node now has the deadline...
expect(patched).not.toBe(afterServer);
expect((treeModel.find(patched, "new") as TN).temporaryExpiresAt).toBe(
deadline,
);
// ...and still exactly one node (no duplicate re-insert).
expect(patched.filter((n) => n.id === "new")).toHaveLength(1);
});
it("(b) does NOT overwrite an existing deadline; returns prev by reference", () => {
const existingDeadline = deadline;
// The node already exists WITH a deadline (the broadcast carried it).
const afterServer = treeModel.insert(fixtureTN, null, {
id: "new",
name: "",
temporaryExpiresAt: existingDeadline,
});
// The create response carries a DIFFERENT deadline; the guard must ignore it.
const created: TN = {
id: "new",
name: "",
temporaryExpiresAt: "2099-01-01T00:00:00.000Z",
};
const after = applyOptimisticInsert(
afterServer,
null,
created,
afterServer.length,
);
// prev returned by reference (no write) and the original deadline is kept.
expect(after).toBe(afterServer);
expect((treeModel.find(after, "new") as TN).temporaryExpiresAt).toBe(
existingDeadline,
);
});
});
// moveTreeNode socket-handler semantics: the receiver must place the moved node // moveTreeNode socket-handler semantics: the receiver must place the moved node
// by `position` (NOT index 0) and apply the `pageData` the payload carries so a // by `position` (NOT index 0) and apply the `pageData` the payload carries so a
// moved node's title/icon/chevron stay correct. This mirrors the reducer in // moved node's title/icon/chevron stay correct. This mirrors the reducer in

View File

@@ -9,26 +9,45 @@ export function sortPositionKeys(keys: any[]) {
}); });
} }
/**
* Single canonical `IPage -> SpaceTreeNode` field mapper. Every place that
* materialises a tree node from a page (buildTree, the optimistic insert in
* handleCreate, restore, duplicate) routes through here so the field copy —
* crucially `temporaryExpiresAt` — can never silently drift between sites. The
* `overrides` cover the small per-site differences (e.g. `name: ""` for an
* optimistic create, `name: title || "Untitled"` for restore, `canEdit: true`
* for duplicate). The default `temporaryExpiresAt` comes straight off the page,
* so restore (which the server nulls) stays permanent and a temporary create
* keeps its clock marker without a reload.
*/
export function pageToTreeNode(
page: IPage,
overrides?: Partial<SpaceTreeNode>,
): SpaceTreeNode {
return {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
...overrides,
};
}
export function buildTree(pages: IPage[]): SpaceTreeNode[] { export function buildTree(pages: IPage[]): SpaceTreeNode[] {
const pageMap: Record<string, SpaceTreeNode> = {}; const pageMap: Record<string, SpaceTreeNode> = {};
const tree: SpaceTreeNode[] = []; const tree: SpaceTreeNode[] = [];
pages.forEach((page) => { pages.forEach((page) => {
pageMap[page.id] = { pageMap[page.id] = pageToTreeNode(page);
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
};
}); });
// Defense-in-depth: a duplicate id in `pages` would push two references to the // Defense-in-depth: a duplicate id in `pages` would push two references to the

View File

@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { IShareAlias } from "@/features/share/types/share.types";
// matchMedia / storage are stubbed globally in vitest.setup.ts.
// The mutation + query hooks reach react-query/network; the availability probe
// hits the API. Stub them so the section renders in isolation and we can drive
// the exact branches (taken name -> hint, 409 -> reassign modal).
const setMutateAsync = vi.fn();
let currentAlias: IShareAlias | null = null;
let availabilityResult: {
valid: boolean;
available: boolean;
currentPageId: string | null;
} = { valid: true, available: true, currentPageId: null };
vi.mock("@/features/share/queries/share-query.ts", () => ({
useShareAliasForPageQuery: () => ({ data: currentAlias }),
useSetShareAliasMutation: () => ({
mutateAsync: setMutateAsync,
isPending: false,
}),
useRemoveShareAliasMutation: () => ({
mutateAsync: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/features/share/services/share-service.ts", () => ({
checkShareAliasAvailability: vi.fn(async () => availabilityResult),
}));
import ShareAliasSection from "./share-alias-section";
const aliasRow = (alias: string, pageId: string): IShareAlias => ({
id: `alias-${alias}`,
workspaceId: "ws-1",
alias,
pageId,
creatorId: "user-1",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
function renderSection(pageId = "page-Y") {
return render(
<MantineProvider>
<ShareAliasSection pageId={pageId} readOnly={false} />
</MantineProvider>,
);
}
describe("ShareAliasSection — taken-name handling is never a dead end", () => {
beforeEach(() => {
setMutateAsync.mockReset();
currentAlias = null;
availabilityResult = { valid: true, available: true, currentPageId: null };
});
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
// Page Y already owns "bee"; the user retypes a name owned by page X.
currentAlias = aliasRow("bee", "page-Y");
availabilityResult = {
valid: true,
available: false,
currentPageId: "page-X",
};
renderSection("page-Y");
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
fireEvent.change(input, { target: { value: "test2" } });
// The reassign hint replaces the old dead-end red error.
await waitFor(
() =>
expect(
screen.getByText(
"This address is in use. Saving will move it to this page.",
),
).toBeDefined(),
{ timeout: 2000 },
);
// The old terminal "already in use" error must NOT be shown.
expect(screen.queryByText("This address is already in use")).toBeNull();
// Save stays enabled so the confirm-reassign flow can run.
const saveBtn = screen.getByRole("button", {
name: "Save",
}) as HTMLButtonElement;
expect(saveBtn.disabled).toBe(false);
});
it("opens the reassign-confirm modal on a 409 ALIAS_REASSIGN_REQUIRED (path forward, not a dead end)", async () => {
currentAlias = aliasRow("bee", "page-Y");
availabilityResult = {
valid: true,
available: false,
currentPageId: "page-X",
};
// The server rejects the un-confirmed save asking the client to confirm.
setMutateAsync.mockRejectedValueOnce({
status: 409,
response: {
status: 409,
data: {
code: "ALIAS_REASSIGN_REQUIRED",
currentPageId: "page-X",
currentPageTitle: "Alias Test Page X",
},
},
});
renderSection("page-Y");
const input = screen.getByPlaceholderText("my-page") as HTMLInputElement;
fireEvent.change(input, { target: { value: "test2" } });
const saveBtn = screen.getByRole("button", {
name: "Save",
}) as HTMLButtonElement;
await waitFor(() => expect(saveBtn.disabled).toBe(false), {
timeout: 2000,
});
fireEvent.click(saveBtn);
// First save sent WITHOUT confirmReassign.
await waitFor(() =>
expect(setMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ alias: "test2", confirmReassign: false }),
),
);
// The "Move custom address?" confirm modal must appear (the path forward).
await waitFor(() =>
expect(screen.getByText("Move custom address?")).toBeDefined(),
);
expect(screen.getByRole("button", { name: "Move here" })).toBeDefined();
// Confirming retries WITH confirmReassign: true.
setMutateAsync.mockResolvedValueOnce(aliasRow("test2", "page-Y"));
fireEvent.click(screen.getByRole("button", { name: "Move here" }));
await waitFor(() =>
expect(setMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ alias: "test2", confirmReassign: true }),
),
);
});
});

View File

@@ -120,8 +120,13 @@ export default function ShareAliasSection({
}; };
const showInvalid = normalized.length > 0 && !isValid; const showInvalid = normalized.length > 0 && !isValid;
const showTaken = // The typed name is already in use by ANOTHER page. This is NOT a dead end:
isValid && !unchanged && availability && !availability.available; // hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens
// the "Move custom address?" confirm modal that retargets the address here.
// So surface it as an informational hint (not a terminal red error) and keep
// Save enabled, instead of looking like the address is unusable.
const reassignable =
isValid && !unchanged && !!availability && !availability.available;
// The slug prefix (e.g. "docs.example.com/l/") is static for the session. // The slug prefix (e.g. "docs.example.com/l/") is static for the session.
const prefixLabel = aliasPrefixLabel(); const prefixLabel = aliasPrefixLabel();
@@ -185,7 +190,6 @@ export default function ShareAliasSection({
fontSize: "var(--mantine-font-size-xs)", fontSize: "var(--mantine-font-size-xs)",
color: "var(--mantine-color-dimmed)", color: "var(--mantine-color-dimmed)",
backgroundColor: "var(--mantine-color-default-hover)", backgroundColor: "var(--mantine-color-default-hover)",
borderRight: "1px solid var(--mantine-color-default-border)",
borderTopLeftRadius: "var(--input-radius)", borderTopLeftRadius: "var(--input-radius)",
borderBottomLeftRadius: "var(--input-radius)", borderBottomLeftRadius: "var(--input-radius)",
}} }}
@@ -199,9 +203,12 @@ export default function ShareAliasSection({
error={ error={
showInvalid showInvalid
? t("Use 2-60 lowercase letters, digits and hyphens") ? t("Use 2-60 lowercase letters, digits and hyphens")
: showTaken : undefined
? t("This address is already in use") }
: undefined description={
reassignable
? t("This address is in use. Saving will move it to this page.")
: undefined
} }
/> />

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
// matchMedia / storage are stubbed globally in vitest.setup.ts.
// Enabling a public share must NOT silently expose the whole sub-tree (#216):
// the create call defaults includeSubPages to false. This was a one-literal,
// security-relevant default with no test — lock it.
const createMutateAsync = vi.fn(async () => ({}));
const deleteMutateAsync = vi.fn(async () => ({}));
// No existing share for this page (toggle starts OFF).
let shareData: any = undefined;
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/features/share/queries/share-query.ts", () => ({
useCreateShareMutation: () => ({ mutateAsync: createMutateAsync }),
useDeleteShareMutation: () => ({ mutateAsync: deleteMutateAsync }),
useUpdateShareMutation: () => ({ mutateAsync: vi.fn() }),
useShareForPageQuery: () => ({ data: shareData }),
}));
vi.mock("@/features/page/queries/page-query.ts", () => ({
usePageQuery: () => ({ data: { id: "page-1", title: "Doc" } }),
}));
vi.mock("@/features/space/queries/space-query.ts", () => ({
useSpaceQuery: () => ({ data: { settings: {} } }),
}));
import ShareModal from "./share-modal";
function renderModal() {
return render(
<MemoryRouter>
<MantineProvider>
<ShareModal readOnly={false} />
</MantineProvider>
</MemoryRouter>,
);
}
describe("ShareModal — enabling a share defaults includeSubPages to false (#216)", () => {
beforeEach(() => {
createMutateAsync.mockClear();
deleteMutateAsync.mockClear();
shareData = undefined;
});
it("creates the share with includeSubPages: false when the user turns it on", async () => {
renderModal();
// Open the share popover.
fireEvent.click(screen.getByRole("button", { name: "Share" }));
// The "Share to web" toggle is the only switch in the not-yet-shared state.
const toggle = await screen.findByRole("switch");
fireEvent.click(toggle);
await waitFor(() => expect(createMutateAsync).toHaveBeenCalledTimes(1));
expect(createMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({
pageId: "page-1",
includeSubPages: false,
}),
);
});
});

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