Compare commits

..

67 Commits

Author SHA1 Message Date
claude code agent 227
01825ccb5d 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-26 21:05:58 +03:00
claude code agent 227
26cce98d8d 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-26 21:05:58 +03:00
claude code agent 227
8a31be9789 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-26 21:05:58 +03:00
claude code agent 227
3c20ab1406 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-26 21:05:58 +03:00
claude code agent 227
0bc42a442e 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-26 21:05:58 +03:00
claude code agent 227
9933079de2 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-26 21:05:58 +03:00
claude_code
c317f5502c 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-26 21:05:58 +03:00
claude_code
8bfe572ebe 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-26 21:05:58 +03:00
claude_code
47414e908c 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-26 21:05:58 +03:00
claude_code
29c620bfe4 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-26 21:05:58 +03:00
claude_code
25304fe7d9 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-26 21:05:58 +03:00
claude_code
87856109fd 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-26 21:05:58 +03:00
719bccd80d Merge pull request 'feat(ai-chat): load full transcript for model history (drop 50-msg window)' (#202) from feat/ai-chat-full-history into develop
Reviewed-on: #202
2026-06-26 20:55:50 +03:00
83e64bad1a Merge pull request 'feat(ai): generate page title from content (#199)' (#210) from feat/199-ai-generate-title into develop
Reviewed-on: #210
2026-06-26 20:55:35 +03:00
ee78a96803 Merge pull request 'feat(ai-chat): interrupt agent + send queued message, keeping partial output (#198)' (#211) from feat/198-interrupt-agent into develop
Reviewed-on: #211
2026-06-26 20:55:20 +03:00
d971d02346 Merge pull request 'feat(page): temporary notes — auto-trash after X hours unless made permanent (#201)' (#215) from feat/201-temporary-notes into develop
Reviewed-on: #215
2026-06-26 20:54:56 +03:00
claude code agent 227
53cbec9354 fix(db): bump temporary-notes migration timestamp past share-aliases (#201)
develop merged 20260626T130000-share-aliases; rename this PR's migration to
20260626T140000 so the two no longer share a timestamp prefix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:40:08 +03:00
claude code agent 227
686c3f9d14 fix(ai-chat): branch sendNow on live status to defuse stale-status race (#198)
Port the only substantive fix #211 was missing relative to #203 (which is
being closed): the "Send now" handler branched on the closure-captured
isStreaming, but a turn can finish between render and click. In that window
stop() is a no-op, so arming flushOnAbortRef/interruptNextSendRef would strand
those one-shot flags and leak into a later, unrelated Stop (auto-sending a
queued message the user never asked to send).

- Mirror the live useChat status in statusRef (updated each render) and branch
  sendNow on it instead of isStreaming, so the not-streaming path runs when the
  turn has already ended and the interrupt flags are never armed against a
  no-op stop().
- Belt-and-suspenders: clear flushOnAbortRef/interruptNextSendRef when a new
  turn starts streaming, defusing the sub-render-tick window where a flag could
  still be armed but the expected abort never fired. No-op for the legit
  interrupt path (both refs are consumed synchronously beforehand).

Keeps #211's existing structure and its flushNext-returns-boolean fix. The
rest of #203's divergence is comment rewording, a server-side rename of the
same pure interrupt-gate, and fewer tests — nothing else to port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:40:06 +03:00
claude code agent 227
6faf2475e6 fix(ai-chat): address PR #211 review (i18n keys, dead export, flag leak)
- Register the new AI-chat keys "Send now" and "Interrupt and send now" in
  both en-US and ru-RU catalogs so the UI never renders mixed-language
  tooltip/aria-label (i18n policy).
- Make INTERRUPT_NOTE module-private (drop the unused re-export), matching the
  module's private DEFAULT_PROMPT/SAFETY_FRAMEWORK siblings.
- Reset interruptNextSendRef in the flush-on-abort branch when nothing is
  actually sent, so a stuck one-shot interrupt flag cannot tag the next
  unrelated send; flushNext now reports whether it sent.
- Add a CHANGELOG [Unreleased]/Added entry for #198.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:40:06 +03:00
claude code agent 227
7d64b11045 test: cover future-deadline (re-armed) branch in temporary-note cleanup guard
The deletion guard skips a note when its re-read deadline is still in the
future (user disarmed-then-re-armed in the race window between the batch
SELECT and the per-row re-read). The default stub returns an epoch deadline
(always < now), so the existing race tests never exercised the
`new Date(temporaryExpiresAt) >= now` branch; a regression dropping it or
inverting the comparison would pass unnoticed. Add a test that re-reads a
fresh future deadline and asserts removePage is not called.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:51 +03:00
claude code agent 227
983f2fa654 Address PR #215 review: temporary notes hardening
Must-fix:
- CHANGELOG: add [Unreleased]/Added entry for temporary notes (#201).
- temporary-note-cleanup: re-check temporary_expires_at at deletion time so a
  concurrent "Make permanent" (sets it NULL) between the batch SELECT and the
  per-row removePage wins the race and the note is not trashed. Add unit tests
  for the make-permanent and already-trashed race windows.

Non-blocking review items:
- temporary-note-cleanup: cap the sweep batch (LIMIT 500) so a large backlog is
  not loaded into memory; remainder drains on the next hourly run.
- client: extract duplicated post-toggle cache sync into
  syncTemporaryExpiresInCache() shared by the header menu and the banner.
- Remove the tautological migration spec that mocked the whole Kysely builder.
- Tests: cover create() frozen temporaryExpiresAt (workspace override + NULL
  default fallback + non-temporary skips lookup) and restorePage disarming the
  timer (temporaryExpiresAt: null).

Deferred (forward-looking, non-blocking): extract
PageService.computeTemporaryExpiresAt() to dedupe the deadline formula and drop
the @InjectKysely from PageTemplateController; replace migration unit test with
a real Postgres up/down integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:51 +03:00
claude code agent 227
e99c00a9ee test(review): pin full-transcript history past 50 rows + changelog (PR #202)
Address the PR #202 review (approve-with-comments). The only actionable
non-blocking item was the test-coverage suggestion: the source switch in
AiChatService.handle from findRecent(chatId, ws, 50) to findAllByChat(chatId,
ws) was not pinned by a test. handle() is a streaming method the project marks
as not unit-testable, so cover the behavioral guarantee it now relies on at the
repo/integration level — seed a chat of 60 messages and assert the default
findAllByChat (exactly how handle calls it) returns the FULL transcript in
chronological order, including the first turn the old 50-window would have
dropped.

Also document the behavior change under CHANGELOG [Unreleased] -> Changed.

The two stability items (token-budget trim before streamText; O(N) history
rebuild per turn) are deferred: the reviewer flagged both as non-blocking
conscious trade-offs aligned with the PR's stated goal, and the trim is a
larger architecture change out of scope for this follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:30 +03:00
claude_code
1f459d8d26 feat(ai-chat): load full transcript for model history (drop 50-msg window)
The per-turn model conversation was rebuilt via findRecent(chatId, ws, 50),
a sliding window that dropped the beginning of any chat longer than ~50 stored
rows. Switch streamChat to the existing findAllByChat, which loads the full
non-deleted transcript chronologically with a 5000-row memory-safety backstop
(keeps the newest rows + logs a warning on overflow) — a safety net, not a
conversational limit. Remove the now-unused findRecent method and update the
comments/log text that referenced it (findAllByChat now feeds both the Markdown
export and the model history).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:39:30 +03:00
claude code agent 227
9632146d23 test(editor): cover focused-title guard and destroyed-editor early-out
Add coverage for the two untested branches in useGeneratePageTitle's
post-generation write: suppressing setContent when the live title editor
is focused (DB write + broadcast still happen, only the visible field
write is skipped), and the early return when the page editor is
destroyed (model never called).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:03 +03:00
claude code agent 227
0314416bfa Address PR #210 review: changelog, navigation guard, hook tests (#199)
- CHANGELOG: add an [Unreleased]/Added bullet documenting the
  "generate title from content" byline button (reads live editor
  content, generates via the workspace AI provider, applies through
  /pages/update, gated by settings.ai.generative, throttled per user).

- use-generate-page-title: guard the visible title write against page
  navigation during generation. The mutation awaits the model for 1-3s;
  its closure captures the editors from the starting render, but the
  global page/title atoms re-point on navigation. We now keep a live ref
  to the current editors and skip setContent unless the live page editor
  still belongs to the page the title was generated for
  (editor.storage.pageId === pageId, mirroring TitleEditor's
  activePageId guard). The DB write stays correct (keyed by the captured
  pageId) and the websocket broadcast is unchanged, so only the wrong-page
  field write is suppressed.

- Add a vitest suite for the hook: empty content, empty model response,
  happy path, the navigation guard, and 403/503/429/other onError mapping.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:39:03 +03:00
claude code agent 227
eb5b696431 feat(page): temporary notes — auto-trash after X hours unless made permanent (#201)
"Temporary notes" with a death timer: created via a dedicated hourglass button
in the space-tree header, a note auto-moves to Trash after a configurable X
hours (default 24) unless explicitly made permanent ("structure or die").

Reuses existing mechanisms, mirroring is_template and the trash-cleanup job:
- New nullable column pages.temporary_expires_at (NULL = permanent; non-NULL =
  frozen deadline) + partial index for the sweep; workspace column
  temporary_note_hours (default via DEFAULT_TEMPORARY_NOTE_HOURS = 24).
- create-page DTO `temporary` flag; the deadline is frozen at creation so later
  setting changes never reschedule existing notes.
- POST /pages/toggle-temporary (mirror of toggle-template): arm/clear the timer,
  CASL-guarded via validateCanEdit, cross-workspace NotFound defense-in-depth.
- TemporaryNoteCleanupService: hourly @Interval sweep that soft-deletes expired
  notes through the exact PageRepo.removePage path (recursive over children,
  emits PAGE_SOFT_DELETED), attributed to the creator; idempotent via
  deletedAt IS NULL filters.
- restorePage clears temporary_expires_at so a restored note can't be re-trashed.
- Workspace setting temporary_note_hours (audit-tracked) + a hours editor in
  workspace General settings.
- Client: second create button, orange tree icon, tree + page-header menu toggle
  ("Make temporary"/"Make permanent"), an open-note banner with a rescue action,
  and en/ru i18n.

Tests (unit): toggle-temporary controller (toggle/explicit/permission/cross-ws +
DTO validation), cleanup-job sweep (selection filters, per-note removePage,
error isolation), and a migration up/down sanity. Server tsc, client tsc -b,
and the page+workspace jest suites are green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:38:42 +03:00
claude code agent 227
422389d84e feat(ai-chat): interrupt agent + send queued message, keeping partial output (#198)
Add a "send now" button to queued AI-chat messages: it interrupts the
running agent and immediately sends that message, while the agent's
partial output at interruption is kept in history and the next turn is
marked as a user interrupt.

Client:
- queue-helpers: pure `promoteToHead` to move a queued message to the head.
- chat-thread: `sendNow` (promote head + abort + flush-on-abort), one-shot
  `flushOnAbortRef`/`interruptNextSendRef`, `interrupted` flag in the
  request body, and the "send now" ActionIcon in the queued list.

Server:
- `interrupted` on AiChatStreamBody; pure `isInterruptResume` confirms the
  client hint against persisted history (prev assistant turn aborted/
  streaming) before honouring it.
- prompt: INTERRUPT_NOTE injected in the context section only on a
  confirmed interrupt-resume turn so the model treats the partial answer
  above as incomplete.

Tests: promoteToHead, chat-thread send-now (abort + resend + one-shot
interrupt flag + non-streaming immediate send), isInterruptResume, and
the prompt interrupt-note injection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 20:38:23 +03:00
claude_code
fad1aa0501 fix(db): move share-aliases migration spec out of migrations/
The #205 share-aliases feature placed share-aliases.migration.spec.ts
inside src/database/migrations/. Kysely's FileMigrationProvider loads
EVERY file in that folder as a migration, so `migration:latest` imported
the test file and crashed with "ReferenceError: describe is not defined"
(no Jest globals under tsx). That broke the migration step shared by the
e2e-server, e2e-mcp and integration-test (test/test) jobs.

Move the spec one level up to src/database/ (matching the existing
src/database/jsonb-bind.spec.ts convention) so the migration runner no
longer sees it, and fix its relative imports
(./migrations/... and ./types/...). Jest still picks it up via the
src/**/*.spec.ts test glob. Verified locally: 3 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:14:31 +03:00
claude_code
8bb4224a20 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 20:02:51 +03:00
13589b3973 Merge pull request 'feat(share): custom /l/:alias pretty links (share_aliases table) (#205)' (#214) from feat/205-share-aliases into develop
Reviewed-on: #214
2026-06-26 20:00:50 +03:00
claude_code
69fcccd6e8 docs(release): clarify tagging and merge flow
Update the release documentation to emphasize tagging on develop before merging to main, detail steps for pushing tags to both gitea and github, and explain the back‑merge and remote tag considerations.
2026-06-26 19:57:49 +03:00
claude_code
0db48f1706 chore(gitignore): add .claude/tmp/ to ignore list 2026-06-26 19:57:43 +03:00
claude_code
2e72a24d13 test(e2e): silence ts-jest allowJs warnings for editor-ext .js
The e2e transform matches .js (required so ESM-only node_modules like
nanoid/@sindresorhus get transpiled), which also sweeps in editor-ext's
prebuilt CommonJS dist/*.js. ts-jest then warns "Got a .js file to
compile while allowJs is not set to true" for each footnote file. The
.js match cannot be dropped without reintroducing the ESM load errors, so
enable allowJs for ts-jest via an inline tsconfig override (merged with
apps/server/tsconfig.json — decorators/paths/module stay intact).

Verified locally: 0 allowJs warnings, app still compiles and boots to the
Redis connection (no DI/metadata regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:45:37 +03:00
claude_code
aad0a37cfd 0.94.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:33:57 +03:00
claude_code
50d3e7b476 style(editor): align footnote marker and center task checkbox
Remove top margin from the first paragraph in footnote definitions so the
marker aligns with the first line of text. Adjust the task‑list label to use
the editor line‑height variable for its height and center the checkbox
vertically, keeping it in line with the item’s first text line.
2026-06-26 19:24:13 +03:00
claude_code
bd62d906bb test(e2e): anchor top-level mcp comment on existing page text
With the image fix in place, the mcp e2e ran through every section and
failed only at the last one (comments): create_comment was hardened to
require an inline "selection" (exact text to anchor on) for a top-level
comment, but the test created one without a selection ("an inline
'selection' ... is required for a top-level comment").

Pass an inline selection ("Добавленный абзац.", a plain paragraph
re-imported in section 5 and still present at the comments stage). The
reply is unchanged: it carries a parentCommentId, so it is a reply and
needs no selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:16:55 +03:00
claude_code
e4b46ddbfc test(e2e): make server e2e actually boot (ESM chain + Fastify adapter)
The previous jest-config fix let the module graph load further and exposed
two more reasons the server e2e never passed since it was added:

1. ESM transform chain: AppModule pulls in editor-ext -> @tiptap ->
   @sindresorhus/slugify -> @sindresorhus/transliterate / escape-string-regexp,
   plus p-limit -> yocto-queue — all ESM-only. Extend the e2e
   transformIgnorePatterns whitelist to transform them (scoped packages need
   both the pnpm `@scope+name` and nested `@scope/name` path forms, hence
   `@sindresorhus[+/][a-z0-9-]+`). Verified locally: the graph now fully
   transforms and resolves.

2. Wrong HTTP adapter: Docmost runs on Fastify (main.ts uses FastifyAdapter)
   and does not depend on @nestjs/platform-express, but the scaffold test used
   the default createNestApplication() (Express) and died with
   "@nestjs/platform-express package is missing". Switch the test to
   FastifyAdapter + getInstance().ready(), close in afterEach. Verified locally:
   createNestApplication + app.init() now proceed to the live Redis/Postgres
   connection (the infra CI provides via services + migrations).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:13:22 +03:00
claude_code
deeec50b5f test(e2e): fix remaining server config and mcp image failures
Follow-up to the first e2e fix: with nanoid/editRes.edits resolved, the
suites failed one layer deeper. Both layers were never green since the
e2e jobs were added (non-blocking in CI), so the failures had stacked up.

server e2e (jest-e2e.json) — align module resolution/transform with the
working unit/integration jest configs so AppModule's full import graph
loads:
- moduleFileExtensions: add "tsx" (React-Email .tsx templates are pulled
  in via the auth controller chain).
- transform: ^.+\.(t|j)s$ -> ^.+\.(t|j)sx?$ so .tsx is transformed.
- moduleNameMapper: add ^src/(.*)$ -> <rootDir>/../src/$1 (code imports
  via the absolute 'src/...' alias). Verified locally: the module graph
  now fully resolves (only env vars, supplied by CI, remain).

mcp e2e (test-e2e.mjs) — insert_image/replace_image accept only http(s)
URLs the server fetches; the test passed local file paths and died with
"Invalid image URL". Serve the PNG bytes over a throwaway 127.0.0.1 HTTP
server (the Docmost server runs on the same CI host) and pass URLs. The
featPng negative test is untouched: replaceImage checks the attachmentId
and throws before fetching, so its local path is never validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:54:42 +03:00
claude_code
7eefdad512 test(e2e): fix failing server and mcp e2e suites
Two unrelated CI failures on the 0.94.0 release PR:

- server e2e: jest-e2e.json lacked transformIgnorePatterns, so the
  ESM-only nanoid@5 package was loaded as CommonJS and crashed with
  "Cannot use import statement outside a module". Add the same
  node_modules whitelist already present in the unit and integration
  jest configs (nanoid|uuid|image-dimensions|marked|happy-dom|lib0).

- mcp e2e: test-e2e.mjs read editRes.edits, but editPageText() returns
  the per-edit results under `applied` (not `edits`), so editRes.edits
  was undefined and .every() threw. Read editRes.applied instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:34:56 +03:00
claude_code
a7f8ee04b3 docs(changelog): 0.94.0 release notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:24 +03:00
claude_code
378d8b676b 0.94.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:24 +03:00
580f7bd5bb Merge pull request 'Батч: бейдж контекста (#189) + e2e в CI (#187) + inline-тест MCP (#170)' (#197) from batch/issues-189-187-170 into develop
Reviewed-on: #197
2026-06-26 18:09:47 +03:00
claude_code
b538c729c3 Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop 2026-06-26 18:09:00 +03:00
claude code agent 227
0643cd1d82 test(share): exercise 70-char title-slug clamp in alias redirect
The controller's buildPageSlug truncates the page title via
`title?.substring(0, 70)` before slugifying, but no test drove that
branch (the only titled case was 16 chars). Add a resolvable-alias
case with a 119-char title whose 70-char boundary falls mid-word and
assert the 302 target's slug reflects only the first 70 characters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:05:21 +03:00
e3b23e0d26 Merge pull request 'fix: bug batch — #161 #190 #207 #159 + #206 findings' (#212) from fix/mainline-bugs-2026-06-26 into develop
Reviewed-on: #212
2026-06-26 17:43:55 +03:00
claude code agent 227
b392219659 fix(ai-mcp): show Failed when the inline Test request itself rejects (#170)
The per-row MCP Test button derived its presentation solely from the test
mutation's data ({ ok, tools } | { ok, error }). When the request itself
rejected (401/403/500/network) there is no payload, so the row silently spun
back to the idle "Test" instead of reporting the failure.

Feed the mutation error into mcpTestButtonView so a reject also renders a red
"Failed", with the tooltip taken from the server message
(error.response.data.message) or a generic i18n fallback. Enable the tooltip
for any non-idle state. Cover the reject branch (with and without a server
message) in the helper unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:37:08 +03:00
claude code agent 227
ba5cd02439 Address PR #197 review: test coverage + dedup + CI log capture
Code-review follow-ups (Approve-with-comments) for batch #197
(context badge #189 / e2e in CI #187 / inline MCP test #170):

- server: extract the duplicated chatContextWindow ::text->positive-int
  coercion (resolve() + getMasked()) into an exported parsePositiveInt
  helper and unit-test its branches (200000/1.9/0/-5/""/abc/undefined),
  closing the untested read-path gap.
- client: merge the two backward scans over messageRows into one pure,
  exported selectContextBadge helper (numerator and denominator still
  taken from the most recent row carrying EACH value) and unit-test the
  different-rows and fresh-zero-doesn't-shadow cases.
- client: extract the MCP "Test" button tristate presentation into a pure
  mcpTestButtonView helper (collapses the two parallel if/else chains) and
  unit-test idle/ok-with-tools/ok-no-tools/failed label+tooltip branches.
- ci: redirect the backgrounded prod server's stdout/stderr to a log file
  in e2e-mcp and cat it on failure, so a start-up crash is diagnosable
  instead of surfacing only as the generic health timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:24:29 +03:00
claude code agent 227
1043fe3b51 test(share): cover alias controllers; address PR #214 review
Add the two blocking test-coverage specs requested in the PR #214 review and
clear the cheap non-blocking items.

Must-fix:
- share-alias-redirect.controller.spec.ts: routing/leak guard for the public
  GET /l/:alias resolver (modeled on share-seo.controller.routing.spec). Pins
  302-to-canonical on a hit; SPA index without a 302 for unknown/dangling/
  unreadable aliases and a null workspace (no name-existence leak); defensive
  percent-decoding treated as unknown; self-hosted findFirst vs subdomain
  findByHostname workspace resolution; 404 when no built client index exists.
- share-alias.controller.spec.ts: authz gates with mocked PageRepo/ShareService/
  ShareAliasService/PageAccessService. Covers cross-workspace/nonexistent page
  -> NotFoundException, validateCanEdit, resolveReadableSharePage null ->
  BadRequestException, isSharingAllowed false -> ForbiddenException, set happy
  path delegation, remove() of a dangling alias (pageId null) skipping
  validateCanEdit but still deleting, and for-page validateCanView.

Cheap review items:
- Remove dead Logger import/field from ShareAliasRedirectController.
- Remove dead PagePermissionRepo import/dependency from ShareAliasController.
- Register the new share-alias UI strings in en-US and ru-RU catalogs.
- Add an [Unreleased]/Added CHANGELOG entry for /l/:alias (#205).
- Drop the tautological boilerplate assertions from the migration spec
  (exports up/down; runtime checks of typed entity literals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:22:29 +03:00
claude code agent 227
df50f23d58 merge: fold #182 (AI-chat stream throttle + render memoization) into #212 2026-06-26 17:07:54 +03:00
claude code agent 227
eb5c8e6611 refactor(ai-chat): simplify share onFinish token extraction and cover the fallback (#159)
onFinish always receives a totalUsage object, so the `?? {}` guard and
optional chaining were dead. Extract the field-level extraction into a
recordTurnUsage method (totalTokens, else input+output) and unit-test that
recordShareTokens receives the summed value when totalTokens is absent and the
authoritative total when present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
d32ad73158 test(editor-ext): cover the transclusionSource NO_REASSIGN branch in id dedupe (#206)
A colliding transclusionSource id is deliberately NOT reassigned (its id is a
cross-reference key), while a missing id is still filled. Add coverage for both:
two sources sharing an id keep it (red if the NO_REASSIGN guard is removed), and
a source with no id gets a fresh one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
acf2241e23 docs: document SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY and changelog the bug-fix batch (#161 #190 #207 #206 #159)
Document the new per-workspace rolling-day token-budget env var in
.env.example alongside the existing share-assistant cost knobs, and add
[Unreleased] Fixed entries for #161/#190/#207/#206 plus a Security entry
for the #159 token budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:01:02 +03:00
claude code agent 227
fdeede003b feat(share): custom /l/:alias pretty links (share_aliases table) (#205)
Add a retargetable, human-readable vanity link namespace /l/<alias> that
sits alongside the untouched /share/... routes.

- New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias),
  page_id nullable ON DELETE SET NULL so the address outlives its target).
- ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard /
  availability / request-time readable-target resolution through the single
  existing share boundary).
- Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301,
  the target is mutable) to the canonical /share/:key/p/:slug page; unknown /
  dangling / no-longer-readable aliases serve the SPA index with no leak.
  'l/:alias' excluded from the global /api prefix.
- Authenticated ShareAliasController (set/remove/availability/for-page).
- Shared ASCII-only normalize/validate util (server + client copies).
- Client: Custom address block in the share modal (live normalize + debounced
  availability + copy + reassign confirmation dialog).
- Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity
  (server jest) + client alias util (vitest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:28:26 +03:00
claude code agent 227
1d610b3a62 fix(ai-chat): add per-workspace rolling-day token budget for anonymous share assistant (#159)
The anonymous public-share assistant only capped the COUNT of requests
(100/hour/workspace), not their cost. One accepted turn runs the agent loop
up to stepCountIs(5), re-sending the whole client-held transcript as input on
every step, while maxOutputTokens caps only the output; the request window is
hourly with no daily ceiling, so a steady stream at the cap sustains ~24x its
count per day. Counting requests therefore does not bound the owner's LLM bill
(red-team finding #5).

Add a second cost contour: a cluster-wide, sliding-window per-workspace TOKEN
budget over a rolling day. It is checked read-only BEFORE a turn streams (429,
no request slot consumed, nothing spent) and the turn's real usage
(totalUsage: input re-sent per step + output, summed across all steps) is
recorded once it finishes via streamText onFinish. Fails closed on the check
(deny when Redis can't prove we're under budget); best-effort on the record.
Env-overridable via SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY (default 1M/day).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:23:48 +03:00
claude code agent 227
6bb9dfdc86 fix(editor-ext): dedupe colliding unique ids on import/normalize (#206)
editor-pm-7: addUniqueIdsToDoc only FILLED missing ids and never deduplicated
existing ones, so a copy/paste or bulk-JSON duplicate that kept its attrs.id
produced two nodes sharing an id. MCP addressed edits (patch_node /
delete_node "before/after id") then hit the wrong node or both.

Walk the configured-type nodes in document order: the first occurrence of an
id keeps it (stable anchor), later duplicates are reassigned a fresh id.
transclusionSource ids are cross-reference keys (references resolve a source by
this id), so they are only filled-when-missing, never reassigned, to avoid
orphaning their references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:09:02 +03:00
claude code agent 227
770ba70541 fix(collab): retry transient store failures so autosave edits aren't lost (#206)
persist-1: onStoreDocument wrapped the page write in a try/catch that only
logged and swallowed the error, then resolved "successfully". hocuspocus
destroys/unloads the in-memory Y.Doc right after the hook resolves (the only
copy of the latest edit), so a transient DB error (deadlock, serialization
failure, dropped connection) silently lost the edit. Worse, the post-store
branch ran on the partially-assigned `page`, broadcasting a phantom
"page.updated" and enqueueing a history snapshot for content never written.

Wrap the write in a small bounded retry (3 attempts) so the save is
re-attempted while we still hold the doc, and clear `page` on failure so the
success-only side effects never report a save that didn't happen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:05:28 +03:00
claude code agent 227
3d47c306fa fix(tree): cycle-guard placeByPosition so out-of-order moves don't drop subtrees (#206)
ui-state-races-1: the server-authoritative move path (placeByPosition, via
applyMoveTreeNode) lacked the isDescendant cycle guard that drag-drop `move`
has. When move events arrive out of order so the destination parent is still
nested inside the moved node's own subtree, remove(source) dropped the whole
subtree (incl. the future parent) and insertByPosition could not re-place it —
the node and all descendants silently vanished with no error/refetch.

Add the isDescendant guard to placeByPosition (returns same ref, like its other
no-op cases) and short-circuit applyMoveTreeNode on the same condition BEFORE
the placed===prev remove-fallback (which would otherwise still drop the
subtree). Leave the tree untouched so a later corrective event / reconnect
reconcile fixes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 06:00:26 +03:00
claude code agent 227
c919d4f636 fix(page): copy shared attachments for every referencing page on duplicate (#206)
attach-1: when the same attachmentId was referenced by more than one page
in a duplicated subtree, the per-attachmentId map held only a single copy
entry, so the last page processed clobbered the others. The downstream
ownership guard (`attachment.pageId !== oldPageId`) then matched at most one
page and skipped the lone DB row entirely: no blob copied, no new row, every
copy's image 404'd. Key the map to a list of entries and copy one blob/row
per referencing page; drop the now-incorrect ownership guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:57:46 +03:00
claude code agent 227
c4807022f2 fix(page): add cycle/depth guard to recursive tree-traversal CTEs (#207)
getPageBreadCrumbs (ancestor CTE) and forceDelete (descendant CTE) used
withRecursive + unionAll with no CYCLE clause or depth cap. If a parent/child
cycle already exists in the data (e.g. one slipped in via the #7 TOCTOU race),
both queries loop forever — hang / statement timeout. Worse, the move guard
itself runs the ancestor CTE, so a cycle would disable the very guard meant to
prevent it (#207 #8).

Add a depth counter bounded by MAX_PAGE_TREE_DEPTH to both recursive CTEs; the
walk stops at the cap, so a cycle yields a bounded result instead of hanging.
Real page trees are only a few levels deep, so the cap never truncates a
legitimate result. getPageBreadCrumbs selects an explicit column list (not
selectAll) so the internal depth counter never leaks into the breadcrumb shape.

Adds an integration test that seeds an A<->B cycle directly and asserts both
getPageBreadCrumbs and forceDelete return bounded / complete under a short
connection-level statement_timeout instead of hanging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:48:01 +03:00
claude code agent 227
00ca4ff3d6 fix(page): make movePage cycle-check + update atomic to prevent concurrent A<->B cycles (#207)
The server-side move cycle guard (getPageBreadCrumbs) and the move UPDATE ran
as two separate, unlocked statements. Two concurrent moves ("A under B" and
"B under A") could each read the same pre-write acyclic snapshot, both pass the
guard, then persist A.parentPageId=B AND B.parentPageId=A — a parent/child
cycle (TOCTOU, #207 #7).

Run the cycle check and the UPDATE inside one transaction (executeTx) guarded
by a per-space advisory lock (pg_advisory_xact_lock, held until COMMIT) so all
moves within a space serialize: the second mover blocks until the first commits
and then sees the freshly written parent, so its guard rejects the cycle.
getPageBreadCrumbs gains an optional trx so the check runs on the locked snapshot.

Adds an integration test driving two opposing concurrent movePage calls and
asserting no cycle ever persists and exactly one move is rejected. Updates the
movePage unit-test stubs for the new transactional path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:43:57 +03:00
claude code agent 227
ef7d04d1e7 fix(ai-chat): clearer tool-call validation error for dropped pageId in parallel batches (#190)
In-app AI-chat tools used bare zod schemas, so when the model dropped a
required arg (typically pageId) in a parallel/batch tool call, the AI SDK
forwarded zod's raw "expected string, received undefined" text to the model
— not actionable. Add a centralized modelFriendlyInput(shape) wrapper that
keeps the exact JSON Schema contract (required/description/constraints via
z.toJSONSchema draft-7) but replaces the raw zod text with a message naming
each missing/invalid parameter and reminding the model not to drop ids like
pageId in parallel batches. No value is guessed/backfilled (cf. #159).

Applied to every in-app tool: the sharedTool() builder and all inline
inputSchema in ai-chat-tools.service.ts, plus public-share-chat-tools.service.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:30:07 +03:00
claude code agent 227
5b59a70e3f fix(ai-chat): New chat during first-turn stream resets the chat, not just the role badge (#161)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:22:20 +03:00
claude_code
fbdb8aa16c docs(backlog): delete obsolete backlog documentation files
Removed six outdated markdown files from the `docs/backlog` and other docs directories that were no longer relevant to the project. This cleans up the repository and reduces clutter.
2026-06-25 22:43:26 +03:00
claude_code
9b61024b95 feat(ai-chat): header badge shows current/max context, max from AI settings (#189)
The floating chat window's header badge flipped meaning — a live per-turn token
counter while streaming, the persisted context size at rest — so it "reset to 1"
on each prompt and conflated two different numbers. Replace it with a stable
"current / max" context badge (e.g. `572 / 200k`). The live "Thinking · N tokens"
inside the chat body stays; only the duplicate live counter is removed from the
header.

Max comes from a new admin setting "Context window (tokens)". The server resolves
it and attaches `maxContextTokens` to the completed assistant turn's metadata
(next to contextTokens), so the badge needs no client-side model resolution and
this survives public shares / per-role models.

Server:
- ai.types: chatContextWindow on AiProviderSettings + PROVIDER_SETTINGS_KEYS +
  ResolvedAiConfig + MaskedAiSettings.
- workspace.repo: chatContextWindow in AI_PROVIDER_SETTINGS_ALLOWED (parity).
- update-ai-settings.dto: @IsInt @Min(0) chatContextWindow.
- ai-settings.service: coerce the ::text-stored value to a positive int in
  resolve()/getMasked().
- ai-chat.service: flushAssistant writes metadata.maxContextTokens (>0); the
  completed turn passes resolved.chatContextWindow.

Client:
- ai-chat.types: maxContextTokens on the message-row metadata.
- ai-chat-window: read maxContextTokens; render "current [/ max]"; drop the
  liveTurnTokens state/branch and the onLiveTurnTokens prop; new tooltip.
- chat-thread: remove the live-turn-token throttle effect and plumbing.
- count-stream-tokens: drop the now-dead liveTurnTokens()/types; keep
  estimateTokens.
- settings: chatContextWindow on IAiSettings(+Update) + a NumberInput in the AI
  provider settings form.

i18n: add the badge/settings keys (en, ru); remove the two now-unused keys.
Tests: flushAssistant maxContextTokens, DTO validation, trim token tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:39:09 +03:00
claude_code
2644fe6a83 feat(ai-chat): inline Test button per external MCP server row (#170)
Add a per-row Test button to the external MCP servers list that shows the
connection result inline (no toasts). Extract the row into AiMcpServerRow so
each row owns its own useTestAiMcpServerMutation instance — independent loading
and result, no cross-row flicker.

States: idle (Test), pending (loading), success (green, "OK · N" with the tool
count), failure (red, "Failed"); a tooltip shows the tool list or the error.
The result resets when url/transport/headers change (the row is keyed by id, so
it does not remount). Backend, service and mutation are unchanged.

- ai-mcp-servers.tsx: AiMcpServerRow + Test button + reset effect + tooltip.
- i18n: add Failed / "OK · {{n}}" (en, ru) and ru Test / tool-list keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:22:48 +03:00
claude_code
993f884e64 ci(develop): run server + mcp e2e on every develop push without blocking deploy (#187)
Add two independent jobs to develop.yml — e2e-server and e2e-mcp — that run on
each push to develop alongside test/build. `build` stays `needs: test` only, so
a failing e2e never blocks the :develop image build/publish; the red run plus
GitHub's email to the pusher is the notification.

- e2e-server: pgvector + redis services, migrations, apps/server test:e2e.
- e2e-mcp: build editor-ext/server/mcp, migrate, start the prod server
  (REST + /collab in one process), wait for /api/health, seed the admin via
  /api/auth/setup, then run @docmost/mcp test:e2e.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:22:48 +03:00
185 changed files with 12814 additions and 2898 deletions

View File

@@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false
# Example: https://intranet.example.com,https://portal.example.com
IFRAME_ALLOWED_ORIGINS=
# Comma-separated list of additional origins allowed to call the API via CORS.
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
# Leave empty for a same-origin (web-only) deployment.
CORS_ALLOWED_ORIGINS=
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
SWAGGER_ENABLED=false
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
# Leave empty for Android bundled mode / local development.
CAP_SERVER_URL=
# Enable debug logging in production (default: false)
DEBUG_MODE=false
@@ -187,3 +200,11 @@ MCP_DOCMOST_PASSWORD=
# Per-request output-token ceiling for the anonymous assistant (default: 512).
# Worst-case output per accepted call = agent steps (5) × this value.
# SHARE_AI_MAX_OUTPUT_TOKENS=512
#
# Second cost backstop: a cluster-wide per-workspace rolling-DAY token budget
# (input re-sent per step + output, summed across every accepted turn). The
# hourly request cap above bounds how MANY calls run, not how expensive each is,
# so this caps the owner's actual provider bill directly. Like the request cap it
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
# per rolling day).
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000

View File

@@ -56,3 +56,160 @@ jobs:
tags: ${{ env.IMAGE }}:develop
cache-from: type=gha,scope=develop-amd64
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
# `build` stays `needs: test` only, so the :develop image still ships even if
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
# to the pusher — that red run + email is the intended notification, not a
# deploy block.
e2e-server:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
REDIS_URL: redis://localhost:6379
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
APP_URL: http://localhost:3000
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U docmost"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
- name: Run server e2e
run: pnpm --filter ./apps/server test:e2e
# Same rationale as e2e-server: this job is intentionally NOT in
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
# a red run plus GitHub's email to the pusher is the notification mechanism.
e2e-mcp:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
REDIS_URL: redis://localhost:6379
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
APP_URL: http://localhost:3000
NODE_ENV: production
services:
postgres:
image: pgvector/pgvector:pg18
env:
POSTGRES_DB: docmost
POSTGRES_USER: docmost
POSTGRES_PASSWORD: docmost
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U docmost"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up pnpm
uses: pnpm/action-setup@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
- name: Build server
run: pnpm server:build
- name: Build mcp
run: pnpm --filter @docmost/mcp build
- name: Run migrations
run: pnpm --filter ./apps/server migration:latest
- name: Start server (prod)
# Capture stdout/stderr so a start-up crash (bind error, stack trace,
# migration mismatch) is diagnosable; without this the only signal is
# the generic health-loop timeout below, ~120s later.
run: pnpm --filter ./apps/server start:prod > /tmp/server.log 2>&1 &
- name: Wait for server health
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:3000/api/health > /dev/null; then
echo "Server is healthy"
exit 0
fi
sleep 2
done
echo "Server did not become healthy in time"
exit 1
- name: Dump server log on failure
if: failure()
run: cat /tmp/server.log || true
- name: Seed admin
run: |
curl -fsS -X POST http://localhost:3000/api/auth/setup \
-H "Content-Type: application/json" \
-d '{"name":"E2E","email":"e2e@example.com","password":"E2ePassword123","workspaceName":"E2E"}'
- name: Run mcp e2e
env:
DOCMOST_API_URL: http://localhost:3000/api
DOCMOST_EMAIL: e2e@example.com
DOCMOST_PASSWORD: E2ePassword123
run: pnpm --filter @docmost/mcp test:e2e

6
.gitignore vendored
View File

@@ -42,9 +42,15 @@ lerna-debug.log*
.nx/installation
.nx/cache
.claude/worktrees/
.claude/tmp/
# TypeScript incremental build artifacts
*.tsbuildinfo
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
apps/client/public/vad/
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
/ios
/android
.capacitor

View File

@@ -283,37 +283,46 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
### Cutting a release
The git tag is the source of truth for the displayed version (UI reads `git describe --tags`); the `package.json` bump is metadata only. Steps:
The git tag is the source of truth for the displayed version (the client UI reads `git describe --tags` via `vite.config.ts`); the `package.json` bump is metadata that backs the server `/version` endpoint (`version.service.ts`).
1. Make sure `main` is clean and pushed (`git status`, `git push`).
**Golden rule — tag on `develop` first, merge to `main` afterwards.** Cut the version-bump commit on `develop`, put the tag on *that* commit, and push it. Merge `develop` into `main` later (it does not block the tag or the release). Because the tag is in `develop`'s ancestry from the moment it is created, `git describe` on `develop` — and the `ghcr.io/vvzvlad/gitmost:develop` image — reports the new version immediately, with **no back-merge dance**. Do **not** tag `main`'s merge commit; that is the mistake described in the pitfall below (we hit it twice).
Steps:
1. Make sure `develop` is up to date, clean, and pushed to **both** remotes (`git status`; `git push gitea develop && git push github develop`).
2. Pick `vX.Y.Z` (SemVer): **minor** bump for a batch of features, **patch** for fixes only. Review what landed with `git log <last-tag>..HEAD --no-merges`.
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit with the bare version as the subject, e.g. `0.91.0` (matches past bump commits).
4. Update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and add the `compare/vPREV...vX.Y.Z` link at the bottom. Fold the bump + changelog into the release commit.
5. Tag the release commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
6. Push commit and tag: `git push origin main && git push origin vX.Y.Z`. Pushing the `v*` tag triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release).
7. **Back-merge the release into `develop`** so develop builds report the new version: `git checkout develop && git merge --no-ff main && git push origin develop` (push to Gitea as well if that is the canonical remote).
3. Bump `"version"` to `X.Y.Z` in the **root** `package.json`, `apps/client/package.json`, and `apps/server/package.json` (keep all three in sync). Leave `packages/mcp` alone — it is versioned independently. Commit **on `develop`** with the bare version as the subject, e.g. `0.94.1` (matches past bump commits).
4. For a real release (skip for a bare hotfix tag), update `CHANGELOG.md` (Keep a Changelog format): add a `## [X.Y.Z] - YYYY-MM-DD` section summarising `git log vPREV..HEAD --no-merges` grouped by type (Breaking / Added / Changed / Fixed / Removed), and the `compare/vPREV...vX.Y.Z` link at the bottom. Fold it into the bump commit.
5. Tag that develop commit with a **lightweight** tag (existing release tags are lightweight): `git tag vX.Y.Z`.
6. Push the branch **and** the tag to **both** writable remotes — `git push <branch>` does **not** push tags, and tags are per-remote:
```bash
git push gitea develop && git push gitea vX.Y.Z
git push github develop && git push github vX.Y.Z
```
Pushing the `v*` tag to `github` triggers `release.yml` (multi-arch GHCR images + a draft GitHub Release). The tag *must* exist on `github`, because the `:develop` and release images are built there by GitHub Actions and `git describe` on the runner only sees the tags present on `github` (not your local clone or `gitea`).
7. Merge `develop` into `main` when ready (commonly later — this does not gate the release):
```bash
git checkout main
git merge --ff-only develop # or a merge commit if fast-forward is not possible
git push gitea main && git push github main
```
The tag is already reachable from `main` (it lives in the `develop` history that `main` now contains), so `main` reports `vX.Y.Z` too — no extra tagging needed.
#### Why develop keeps showing the *previous* version (and why step 7 matters)
#### Pitfall: tagging `main` instead of `develop` (the mistake to avoid)
The UI version is `git describe --tags --always` (see `vite.config.ts`), which walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
`git describe --tags --always` (see `vite.config.ts`) walks **backwards from the current commit** and picks the **nearest tag reachable in that commit's ancestry**, then appends `-<commits-since-tag>-g<short-hash>`.
The release tag (`vX.Y.Z`) is created on **`main`'s release merge commit**, and that commit is **not** in `develop`'s history. So until the release is back-merged, `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable tag. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.91.0-NNN-g<hash>` even though `main` is already tagged `v0.93.0`. This is the classic git-flow pitfall: the version on `develop` does **not** advance just because a release was tagged on `main`.
The wrong flow we fell into twice: merge `develop` into `main` *first*, then tag `main`'s **release merge commit**. That merge commit is **not** in `develop`'s history, so `git describe` on `develop` cannot see the new tag and falls back to the *previous* reachable one. Result: every develop build — and the `ghcr.io/vvzvlad/gitmost:develop` image — keeps reporting e.g. `v0.93.0-NNN-g<hash>` even though a release was "cut". Tagging on `develop` (the golden rule above) avoids this entirely: the tag is in `develop`'s ancestry from the start, and `main` still gets it once `develop` is merged in.
Back-merging `main → develop` (step 7) pulls the tagged release commit into `develop`'s ancestry, after which develop builds correctly show `vX.Y.Z-NNN-g<hash>`. If `develop` already drifted (release tagged but never back-merged), just run step 7 now — no new tag is needed.
Second gotcha — the tag must exist on the remote CI builds from. `git describe` names a tag **ref**, not just a commit. The `:develop` and release images are built by GitHub Actions (`develop.yml` / `release.yml`, `actions/checkout` with `fetch-depth: 0`), so the version they print depends on which tags exist **on the `github` remote** — not on your local clone or on `gitea`. `git push <branch>` does **not** push tags; push them explicitly to **each** remote (`gitea` and `github`). A tag that only lives on `gitea` is invisible to the GitHub build.
##### The tag must also exist on the remote that CI builds from (multi-remote gotcha)
If you already tagged `main` (or `develop` still shows the old version), recover without re-tagging:
`git describe` names a tag **ref**, not just a commit — so the back-merge is *necessary but not sufficient*. The develop image is built by GitHub Actions (`develop.yml`, `actions/checkout` with `fetch-depth: 0`, then `git describe --tags --always`), so the version it prints depends on which tags exist **on the `github` remote**, not on your local clone or on `gitea`.
1. Make the tagged commit reachable from `develop` — either back-merge `main → develop` (`git checkout develop && git merge --no-ff main`), or confirm the tagged commit is already an ancestor of `develop`.
2. Make sure the tag exists on `github`: compare `git ls-remote --tags github` with `gitea`, and push the missing one (`git push github vX.Y.Z` / `git push gitea vX.Y.Z`). Pushing a `v*` tag to `github` also fires `release.yml` — expected, just be aware.
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now in scope.
This repo has two writable remotes — `gitea` (canonical, where commits land) and `github` (where the `:develop` and release images are built) — plus `upstream` (docmost, never push). **`git push <branch>` does NOT push tags**; tags must be pushed explicitly and *to each remote separately*. A release tag that only lives on `gitea` is invisible to the GitHub Actions build: even with the tagged commit fully in `develop`'s history (step 7 done), `git describe` on the GitHub runner falls back to the previous tag it *does* have, so the develop image keeps showing e.g. `v0.91.0-NNN` while `git describe` locally already says `v0.93.0-NN`.
Fix / checklist when develop still shows the old version after a back-merge:
1. Confirm the tag is missing on github: `git ls-remote --tags github` (compare with `gitea`).
2. Push it there: `git push github vX.Y.Z` (and `git push gitea vX.Y.Z` if it is missing on gitea too). Note: pushing a `v*` tag to `github` also triggers `release.yml` (multi-arch GHCR images + draft Release) — expected, but be aware.
3. Re-run the develop build (`gh workflow run Develop`, or push any commit to `develop`) so `git describe` re-resolves with the tag now present.
(The `git push origin ...` in steps 6–7 above is shorthand — there is no `origin` remote here; substitute `gitea` **and** `github` as appropriate, and always push release tags to both.)
(There is no `origin` remote here — push to `gitea` **and** `github` explicitly, and always push release tags to both.)
## Planning docs

View File

@@ -12,6 +12,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Interrupt the AI agent and send a queued message now.** A queued AI-chat
message gains a "send now" action that interrupts the streaming turn and
immediately sends that message, keeping the agent's partial output. The
follow-up turn is tagged as an interrupt so the model is told its previous
answer was cut off and builds on it instead of restarting; the rest of the
queue still flushes normally afterward. (#198)
## [0.94.0] - 2026-06-26
This release makes AI chat durable and fast: assistant turns are persisted to
the database step by step and exported server-side, the desktop app no longer
freezes at 100% CPU on long agent runs, and MCP writes are badged with
unspoofable AI attribution. It also reworks footnotes (Pandoc-style reuse and
per-reference back-links), hardens page moves and duplication against cycles
and lost edits, and caps the anonymous public-share assistant with a
per-workspace rolling-day token budget.
### Added
- **Custom pretty-links for shared pages (`/l/:alias`).** A page editor can give
any publicly shared page a short, memorable, workspace-scoped vanity address
backed by a new `share_aliases` table. Hitting `/l/<alias>` issues a `302`
(never `301`, since the target is retargetable) to the canonical
`/share/<key>/p/<slug>` page; an unknown, dangling, or no-longer-readable alias
serves the plain SPA index so that the existence of a name never leaks. An
alias can be moved to another page (with a confirm-reassign guard) and the
foreign key is `ON DELETE SET NULL`, so deleting the target leaves a dangling
alias any workspace member can reclaim. (#205)
- **Temporary notes — auto-move to Trash after a workspace lifetime.** A note can
be marked temporary so it auto-moves to Trash once a configurable workspace
lifetime elapses (default `DEFAULT_TEMPORARY_NOTE_HOURS` = 24h) unless made
permanent first. The deadline is frozen at creation time, so later changes to
the workspace setting never reschedule existing notes; an hourly background
sweep trashes notes past their deadline (children ride along). An open
temporary note shows a banner with a "Make permanent" rescue action; restoring
a note from Trash disarms the timer so it is not immediately re-trashed.
Operators configure the lifetime per workspace. (#201)
- **Persistent AI-chat history as the source of truth + server-side export.**
An assistant turn is now persisted to the database step by step: the row is
inserted upfront as `streaming` and updated as each agent step finishes, then
@@ -52,9 +91,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Footnote multi-backlinks.** A footnote referenced more than once now shows a
back-link per reference (↩ a b c …), each scrolling to its own occurrence, like
Pandoc/Wikipedia; a single-reference footnote keeps the plain ↩. (#168)
- **Generate a page title from its content.** A "sparkles" button in the page
byline reads the live editor content (including unsaved edits), generates a
title via the workspace AI provider (`POST /ai-chat/generate-page-title`), and
applies it through the existing `/pages/update` route — reflecting it in the
title field and broadcasting to other clients. Gated by the `settings.ai.generative`
flag and throttled per user. (#199)
### Changed
- **AI chat now feeds the model the full stored transcript.** The per-turn model
conversation was rebuilt from a sliding window of the 50 most recent stored
rows, which silently dropped the beginning of any longer chat. It is now
rebuilt from the complete non-deleted transcript in chronological order, so
the model sees every turn (a 5000-row backstop guards process memory — a
safety net far above any realistic chat, not a conversational limit). On a
very long chat this can eventually reach the model's context window; the
client already surfaces that as "start a new chat". (#202)
- **AI chat default provider is now `openai-compatible` (reasoning surfaced).**
For the `openai` driver the chat provider defaults to the openai-compatible
implementation, so a workspace pointing at z.ai/GLM/DeepSeek now streams the
@@ -99,6 +153,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
no longer froze on the previous step's authoritative usage; the current step's
estimate is combined per-component with `max`, so the count rises smoothly and
never jumps backwards. (#163)
- **AI chat: "New chat" during a streaming first turn now resets the whole
chat, not just the role badge.** Starting a new chat mid-stream cleared the
header but left the in-flight turn's messages behind, so the fresh chat opened
pre-populated with the previous conversation; it now fully resets. (#161)
- **AI chat: a dropped tool argument now yields an actionable error.** When the
model omitted a required parameter (typically `pageId`) in a parallel/batch
tool call, the assistant forwarded zod's raw "expected string, received
undefined" text; tool inputs now return a message naming each missing/invalid
parameter (the JSON Schema contract is unchanged and nothing is backfilled).
(#190)
- **Page move: cycle checks are now atomic and depth-bounded.** Moving a page
under one of its own descendants is rejected in the same transaction as the
update (closing a TOCTOU window where two concurrent A→B / B→A moves could
form a cycle), and the recursive tree-traversal CTEs carry a cycle/depth guard
so a pre-existing cycle can no longer spin a query. (#207)
- **Page/editor robustness batch.** Duplicating a page now copies shared
attachments for every referencing page (not just the first); colliding block
ids are de-duplicated on import/normalize so MCP addressed edits can't hit the
wrong node; transient collab store failures are retried so autosave edits
aren't lost; and an out-of-order tree move no longer drops the moved subtree.
(#206)
### Security
- **Public share AI: per-workspace rolling-day token budget.** The anonymous
share assistant now caps a workspace's actual token spend (input + output,
summed across every accepted turn) over a trailing day, on top of the hourly
request cap — so a caller who evades the per-IP throttle still cannot run up
the owner's provider bill without bound. Cluster-wide via Redis and FAILS
CLOSED if Redis is down; default 1,000,000 tokens/day, overridable via
`SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY`. (#159)
## [0.93.0] - 2026-06-21
@@ -142,6 +227,18 @@ embeds — plus a large batch of security hardening and test coverage.
injected into the `<head>` of public share pages only (for analytics such as
Google Analytics or Yandex.Metrika), kept separate from the member-facing
HTML-embed feature.
- **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 offline cache (persisted query cache, Yjs page documents, and the
service-worker API cache) is cleared on logout so a previous user's private
data does not remain in the browser.
- **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`.
- **MCP**: a hierarchical tree mode for `list_pages`, and per-user auth for the
embedded `/mcp` endpoint.
- **Page tree**: Expand all / Collapse all for the space tree, and
@@ -157,6 +254,12 @@ embeds — plus a large batch of security hardening and test coverage.
### 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.
- HTML embed blocks now render inside a sandboxed iframe (separate origin) and,
when the workspace HTML-embed toggle is on, can be inserted by any member
(previously admin-only). Turning the toggle off hides existing embeds and

View File

@@ -114,7 +114,7 @@ community feature, with no enterprise license. Open it from the page header; the
- 🔭 **Viewer comments** — let read-only viewers leave comments.
- 🔭 **Password-protected pages** — protect individual pages / shares with a password.
- 🔭 **Windows / Linux app** — native desktop app for Windows and Linux.
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
- 🔭 **Mobile app** — mobile apps (iOS first, Android to follow), reusing the existing responsive web UI and editor via a Capacitor wrapper, with offline planned for later. See [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
- 🔭 **Offline mode** — offline sync & PWA support.
- 🔭 **Editor & UX improvements** — blocks inside tables (lists, to-do items), column layout, additional heading levels, highlight blocks, custom emoji in callouts, floating images, anchor links for page mentions, toggles (shared-page width, aside/sidebar, spellcheck, ligatures), sanitized space-tree export, and mentions in breadcrumbs.

View File

@@ -115,7 +115,7 @@ real-time-коллаборации Docmost, поэтому запись нико
- 🔭 **Комментарии зрителей** — возможность комментировать для пользователей с доступом только на чтение.
- 🔭 **Защищённые паролем страницы** — защита отдельных страниц / шар паролем.
- 🔭 **Приложение для Windows / Linux** — нативное десктоп-приложение для Windows и Linux.
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [docs/mobile-app-plan.md](docs/mobile-app-plan.md).
- 🔭 **Мобильное приложение** — мобильные приложения (iOS обязательно, Android как пойдёт) на базе существующей адаптивной веб-версии и редактора через обёртку Capacitor; оффлайн запланирован на будущее. См. [issue #195](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/issues/195).
- 🔭 **Офлайн-режим** — офлайн-синхронизация и поддержка PWA.
- 🔭 **Улучшения редактора и UX** — блоки внутри таблиц (списки, чек-листы), колоночная вёрстка, дополнительные уровни заголовков, highlight-блоки, кастомные эмодзи в callout-ах, плавающие изображения, anchor-ссылки на упоминания страниц, тоглы (ширина шары, aside/сайдбар, spellcheck, лигатуры), санитизация экспорта дерева спейса и mentions в хлебных крошках.

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.93.0",
"version": "0.94.1",
"scripts": {
"dev": "node scripts/copy-vad-assets.mjs && vite",
"build": "node scripts/copy-vad-assets.mjs && tsc && vite build",
@@ -33,7 +33,9 @@
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.17",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-query-persist-client": "5.90.17",
"@tanstack/react-virtual": "3.13.24",
"ai": "6.0.207",
"alfaaz": "1.1.0",
@@ -45,6 +47,7 @@
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"idb-keyval": "6.2.5",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
@@ -95,6 +98,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite-plugin-pwa": "1.3.0",
"vitest": "4.1.6"
}
}

View File

@@ -464,6 +464,15 @@
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
"Syncing changes…": "Syncing changes…",
"All changes synced": "All changes synced",
"Update available": "Update available",
"Reload": "Reload",
"Make available offline": "Make available offline",
"Saving page for offline use...": "Saving page for offline use...",
"Page is now available offline": "Page is now available offline",
"Failed to make page available offline": "Failed to make page available offline",
"Table of contents": "Table of contents",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share",
@@ -598,6 +607,17 @@
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.",
"Restore '{{title}}' and its sub-pages?": "Restore '{{title}}' and its sub-pages?",
"Move to trash": "Move to trash",
"Make temporary": "Make temporary",
"Make permanent": "Make permanent",
"New temporary note": "New temporary note",
"Temporary note": "Temporary note",
"Temporary notes": "Temporary notes",
"Temporary note — moves to trash unless made permanent": "Temporary note — moves to trash unless made permanent",
"Note will move to trash unless made permanent": "Note will move to trash unless made permanent",
"Note is now permanent": "Note is now permanent",
"Temporary note lifetime (hours)": "Temporary note lifetime (hours)",
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.",
"Move this page to trash?": "Move this page to trash?",
"Restore page": "Restore page",
"Permanently delete": "Permanently delete",
@@ -715,6 +735,8 @@
"Test": "Test",
"Available tools": "Available tools",
"No tools available": "No tools available",
"Failed": "Failed",
"OK · {{n}}": "OK · {{n}}",
"Created successfully": "Created successfully",
"Deleted successfully": "Deleted successfully",
"Clear": "Clear",
@@ -1167,8 +1189,9 @@
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
"Built-in assistant persona": "Built-in assistant persona",
"Minimize": "Minimize",
"Current context size": "Current context size",
"Tokens generated this turn": "Tokens generated this turn",
"Context size / model limit": "Context size / model limit",
"Context window (tokens)": "Context window (tokens)",
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
"AI agent": "AI agent",
"Take a look at the current document": "Take a look at the current document",
"AI agent is typing…": "AI agent is typing…",
@@ -1177,6 +1200,8 @@
"Send when the agent finishes": "Send when the agent finishes",
"Queue message": "Queue message",
"Remove queued message": "Remove queued message",
"Send now": "Send now",
"Interrupt and send now": "Interrupt and send now",
"Stop": "Stop",
"Response stopped.": "Response stopped.",
"Connection lost — the answer was interrupted.": "Connection lost — the answer was interrupted.",
@@ -1315,5 +1340,23 @@
"Protocol": "Protocol",
"How chat requests are sent and how reasoning is surfaced": "How chat requests are sent and how reasoning is surfaced",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-compatible (surfaces reasoning)",
"OpenAI (official)": "OpenAI (official)"
"OpenAI (official)": "OpenAI (official)",
"Custom address": "Custom address",
"A short, memorable link you can point at any shared page.": "A short, memorable link you can point at any shared page.",
"Use 2-60 lowercase letters, digits and hyphens": "Use 2-60 lowercase letters, digits and hyphens",
"This address is already in use": "This address is already in use",
"Move custom address?": "Move custom address?",
"Move here": "Move here",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "The address \"{{alias}}\" is already in use. Move it to this page?",
"Failed to set custom address": "Failed to set custom address",
"Failed to remove custom address": "Failed to remove custom address",
"Generate title with AI": "Generate title with AI",
"Title generated": "Title generated",
"Failed to generate title": "Failed to generate title",
"The note is empty": "The note is empty",
"Could not generate a title": "Could not generate a title",
"AI title generation is disabled": "AI title generation is disabled",
"AI is not configured": "AI is not configured",
"Too many requests, please try again later": "Too many requests, please try again later"
}

View File

@@ -474,6 +474,15 @@
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
"Syncing changes…": "Синхронизация изменений…",
"All changes synced": "Все изменения синхронизированы",
"Update available": "Доступно обновление",
"Reload": "Перезагрузить",
"Make available offline": "Сделать доступным офлайн",
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
"Page is now available offline": "Страница доступна офлайн",
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
"Table of contents": "Оглавление",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться",
@@ -607,6 +616,17 @@
"Are you sure you want to permanently delete '{{title}}'? This action cannot be undone.": "Вы уверены, что хотите окончательно удалить '{{title}}'? Это действие невозможно отменить.",
"Restore '{{title}}' and its sub-pages?": "Восстановить '{{title}}' и её подстраницы?",
"Move to trash": "Переместить в корзину",
"Make temporary": "Сделать временной",
"Make permanent": "Сделать постоянной",
"New temporary note": "Новая временная заметка",
"Temporary note": "Временная заметка",
"Temporary notes": "Временные заметки",
"Temporary note — moves to trash unless made permanent": "Временная заметка — уедет в корзину, если не сделать постоянной",
"Note will move to trash unless made permanent": "Заметка уедет в корзину, если не сделать её постоянной",
"Note is now permanent": "Заметка теперь постоянная",
"Temporary note lifetime (hours)": "Время жизни временной заметки (часы)",
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.": "Временная заметка автоматически уезжает в корзину через указанное число часов, если не сделать её постоянной. Дедлайн фиксируется при создании заметки.",
"This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent.": "Эта временная заметка уедет в корзину {{time}} (вместе с подстраницами), если не сделать её постоянной.",
"Move this page to trash?": "Переместить эту страницу в корзину?",
"Restore page": "Восстановить страницу",
"Permanently delete": "Удалить навсегда",
@@ -704,19 +724,27 @@
"Ask the AI agent…": "Спросите AI-агента…",
"Copy chat": "Копировать чат",
"Created successfully": "Успешно создано",
"Current context size": "Текущий размер контекста",
"Tokens generated this turn": "Токенов сгенерировано за ход",
"Context size / model limit": "Размер контекста / лимит модели",
"Context window (tokens)": "Окно контекста (токены)",
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
"OK · {{n}}": "OK · {{n}}",
"Test": "Тест",
"No tools available": "Инструменты недоступны",
"Available tools": "Доступные инструменты",
"Minimize": "Свернуть",
"No chats yet.": "Чатов пока нет.",
"Send": "Отправить",
"Send when the agent finishes": "Отправить, когда агент закончит",
"Queue message": "Поставить в очередь",
"Remove queued message": "Убрать из очереди",
"Send now": "Отправить сейчас",
"Interrupt and send now": "Прервать и отправить сейчас",
"Something went wrong": "Что-то пошло не так",
"Stop": "Стоп",
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
@@ -1169,5 +1197,23 @@
"Protocol": "Протокол",
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
"OpenAI (official)": "OpenAI (официальный)"
"OpenAI (official)": "OpenAI (официальный)",
"Custom address": "Пользовательский адрес",
"A short, memorable link you can point at any shared page.": "Короткая запоминающаяся ссылка, которую можно направить на любую опубликованную страницу.",
"Use 2-60 lowercase letters, digits and hyphens": "Используйте 2–60 строчных букв, цифр и дефисов",
"This address is already in use": "Этот адрес уже занят",
"Move custom address?": "Переместить пользовательский адрес?",
"Move here": "Переместить сюда",
"The address \"{{alias}}\" currently points to \"{{title}}\". Move it to this page?": "Адрес «{{alias}}» сейчас указывает на «{{title}}». Переместить его на эту страницу?",
"The address \"{{alias}}\" is already in use. Move it to this page?": "Адрес «{{alias}}» уже используется. Переместить его на эту страницу?",
"Failed to set custom address": "Не удалось задать пользовательский адрес",
"Failed to remove custom address": "Не удалось удалить пользовательский адрес",
"Generate title with AI": "Сгенерировать название через AI",
"Title generated": "Название сгенерировано",
"Failed to generate title": "Не удалось сгенерировать название",
"The note is empty": "Заметка пустая",
"Could not generate a title": "Не удалось придумать название",
"AI title generation is disabled": "Генерация названий через AI отключена",
"AI is not configured": "AI не настроен",
"Too many requests, please try again later": "Слишком много запросов, попробуйте позже"
}

View File

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

View File

@@ -45,6 +45,7 @@ import {
shouldCollapseOnOutsidePointer,
isHeaderClick,
} from "@/features/ai-chat/utils/collapse-helpers.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { notifications } from "@mantine/notifications";
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
@@ -161,12 +162,6 @@ export default function AiChatWindow() {
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
// `null` means no turn is in flight -> the badge falls back to the persisted
// context size below.
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
// The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full
// pathname against the authenticated page route instead so "the current page"
@@ -193,6 +188,7 @@ export default function AiChatWindow() {
const {
threadKey,
waitingForHistory,
startFreshThread,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,
@@ -215,12 +211,25 @@ export default function AiChatWindow() {
// just-failed chat after they chose a fresh one.
const startNewChat = useCallback((): void => {
cancelPendingAdoption();
// Force a fresh, empty thread UNCONDITIONALLY (#161). Pressing "New chat"
// while a brand-new chat's first turn is still streaming leaves activeChatId
// null (the real id is adopted only at turn end), so setActiveChatId(null)
// alone is a no-op and the reconciler never remounts — the chat/stream/history
// would persist and only the role badge would drop. This always remounts the
// thread into a clean new chat.
startFreshThread();
setActiveChatId(null);
setHistoryOpen(false);
setDraft("");
// Default the picker back to "Universal assistant" for the fresh chat.
setSelectedRoleId(null);
}, [cancelPendingAdoption, setActiveChatId, setDraft, setSelectedRoleId]);
}, [
cancelPendingAdoption,
startFreshThread,
setActiveChatId,
setDraft,
setSelectedRoleId,
]);
const selectChat = useCallback(
(chatId: string): void => {
@@ -287,24 +296,19 @@ export default function AiChatWindow() {
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
// PERSISTED rows (updates on chat open/switch); it does not tick live
// mid-stream — acceptable for v1.
const contextTokens = useMemo(() => {
if (!activeChatId || !messageRows) return 0;
for (let i = messageRows.length - 1; i >= 0; i--) {
const meta = messageRows[i].metadata;
if (!meta) continue;
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
return meta.contextTokens;
}
const usage = meta.usage;
if (usage) {
const fallback =
usage.totalTokens ??
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
if (fallback > 0) return fallback;
}
}
return 0;
}, [activeChatId, messageRows]);
//
// The denominator `maxContextTokens` (the model's configured max window) is
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
// on a completed turn, but the numerator and denominator are taken from the
// most recent row carrying EACH value independently — they may land on
// different rows (e.g. a fresh error row can carry contextTokens but not
// maxContextTokens), so we keep scanning for whichever is still unset. 0 when
// no row has it (older rows, or no admin-configured limit) — the badge then
// shows just the current size with no denominator.
const { contextTokens, maxContextTokens } = useMemo(
() => selectContextBadge(activeChatId ? messageRows : undefined),
[activeChatId, messageRows],
);
// On (re)open, settle the geometry before paint (useLayoutEffect → no
// first-frame jump): compute an initial top-right placement the first time,
@@ -495,20 +499,17 @@ export default function AiChatWindow() {
)}
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
once it finishes, fall back to the persisted context size. Require
> 0 so the very first emit (an empty tail message, count 0) does not
flash a "0" badge before any token streams in (#151 review). */}
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
<Tooltip label={t("Tokens generated this turn")} withArrow>
<span className={classes.badge}>
{formatTokens(liveTurnTokens)}
</span>
</Tooltip>
) : contextTokens > 0 ? (
<Tooltip label={t("Current context size")} withArrow>
{/* Always show the persisted "current / max" context. The denominator
(the admin-configured model limit) is appended only when known;
not clamped when current > max (shown as-is, e.g. "210k / 200k").
Hidden entirely until a turn has recorded a context figure. */}
{contextTokens > 0 ? (
<Tooltip label={t("Context size / model limit")} withArrow>
<span className={classes.badge}>
{formatTokens(contextTokens)}
{maxContextTokens > 0
? ` / ${formatTokens(maxContextTokens)}`
: ""}
</span>
</Tooltip>
) : null}
@@ -622,6 +623,7 @@ export default function AiChatWindow() {
) : (
<ChatThread
key={threadKey}
threadKey={threadKey}
chatId={activeChatId}
initialRows={activeChatId ? messageRows : []}
openPage={openPage}
@@ -634,7 +636,6 @@ export default function AiChatWindow() {
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
onServerChatId={onServerChatId}
onLiveTurnTokens={setLiveTurnTokens}
/>
)}
</div>

View File

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

View File

@@ -1,7 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { generateId } from "ai";
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
import { IconClockHour4, IconX } from "@tabler/icons-react";
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import {
IconClockHour4,
IconPlayerPlayFilled,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
@@ -20,10 +24,10 @@ import {
} from "@/features/ai-chat/utils/role-launch.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import {
dequeue,
enqueueMessage,
promoteToHead,
removeQueuedById,
type QueuedMessage,
} from "@/features/ai-chat/utils/queue-helpers.ts";
@@ -46,6 +50,11 @@ export interface OpenPageContext {
interface ChatThreadProps {
/** The open chat id, or null for a brand-new (not-yet-created) chat. */
chatId: string | null;
/** This thread's mount key (the same value the parent uses as React `key`).
* Forwarded to onTurnFinished so the session can tell a turn finishing on the
* CURRENT thread from one ABANDONED by New chat mid-stream — whose onFinish/
* onError still fire after unmount and must not adopt the abandoned chat (#161). */
threadKey?: string;
/** Persisted rows to seed initial messages (existing chats only). */
initialRows?: IAiChatMessageRow[];
/** The page currently open in the workspace, or null on a non-page route.
@@ -67,20 +76,16 @@ interface ChatThreadProps {
/** Called when a turn finishes; the parent refreshes the chat list and, for a
* new chat, adopts the freshly created chat id. `serverChatId` is the
* authoritative id the server streamed on the assistant message metadata, or
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design. */
onTurnFinished: (serverChatId?: string) => void;
* undefined on a failed turn — see adopt-chat-id.ts for the full #137 design.
* `finishingThreadKey` (this thread's mount key) lets the session ignore a turn
* finishing on a thread already abandoned by New chat mid-stream (#161). */
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
/** Called EARLY (at the stream's `start` chunk) with the authoritative server
* chat id streamed on the assistant message metadata, so a brand-new chat
* adopts its real id WHILE the first turn is still streaming (#174 — makes the
* Copy/export button available mid-stream). Distinct from onTurnFinished,
* which fires only at the terminal outcome. */
onServerChatId?: (serverChatId?: string) => void;
/** Reports the live turn-token total (reasoning + output) for the in-flight
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
* every streamed delta. Called with `null` when no turn is in flight (the
* parent then reverts the badge to the persisted context size). */
onLiveTurnTokens?: (tokens: number | null) => void;
}
/**
@@ -117,6 +122,7 @@ function rowToUiMessage(row: IAiChatMessageRow): UIMessage {
*/
export default function ChatThread({
chatId,
threadKey,
initialRows,
openPage,
roleId,
@@ -125,7 +131,6 @@ export default function ChatThread({
assistantName,
onTurnFinished,
onServerChatId,
onLiveTurnTokens,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -201,12 +206,25 @@ export default function ChatThread({
// helper can call the current instance from the stable `onFinish` callback.
const sendMessageRef = useRef<((m: { text: string }) => void) | null>(null);
// "Send now" single-flight flags. Kept in refs (not state) so they are read
// inside the stable `onFinish` callback and the transport closure WITHOUT a
// re-render or a stale closure. Both are one-shot (read-and-clear).
// - flushOnAbortRef: flush the promoted head on the abort WE triggered, even
// though an aborted turn normally keeps the queue intact.
// - interruptNextSendRef: tag the next send as a user interrupt so the server
// injects the "your previous answer was interrupted" note for that turn only.
const flushOnAbortRef = useRef(false);
const interruptNextSendRef = useRef(false);
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
// Returns whether a message was actually sent, so callers can tell an empty
// dequeue (nothing to flush) from a real send.
const flushNext = useCallback(() => {
const { head, rest } = dequeue(queuedRef.current);
if (!head) return;
if (!head) return false;
setQueue(rest);
sendMessageRef.current?.({ text: head.text });
return true;
}, [setQueue]);
const enqueue = useCallback(
@@ -232,17 +250,26 @@ export default function ChatThread({
// when null) and tell the agent which page "this page" refers to. Both
// are read live from refs so changing chats/pages does NOT recreate the
// transport. `openPage` is null on a non-page route.
prepareSendMessagesRequest: ({ messages, body }) => ({
body: {
...body,
chatId: chatIdRef.current,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
messages,
},
}),
prepareSendMessagesRequest: ({ messages, body }) => {
// Read-and-clear the interrupt flag so the "you were interrupted" note
// is carried by ONLY this request (the one resending the promoted
// message right after we aborted the previous turn). The server still
// confirms it against history before acting on it.
const interrupted = interruptNextSendRef.current;
interruptNextSendRef.current = false; // one-shot
return {
body: {
...body,
chatId: chatIdRef.current,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
interrupted,
messages,
},
};
},
}),
[],
);
@@ -267,14 +294,31 @@ export default function ChatThread({
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
// Forward the authoritative server chatId (streamed on the assistant
// message metadata) so the parent adopts the REAL created chat id for a new
// chat — see adopt-chat-id.ts for the full #137 design.
onTurnFinished(extractServerChatId(message));
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
// session ignore this finish if it belongs to a thread abandoned by New chat
// mid-stream (#161).
onTurnFinished(extractServerChatId(message), threadKey);
// Show a neutral "stopped" marker for an aborted turn; the red error banner
// (via `error`) already covers isError, and a clean finish clears any marker.
if (isError) setStopNotice(null);
else if (isAbort) setStopNotice("manual");
else if (isDisconnect) setStopNotice("disconnect");
else setStopNotice(null);
// "Send now": WE triggered this abort to interrupt the current turn and
// immediately send the promoted head. Flush it even though the turn was
// aborted (the normal abort path below keeps the queue intact). The
// interrupt note travels with this send via interruptNextSendRef.
if (flushOnAbortRef.current) {
flushOnAbortRef.current = false;
// Suppress the "Response stopped." flash for an intentional interrupt.
setStopNotice(null);
// If the promoted head vanished (e.g. the user removed it before the
// abort landed) flushNext sends nothing — clear the one-shot interrupt
// tag so it can't leak onto the next unrelated send. On a real send the
// tag is consumed by prepareSendMessagesRequest and stays untouched.
if (!flushNext()) interruptNextSendRef.current = false;
return;
}
if (isAbort || isDisconnect || isError) return;
flushNext();
},
@@ -289,13 +333,20 @@ export default function ChatThread({
// Surface the raw failure in the browser console (devtools) for debugging;
// the UI separately shows a friendly classified banner (see errorView).
console.error("AI chat stream error:", streamError);
onTurnFinished();
onTurnFinished(undefined, threadKey);
},
});
// Keep the flush helper pointed at the latest sendMessage instance.
sendMessageRef.current = sendMessage;
// Mirror the live turn status in a ref so event handlers (sendNow) branch on the
// CURRENT status rather than a value captured in a stale render closure — a turn
// can finish between render and click, and arming the interrupt refs against a
// no-op stop() would leave them set to leak into a later, unrelated Stop.
const statusRef = useRef(status);
statusRef.current = status;
// EARLY chat-id adoption (#174): the server streams the authoritative chat id
// on the assistant message metadata at the `start` chunk (message.metadata.
// chatId — see adopt-chat-id.ts / chatStreamMetadata). Forward it to the parent
@@ -327,9 +378,49 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
// Clear the stopped marker as soon as a new turn begins streaming.
// "Send now" on a queued message: interrupt the current turn and immediately
// send THIS message, keeping the agent's partial output. Other queued messages
// stay queued and flush normally after the new turn. Reuses the existing
// queue/flush machinery: promote the target to the head, then abort — the
// onFinish flush-on-abort branch sends exactly that head, tagged as an
// interrupt so the server notes the previous answer was cut off.
const sendNow = useCallback(
(id: string) => {
// Branch on the LIVE status (statusRef), NOT the closure-captured isStreaming:
// the turn may have finished between this render and the click, in which case
// stop() is a no-op and arming the interrupt refs would strand them for a
// later, unrelated Stop. Reading the ref always sees the current status.
const liveStreaming =
statusRef.current === "submitted" || statusRef.current === "streaming";
if (liveStreaming) {
// Promote to head so the onFinish -> flushNext path sends exactly it.
setQueue(promoteToHead(queuedRef.current, id));
flushOnAbortRef.current = true;
interruptNextSendRef.current = true;
stop(); // -> onFinish({ isAbort: true }) flushes the promoted head
} else {
// Nothing to interrupt: just send it now (no interrupt note).
const msg = queuedRef.current.find((m) => m.id === id);
if (!msg) return;
setQueue(removeQueuedById(queuedRef.current, id));
sendMessageRef.current?.({ text: msg.text });
}
},
[setQueue, stop],
);
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
// this effect runs, so clearing here is a no-op for it; its purpose is to defuse
// the race where a flag was armed but the expected abort never fired (the turn
// finished in the same tick as the click), so it cannot leak into a later turn.
useEffect(() => {
if (isStreaming) setStopNotice(null);
if (isStreaming) {
setStopNotice(null);
flushOnAbortRef.current = false;
interruptNextSendRef.current = false;
}
}, [isStreaming]);
// Classify the turn error into a heading + detail so the banner names the cause
@@ -338,53 +429,6 @@ export default function ChatThread({
// the SAME on-screen banner text can be mirrored into the export (issue #160).
const errorView = error ? describeChatError(error.message ?? "", t) : null;
// Report the live turn-token total to the parent header badge, THROTTLED to
// ~8 Hz so the parent re-renders a few times a second instead of on every
// streamed delta. The tail assistant message's reasoning+output (estimate while
// streaming, authoritative once a step reports usage) is the live figure. When
// the turn ends we emit a final exact value, then `null` so the parent reverts
// the badge to the persisted context size.
const lastEmitRef = useRef(0);
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!onLiveTurnTokens) return;
if (!isStreaming) {
// Turn ended (or never started): clear any pending throttle and revert.
if (emitTimerRef.current) {
clearTimeout(emitTimerRef.current);
emitTimerRef.current = null;
}
lastEmitRef.current = 0;
onLiveTurnTokens(null);
return;
}
const tail = messages[messages.length - 1];
const live = tail?.role === "assistant" ? liveTurnTokens(tail) : null;
const total = live ? live.reasoning + live.output : 0;
const now = Date.now();
const MIN_INTERVAL = 120; // ms (~8 Hz)
const elapsed = now - lastEmitRef.current;
if (elapsed >= MIN_INTERVAL) {
lastEmitRef.current = now;
onLiveTurnTokens(total);
} else if (!emitTimerRef.current) {
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
emitTimerRef.current = setTimeout(() => {
emitTimerRef.current = null;
lastEmitRef.current = Date.now();
onLiveTurnTokens(total);
}, MIN_INTERVAL - elapsed);
}
}, [messages, isStreaming, onLiveTurnTokens]);
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
// trailing emit can't fire into a torn-down thread's parent.
useEffect(() => {
return () => {
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
};
}, []);
// A role was picked with autoStart=false: the role is bound but NOTHING was
// sent, so chatId stays null and the empty state would keep showing the cards.
// This flag hides the cards and reveals the composer (with the role indicated)
@@ -468,6 +512,17 @@ export default function ChatThread({
<Text size="xs" lineClamp={2} className={classes.queuedText}>
{m.text}
</Text>
<Tooltip label={t("Interrupt and send now")} withArrow>
<ActionIcon
size="xs"
variant="subtle"
color="blue"
onClick={() => sendNow(m.id)}
aria-label={t("Send now")}
>
<IconPlayerPlayFilled size={12} />
</ActionIcon>
</Tooltip>
<ActionIcon
size="xs"
variant="subtle"

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { renderHook, act } from "@testing-library/react";
import { useChatSession } from "./use-chat-session";
import type { UseChatSessionOptions } from "./use-chat-session";
@@ -227,6 +227,50 @@ describe("useChatSession", () => {
expect(result.current.threadKey).toBe("C");
});
it("#161: New chat during a streaming first turn forces a fresh thread (remount), not just a no-op", () => {
// Brand-new chat whose first turn is still streaming: the id is adopted only
// at turn end, so activeChatId AND thread.chatId are both null. Pressing "New
// chat" must still remount to a clean thread even though the atom is unchanged
// — the render-phase reconciler (null === null) would otherwise do nothing,
// leaving the old chat/stream/history in place (the bug: only the role badge
// dropped).
const { result } = setup({ activeChatId: null, chats: { items: [] } });
const keyBefore = result.current.threadKey;
act(() => result.current.startFreshThread());
expect(result.current.threadKey).not.toBe(keyBefore);
});
it("#161: an abandoned thread's late onTurnFinished does NOT adopt its chat (thread-aware guard)", () => {
// New chat mid-stream remounts to a fresh thread, but @ai-sdk/react does not
// abort the abandoned stream on unmount: its onFinish still fires later with
// the real server id, tagged with the OLD (abandoned) mount key. That must not
// adopt — it would yank the user back into the chat they just left.
const { result, setActiveChatId, onInvalidateChatList } = setup({
activeChatId: null,
chats: { items: [] },
});
const abandonedKey = result.current.threadKey;
act(() => result.current.startFreshThread());
expect(result.current.threadKey).not.toBe(abandonedKey);
// The abandoned turn finishes in the background, streaming its real id "A".
result.current.onTurnFinished("A", abandonedKey);
expect(setActiveChatId).not.toHaveBeenCalledWith("A");
// It still refreshes the chat list so the left-behind chat shows in history.
expect(onInvalidateChatList).toHaveBeenCalled();
});
it("#161: a turn finishing on the CURRENT thread still adopts (guard is key-scoped, not blanket)", () => {
// The happy path must keep working: onTurnFinished tagged with the mounted
// thread's own key adopts in place as before.
const { result, setActiveChatId } = setup({
activeChatId: null,
chats: { items: [] },
});
const currentKey = result.current.threadKey;
result.current.onTurnFinished("A", currentKey);
expect(setActiveChatId).toHaveBeenCalledWith("A");
});
it("waitingForHistory gates the loader only while opening an unloaded existing chat", () => {
// Open an existing chat whose history is still loading => loader on.
const { result, rerender } = setup({

View File

@@ -31,9 +31,19 @@ export interface UseChatSessionResult {
threadKey: string;
/** Show the history loader instead of the live thread. */
waitingForHistory: boolean;
/** Force a brand-new, empty thread (new mount key, no chat id) UNCONDITIONALLY,
* even when `activeChatId` is unchanged. The window calls this from
* startNewChat so "New chat" pressed WHILE a brand-new chat's first turn is
* still streaming (activeChatId still null, nothing to diverge) actually
* resets the chat instead of only dropping the role badge (#161). */
startFreshThread: () => void;
/** Call when a turn finishes; `serverChatId` is the authoritative streamed id
* (undefined on a failed turn). Handles new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string) => void;
* (undefined on a failed turn). `finishingThreadKey` is the mount key of the
* thread that produced the turn (omit => "current thread", back-compatible):
* a turn ABANDONED by New chat mid-stream still fires this after its thread
* unmounted, so adoption is gated to the still-mounted thread (#161). Handles
* new-chat id adoption + invalidations. */
onTurnFinished: (serverChatId?: string, finishingThreadKey?: string) => void;
/** Call EARLY (at the stream's `start` chunk) with the authoritative streamed
* chat id so a brand-new chat adopts its real id WHILE its first turn is still
* streaming — making `activeChatId`-gated affordances (e.g. the Copy/export
@@ -98,6 +108,15 @@ export function useChatSession(
: switchThread(activeChatId),
);
// Live mirror of the mounted thread's mount key, read by onTurnFinished to tell
// the CURRENT thread from one ABANDONED by New chat mid-stream. @ai-sdk/react
// does not abort a stream on unmount and proxies callbacks through a ref, so an
// abandoned turn's onFinish/onError still fires AFTER its ChatThread unmounted;
// matching its key against this ref keeps that late finish from adopting the
// abandoned chat and yanking the user out of the fresh chat they opened (#161).
const threadKeyRef = useRef(thread.key);
threadKeyRef.current = thread.key;
// Error-path fallback for new-chat id adoption. When a brand-new chat's first
// turn errors BEFORE the server's `start` chunk, no authoritative chatId ever
// reaches the client, so the primary metadata adoption cannot run. We then ARM
@@ -115,7 +134,23 @@ export function useChatSession(
// yet) we adopt the server's AUTHORITATIVE streamed id (never the newest in the
// list, which races a second tab — #137; see adopt-chat-id.ts).
const onTurnFinished = useCallback(
(serverChatId?: string) => {
(serverChatId?: string, finishingThreadKey?: string) => {
// Thread-aware guard (#161). A turn ABANDONED by "New chat" mid-stream still
// fires onFinish/onError after its ChatThread unmounted (@ai-sdk/react does
// not abort on unmount and proxies callbacks through a ref). If that late
// finish ran the adoption path it would set activeChatId to the abandoned
// chat's real id and yank the user out of the fresh chat they just opened.
// So adopt / arm the fallback ONLY for the still-mounted thread; an
// abandoned one merely refreshes the chat list (so the left-behind chat
// surfaces in history) and does nothing else. A missing key (undefined)
// means "current thread" — keeps old call sites/tests working.
if (
finishingThreadKey !== undefined &&
finishingThreadKey !== threadKeyRef.current
) {
onInvalidateChatList();
return;
}
// Read the live id from the ref, not the closure: on a failed turn this can
// run twice in one turn (onFinish + onError) before any re-render, and the
// primary branch below updates the ref so the second call sees the adopted id.
@@ -258,9 +293,28 @@ export function useChatSession(
pendingNewChatRef.current = null;
}, []);
// Force a fresh, empty thread regardless of `activeChatId` (#161). The render-
// phase reconciler only remounts when activeChatId diverges from thread.chatId,
// so "New chat" pressed while a brand-new chat's first turn is still streaming
// (activeChatId AND thread.chatId both null — the real id is adopted only at the
// end of the turn) is a no-op for it and the abandoned thread/stream/history
// would persist. Dispatching reconcile with a fresh key and chatId:null here
// always produces a new mount key, so React remounts ChatThread (a clean useChat
// store) and the post-dispatch state (activeChatId null === thread.chatId null)
// keeps the reconciler from interfering. Also disarms any pending fallback.
const startFreshThread = useCallback(() => {
pendingNewChatRef.current = null;
dispatch({
type: "reconcile",
chatId: null,
newKey: `new-${generateId()}`,
});
}, []);
return {
threadKey: thread.key,
waitingForHistory,
startFreshThread,
onTurnFinished,
onServerChatId,
cancelPendingAdoption,

View File

@@ -68,6 +68,19 @@ export async function exportAiChat(
return req.data.markdown;
}
/**
* Generate a page title from note content (markdown). One-shot, non-streaming
* (#199): the server only summarizes the supplied text and returns a suggestion;
* it never writes the page. The caller applies the title via /pages/update.
*/
export async function generatePageTitle(content: string): Promise<string> {
const req = await api.post<{ title: string }>(
"/ai-chat/generate-page-title",
{ content },
);
return req.data.title;
}
/**
* Agent roles API (`/ai-chat/roles`). `list` is available to any workspace
* member (for the chat-creation picker); create/update/delete are admin-only

View File

@@ -116,6 +116,9 @@ export interface IAiChatMessageRow {
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
// floating window's header badge.
contextTokens?: number;
// The model's max context window (denominator for the header badge); set
// alongside contextTokens on a completed turn; absent on older rows.
maxContextTokens?: number;
// Set on an assistant row whose turn ended in a provider/stream error; the
// raw provider error text (e.g. "402: ...") for inline display in the thread.
error?: string;

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
/**
* Pure-helper tests for the header context badge selection. Covers the two
* non-obvious rules: numerator and denominator are each taken from the most
* recent row carrying THAT value (they may live on different rows), and a fresh
* row with a zero/absent value must NOT shadow an older positive one.
*/
const row = (metadata: IAiChatMessageRow["metadata"]): IAiChatMessageRow => ({
id: Math.random().toString(),
role: "assistant",
content: null,
metadata,
createdAt: "2026-01-01T00:00:00.000Z",
});
describe("selectContextBadge", () => {
it("returns zeros for empty / nullish input", () => {
expect(selectContextBadge(undefined)).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
expect(selectContextBadge(null)).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
expect(selectContextBadge([])).toEqual({
contextTokens: 0,
maxContextTokens: 0,
});
});
it("reads both figures from the most recent row that carries them", () => {
expect(
selectContextBadge([
row({ contextTokens: 100, maxContextTokens: 200000 }),
row({ contextTokens: 1500, maxContextTokens: 200000 }),
]),
).toEqual({ contextTokens: 1500, maxContextTokens: 200000 });
});
it("falls back to legacy usage total for older rows without contextTokens", () => {
expect(
selectContextBadge([
row({ usage: { inputTokens: 30, outputTokens: 70 } }),
]),
).toEqual({ contextTokens: 100, maxContextTokens: 0 });
expect(
selectContextBadge([row({ usage: { totalTokens: 250 } })]),
).toEqual({ contextTokens: 250, maxContextTokens: 0 });
});
it("takes numerator and denominator from different rows", () => {
// Freshest row (an error turn) carries contextTokens but no max; the older
// completed turn carries the max. Each is picked from its own latest row.
expect(
selectContextBadge([
row({ contextTokens: 800, maxContextTokens: 200000 }),
row({ contextTokens: 1200, error: "402: nope" }),
]),
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
});
it("does not let a fresh zero/absent max shadow an older positive max", () => {
expect(
selectContextBadge([
row({ contextTokens: 100, maxContextTokens: 200000 }),
row({ contextTokens: 1200, maxContextTokens: 0 }),
]),
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
});
it("skips rows with null metadata", () => {
expect(
selectContextBadge([
row({ contextTokens: 500, maxContextTokens: 200000 }),
row(null),
]),
).toEqual({ contextTokens: 500, maxContextTokens: 200000 });
});
it("reports current > max as-is (no clamp)", () => {
expect(
selectContextBadge([row({ contextTokens: 250000, maxContextTokens: 200000 })]),
).toEqual({ contextTokens: 250000, maxContextTokens: 200000 });
});
});

View File

@@ -0,0 +1,49 @@
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Derive the header context badge figures from the persisted message rows.
*
* - `contextTokens` (numerator): how much the conversation now occupies in the
* model's context window. Read from the most recent row carrying a context
* figure — `contextTokens` (final-step input+output) on rows recorded after
* this shipped, else that turn's legacy `usage` total for older rows.
* - `maxContextTokens` (denominator): the model's configured max window, stamped
* alongside `contextTokens` on a completed turn.
*
* Each value is taken from the most recent row carrying THAT value
* independently — they may land on different rows (e.g. a fresh error row can
* carry `contextTokens` but not `maxContextTokens`), so the scan continues for
* whichever is still unset. `0` means "no row has it" (older rows, or no
* admin-configured limit); the badge then omits the value.
*/
export function selectContextBadge(
messageRows: readonly IAiChatMessageRow[] | undefined | null,
): { contextTokens: number; maxContextTokens: number } {
let contextTokens = 0;
let maxContextTokens = 0;
if (!messageRows) return { contextTokens, maxContextTokens };
for (let i = messageRows.length - 1; i >= 0; i--) {
const meta = messageRows[i].metadata;
if (!meta) continue;
if (contextTokens === 0) {
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
contextTokens = meta.contextTokens;
} else if (meta.usage) {
const usage = meta.usage;
const fallback =
usage.totalTokens ??
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
if (fallback > 0) contextTokens = fallback;
}
}
if (
maxContextTokens === 0 &&
typeof meta.maxContextTokens === "number" &&
meta.maxContextTokens > 0
) {
maxContextTokens = meta.maxContextTokens;
}
if (contextTokens !== 0 && maxContextTokens !== 0) break;
}
return { contextTokens, maxContextTokens };
}

View File

@@ -1,17 +1,5 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import {
estimateTokens,
liveTurnTokens,
} from "@/features/ai-chat/utils/count-stream-tokens.ts";
const msg = (parts: unknown[], metadata?: unknown): UIMessage =>
({
id: Math.random().toString(),
role: "assistant",
parts,
metadata,
}) as UIMessage;
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
describe("estimateTokens", () => {
it("returns 0 for the empty string", () => {
@@ -25,147 +13,3 @@ describe("estimateTokens", () => {
expect(estimateTokens("12345678")).toBe(2);
});
});
describe("liveTurnTokens — estimate path", () => {
it("is all zeros for an undefined message", () => {
expect(liveTurnTokens(undefined)).toEqual({
reasoning: 0,
output: 0,
authoritative: false,
});
});
it("is all zeros for a parts-less message", () => {
expect(liveTurnTokens({ id: "x", role: "assistant" } as UIMessage)).toEqual({
reasoning: 0,
output: 0,
authoritative: false,
});
});
it("estimates output from text parts", () => {
// 8 chars -> 2 tokens.
const r = liveTurnTokens(msg([{ type: "text", text: "12345678" }]));
expect(r).toEqual({ reasoning: 0, output: 2, authoritative: false });
});
it("estimates reasoning from reasoning parts (kept separate from output)", () => {
const r = liveTurnTokens(
msg([
{ type: "reasoning", text: "12345678" },
{ type: "text", text: "abcd" },
]),
);
expect(r).toEqual({ reasoning: 2, output: 1, authoritative: false });
});
it("accumulates across multiple text + reasoning parts (multi-step)", () => {
const r = liveTurnTokens(
msg([
{ type: "reasoning", text: "abcd" }, // 1
{ type: "text", text: "abcd" }, // 1
{ type: "tool-getPage", state: "output-available" }, // ignored
{ type: "reasoning", text: "abcd" }, // 1
{ type: "text", text: "abcdefgh" }, // 2
]),
);
expect(r).toEqual({ reasoning: 2, output: 3, authoritative: false });
});
it("ignores non text/reasoning parts (tools, step-start)", () => {
const r = liveTurnTokens(
msg([
{ type: "step-start" },
{ type: "tool-getPage", state: "input-available" },
]),
);
expect(r).toEqual({ reasoning: 0, output: 0, authoritative: false });
});
});
describe("liveTurnTokens — authoritative path", () => {
it("returns authoritative usage verbatim, splitting reasoning out of output", () => {
// outputTokens INCLUDES reasoning in the AI SDK shape -> answer = 100 - 30.
const r = liveTurnTokens(
msg([{ type: "text", text: "estimate would be tiny" }], {
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
}),
);
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
});
it("treats missing reasoningTokens as 0 and keeps full output", () => {
const r = liveTurnTokens(
msg([{ type: "text", text: "x" }], {
usage: { inputTokens: 10, outputTokens: 42 },
}),
);
expect(r).toEqual({ reasoning: 0, output: 42, authoritative: true });
});
it("never returns a negative output when reasoning exceeds reported output", () => {
const r = liveTurnTokens(
msg([], { usage: { outputTokens: 10, reasoningTokens: 40 } }),
);
expect(r).toEqual({ reasoning: 40, output: 0, authoritative: true });
});
it("falls back to the estimate when metadata has no usage object", () => {
const r = liveTurnTokens(
msg([{ type: "text", text: "abcd" }], { chatId: "c1" }),
);
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
});
});
describe("liveTurnTokens — combined authoritative + estimate (#163)", () => {
it("ticks the in-flight step above the completed-steps authoritative base", () => {
// The authoritative usage is the sum over COMPLETED steps (step 1). The
// CURRENT step is streaming and its text is NOT in `usage` yet, but it IS in
// the parts -> the running estimate must push the live figure above the base
// so the badge keeps growing between step boundaries.
const longText = "x".repeat(800); // 800 chars -> 200 est output tokens
const r = liveTurnTokens(
msg([{ type: "text", text: longText }], {
usage: { inputTokens: 500, outputTokens: 40 }, // step-1 base: 40 output
}),
);
// max(authOutput=40, estOutput=200) = 200 -> the counter ticks, not frozen.
expect(r.output).toBe(200);
expect(r.authoritative).toBe(true);
});
it("ticks reasoning of the in-flight step above the authoritative reasoning base", () => {
const longReasoning = "r".repeat(400); // 400 chars -> 100 est reasoning
const r = liveTurnTokens(
msg([{ type: "reasoning", text: longReasoning }], {
usage: { inputTokens: 100, outputTokens: 20, reasoningTokens: 20 },
}),
);
// reasoning: max(20, 100) = 100 ; output: max(max(0,20-20)=0, 0) = 0.
expect(r.reasoning).toBe(100);
expect(r.output).toBe(0);
expect(r.authoritative).toBe(true);
});
it("snaps to the authoritative figure once it exceeds the rough estimate", () => {
// Short on-screen text (estimate tiny) but a large authoritative output:
// the exact figure wins at the boundary (the counter never under-reports).
const r = liveTurnTokens(
msg([{ type: "text", text: "abcd" }], {
usage: { inputTokens: 10, outputTokens: 5000 },
}),
);
expect(r.output).toBe(5000);
});
it("is monotonic: max never drops below the authoritative base when the estimate is smaller", () => {
// Mirrors the legacy 'verbatim' tests: estimate < authoritative -> unchanged.
const r = liveTurnTokens(
msg([{ type: "text", text: "tiny" }], {
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
}),
);
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
});
});

View File

@@ -1,18 +1,11 @@
import type { UIMessage } from "@ai-sdk/react";
/**
* Live token counting for a streaming AI-chat turn — split into REASONING
* (thinking) and OUTPUT (answer) tokens, mirroring how Claude Code shows
* `Thinking… · 60 tokens` next to its thinking indicator.
* Rough client-side token estimation for AI-chat UI affordances.
*
* No provider streams exact per-token usage mid-stream, so the live number is a
* CLIENT ESTIMATE (chars/≈4 heuristic) that is reconciled to AUTHORITATIVE usage
* once the server attaches it on a step/turn boundary (see the server's
* `chatStreamMetadata` + the client's read of `message.metadata.usage`). When
* authoritative usage is present we return it verbatim (the number "jumps to
* exact"); otherwise we return the running estimate. Pure + unit-testable: it
* never runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
* bundle, and be wrong for Gemini/Ollama anyway).
* No provider streams exact per-token usage mid-stream, so any in-flight figure
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
* ("Thinking · N tokens").
*/
/**
@@ -24,90 +17,3 @@ export function estimateTokens(text: string): number {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
/** Authoritative per-step/turn usage the server attaches to message metadata. */
export interface AuthoritativeUsage {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
reasoningTokens?: number;
}
/** Live token split for a turn's tail (streaming) assistant message. */
export interface LiveTurnTokens {
/** Thinking/reasoning tokens (estimate, or authoritative when available). */
reasoning: number;
/** Answer/output tokens (estimate, or authoritative when available). */
output: number;
/** True when the numbers come from authoritative server usage, not estimate. */
authoritative: boolean;
}
/** Read the authoritative usage off a UIMessage's metadata, if the server set it. */
function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
const meta = message?.metadata as
| { usage?: AuthoritativeUsage }
| undefined;
const usage = meta?.usage;
if (!usage || typeof usage !== "object") return undefined;
return usage;
}
/**
* Token split for the given (streaming) assistant message.
*
* COMBINES the authoritative server usage with the running text estimate so the
* counter ticks in real time AND lands exact. The server only attaches
* `metadata.usage` at a step/turn boundary (`finish-step`/`finish`) and it is
* CUMULATIVE over COMPLETED steps — it does NOT yet include the in-flight step.
* So a multi-step turn that returned the authoritative figure verbatim would
* FREEZE between boundaries and jump in steps (issue #163).
*
* Instead we always compute the running ESTIMATE (chars/≈4 over the message's
* `reasoning`/`text` parts, which grows on every streamed delta) and take the
* per-component MAX of the authoritative base and the estimate:
* - between boundaries the estimate of the in-flight step ticks the number up;
* - at a boundary the authoritative figure snaps it to exact;
* - because the server's usage is cumulative and we only ever take the max, the
* number is MONOTONIC — it never drops.
*
* Providers that don't stream reasoning text still surface a reasoning count once
* the authoritative usage arrives (`max(reasoningTokens, 0)`); on the pure
* estimate path (no usage yet) such a turn shows `reasoning: 0` until then.
*/
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
if (!message) return { reasoning: 0, output: 0, authoritative: false };
// Running ESTIMATE over every reasoning/text part — grows on each delta. This
// includes the IN-FLIGHT step, which the authoritative usage does not cover yet.
let estReasoning = 0;
let estOutput = 0;
for (const part of message.parts ?? []) {
if (part.type === "reasoning") {
estReasoning += estimateTokens((part as { text?: string }).text ?? "");
} else if (part.type === "text") {
estOutput += estimateTokens((part as { text?: string }).text ?? "");
}
}
const usage = metadataUsage(message);
if (!usage) {
// No authoritative usage streamed yet: the estimate IS the live figure.
return { reasoning: estReasoning, output: estOutput, authoritative: false };
}
// Authoritative sum over COMPLETED steps. `outputTokens` already INCLUDES
// reasoning in the AI SDK usage shape, so subtract it out for the "answer"
// figure (never go negative if a provider reports them inconsistently).
const authReasoning = usage.reasoningTokens ?? 0;
const authOutput = Math.max(0, (usage.outputTokens ?? 0) - authReasoning);
// Per-component max: the in-flight step's estimate ticks above the completed-
// steps base between boundaries, and the authoritative figure wins once it
// exceeds the (rough) estimate at the next boundary. Monotonic by construction.
return {
reasoning: Math.max(authReasoning, estReasoning),
output: Math.max(authOutput, estOutput),
authoritative: true,
};
}

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
export default function useAuth() {
const { t } = useTranslation();
@@ -123,6 +124,13 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
await logout();
// Purge the previous user's offline data while the page is still alive —
// window.location.replace below would otherwise interrupt async cleanup.
try {
await clearOfflineCache();
} catch {
// best-effort: never block logout on cache cleanup
}
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
};

View File

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

View File

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

View File

@@ -104,6 +104,19 @@
min-width: 0;
}
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
which pushes the first text line ~0.5em below the "N." marker (aligned to
flex-start), making the number float above the text. Drop the outer margins
so the marker and the first line share the same top edge — same approach
used for callouts in core.css. */
.definitionContent > :first-child {
margin-top: 0;
}
.definitionContent > :last-child {
margin-bottom: 0;
}
.backLink {
flex: 0 0 auto;
cursor: pointer;

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

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

View File

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

View File

@@ -0,0 +1,134 @@
import { useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query.ts";
import { generatePageTitle } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { UpdateEvent } from "@/features/websocket/types";
import localEmitter from "@/lib/local-emitter.ts";
// Maximum length we send to the model. The server truncates again; this is a
// cheap client-side bound so we never ship a huge body over the wire.
const MAX_CONTENT_CHARS = 20000;
/**
* Generate a title for the given page from the LIVE editor content (#199),
* including unsaved edits, then apply it IMMEDIATELY (per product decision). The
* server endpoint only summarizes the supplied markdown — it never writes the
* page; the actual title write goes through the existing /pages/update mutation
* (which enforces edit permission), and is mirrored to the title field + other
* clients exactly like TitleEditor.saveTitle. Returns a mutation-like API so the
* button can show a loading state via `isPending`.
*/
export function useGeneratePageTitle(pageId: string) {
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const titleEditor = useAtomValue(titleEditorAtom);
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
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>({
mutationFn: async () => {
if (!pageEditor || pageEditor.isDestroyed) return;
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
if (!markdown) {
notifications.show({ message: t("The note is empty"), color: "yellow" });
return;
}
const title = (
await generatePageTitle(markdown.slice(0, MAX_CONTENT_CHARS))
).trim();
if (!title) {
// The model returned nothing usable — keep the existing title untouched.
notifications.show({
message: t("Could not generate a title"),
color: "yellow",
});
return;
}
const page = await updateTitle({ pageId, title }); // POST /pages/update
updatePageData(page); // refresh the react-query cache
// Reflect the new title in the field immediately. The button lives in the
// byline, so the title editor is not focused — setContent is safe and stays
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
//
// Guard against navigation during generation: if the user switched pages
// while the model ran, the (persistent) title editor now shows ANOTHER
// page, so writing here would drop page A's title into page B's visible
// field. page-editor.tsx stamps the live page editor with its pageId
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
// 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.
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: {
title: page.title,
slugId: page.slugId,
parentPageId: page.parentPageId,
icon: page.icon,
},
};
localEmitter.emit("message", event);
emit(event);
notifications.show({ message: t("Title generated") });
},
onError: (err) => {
// Map known HTTP statuses to friendly messages, falling back to generic.
const status = (err as { response?: { status?: number } })?.response
?.status;
const message =
status === 403
? t("AI title generation is disabled")
: status === 503
? t("AI is not configured")
: status === 429
? t("Too many requests, please try again later")
: t("Failed to generate title");
notifications.show({ message, color: "red" });
},
});
}

View File

@@ -0,0 +1,205 @@
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 { jwtDecode } from "jwt-decode";
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);
// Mirror the local/remote sync flags into shared atoms so the header
// indicator can read them. These atoms are the single source of truth; the
// wrappers keep the existing call sites valid while driving only the atoms.
const setLocalSynced = (value: boolean) => {
setIsLocalSyncedAtom(value);
};
const setRemoteSynced = (value: boolean) => {
setIsRemoteSyncedAtom(value);
};
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setRemoteSynced(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 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.
const token = collabTokenRef.current;
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
if (token) {
try {
const payload = jwtDecode<{ exp: number }>(token);
needsRefresh = Date.now() / 1000 >= payload.exp;
} catch {
needsRefresh = true; // malformed token -> refresh
}
}
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 = { 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.
setLocalSynced(false);
setRemoteSynced(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,
useState,
} from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import { WebSocketStatus } from "@hocuspocus/provider";
import {
Editor,
EditorContent,
@@ -28,13 +19,15 @@ import {
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
import {
activeCommentIdAtom,
@@ -58,10 +51,8 @@ import {
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { useDebouncedCallback } from "@mantine/hooks";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
@@ -72,9 +63,7 @@ import {
GitmostInsertRecordingResult,
gitmostInsertRecordingIntoEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
@@ -99,7 +88,6 @@ export default function PageEditor({
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
@@ -113,22 +101,10 @@ export default function PageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
// Always holds the latest collab token. The provider effect below runs once
// per pageId, so a handler created inside it would otherwise close over a
// stale `collabQuery`. Reading the ref gives the current token instead.
const collabTokenRef = useRef<string | undefined>(undefined);
useEffect(() => {
collabTokenRef.current = collabQuery?.token;
}, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
@@ -137,141 +113,27 @@ export default function PageEditor({
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSynced(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSynced(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
if (pageData) {
queryClient.setQueryData(["pages", slugId], {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
// Read the latest token via the ref (the closure-captured `collabQuery`
// may be stale). Guard the decode: a missing or unparseable token must
// not throw "Invalid token specified" and should trigger a refresh so
// the editor reconnects even when the initial token fetch failed.
const token = collabTokenRef.current;
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
if (token) {
try {
// A token that decodes but lacks a numeric `exp` must be treated as
// expired (`Date.now()/1000 >= undefined` is `false`, which would
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
const exp = jwtDecode<{ exp?: number }>(token).exp;
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
} catch {
needsRefresh = true;
}
}
if (!needsRefresh) return;
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
};
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
) {
socket.disconnect();
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
// Shared providers + Y.Doc lifted into full-editor via context. The provider
// lifecycle (creation, idle/visibility connect, attach, destroy, token
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
// the context (defensive) — in practice full-editor always provides it.
const editorProviders = useEditorProviders();
const remote = editorProviders?.remote ?? null;
const providersReady = editorProviders?.providersReady ?? false;
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) {
if (!providersReady || !remote || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
...collabExtensions(remote, currentUser?.user),
];
}, [providersReady, currentUser?.user]);
}, [providersReady, remote, currentUser?.user]);
const editor = useEditor(
{
@@ -513,7 +375,7 @@ export default function PageEditor({
{editor &&
!editorIsEditable &&
(editable || canComment) &&
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
remote && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}

View File

@@ -10,9 +10,15 @@ ul[data-type="taskList"] {
display: flex;
> label {
padding-top: 0.2rem;
/* Box exactly one text-line tall and center the checkbox in it, so the
checkbox lines up with the first line of the item's text. This tracks
the editor line-height (--mantine-line-height-xl) instead of a magic
padding-top that drifts from the real line box. */
flex: 0 0 auto;
margin-right: 0.5rem;
height: calc(var(--mantine-line-height-xl, 1.65) * 1em);
display: inline-flex;
align-items: center;
user-select: none;
}

View File

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

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,92 @@
import { del } from "idb-keyval";
import { queryClient } from "@/main.tsx";
import { OFFLINE_CACHE_KEY } from "./query-persister";
/**
* 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> {
// 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.")) 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
}
}

View File

@@ -0,0 +1,258 @@
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", 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" } });
await warmInfiniteAll(["comments", "p1"], fetchPage, 2);
expect(fetchPage).toHaveBeenCalledTimes(2);
const payload = setQueryData.mock.calls[0][1];
expect(payload.pages).toHaveLength(2);
});
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();
});
});
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,272 @@
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 { IPage } from "@/features/page/types/page.types";
import { IPagination } from "@/lib/types.ts";
/**
* 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 if the whole list was paginated and written, false on any error.
*
* 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;
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) break;
}
queryClient.setQueryData(queryKey, { pages, pageParams });
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: "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[] = [];
// 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 = `page.${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,84 @@
import { describe, it, expect } from "vitest";
import {
shouldDehydrateOfflineQuery,
OFFLINE_PERSIST_ROOTS,
} 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);
});
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 8 navigation/read roots", () => {
const expected = [
"pages",
"sidebar-pages",
"root-sidebar-pages",
"breadcrumbs",
"comments",
"space",
"spaces",
"recent-changes",
];
expect(OFFLINE_PERSIST_ROOTS.size).toBe(8);
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);
});
});

View File

@@ -0,0 +1,50 @@
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),
};
export const queryPersister = createAsyncStoragePersister({
storage: idbStorage,
key: OFFLINE_CACHE_KEY,
throttleTime: 1000,
});
// Only navigation/read query roots are persisted for offline reading.
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
"pages",
"sidebar-pages",
"root-sidebar-pages",
"breadcrumbs",
"comments",
"space",
"spaces",
"recent-changes",
]);
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
return (
query.state.status === "success" &&
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
);
}

View File

@@ -1,7 +1,40 @@
import { useMutation } from "@tanstack/react-query";
import { notifications } from "@mantine/notifications";
import { toggleTemplate } from "@/features/page-embed/services/page-embed-api";
import type { ToggleTemplateResponse } from "@/features/page-embed/types/page-embed.types";
import {
toggleTemplate,
toggleTemporary,
} from "@/features/page-embed/services/page-embed-api";
import type {
ToggleTemplateResponse,
ToggleTemporaryResponse,
} from "@/features/page-embed/types/page-embed.types";
import { queryClient } from "@/main.tsx";
/**
* After toggling a note's temporary state, mirror the new deadline into the
* shared page cache (keyed by both slugId and id) and refresh the sidebar so the
* menu label, the in-page banner, and the tree icon all reflect the change.
* Centralised here so the header menu and the banner can't drift apart on the
* cache-key plumbing.
*/
export function syncTemporaryExpiresInCache(
page: { id: string; slugId: string },
temporaryExpiresAt: string | null,
) {
for (const key of [page.slugId, page.id]) {
const cached = queryClient.getQueryData<any>(["pages", key]);
if (cached) {
queryClient.setQueryData(["pages", key], {
...cached,
temporaryExpiresAt,
});
}
}
queryClient.invalidateQueries({
predicate: (item) =>
["sidebar-pages"].includes(item.queryKey[0] as string),
});
}
export function useToggleTemplateMutation() {
return useMutation<
@@ -18,3 +51,20 @@ export function useToggleTemplateMutation() {
},
});
}
export function useToggleTemporaryMutation() {
return useMutation<
ToggleTemporaryResponse,
Error,
{ pageId: string; temporary?: boolean }
>({
mutationFn: (data) => toggleTemporary(data),
onError: (err: any) => {
notifications.show({
message:
err?.response?.data?.message || "Failed to update temporary note",
color: "red",
});
},
});
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { ActionIcon, Button, Group, Menu, Text, ThemeIcon, Tooltip } from "@mant
import {
IconArrowRight,
IconArrowsHorizontal,
IconClockHour4,
IconDots,
IconEye,
IconEyeOff,
@@ -11,6 +12,8 @@ import {
IconList,
IconMarkdown,
IconPrinter,
IconCloud,
IconCloudCheck,
IconStar,
IconStarFilled,
IconTrash,
@@ -24,6 +27,10 @@ import { useDisclosure, useHotkeys } from "@mantine/hooks";
import { useClipboard } from "@/hooks/use-clipboard";
import { useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
useToggleTemporaryMutation,
syncTemporaryExpiresInCache,
} from "@/features/page-embed/queries/page-embed-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
@@ -34,6 +41,8 @@ import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
@@ -160,6 +169,29 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
const { data: watchStatus } = useWatchStatusQuery(page?.id);
const watchPage = useWatchPageMutation();
const unwatchPage = useUnwatchPageMutation();
const toggleTemporary = useToggleTemporaryMutation();
const isTemporary = !!page?.temporaryExpiresAt;
const handleToggleTemporary = async () => {
if (!page?.id) return;
const next = !isTemporary;
try {
const res = await toggleTemporary.mutateAsync({
pageId: page.id,
temporary: next,
});
// Reflect the new deadline in the page cache so the menu label flips and
// any banner updates. The sidebar icon refreshes via its own query.
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
notifications.show({
message: next
? t("Note will move to trash unless made permanent")
: t("Note is now permanent"),
});
} catch {
// mutation surfaces the error via notifications
}
};
const handleCopyLink = () => {
const pageUrl =
@@ -309,6 +341,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
{!readOnly && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<IconClockHour4 size={16} />}
onClick={handleToggleTemporary}
>
{isTemporary ? t("Make permanent") : t("Make temporary")}
</Menu.Item>
<Menu.Item
color={"red"}
leftSection={<IconTrash size={16} />}
@@ -377,14 +415,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
function ConnectionWarning() {
const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
useEffect(() => {
if (isDisconnected) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
@@ -396,7 +436,7 @@ function ConnectionWarning() {
}
setShowWarning(false);
}
}, [yjsConnectionStatus]);
}, [isDisconnected]);
// Cleanup only on unmount
useEffect(() => {
@@ -407,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 (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<Tooltip label={syncedLabel} openDelay={250} withArrow>
<ThemeIcon
variant="default"
c="red"
c="dimmed"
role="status"
aria-label={t("Real-time editor connection lost. Retrying...")}
aria-label={syncedLabel}
style={{ border: "none" }}
>
<IconWifiOff size={20} stroke={2} />
<IconCloudCheck size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);

View File

@@ -0,0 +1,87 @@
import { Button, Group, Paper, Text } from "@mantine/core";
import { IconClockHour4 } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
useToggleTemporaryMutation,
syncTemporaryExpiresInCache,
} from "@/features/page-embed/queries/page-embed-query.ts";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
type TemporaryNoteBannerProps = {
slugId: string;
};
/**
* Banner shown on an open temporary note ("structure or die"). Mirrors
* DeletedPageBanner: it reads the page from the shared query cache and offers
* the explicit rescue action — "Make permanent". Children ride along to trash
* with the note, which is noted in the copy.
*/
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
const { t } = useTranslation();
const { data: page } = usePageQuery({ pageId: slugId });
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
const toggleTemporary = useToggleTemporaryMutation();
// Don't show on a note that is already in trash; the deleted-page banner
// owns that state.
if (!page?.temporaryExpiresAt || page?.deletedAt) return null;
const canEdit = spaceAbility.can(SpaceCaslAction.Edit, SpaceCaslSubject.Page);
const handleMakePermanent = async () => {
try {
const res = await toggleTemporary.mutateAsync({
pageId: page.id,
temporary: false,
});
syncTemporaryExpiresInCache(page, res.temporaryExpiresAt);
} catch {
// mutation surfaces the error via notifications
}
};
return (
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<IconClockHour4
size={18}
stroke={1.5}
style={{
flexShrink: 0,
color: "var(--mantine-color-orange-7)",
}}
/>
<Text size="sm">
<Trans
i18nKey="This temporary note moves to trash {{time}} (with its sub-pages) unless made permanent."
values={{ time: expiresTimeAgo }}
/>
</Text>
</Group>
{canEdit && (
<Button
size="xs"
variant="light"
color="orange"
leftSection={<IconClockHour4 size={16} />}
onClick={handleMakePermanent}
loading={toggleTemporary.isPending}
>
{t("Make permanent")}
</Button>
)}
</Group>
</Paper>
);
}

View File

@@ -1,6 +1,7 @@
import {
InfiniteData,
QueryKey,
queryOptions,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
@@ -43,11 +44,36 @@ import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
/**
* 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(
pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> {
const query = useQuery({
queryKey: ["pages", pageInput.pageId],
queryKey: pageKeys.detail(pageInput.pageId),
queryFn: () => getPageById(pageInput),
enabled: !!pageInput.pageId,
staleTime: 5 * 60 * 1000,
@@ -56,9 +82,9 @@ export function usePageQuery(
useEffect(() => {
if (query.data) {
if (isValidUuid(pageInput.pageId)) {
queryClient.setQueryData(["pages", query.data.slugId], query.data);
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
} else {
queryClient.setQueryData(["pages", query.data.id], query.data);
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
}
}
}, [query.data]);
@@ -80,18 +106,20 @@ export function useCreatePageMutation() {
}
export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
const pageBySlug = queryClient.getQueryData<IPage>(
pageKeys.detail(data.slugId),
);
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
if (pageBySlug) {
queryClient.setQueryData(["pages", data.slugId], {
queryClient.setQueryData(pageKeys.detail(data.slugId), {
...pageBySlug,
...data,
});
}
if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
}
invalidateOnUpdatePage(
@@ -145,11 +173,11 @@ export function useRemovePageMutation() {
});
// 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) {
const stamped = { ...cached, deletedAt: new Date() };
queryClient.setQueryData(["pages", cached.id], stamped);
queryClient.setQueryData(["pages", cached.slugId], stamped);
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
}
invalidateOnDeletePage(pageId);
@@ -270,8 +298,11 @@ export function useRestorePageMutation() {
// Replace would strip space/permissions/content and break the editor.
const merge = (cached: IPage | undefined) =>
cached ? { ...cached, ...restoredPage } : cached;
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
queryClient.setQueryData<IPage>(
pageKeys.detail(restoredPage.slugId),
merge,
);
},
onError: (error) => {
notifications.show({
@@ -286,7 +317,7 @@ export function useGetSidebarPagesQuery(
data: SidebarPagesParams | null,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
queryKey: pageKeys.sidebar(data),
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) =>
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
@@ -297,7 +328,7 @@ export function useGetSidebarPagesQuery(
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId],
queryKey: pageKeys.rootSidebar(data.spaceId),
queryFn: async ({ pageParam }) => {
return getSidebarPages({
spaceId: data.spaceId,
@@ -323,7 +354,7 @@ export function usePageBreadcrumbsQuery(
pageId: string,
): UseQueryResult<Partial<IPage[]>, Error> {
return useQuery({
queryKey: ["breadcrumbs", pageId],
queryKey: pageKeys.breadcrumbs(pageId),
queryFn: () => getPageBreadcrumbs(pageId),
enabled: !!pageId,
});
@@ -335,10 +366,12 @@ export async function fetchAllAncestorChildren(
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
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({
queryKey: ["sidebar-pages", params],
queryFn: () => getAllSidebarPages(params),
...sidebarPagesQueryOptions(params),
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
});
@@ -348,7 +381,7 @@ export async function fetchAllAncestorChildren(
export function useRecentChangesQuery(spaceId?: string) {
return useInfiniteQuery({
queryKey: ["recent-changes", spaceId],
queryKey: pageKeys.recentChanges(spaceId),
queryFn: ({ pageParam }) =>
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
@@ -414,12 +447,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
let queryKey: QueryKey = null;
if (data.parentPageId === null) {
queryKey = ["root-sidebar-pages", data.spaceId];
queryKey = pageKeys.rootSidebar(data.spaceId);
} else {
queryKey = [
"sidebar-pages",
{ pageId: data.parentPageId, spaceId: data.spaceId },
];
queryKey = pageKeys.sidebar({
pageId: data.parentPageId,
spaceId: data.spaceId,
});
}
//update all sidebar pages
@@ -479,7 +512,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({
queryKey: ["root-sidebar-pages", data.spaceId],
queryKey: pageKeys.rootSidebar(data.spaceId),
exact: false,
});
@@ -503,7 +536,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes", data.spaceId],
queryKey: pageKeys.recentChanges(data.spaceId),
});
}
@@ -517,9 +550,9 @@ export function invalidateOnUpdatePage(
invalidatePageTree();
let queryKey: QueryKey = null;
if (parentPageId === null) {
queryKey = ["root-sidebar-pages", spaceId];
queryKey = pageKeys.rootSidebar(spaceId);
} else {
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
@@ -542,7 +575,7 @@ export function invalidateOnUpdatePage(
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes", spaceId],
queryKey: pageKeys.recentChanges(spaceId),
});
}
@@ -557,8 +590,8 @@ export function updateCacheOnMovePage(
// Remove page from old parent's cache
const oldQueryKey =
oldParentId === null
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
? pageKeys.rootSidebar(spaceId)
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey,
@@ -578,7 +611,7 @@ export function updateCacheOnMovePage(
if (oldParentId !== null) {
const oldParentCache = queryClient.getQueryData<
InfiniteData<IPagination<IPage>>
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
const remainingChildren =
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
@@ -616,8 +649,8 @@ export function updateCacheOnMovePage(
// Add page to new parent's cache
const newQueryKey =
newParentId === null
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: newParentId, spaceId }];
? pageKeys.rootSidebar(spaceId)
: pageKeys.sidebar({ pageId: newParentId, spaceId });
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey,

View File

@@ -6,6 +6,8 @@ import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconArrowRight,
IconClockHour4,
IconCloudDownload,
IconCopy,
IconDotsVertical,
IconFileExport,
@@ -30,7 +32,16 @@ import {
useRemoveFavoriteMutation,
} from "@/features/favorite/queries/favorite-query";
import { useToggleTemplateMutation } from "@/features/page-embed/queries/page-embed-query";
import {
useToggleTemplateMutation,
useToggleTemporaryMutation,
} 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 { treeModel } from "@/features/page/tree/model/tree-model";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
@@ -65,6 +76,42 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const isFavorited = favoriteIds.has(node.id);
const toggleTemplate = useToggleTemplateMutation();
const isTemplate = !!node.isTemplate;
const toggleTemporary = useToggleTemporaryMutation();
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.
notifications.show({
message: t("Failed to make page available offline"),
color: "red",
});
}
} catch {
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
// unexpected failures stay guarded here.
notifications.show({
message: t("Failed to make page available offline"),
color: "red",
});
}
};
const handleToggleTemplate = async () => {
const next = !isTemplate;
@@ -84,6 +131,29 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
}
};
const handleToggleTemporary = async () => {
const next = !isTemporary;
try {
const res = await toggleTemporary.mutateAsync({
pageId: node.id,
temporary: next,
});
// Reflect the new deadline locally so the icon/menu update immediately.
setData((prev) =>
treeModel.update(prev, node.id, {
temporaryExpiresAt: res.temporaryExpiresAt,
} as any),
);
notifications.show({
message: next
? t("Note will move to trash unless made permanent")
: t("Note is now permanent"),
});
} catch {
// mutation surfaces the error via notifications
}
};
const handleCopyLink = () => {
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
@@ -202,6 +272,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
{t("Export")}
</Menu.Item>
<Menu.Item
leftSection={<IconCloudDownload size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleMakeAvailableOffline();
}}
>
{t("Make available offline")}
</Menu.Item>
{canEdit && (
<>
<Menu.Item
@@ -248,6 +329,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
{isTemplate ? t("Unset as template") : t("Make template")}
</Menu.Item>
<Menu.Item
leftSection={<IconClockHour4 size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggleTemporary();
}}
>
{isTemporary ? t("Make permanent") : t("Make temporary")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"

View File

@@ -6,6 +6,7 @@ import { ActionIcon, rem, Tooltip } from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
IconClockHour4,
IconFileDescription,
IconPlus,
IconPointFilled,
@@ -191,6 +192,28 @@ export function SpaceTreeRow({
</Tooltip>
)}
{node.temporaryExpiresAt && (
<Tooltip
// Children ride along to trash with the note (recursive removePage).
label={t("Temporary note — moves to trash unless made permanent")}
withArrow
>
<IconClockHour4
size={14}
stroke={1.5}
// Same visual-only indicator pattern as the template icon, but
// orange to flag the impending death timer.
style={{
flexShrink: 0,
marginLeft: rem(4),
color: "var(--mantine-color-orange-6)",
}}
aria-label={t("Temporary note")}
role="img"
/>
</Tooltip>
)}
<div className={classes.actions}>
<NodeMenu node={node} canEdit={canEdit} />

View File

@@ -14,7 +14,6 @@ import {
useCreatePageMutation,
useRemovePageMutation,
useMovePageMutation,
useUpdatePageMutation,
updateCacheOnMovePage,
} from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -22,8 +21,10 @@ import { getSpaceUrl } from "@/lib/config.ts";
export type UseTreeMutation = {
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
handleCreate: (parentId: string | null) => Promise<void>;
handleRename: (id: string, name: string) => Promise<void>;
handleCreate: (
parentId: string | null,
opts?: { temporary?: boolean },
) => Promise<void>;
handleDelete: (id: string) => Promise<void>;
};
@@ -35,7 +36,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
// children) and then immediately invokes a handler.
const store = useStore();
const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
@@ -119,9 +119,15 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
);
const handleCreate = useCallback(
async (parentId: string | null) => {
const payload: { spaceId: string; parentPageId?: string } = { spaceId };
async (parentId: string | null, opts?: { temporary?: boolean }) => {
const payload: {
spaceId: string;
parentPageId?: string;
temporary?: boolean;
} = { spaceId };
if (parentId) payload.parentPageId = parentId;
// Ask the server to arm the death timer for a "temporary note".
if (opts?.temporary) payload.temporary = true;
let createdPage: IPage;
try {
@@ -138,6 +144,8 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
spaceId: createdPage.spaceId,
parentPageId: createdPage.parentPageId,
hasChildren: false,
// Show the temporary-note icon immediately on optimistic insert.
temporaryExpiresAt: createdPage.temporaryExpiresAt,
children: [],
};
@@ -181,20 +189,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[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(
async (id: string) => {
const node = treeModel.find(
@@ -240,7 +234,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
);
return { handleMove, handleCreate, handleRename, handleDelete };
return { handleMove, handleCreate, handleDelete };
}
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {

View File

@@ -752,6 +752,27 @@ describe("treeModel.placeByPosition", () => {
});
expect(t.map((n) => n.id)).toEqual(["r1", "child", "r2", "rp"]);
});
it("returns same reference (no-op) when the destination parent is inside the source's own subtree (#206 ui-state-races-1)", () => {
// Moving `a` under its own descendant `b` is a cycle. Without the guard,
// remove(a) drops b too and insertByPosition can't re-place a -> the whole
// subtree silently vanishes. The guard refuses the move (same reference).
const cyclic: P[] = [
{
id: "a",
name: "A",
position: "a0",
children: [{ id: "b", name: "B", position: "a1" }],
},
];
const t = treeModel.placeByPosition(cyclic, "a", {
parentId: "b",
position: "a5",
});
expect(t).toBe(cyclic);
expect(treeModel.find(t, "a")).not.toBeNull();
expect(treeModel.find(t, "b")).not.toBeNull();
});
});
describe("treeModel.move", () => {

View File

@@ -294,6 +294,20 @@ export const treeModel = {
const source = treeModel.find(tree, sourceId);
if (!source) return tree;
if (to.parentId !== null && !treeModel.find(tree, to.parentId)) return tree;
// Cycle guard, mirroring `move`'s `isDescendant` check (#206 ui-state-races-1).
// If the destination parent is INSIDE the moved node's own subtree (reachable
// when server-authoritative move events arrive out of order — e.g. X moved
// under Y, then Y under X, but on this receiver Y is still inside X), then
// `remove(sourceId)` would drop the future parent along with the whole subtree
// and `insertByPosition` could not find it again — the node and ALL its
// descendants would silently vanish. Refuse the move and return the same
// reference so callers can detect the no-op and reconcile (refetch) instead.
if (
to.parentId !== null &&
treeModel.isDescendant(tree, sourceId, to.parentId)
) {
return tree;
}
const removed = treeModel.remove(tree, sourceId);
// Reuse the same position-ordered insertion as `insertByPosition` by
// stamping the authoritative position onto the moved node first.

View File

@@ -9,5 +9,7 @@ export type SpaceTreeNode = {
hasChildren: boolean;
canEdit?: boolean;
isTemplate?: boolean;
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
temporaryExpiresAt?: string | null;
children: SpaceTreeNode[];
};

View File

@@ -26,6 +26,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
};
});

View File

@@ -13,6 +13,10 @@ export interface IPage {
workspaceId: string;
isLocked: boolean;
isTemplate?: boolean;
// Death-timer deadline. null/absent => permanent; ISO string => temporary note.
temporaryExpiresAt?: string | null;
// Create-only input flag: ask the server to arm the timer on a new page.
temporary?: boolean;
lastUpdatedById: string;
createdAt: Date;
updatedAt: Date;

View File

@@ -0,0 +1,237 @@
import {
ActionIcon,
Button,
Group,
Modal,
Text,
TextInput,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import {
useRemoveShareAliasMutation,
useSetShareAliasMutation,
useShareAliasForPageQuery,
} from "@/features/share/queries/share-query.ts";
import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts";
import {
isValidShareAlias,
normalizeShareAlias,
} from "@/features/share/share-alias.util.ts";
interface ShareAliasSectionProps {
pageId: string;
readOnly: boolean;
}
// The prefix label shown next to the slug input, e.g. "docs.example.com/l/".
function aliasPrefixLabel(): string {
const url = getAppUrl();
const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
return `${host}/l/`;
}
export default function ShareAliasSection({
pageId,
readOnly,
}: ShareAliasSectionProps) {
const { t } = useTranslation();
const { data: currentAlias } = useShareAliasForPageQuery(pageId);
const setAliasMutation = useSetShareAliasMutation();
const removeAliasMutation = useRemoveShareAliasMutation();
const [value, setValue] = useState("");
const [availability, setAvailability] = useState<{
valid: boolean;
available: boolean;
currentPageId: string | null;
} | null>(null);
const [reassign, setReassign] = useState<{
alias: string;
currentPageTitle: string | null;
} | null>(null);
// Seed the input from the page's current alias (if any).
useEffect(() => {
setValue(currentAlias?.alias ?? "");
}, [currentAlias?.alias, pageId]);
const normalized = useMemo(() => normalizeShareAlias(value), [value]);
const isValid = isValidShareAlias(normalized);
const unchanged = currentAlias?.alias === normalized;
// Debounced availability probe (skips when invalid or unchanged).
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
setAvailability(null);
if (!isValid || unchanged) return;
debounceRef.current && clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
try {
const res = await checkShareAliasAvailability(normalized);
setAvailability({
valid: res.valid,
available: res.available,
currentPageId: res.currentPageId,
});
} catch {
setAvailability(null);
}
}, 400);
return () => {
debounceRef.current && clearTimeout(debounceRef.current);
};
}, [normalized, isValid, unchanged]);
const prettyLink = currentAlias?.alias
? `${getAppUrl()}/l/${currentAlias.alias}`
: null;
const handleSave = async (confirmReassign = false) => {
try {
await setAliasMutation.mutateAsync({
pageId,
alias: normalized,
confirmReassign,
});
setReassign(null);
} catch (error: any) {
// The address already points at another page: prompt to move it here.
if (error?.status === 409 || error?.response?.status === 409) {
const data = error?.response?.data;
if (data?.code === "ALIAS_REASSIGN_REQUIRED") {
setReassign({
alias: normalized,
currentPageTitle: data?.currentPageTitle ?? null,
});
}
}
}
};
const handleRemove = async () => {
if (!currentAlias?.id) return;
await removeAliasMutation.mutateAsync(currentAlias.id);
setValue("");
};
const showInvalid = normalized.length > 0 && !isValid;
const showTaken =
isValid && !unchanged && availability && !availability.available;
return (
<>
<Text size="sm" fw={500} mt="md">
{t("Custom address")}
</Text>
<Text size="xs" c="dimmed" mb={4}>
{t("A short, memorable link you can point at any shared page.")}
</Text>
{prettyLink && (
<Group my="xs" gap={4} wrap="nowrap">
<TextInput
variant="filled"
value={prettyLink}
readOnly
rightSection={<CopyTextButton text={prettyLink} />}
style={{ width: "100%" }}
/>
<ActionIcon
component="a"
variant="default"
target="_blank"
href={prettyLink}
size="sm"
>
<IconExternalLink size={16} />
</ActionIcon>
</Group>
)}
<TextInput
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
// Show the canonical form once the user pauses so what they type maps
// visibly to what gets stored.
onBlur={() => setValue(normalized)}
leftSection={
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
{aliasPrefixLabel()}
</Text>
}
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
placeholder={t("my-page")}
disabled={readOnly}
error={
showInvalid
? t("Use 2-60 lowercase letters, digits and hyphens")
: showTaken
? t("This address is already in use")
: undefined
}
/>
<Group mt="xs" gap="xs">
<Button
size="compact-sm"
onClick={() => handleSave(false)}
loading={setAliasMutation.isPending}
disabled={readOnly || !isValid || unchanged}
>
{t("Save")}
</Button>
{currentAlias?.id && (
<Button
size="compact-sm"
variant="default"
color="red"
onClick={handleRemove}
loading={removeAliasMutation.isPending}
disabled={readOnly}
>
{t("Remove")}
</Button>
)}
</Group>
<Modal
opened={!!reassign}
onClose={() => setReassign(null)}
title={t("Move custom address?")}
centered
size="sm"
>
<Text size="sm">
{reassign?.currentPageTitle
? t(
'The address "{{alias}}" currently points to "{{title}}". Move it to this page?',
{
alias: reassign?.alias,
title: reassign?.currentPageTitle,
},
)
: t(
'The address "{{alias}}" is already in use. Move it to this page?',
{ alias: reassign?.alias },
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={() => setReassign(null)}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={() => handleSave(true)}
loading={setAliasMutation.isPending}
>
{t("Move here")}
</Button>
</Group>
</Modal>
</>
);
}

View File

@@ -25,6 +25,7 @@ import CopyTextButton from "@/components/common/copy.tsx";
import { getAppUrl } from "@/lib/config.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import classes from "@/features/share/components/share.module.css";
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
@@ -253,6 +254,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
disabled={readOnly}
/>
</Group>
{pageId && (
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
)}
</>
)}
</>

View File

@@ -10,6 +10,8 @@ import { useTranslation } from "react-i18next";
import {
ICreateShare,
IShare,
IShareAlias,
ISetShareAlias,
ISharedItem,
ISharedPage,
ISharedPageTree,
@@ -20,11 +22,14 @@ import {
import {
createShare,
deleteShare,
getShareAliasForPage,
getSharedPageTree,
getShareForPage,
getShareInfo,
getSharePageInfo,
getShares,
removeShareAlias,
setShareAlias,
updateShare,
} from "@/features/share/services/share-service.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
@@ -170,6 +175,72 @@ export function useDeleteShareMutation() {
});
}
export function useShareAliasForPageQuery(
pageId: string,
): UseQueryResult<IShareAlias | null, Error> {
return useQuery({
// The endpoint resolves to null when the page has no alias; normalize the
// absence so React Query never sees `undefined`.
queryKey: ["share-alias-for-page", pageId],
queryFn: async () => (await getShareAliasForPage(pageId)) ?? null,
enabled: !!pageId,
staleTime: 60 * 1000,
retry: false,
});
}
export function useSetShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<IShareAlias, Error, ISetShareAlias>({
mutationFn: (data) => setShareAlias(data),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
// A 409 reassign-required is handled inline by the modal (it shows the
// "move address here?" confirmation), so don't surface a generic toast.
if (error?.["status"] === 409) return;
notifications.show({
message:
error?.["response"]?.data?.message || t("Failed to set custom address"),
color: "red",
});
},
});
}
export function useRemoveShareAliasMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (aliasId) => removeShareAlias(aliasId),
onSuccess: () => {
queryClient.invalidateQueries({
predicate: (item) =>
["share-alias-for-page", "share-list"].includes(
item.queryKey[0] as string,
),
});
},
onError: (error) => {
notifications.show({
message:
error?.["response"]?.data?.message ||
t("Failed to remove custom address"),
color: "red",
});
},
});
}
export function useGetSharedPageTreeQuery(
shareId: string,
): UseQueryResult<ISharedPageTree, Error> {

View File

@@ -4,6 +4,9 @@ import { IPage } from "@/features/page/types/page.types";
import {
ICreateShare,
IShare,
IShareAlias,
IShareAliasAvailability,
ISetShareAlias,
ISharedItem,
ISharedPage,
ISharedPageTree,
@@ -57,3 +60,33 @@ export async function getSharedPageTree(
const req = await api.post<ISharedPageTree>("/shares/tree", { shareId });
return req.data;
}
export async function getShareAliasForPage(
pageId: string,
): Promise<IShareAlias | null> {
const req = await api.post<IShareAlias | null>("/share-aliases/for-page", {
pageId,
});
return req.data;
}
export async function setShareAlias(
data: ISetShareAlias,
): Promise<IShareAlias> {
const req = await api.post<IShareAlias>("/share-aliases/set", data);
return req.data;
}
export async function removeShareAlias(aliasId: string): Promise<void> {
await api.post("/share-aliases/remove", { aliasId });
}
export async function checkShareAliasAvailability(
alias: string,
): Promise<IShareAliasAvailability> {
const req = await api.post<IShareAliasAvailability>(
"/share-aliases/availability",
{ alias },
);
return req.data;
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import {
isValidShareAlias,
normalizeShareAlias,
} from "@/features/share/share-alias.util.ts";
// Mirrors the server-side util so the modal's live feedback matches what the
// server will accept/store.
describe("normalizeShareAlias", () => {
it("lowercases, trims and maps separators to single hyphens", () => {
expect(normalizeShareAlias(" My Cool_Page ")).toBe("my-cool-page");
});
it("collapses repeated hyphens and trims edges", () => {
expect(normalizeShareAlias("--a---b--")).toBe("a-b");
});
});
describe("isValidShareAlias", () => {
it("accepts ascii hyphen-separated slugs of length 2..60", () => {
expect(isValidShareAlias("hello-world")).toBe(true);
expect(isValidShareAlias("a".repeat(60))).toBe(true);
});
it("rejects too short, edge/double hyphens, uppercase and non-ascii", () => {
expect(isValidShareAlias("a")).toBe(false);
expect(isValidShareAlias("-a")).toBe(false);
expect(isValidShareAlias("a--b")).toBe(false);
expect(isValidShareAlias("Hello")).toBe(false);
expect(isValidShareAlias("привет")).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
/**
* Client copy of the vanity share-alias helpers. Kept in sync with the server
* (`apps/server/src/core/share/share-alias.util.ts`) so live input feedback
* matches what the server will store/accept. ASCII-only, lowercase, hyphen
* separated, length 2..60.
*/
// Normalize a user-provided vanity alias into canonical ASCII storage form.
export function normalizeShareAlias(raw: string): string {
return (raw ?? "")
.trim()
.toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "");
}
const ALIAS_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function isValidShareAlias(alias: string): boolean {
return (
typeof alias === "string" &&
alias.length >= 2 &&
alias.length <= 60 &&
ALIAS_RE.test(alias)
);
}

View File

@@ -75,6 +75,30 @@ export interface IShareInfoInput {
pageId: string;
}
// Vanity /l/:alias pointer.
export interface IShareAlias {
id: string;
workspaceId: string;
alias: string;
pageId: string | null;
creatorId: string | null;
createdAt: string;
updatedAt: string;
}
export interface ISetShareAlias {
pageId: string;
alias: string;
confirmReassign?: boolean;
}
export interface IShareAliasAvailability {
alias: string;
valid: boolean;
available: boolean;
currentPageId: string | null;
}
export interface ISharedPageTree {
share: IShare;
pageTree: Partial<IPage[]>;

View File

@@ -13,6 +13,7 @@ import {
IconEye,
IconEyeOff,
IconFileExport,
IconHourglass,
IconPlus,
IconSettings,
IconStar,
@@ -71,6 +72,10 @@ export function SpaceSidebar() {
handleCreate(null);
}
function handleCreateTemporaryPage() {
handleCreate(null, { temporary: true });
}
return (
<>
<div className={classes.navbar}>
@@ -111,16 +116,39 @@ export function SpaceSidebar() {
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
) && (
<Tooltip label={t("Create page")} withArrow position="right">
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
aria-label={t("Create page")}
<>
<Tooltip
label={t("Create page")}
withArrow
position="right"
>
<IconPlus />
</ActionIcon>
</Tooltip>
<ActionIcon
variant="default"
size={18}
onClick={handleCreatePage}
aria-label={t("Create page")}
>
<IconPlus />
</ActionIcon>
</Tooltip>
{/* Standalone second button: a "temporary note" auto-moves to
trash after the workspace lifetime unless made permanent. */}
<Tooltip
label={t("New temporary note")}
withArrow
position="right"
>
<ActionIcon
variant="default"
size={18}
onClick={handleCreateTemporaryPage}
aria-label={t("New temporary note")}
>
<IconHourglass />
</ActionIcon>
</Tooltip>
</>
)}
</Group>
</Group>

View File

@@ -1,5 +1,6 @@
import {
keepPreviousData,
queryOptions,
useInfiniteQuery,
useMutation,
useQuery,
@@ -31,11 +32,37 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
/**
* Centralized React Query key factories for space 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 spaceKeys = {
detail: (idOrSlug: string) => ["space", idOrSlug] as const,
list: (params?: QueryParams) => ["spaces", params] as const,
members: (spaceId: string, query?: string) =>
["spaceMembers", spaceId, query] as const,
};
/**
* Shared queryOptions for fetching a space by id/slug. Both
* useGetSpaceBySlugQuery and the offline warm path consume this so the key,
* queryFn and staleTime stay identical. (`enabled` is intentionally omitted —
* prefetchQuery ignores it anyway and the warm path always passes a real id;
* the hook reapplies `enabled` itself.)
*/
export const spaceByIdQueryOptions = (spaceId: string) =>
queryOptions({
queryKey: spaceKeys.detail(spaceId),
queryFn: () => getSpaceById(spaceId),
staleTime: 5 * 60 * 1000,
});
export function useGetSpacesQuery(
params?: QueryParams,
): UseQueryResult<IPagination<ISpace>, Error> {
return useQuery({
queryKey: ["spaces", params],
queryKey: spaceKeys.list(params),
queryFn: () => getSpaces(params),
placeholderData: keepPreviousData,
refetchOnMount: true,
@@ -44,16 +71,16 @@ export function useGetSpacesQuery(
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
const query = useQuery({
queryKey: ["space", spaceId],
queryKey: spaceKeys.detail(spaceId),
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
});
useEffect(() => {
if (query.data) {
if (isValidUuid(spaceId)) {
queryClient.setQueryData(["space", query.data.slug], query.data);
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
} else {
queryClient.setQueryData(["space", query.data.id], query.data);
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
}
}
}, [query.data]);
@@ -62,8 +89,11 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
}
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
// Note: intentionally NOT using spaceByIdQueryOptions here — that factory sets
// a 5min staleTime which would let this prefetch skip fetching fresh data;
// prefetchSpace must always refetch (default staleTime: 0).
queryClient.prefetchQuery({
queryKey: ["space", spaceSlug],
queryKey: spaceKeys.detail(spaceSlug),
queryFn: () => getSpaceById(spaceSlug),
});
@@ -100,10 +130,8 @@ export function useGetSpaceBySlugQuery(
spaceId: string,
): UseQueryResult<ISpace, Error> {
return useQuery({
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
...spaceByIdQueryOptions(spaceId),
enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
});
}
@@ -116,14 +144,16 @@ export function useUpdateSpaceMutation() {
onSuccess: (data, variables) => {
notifications.show({ message: t("Space updated successfully") });
const space = queryClient.getQueryData([
"space",
variables.spaceId,
]) as ISpace;
const space = queryClient.getQueryData(
spaceKeys.detail(variables.spaceId),
) as ISpace;
if (space) {
const updatedSpace = { ...space, ...data };
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
queryClient.setQueryData(["space", data.slug], updatedSpace);
queryClient.setQueryData(
spaceKeys.detail(variables.spaceId),
updatedSpace,
);
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
}
queryClient.invalidateQueries({
@@ -148,7 +178,7 @@ export function useDeleteSpaceMutation() {
if (variables.slug) {
queryClient.removeQueries({
queryKey: ["space", variables.slug],
queryKey: spaceKeys.detail(variables.slug),
exact: true,
});
}
@@ -156,7 +186,7 @@ export function useDeleteSpaceMutation() {
// Remove space-specific queries
if (variables.id) {
queryClient.removeQueries({
queryKey: ["space", variables.id],
queryKey: spaceKeys.detail(variables.id),
exact: true,
});
@@ -196,7 +226,7 @@ export function useSpaceMembersInfiniteQuery(
query?: string,
) {
return useInfiniteQuery({
queryKey: ["spaceMembers", spaceId, query],
queryKey: spaceKeys.members(spaceId, query),
queryFn: ({ pageParam }) =>
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
enabled: !!spaceId,

View File

@@ -183,6 +183,34 @@ describe("applyMoveTreeNode", () => {
expect(moved?.hasChildren).toBe(true);
expect(moved?.position).toBe("a4");
});
it("does NOT drop a subtree on a cyclic/out-of-order move (parent inside source) (#206 ui-state-races-1)", () => {
// Locally `b` is still nested inside `a` (an earlier "a under b" echo hasn't
// applied yet). An out-of-order "move a under b" event now arrives — b is a
// descendant of a, so re-parenting would make placeByPosition remove a (and
// its whole subtree, incl. b) and fail to re-insert. Before the fix BOTH a
// and b silently vanished; now the reducer leaves the tree untouched.
const tree: SpaceTreeNode[] = [
node("a", {
position: "a0",
hasChildren: true,
children: [node("b", { position: "a1", parentPageId: "a" })],
}),
];
const next = applyMoveTreeNode(tree, {
id: "a",
parentId: "b",
oldParentId: null,
index: 0,
position: "a4",
pageData: {},
});
// No silent data loss: both nodes survive.
expect(treeModel.find(next, "a")).not.toBeNull();
expect(treeModel.find(next, "b")).not.toBeNull();
// The cyclic move is refused as a no-op (same reference) pending reconcile.
expect(next).toBe(tree);
});
});
describe("applyDeleteTreeNode", () => {

View File

@@ -76,6 +76,19 @@ export function applyMoveTreeNode(
const oldParentId = (sourceBefore as SpaceTreeNode).parentPageId ?? null;
const newParentId = payload.parentId as string | null;
// Cyclic / out-of-order move guard (#206 ui-state-races-1): if the
// authoritative new parent is currently INSIDE the moved node's own subtree on
// this client (e.g. server moved X under Y then Y under X and the events
// arrived such that Y is still nested in X here), re-parenting is impossible to
// represent locally. `placeByPosition` returns `prev` for this, but the
// `placed === prev` fallback below would then `remove` the source — dropping
// the node AND every descendant (incl. the would-be parent) silently. Leave the
// tree untouched instead; a later corrective event or a reconnect refetch
// reconciles it. Never delete a subtree we cannot safely re-place.
if (newParentId && treeModel.isDescendant(prev, payload.id, newParentId)) {
return prev;
}
// Place the node by its fractional `position` among the new siblings — NOT by
// the sender's absolute `index` (the sender computed that against its own
// loaded set, which differs from this receiver's). Using the position keeps

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { mcpTestButtonView } from "./ai-mcp-server-test-view";
/**
* Pure-helper tests for the inline "Test" button presentation. Covers the four
* states (idle / loading is handled by the component's `isPending`, so here:
* idle / ok-with-tools / ok-without-tools / failed) and the tooltip text
* branches that are easiest to break silently.
*/
// Identity-ish translator that echoes the key and interpolates {{n}} so the
// label/tooltip branches are observable without the real i18n bundle.
const t = (key: string, options?: Record<string, unknown>): string =>
options && "n" in options
? key.replace("{{n}}", String((options as { n: unknown }).n))
: key;
describe("mcpTestButtonView", () => {
it("idle when there is no result", () => {
expect(mcpTestButtonView(undefined, t)).toEqual({
state: "idle",
color: undefined,
variant: "default",
label: "Test",
tooltip: "",
});
});
it("ok with tools lists them in the tooltip", () => {
expect(mcpTestButtonView({ ok: true, tools: ["a", "b"] }, t)).toEqual({
state: "ok",
color: "green",
variant: "light",
label: "OK · 2",
tooltip: "a, b",
});
});
it('ok with zero tools shows "No tools available"', () => {
expect(mcpTestButtonView({ ok: true, tools: [] }, t)).toEqual({
state: "ok",
color: "green",
variant: "light",
label: "OK · 0",
tooltip: "No tools available",
});
});
it("failed surfaces the error text in the tooltip", () => {
expect(
mcpTestButtonView({ ok: false, error: "402: nope" }, t),
).toEqual({
state: "failed",
color: "red",
variant: "light",
label: "Failed",
tooltip: "402: nope",
});
});
it("failed when the request itself rejects (no result payload)", () => {
// 401/403/500/network: there is no { ok } body, only a thrown error. The
// row must still show a red "Failed" rather than reverting to idle "Test".
expect(
mcpTestButtonView(undefined, t, {
response: { data: { message: "Unauthorized" } },
}),
).toEqual({
state: "failed",
color: "red",
variant: "light",
label: "Failed",
tooltip: "Unauthorized",
});
});
it("reject without a server message falls back to the generic label", () => {
// A bare network error (no response body) still surfaces as failed, using
// the i18n fallback for the tooltip.
expect(mcpTestButtonView(undefined, t, new Error("network down"))).toEqual({
state: "failed",
color: "red",
variant: "light",
label: "Failed",
tooltip: "Failed to update data",
});
});
});

View File

@@ -0,0 +1,90 @@
import type { IAiMcpServerTestResult } from "@/features/workspace/services/ai-mcp-server-service.ts";
/** Minimal translator shape (i18next `t`): key + optional interpolation. */
type Translate = (key: string, options?: Record<string, unknown>) => string;
/** Subset of an axios-style rejection we read for the reject tooltip. */
type McpTestRequestError = {
response?: { data?: { message?: string } };
};
/**
* Best-effort extraction of a server-sent message from a rejected test request
* (axios stores it at `error.response.data.message`). Returns undefined for a
* bare/network error so the caller can fall back to a generic label.
*/
function readRequestErrorMessage(error: unknown): string | undefined {
if (error && typeof error === "object" && "response" in error) {
return (error as McpTestRequestError).response?.data?.message;
}
return undefined;
}
/**
* Presentation for the inline "Test" button, derived from the current test
* result tristate (no result yet / ok / failed). Color is never the only signal
* — the label and icon change too (a11y / colorblind-friendly). Kept as a single
* pure derivation (rather than two parallel if/else chains) so the button and
* tooltip can never drift apart, and so the text branches are unit-testable
* without rendering the row.
*/
export interface McpTestButtonView {
/** Tristate; the component maps this to the leftSection icon. */
state: "idle" | "ok" | "failed";
/** Mantine Button color; undefined = theme default (idle). */
color?: string;
/** Mantine Button variant. */
variant: string;
/** Translated button label. */
label: string;
/** Translated tooltip text; "" while there is no result (tooltip disabled). */
tooltip: string;
}
export function mcpTestButtonView(
result: IAiMcpServerTestResult | undefined,
t: Translate,
error?: unknown,
): McpTestButtonView {
if (result?.ok) {
return {
state: "ok",
color: "green",
variant: "light",
label: t("OK · {{n}}", { n: result.tools.length }),
tooltip:
result.tools.length > 0
? result.tools.join(", ")
: t("No tools available"),
};
}
if (result && result.ok === false) {
return {
state: "failed",
color: "red",
variant: "light",
label: t("Failed"),
tooltip: result.error,
};
}
if (error) {
// The test request itself rejected (401/403/500/network) — there is no
// `{ ok }` payload, so without this branch the row would silently revert to
// the idle "Test" instead of reporting the failure. Tooltip prefers the
// server-sent message, else the generic i18n fallback.
return {
state: "failed",
color: "red",
variant: "light",
label: t("Failed"),
tooltip: readRequestErrorMessage(error) ?? t("Failed to update data"),
};
}
return {
state: "idle",
color: undefined,
variant: "default",
label: t("Test"),
tooltip: "",
};
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import {
ActionIcon,
Badge,
@@ -10,18 +10,28 @@ import {
Stack,
Switch,
Text,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import {
IconCheck,
IconPencil,
IconPlugConnected,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import {
useAiMcpServersQuery,
useDeleteAiMcpServerMutation,
useTestAiMcpServerMutation,
useUpdateAiMcpServerMutation,
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
import { mcpTestButtonView } from "@/features/workspace/components/settings/components/ai-mcp-server-test-view.ts";
import AiMcpServerForm from "./ai-mcp-server-form.tsx";
/**
@@ -112,55 +122,15 @@ export default function AiMcpServers() {
<Stack gap="xs" mt="sm">
{servers?.map((server) => (
<Group key={server.id} justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
{server.name}
</Text>
<Badge size="xs" variant="light">
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text
size="xs"
c="dimmed"
truncate
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) =>
updateMutation.mutate({
id: server.id,
enabled: event.currentTarget.checked,
})
}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => openEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => confirmDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
<AiMcpServerRow
key={server.id}
server={server}
onEdit={openEdit}
onDelete={confirmDelete}
onToggleEnabled={(enabled) =>
updateMutation.mutate({ id: server.id, enabled })
}
/>
))}
</Stack>
@@ -180,3 +150,127 @@ export default function AiMcpServers() {
</Paper>
);
}
interface AiMcpServerRowProps {
server: IAiMcpServer;
onEdit: (server: IAiMcpServer) => void;
onDelete: (server: IAiMcpServer) => void;
onToggleEnabled: (enabled: boolean) => void;
}
/**
* A single external MCP server row: name/badge/url on the left and the
* Test / Switch / Edit / Delete controls on the right. Each row owns its own
* `useTestAiMcpServerMutation()` so the inline Test result and loading state are
* independent per row (a shared mutation would make `isPending` global and make
* every row flicker).
*/
function AiMcpServerRow({
server,
onEdit,
onDelete,
onToggleEnabled,
}: AiMcpServerRowProps) {
const { t } = useTranslation();
const testMutation = useTestAiMcpServerMutation();
const result = testMutation.data;
// The row is keyed by `server.id`, so editing the connection-relevant fields
// (url/transport/headers) does NOT remount it — an old success/failure result
// would otherwise stick. Clear the result when those fields change.
useEffect(() => {
testMutation.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [server.url, server.transport, server.hasHeaders]);
// Single derivation of the button/tooltip presentation from the test tristate
// (idle / ok / failed), so the two can never drift apart. Tooltip is "" while
// there is no result; the icon is mapped from `view.state` below. When the
// request itself rejects (401/403/500/network) there is no `data` payload, so
// we feed the mutation error in too — otherwise the row would silently revert
// to "Test" instead of showing a red "Failed".
const view = mcpTestButtonView(
result,
t,
testMutation.isError ? testMutation.error : undefined,
);
const tooltipLabel = view.tooltip;
const buttonColor = view.color;
const buttonVariant = view.variant;
const buttonLabel = view.label;
const buttonIcon =
view.state === "ok" ? (
<IconCheck size={16} />
) : view.state === "failed" ? (
<IconX size={16} />
) : (
<IconPlugConnected size={16} />
);
return (
<Group justify="space-between" wrap="nowrap">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap="xs">
<Text fw={500} truncate>
{server.name}
</Text>
<Badge size="xs" variant="light">
{server.transport.toUpperCase()}
</Badge>
</Group>
<Text
size="xs"
c="dimmed"
truncate
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
>
{server.url}
</Text>
</Stack>
<Group gap="xs" wrap="nowrap">
{/* Always clickable: testing a disabled server before enabling it is useful. */}
<Tooltip
label={tooltipLabel}
disabled={view.state === "idle"}
multiline
maw={320}
withinPortal
>
<Button
size="xs"
miw={88}
color={buttonColor}
variant={buttonVariant}
leftSection={testMutation.isPending ? undefined : buttonIcon}
loading={testMutation.isPending}
onClick={() => testMutation.mutate(server.id)}
>
{buttonLabel}
</Button>
</Tooltip>
<Switch
size="sm"
checked={server.enabled}
aria-label={t("Enabled")}
onChange={(event) => onToggleEnabled(event.currentTarget.checked)}
/>
<ActionIcon
variant="subtle"
aria-label={t("Edit")}
onClick={() => onEdit(server)}
>
<IconPencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Delete")}
onClick={() => onDelete(server)}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
</Group>
);
}

View File

@@ -7,6 +7,7 @@ import {
Button,
Group,
Modal,
NumberInput,
Paper,
PasswordInput,
Select,
@@ -83,6 +84,9 @@ const STT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [
// (empty means "leave unchanged" unless explicitly cleared).
const formSchema = z.object({
chatModel: z.string(),
// Max context window in tokens shown in the chat header badge. A number, or ""
// when the NumberInput is empty (no limit).
chatContextWindow: z.union([z.number(), z.literal("")]),
// Chat provider implementation (reasoning surfacing). Default openai-compatible.
chatApiStyle: z.enum(["openai-compatible", "openai"]),
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
@@ -311,6 +315,7 @@ export default function AiProviderSettings() {
validate: zod4Resolver(formSchema),
initialValues: {
chatModel: "",
chatContextWindow: "",
chatApiStyle: "openai-compatible" as ChatApiStyle,
publicShareChatModel: "",
publicShareAssistantRoleId: "",
@@ -334,6 +339,7 @@ export default function AiProviderSettings() {
if (!settings) return;
form.setValues({
chatModel: settings.chatModel ?? "",
chatContextWindow: settings.chatContextWindow ?? "",
chatApiStyle: settings.chatApiStyle ?? "openai-compatible",
publicShareChatModel: settings.publicShareChatModel ?? "",
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
@@ -364,6 +370,12 @@ export default function AiProviderSettings() {
// Everything is OpenAI-compatible.
driver: "openai",
chatModel: values.chatModel,
// Max context window for the chat header badge; empty NumberInput ("") →
// 0, which clears the limit server-side (no denominator shown).
chatContextWindow:
typeof values.chatContextWindow === "number"
? values.chatContextWindow
: 0,
chatApiStyle: values.chatApiStyle,
// Cheap model id for the anonymous public-share assistant; empty falls
// back to chatModel server-side.
@@ -767,6 +779,18 @@ export default function AiProviderSettings() {
{t("Resolves to {{url}}", { url: chatResolved })}
</Text>
<NumberInput
mt="sm"
label={t("Context window (tokens)")}
description={t(
"Shown as used / total in the chat header. Leave empty to hide the limit.",
)}
min={0}
allowDecimal={false}
disabled={isLoading}
{...form.getInputProps("chatContextWindow")}
/>
<Select
mt="sm"
label={t("Protocol")}

View File

@@ -0,0 +1,86 @@
import { useState } from "react";
import { useAtom } from "jotai";
import {
Button,
Group,
NumberInput,
Paper,
Stack,
Text,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
// Mirrors DEFAULT_TEMPORARY_NOTE_HOURS on the server. Shown when the workspace
// has no explicit value configured yet.
const DEFAULT_TEMPORARY_NOTE_HOURS = 24;
/**
* Workspace-level editor for the temporary-note lifetime, in HOURS. The deadline
* is frozen per-note at creation, so changing this only affects notes created
* afterwards. `temporaryNoteHours` is a top-level workspace column (like
* trashRetentionDays), not a nested setting.
*/
export default function TemporaryNoteSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
const [isLoading, setIsLoading] = useState(false);
const [value, setValue] = useState<number>(
workspace?.temporaryNoteHours ?? DEFAULT_TEMPORARY_NOTE_HOURS,
);
async function handleSave() {
if (!value || value < 1) return;
setIsLoading(true);
try {
const updated = await updateWorkspace({
temporaryNoteHours: value,
} as Partial<IWorkspace>);
setWorkspace({ ...updated, temporaryNoteHours: value });
notifications.show({ message: t("Updated successfully") });
} catch (err) {
notifications.show({
message:
(err as any)?.response?.data?.message ?? t("Failed to update data"),
color: "red",
});
} finally {
setIsLoading(false);
}
}
return (
<Stack mt="sm">
<Text fw={700} size="lg">
{t("Temporary notes")}
</Text>
<Paper withBorder radius="md" p="lg">
<Text size="xs" c="dimmed" mb="xs">
{t(
"A temporary note is automatically moved to trash after this many hours unless it is made permanent. The deadline is fixed when the note is created.",
)}
</Text>
<NumberInput
label={t("Temporary note lifetime (hours)")}
min={1}
allowDecimal={false}
value={value}
onChange={(v) => setValue(typeof v === "number" ? v : Number(v))}
disabled={!isAdmin || isLoading}
w={220}
/>
<Group justify="flex-end" mt="md">
<Button onClick={handleSave} loading={isLoading} disabled={!isAdmin}>
{t("Save")}
</Button>
</Group>
</Paper>
</Stack>
);
}

View File

@@ -22,6 +22,8 @@ export type ChatApiStyle = "openai-compatible" | "openai";
export interface IAiSettings {
driver?: AiDriver;
chatModel?: string;
// Max context window in tokens shown in the chat header badge; 0/unset = no limit.
chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
publicShareChatModel?: string;
@@ -56,6 +58,8 @@ export interface IAiSettings {
export interface IAiSettingsUpdate {
driver?: AiDriver;
chatModel?: string;
// Max context window in tokens for the chat header badge; 0 = clear the limit.
chatContextWindow?: number;
chatApiStyle?: ChatApiStyle;
publicShareChatModel?: string;
// Agent-role id whose persona the public-share assistant adopts; empty =

View File

@@ -28,6 +28,8 @@ export interface IWorkspace {
aiDictationStreaming?: boolean;
aiPublicShareAssistant?: boolean;
trashRetentionDays?: number;
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
temporaryNoteHours?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
isScimEnabled?: boolean;

View File

@@ -11,7 +11,8 @@ import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { HelmetProvider } from "react-helmet-async";
import "./i18n";
import { PostHogProvider } from "posthog-js/react";
@@ -21,6 +22,12 @@ import {
isCloud,
isPostHogEnabled,
} from "@/lib/config.ts";
import {
queryPersister,
shouldDehydrateOfflineQuery,
} from "@/features/offline/query-persister";
import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt";
import { isCapacitorNativePlatform } from "@/pwa/is-capacitor";
import posthog from "posthog-js";
export const queryClient = new QueryClient({
@@ -30,6 +37,8 @@ export const queryClient = new QueryClient({
refetchOnWindowFocus: false,
retry: false,
staleTime: 5 * 60 * 1000,
// Keep cached read data around long enough to be persisted/restored for offline use.
gcTime: 1000 * 60 * 60 * 24,
},
},
});
@@ -50,15 +59,34 @@ root.render(
<BrowserRouter>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<ModalsProvider>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: 1000 * 60 * 60 * 24,
buster: APP_VERSION,
dehydrateOptions: {
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
},
}}
>
<Notifications position="bottom-center" limit={3} zIndex={10000} />
{/* Skip SW registration inside the Capacitor native WebView — the
native shell serves assets itself; a browser SW would conflict. */}
{!isCapacitorNativePlatform() && <PwaUpdatePrompt />}
<HelmetProvider>
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
</HelmetProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
</ModalsProvider>
</MantineProvider>
</BrowserRouter>,
);
// Service worker registration is owned by <PwaUpdatePrompt /> above (via
// vite-plugin-pwa's useRegisterSW: Workbox precache + prompt-based updates,
// and skipped inside the Capacitor native WebView). The earlier hand-written
// /sw.js registration from the mobile bootstrap was removed here to avoid a
// double registration / competing service worker.

View File

@@ -3,6 +3,7 @@ import WorkspaceNameForm from "@/features/workspace/components/settings/componen
import WorkspaceIcon from "@/features/workspace/components/settings/components/workspace-icon.tsx";
import HtmlEmbedSettings from "@/features/workspace/components/settings/components/html-embed-settings.tsx";
import TrackerSettings from "@/features/workspace/components/settings/components/tracker-settings.tsx";
import TemporaryNoteSettings from "@/features/workspace/components/settings/components/temporary-note-settings.tsx";
import { useTranslation } from "react-i18next";
import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
@@ -19,6 +20,7 @@ export default function WorkspaceSettings() {
<WorkspaceNameForm />
<HtmlEmbedSettings />
<TrackerSettings />
<TemporaryNoteSettings />
</>
);
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, afterEach } from "vitest";
import { isCapacitorNativePlatform } from "./is-capacitor";
describe("isCapacitorNativePlatform", () => {
afterEach(() => {
// Keep tests isolated from each other and from the rest of the suite.
delete (globalThis as any).Capacitor;
});
it("returns false when Capacitor is undefined", () => {
expect(isCapacitorNativePlatform()).toBe(false);
});
it("uses isNativePlatform() when it is a function", () => {
(globalThis as any).Capacitor = { isNativePlatform: () => true };
expect(isCapacitorNativePlatform()).toBe(true);
(globalThis as any).Capacitor = { isNativePlatform: () => false };
expect(isCapacitorNativePlatform()).toBe(false);
});
it("falls back to the boolean property when isNativePlatform is not a function", () => {
(globalThis as any).Capacitor = { isNativePlatform: true };
expect(isCapacitorNativePlatform()).toBe(true);
(globalThis as any).Capacitor = { isNativePlatform: false };
expect(isCapacitorNativePlatform()).toBe(false);
});
it("returns false when reading Capacitor throws (try/catch)", () => {
Object.defineProperty(globalThis, "Capacitor", {
configurable: true,
get() {
throw new Error("boom");
},
});
expect(isCapacitorNativePlatform()).toBe(false);
});
});

View File

@@ -0,0 +1,23 @@
/**
* Detects whether the client is running inside a Capacitor native WebView
* (native iOS/Android shell from the feature/mobile-app-bootstrap branch).
*
* This is a pure runtime check against the global `Capacitor` object that the
* native bridge injects — no `@capacitor/*` dependency is added. On the plain
* browser / installed-PWA path `window.Capacitor` is undefined, so this returns
* false and the Workbox service worker registers normally.
*
* Inside the native WebView the SW must NOT register: it would layer a redundant
* (and conflicting) cache over Capacitor's own asset serving and interfere with
* the native auth/CORS flow.
*/
export function isCapacitorNativePlatform(): boolean {
try {
const cap = (globalThis as any)?.Capacitor;
return !!(cap && typeof cap.isNativePlatform === "function"
? cap.isNativePlatform()
: cap?.isNativePlatform);
} catch {
return false;
}
}

View File

@@ -0,0 +1,59 @@
import { useEffect } from "react";
import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { useRegisterSW } from "virtual:pwa-register/react";
// Stable notification id so we can show/hide a single update prompt.
const UPDATE_NOTIFICATION_ID = "pwa-update-available";
/**
* Listens for a waiting service worker and surfaces a Mantine notification
* prompting the user to reload into the new version.
*
* Must be mounted inside the Mantine provider subtree (Notifications must be
* available). Renders nothing itself.
*/
export function PwaUpdatePrompt() {
const { t } = useTranslation();
const {
needRefresh: [needRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisterError(error) {
// Best-effort: a failed registration must not break the app.
console.error("Service worker registration error:", error);
},
});
useEffect(() => {
if (!needRefresh) return;
notifications.show({
id: UPDATE_NOTIFICATION_ID,
title: t("Update available"),
message: (
<Button
size="xs"
variant="light"
mt="xs"
onClick={() => updateServiceWorker(true)}
>
{t("Reload")}
</Button>
),
autoClose: false,
withCloseButton: true,
});
// Hide the notification when the prompt is no longer needed / on cleanup.
return () => {
notifications.hide(UPDATE_NOTIFICATION_ID);
};
}, [needRefresh, t, updateServiceWorker]);
return null;
}
export default PwaUpdatePrompt;

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { isApiPath, isCollabOrSocketPath } from "./sw-strategy";
describe("isApiPath", () => {
it("matches the /api segment and its subtree", () => {
expect(isApiPath("/api")).toBe(true);
expect(isApiPath("/api/")).toBe(true);
expect(isApiPath("/api/pages")).toBe(true);
});
it("does not over-match sibling paths", () => {
expect(isApiPath("/apidocs")).toBe(false);
expect(isApiPath("/apixyz")).toBe(false);
expect(isApiPath("/")).toBe(false);
expect(isApiPath("/pages")).toBe(false);
});
});
describe("isCollabOrSocketPath", () => {
it("matches the /collab and /socket.io segments and their subtrees", () => {
expect(isCollabOrSocketPath("/collab")).toBe(true);
expect(isCollabOrSocketPath("/collab/x")).toBe(true);
expect(isCollabOrSocketPath("/socket.io")).toBe(true);
expect(isCollabOrSocketPath("/socket.io/abc")).toBe(true);
});
it("does not over-match sibling paths", () => {
expect(isCollabOrSocketPath("/collaborators")).toBe(false);
expect(isCollabOrSocketPath("/collabx")).toBe(false);
expect(isCollabOrSocketPath("/socket.iox")).toBe(false);
});
});

View File

@@ -0,0 +1,32 @@
/**
* Canonical service-worker routing predicates.
*
* IMPORTANT: With vite-plugin-pwa using Workbox `generateSW`, the
* `runtimeCaching[].urlPattern` functions are serialized standalone into the
* generated service worker and CANNOT reference imported symbols. The matching
* logic is therefore duplicated as inline regex literals in
* apps/client/vite.config.ts. This module is the testable source of truth, and
* the two MUST be kept in sync. This duplication is intentional and is the
* documented Workbox limitation.
*
* Matching is anchored to a path SEGMENT boundary (`^/<seg>(/|$)`) so that
* sibling paths like `/apidocs`, `/collaborators`, `/socket.iox` are NOT
* wrongly treated as API/realtime traffic.
*/
/**
* True when `pathname` is the `/api` segment or anything beneath it.
* `/api` and `/api/...` -> true; `/apidocs`, `/apixyz` -> false.
*/
export function isApiPath(pathname: string): boolean {
return /^\/api(\/|$)/.test(pathname);
}
/**
* True when `pathname` is the `/collab` or `/socket.io` segment (or beneath it).
* `/collab`, `/collab/x`, `/socket.io`, `/socket.io/abc` -> true;
* `/collaborators`, `/collabx`, `/socket.iox` -> false.
*/
export function isCollabOrSocketPath(pathname: string): boolean {
return /^\/(collab|socket\.io)(\/|$)/.test(pathname);
}

View File

@@ -1,2 +1,4 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />
/// <reference types="vite-plugin-pwa/info" />
declare const APP_VERSION: string

View File

@@ -1,5 +1,6 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
import * as path from "path";
import { execSync } from "node:child_process";
@@ -53,7 +54,51 @@ export default defineConfig(({ mode }) => {
},
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
},
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: "prompt",
injectRegister: null,
strategies: "generateSW",
manifest: false,
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2,json}"],
navigateFallback: "index.html",
// Segment-anchored (`^/<seg>(/|$)`) so navigation requests to these
// segments are consistently excluded from the SPA fallback, mirroring
// the runtimeCaching urlPattern regexes below.
//
// `/share`, `/mcp`, and `/robots.txt` mirror the server static-serve
// exclude list (apps/server/src/main.ts setGlobalPrefix `exclude`):
// robots.txt, the SEO/OG/analytics-injected public share HTML, and the
// embedded MCP endpoint are served by server controllers, so the SW must
// never shadow them with the precached index.html app shell (doing so
// would break SEO and MCP).
navigateFallbackDenylist: [
/^\/api(\/|$)/,
/^\/collab(\/|$)/,
/^\/socket\.io(\/|$)/,
/^\/share(\/|$)/,
/^\/mcp(\/|$)/,
/^\/robots\.txt$/,
],
cleanupOutdatedCaches: true,
clientsClaim: true,
// The urlPattern regexes below mirror apps/client/src/pwa/sw-strategy.ts
// and MUST be kept in sync with it. Workbox `generateSW` serializes these
// functions standalone into the generated service worker, so they cannot
// import the module — the matching logic is intentionally duplicated as
// self-contained inline regex literals anchored to a path segment boundary.
runtimeCaching: [
{ urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
// All /api stays network-only; offline reads come from the persisted
// React Query cache (IndexedDB) + y-indexeddb, not the SW HTTP cache.
{ urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
],
},
devOptions: { enabled: false },
}),
],
build: {
rolldownOptions: {
output: {

View File

@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.93.0",
"version": "0.94.1",
"description": "",
"author": "",
"private": true,
@@ -64,6 +64,7 @@
"@nestjs/platform-fastify": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.19",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.2.0",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.19",

View File

@@ -24,7 +24,9 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
import {
CollaborationHandler,
CollabEventHandlers,
writeTitleFragment,
} from './collaboration.handler';
import { User } from '@docmost/db/types/entity.types';
@Injectable()
export class CollaborationGateway {
@@ -149,6 +151,45 @@ export class CollaborationGateway {
return this.hocuspocus.openDirectConnection(documentName, context);
}
/**
* Write a new page title INTO the page's Yjs 'title' fragment, Redis-INDEPENDENT.
*
* Unlike the Redis-routed `handleYjsEvent` path — which routes through
* `redisSync?.handleEvent` and SILENTLY no-ops when Redis is disabled
* (COLLAB_DISABLE_REDIS=true → redisSync === null) — this goes straight
* through the local Hocuspocus `openDirectConnection`. The title sync
* therefore works in BOTH single-process (no Redis) and Redis-clustered
* deployments.
*
* openDirectConnection loads the doc from persistence when no editor is
* connected, so this works whether or not an editor is currently open: the
* clear+reseed lands on the loaded doc and is persisted by onStoreDocument.
*
* Provenance: when the caller is the agent, the actor/aiChatId are threaded
* into the connection `context` so onStoreDocument sees `context.actor ===
* 'agent'` for the resulting title store (mirrors the body/REST path). The
* resulting title store is usually a no-op anyway — PageService already wrote
* the same title to the page.title column, so onStoreDocument's
* `titleText !== page.title` guard skips the column write — but we wire the
* context for correctness regardless.
*/
async writePageTitle(
pageId: string,
title: string,
context?: { user?: User; actor?: string; aiChatId?: string },
): Promise<void> {
const documentName = `page.${pageId}`;
const connection = await this.hocuspocus.openDirectConnection(
documentName,
context ?? {},
);
try {
await connection.transact((doc) => writeTitleFragment(doc, title));
} finally {
await connection.disconnect();
}
}
/*
*Can be used before calling openDirectConnection directly
*/

View File

@@ -0,0 +1,139 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { writeTitleFragment } from './collaboration.handler';
import { CollaborationGateway } from './collaboration.gateway';
import {
buildTitleSeedYdoc,
jsonToText,
tiptapExtensions,
} from './collaboration.util';
// Read the plain text held in the doc's 'title' XmlFragment, the same way
// PersistenceExtension.onStoreDocument extracts it before writing page.title.
const readTitleText = (doc: Y.Doc): string => {
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
return titleJson ? jsonToText(titleJson).trim() : '';
};
describe('writeTitleFragment — the clear+seed title write (Bug 1)', () => {
it('replaces an OLD title fragment with EXACTLY the new title (no duplication)', () => {
// Seed the doc's 'title' fragment with an OLD title, like a real page.
const doc = new Y.Doc();
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
expect(readTitleText(doc)).toBe('Old Title');
writeTitleFragment(doc, 'New Title');
// The fragment must contain EXACTLY the new title — not "Old TitleNew Title"
// (append) or "New TitleNew Title" (duplication). A single heading node.
expect(readTitleText(doc)).toBe('New Title');
const titleJson = TiptapTransformer.fromYdoc(doc, 'title') as any;
expect(titleJson.content).toHaveLength(1);
expect(titleJson.content[0].type).toBe('heading');
});
it('seeds the title fragment when it started empty', () => {
const doc = new Y.Doc();
// Force the 'title' fragment to exist but be empty.
doc.getXmlFragment('title');
expect(readTitleText(doc)).toBe('');
writeTitleFragment(doc, 'First Title');
expect(readTitleText(doc)).toBe('First Title');
});
it('does not corrupt the body when rewriting the title', () => {
// A doc with both a body and an old title; the body must survive untouched.
const doc = new Y.Doc();
const bodyDoc = TiptapTransformer.toYdoc(
{
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
],
},
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(bodyDoc));
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old')));
writeTitleFragment(doc, 'New');
expect(readTitleText(doc)).toBe('New');
const bodyJson = TiptapTransformer.fromYdoc(doc, 'default');
expect(jsonToText(bodyJson)).toContain('body text');
});
});
describe('CollaborationGateway.writePageTitle — Redis-independent path', () => {
// Build a gateway with only its hocuspocus.openDirectConnection stubbed; the
// method must drive the clear+seed through that direct connection (NOT through
// redisSync), so the title write survives COLLAB_DISABLE_REDIS.
const makeGateway = (doc: Y.Doc) => {
const disconnect = jest.fn().mockResolvedValue(undefined);
const transact = jest.fn(async (fn: (d: Y.Doc) => void) => {
fn(doc);
});
const openDirectConnection = jest
.fn()
.mockResolvedValue({ transact, disconnect });
const gateway = Object.create(CollaborationGateway.prototype);
// redisSync is intentionally null — this is the no-Redis scenario.
gateway.redisSync = null;
gateway.hocuspocus = { openDirectConnection } as any;
return { gateway, openDirectConnection, transact, disconnect };
};
it('writes the new title via openDirectConnection and disconnects', async () => {
const doc = new Y.Doc();
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
const { gateway, openDirectConnection, disconnect } = makeGateway(doc);
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
expect(openDirectConnection).toHaveBeenCalledWith(
'page.page-1',
expect.objectContaining({ user: { id: 'u1' } }),
);
expect(readTitleText(doc)).toBe('New Title');
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('threads agent provenance into the connection context', async () => {
const doc = new Y.Doc();
const { gateway, openDirectConnection } = makeGateway(doc);
await gateway.writePageTitle('page-1', 'Agent Title', {
user: { id: 'u1' },
actor: 'agent',
aiChatId: 'chat-1',
});
expect(openDirectConnection).toHaveBeenCalledWith(
'page.page-1',
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
);
});
it('disconnects even when the transaction throws', async () => {
const disconnect = jest.fn().mockResolvedValue(undefined);
const openDirectConnection = jest.fn().mockResolvedValue({
transact: jest.fn().mockRejectedValue(new Error('boom')),
disconnect,
});
const gateway = Object.create(CollaborationGateway.prototype);
gateway.redisSync = null;
gateway.hocuspocus = { openDirectConnection } as any;
await expect(
gateway.writePageTitle('page-1', 'X', {}),
).rejects.toThrow('boom');
expect(disconnect).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
buildTitleSeedYdoc,
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
@@ -13,6 +14,35 @@ export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers']
>;
/**
* Clear+reseed the 'title' XmlFragment of `doc` so it holds EXACTLY `title`.
*
* Used by the gateway's direct `writePageTitle` method to write a new page
* title INTO the page's Yjs 'title' fragment. The title lives in the same
* Y.Doc as the body; onStoreDocument extracts it on every save, so a REST/MCP
* rename that only updated the page.title DB column would be reverted on the
* next collaborative save unless the Yjs 'title' fragment is kept in sync.
* The whole fragment is replaced (no merge/append),
* mirroring the 'replace' body path: the new title fully supersedes the old.
*
* DELIBERATE TRADE-OFF: because this does a FULL clear+replace of the 'title'
* fragment, a REST/MCP rename arriving while a user is actively editing the
* title in an open editor WILL overwrite that in-progress edit. This is
* acceptable — the title is a short, rarely-concurrently-edited field — and is
* preferable to leaving a stale Yjs title that onStoreDocument would revert the
* DB column to on the next save.
*/
export function writeTitleFragment(doc: Y.Doc, title: string): void {
const titleFragment = doc.getXmlFragment('title');
if (titleFragment.length > 0) {
titleFragment.delete(0, titleFragment.length);
}
const newTitleDoc = buildTitleSeedYdoc(title);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newTitleDoc));
}
@Injectable()
export class CollaborationHandler {
private readonly logger = new Logger(CollaborationHandler.name);

View File

@@ -1,9 +1,13 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
getPageId,
isEmptyParagraphDoc,
jsonToNode,
prosemirrorNodeToYElement,
buildTitleSeedYdoc,
jsonToText,
tiptapExtensions,
} from './collaboration.util';
import { Node } from '@tiptap/pm/model';
@@ -241,3 +245,49 @@ describe('prosemirrorNodeToYElement', () => {
expect(element.get(1).get(0).toString()).toBe('two');
});
});
describe('buildTitleSeedYdoc', () => {
it('builds a level-1 heading carrying the title text', () => {
const doc = buildTitleSeedYdoc('Hello World');
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
const first = json.content?.[0];
expect(first.type).toBe('heading');
expect(first.attrs.level).toBe(1);
expect(jsonToText(json).trim()).toBe('Hello World');
});
it('produces a non-empty title fragment for a non-empty title', () => {
const doc = buildTitleSeedYdoc('Some Title');
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
});
it('produces a heading with no text child for an empty title', () => {
const doc = buildTitleSeedYdoc('');
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
const first = json.content?.[0];
expect(first.type).toBe('heading');
// No text content for an empty title.
expect(first.content ?? []).toHaveLength(0);
expect(jsonToText(json).trim()).toBe('');
});
it('round-trips a title through build -> extract -> build -> extract', () => {
const title = 'Round Trip Title';
const doc1 = buildTitleSeedYdoc(title);
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
const doc2 = buildTitleSeedYdoc(text1);
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
expect(text1).toBe(title);
expect(text2).toBe(text1);
});
// Touch tiptapExtensions so the import is exercised (mirrors the brief's import
// list and guards against accidental tree-shaking of the schema dependency).
it('uses the shared tiptap extensions schema', () => {
expect(Array.isArray(tiptapExtensions)).toBe(true);
});
});

View File

@@ -59,6 +59,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { Logger } from '@nestjs/common';
import { TiptapTransformer } from '@hocuspocus/transformer';
export const tiptapExtensions = [
StarterKit.configure({
@@ -143,6 +144,34 @@ export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions);
}
/**
* Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs
* fragment named exactly 'title' (the collaborative title-editor contract with
* the client). The ProseMirror shape is a doc with a single level-1 heading
* whose text is the title (empty title => heading with no text child).
*
* The encoded state of the returned doc can be merged into a body doc via
* `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title
* fragment for legacy pages. Seeding MUST be guarded by an emptiness check on
* the existing 'title' fragment to avoid the Yjs duplication trap.
*/
export function buildTitleSeedYdoc(title: string): Y.Doc {
return TiptapTransformer.toYdoc(
{
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: title ? [{ type: 'text', text: title }] : [],
},
],
},
'title',
tiptapExtensions,
);
}
export function jsonToNode(tiptapJson: JSONContent) {
const schema = getSchema(tiptapExtensions);
try {

View File

@@ -1,3 +1,9 @@
export const HISTORY_INTERVAL = 5 * 60 * 1000;
export const HISTORY_FAST_INTERVAL = 60 * 1000;
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
// Redis pub/sub channel that bridges a PAGE_UPDATED tree snapshot (a title/icon
// rename) from the standalone collab process to the API process, which is the
// single broadcast authority. Imported by both halves of the bridge:
// PageTreeBridgePublisher (collab process) and PageTreeBridgeSubscriber (API process).
export const COLLAB_TREE_UPDATE_CHANNEL = 'collab:tree-update';

View File

@@ -182,4 +182,46 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
// persist-1 — a transient DB failure during store must not silently lose the
// edit. hocuspocus unloads (destroys) the in-memory Y.Doc right after this
// hook resolves, so the store has to retry while it still holds the only copy.
it('retries a transient DB failure and still persists the edit (persist-1)', async () => {
const document = ydocFor(doc('NEW HUMAN CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
let attempts = 0;
pageRepo.updatePage.mockImplementation(async () => {
attempts += 1;
if (attempts === 1) throw new Error('deadlock detected'); // transient
callOrder.push('updatePage');
});
await ext.onStoreDocument(buildData(document, 'user') as any);
// First attempt failed and rolled back; the retry persisted the edit.
expect(pageRepo.updatePage).toHaveBeenCalledTimes(2);
// The edit WAS saved, so the post-store success path runs as normal.
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
expect(historyQueue.add).toHaveBeenCalledTimes(1);
});
// persist-1 — when every attempt fails the hook must NOT report a phantom
// success: no "page.updated" badge broadcast and no history snapshot for
// content that was never written.
it('does not run post-store side effects when every store attempt fails (persist-1)', async () => {
const document = ydocFor(doc('NEW HUMAN CONTENT'));
pageRepo.findById.mockResolvedValue(persistedHumanPage('NEW HUMAN CONTENT'));
pageRepo.updatePage.mockRejectedValue(new Error('connection reset'));
await expect(
ext.onStoreDocument(buildData(document, 'user') as any),
).resolves.toBeUndefined();
// Bounded retry exhausted (MAX_STORE_ATTEMPTS).
expect(pageRepo.updatePage).toHaveBeenCalledTimes(3);
// No false-success: nothing downstream fires for the unsaved content.
expect((document as any).broadcastStateless).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
expect(aiQueue.add).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,483 @@
import * as Y from 'yjs';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { PersistenceExtension } from './persistence.extension';
import { buildTitleSeedYdoc, tiptapExtensions } from '../collaboration.util';
// Direct instantiation with stub deps, mirroring the auth/env unit specs.
const bodyJson = {
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] }],
};
// Build a body Y.Doc with a known JSON, plus a monkey-patched broadcastStateless
// (the real Hocuspocus Document supplies it; a bare Y.Doc does not).
const buildDoc = () => {
const d: any = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
d.broadcastStateless = jest.fn();
return d;
};
const cloneOut = (doc: any) =>
JSON.parse(JSON.stringify(TiptapTransformer.fromYdoc(doc, 'default')));
const addTitleFragment = (doc: any, title: string) =>
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc(title)));
describe('PersistenceExtension', () => {
let pageRepo: any;
let pageHistoryRepo: any;
let trx: any;
let db: any;
let aiQueue: any;
let historyQueue: any;
let notificationQueue: any;
let collabHistory: any;
let transclusionService: any;
let ext: PersistenceExtension;
beforeEach(() => {
pageRepo = {
findById: jest.fn(),
updatePage: jest.fn().mockResolvedValue(undefined),
};
pageHistoryRepo = {
findPageLastHistory: jest.fn(),
saveHistory: jest.fn(),
};
trx = {};
db = { transaction: () => ({ execute: (fn: any) => fn(trx) }) };
aiQueue = { add: jest.fn().mockResolvedValue(undefined) };
historyQueue = { add: jest.fn().mockResolvedValue(undefined) };
notificationQueue = { add: jest.fn().mockResolvedValue(undefined) };
collabHistory = { addContributors: jest.fn().mockResolvedValue(undefined) };
transclusionService = {
syncPageTransclusions: jest.fn().mockResolvedValue(undefined),
syncPageReferences: jest.fn().mockResolvedValue(undefined),
syncPageTemplateReferences: jest.fn().mockResolvedValue(undefined),
};
ext = new PersistenceExtension(
pageRepo as any,
pageHistoryRepo as any,
db as any,
aiQueue as any,
historyQueue as any,
notificationQueue as any,
collabHistory as any,
transclusionService as any,
);
});
describe('seedTitleFragment', () => {
it('returns false for empty/whitespace/null titles', () => {
const doc = new Y.Doc();
expect((ext as any).seedTitleFragment(doc, '')).toBe(false);
expect((ext as any).seedTitleFragment(doc, ' ')).toBe(false);
expect((ext as any).seedTitleFragment(doc, null)).toBe(false);
});
it('does NOT re-seed an existing non-empty title fragment', () => {
const doc = new Y.Doc();
addTitleFragment(doc, 'Existing');
expect((ext as any).seedTitleFragment(doc, 'Other')).toBe(false);
const text = TiptapTransformer.fromYdoc(doc, 'title');
expect(JSON.stringify(text)).toContain('Existing');
expect(JSON.stringify(text)).not.toContain('Other');
});
it('seeds an empty fragment from a non-empty title and returns true', () => {
const doc = new Y.Doc();
expect((ext as any).seedTitleFragment(doc, 'Hello')).toBe(true);
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
expect(JSON.stringify(json)).toContain('Hello');
});
it('returns false (defensive) when reading the fragment throws', () => {
const fakeDoc = {
get: () => {
throw new Error('boom');
},
};
expect((ext as any).seedTitleFragment(fakeDoc as any, 'X')).toBe(false);
});
});
describe('onStoreDocument', () => {
const basePage = (overrides: any) => ({
id: 'PAGE_ID',
slugId: 'slug',
spaceId: 'space',
parentPageId: null,
creatorId: 'creator',
contributorIds: ['creator'],
workspaceId: 'ws',
title: 'whatever',
content: null,
lastUpdatedSource: 'user',
createdAt: new Date().toISOString(),
...overrides,
});
const context = { user: { id: 'u1', name: 'U', avatarUrl: null } };
it('no-op when neither body nor title changed', async () => {
const document = buildDoc();
const page = basePage({
content: cloneOut(document),
title: 'hello title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(document.broadcastStateless).not.toHaveBeenCalled();
expect(collabHistory.addContributors).not.toHaveBeenCalled();
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
expect(aiQueue.add).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
it('title-only change persists the title without body side-effects', async () => {
const document = buildDoc();
addTitleFragment(document, 'New Title');
const page = basePage({
content: cloneOut(document),
title: 'Old Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(call[0].title).toBe('New Title');
expect(call[0].ydoc).toBeDefined();
expect(call[0].contributorIds).toBeDefined();
expect('content' in call[0]).toBe(false);
// Title-only must not touch the body-authorship provenance.
expect('lastUpdatedSource' in call[0]).toBe(false);
expect(call[1]).toBe('PAGE_ID');
expect(call[3].treeUpdate.title).toBe('New Title');
expect(collabHistory.addContributors).toHaveBeenCalledTimes(1);
expect(collabHistory.addContributors).toHaveBeenCalledWith(
'PAGE_ID',
expect.any(Array),
);
expect(document.broadcastStateless).toHaveBeenCalledTimes(1);
expect(transclusionService.syncPageTransclusions).not.toHaveBeenCalled();
expect(aiQueue.add).not.toHaveBeenCalled();
expect(historyQueue.add).not.toHaveBeenCalled();
});
it('an EMPTY title fragment does NOT overwrite a non-empty page.title (anti-corruption guard, Bug 2)', async () => {
// The client can momentarily seed the 'title' fragment as an EMPTY heading
// (hasTitleFragment true, extracted text '') before the real title syncs.
// Body is unchanged here, so the only candidate write is the title -> the
// guard must turn this into a full no-op (no updatePage, no broadcast).
const document = buildDoc();
addTitleFragment(document, ''); // empty heading: length > 0 but text ''
const page = basePage({
content: cloneOut(document),
title: 'Real Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
// No write at all: the empty title is not authoritative and the body is
// unchanged, so onStoreDocument must take the no-op fast path.
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(document.broadcastStateless).not.toHaveBeenCalled();
});
it('an EMPTY title fragment alongside a body change persists the body but NOT an empty title (anti-corruption guard, Bug 2)', async () => {
const document = buildDoc();
addTitleFragment(document, ''); // empty title fragment
const page = basePage({
content: { type: 'doc', content: [] }, // different body -> bodyChanged
title: 'Real Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
// Body is persisted, but the title is NOT included (empty == not
// authoritative) and no tree update is broadcast for the title.
expect(call[0].content).toBeTruthy();
expect('title' in call[0]).toBe(false);
expect(call[3]).toBeUndefined();
});
it('body + title change persists both with full body side-effects', async () => {
const document = buildDoc();
addTitleFragment(document, 'New Title');
const page = basePage({
content: { type: 'doc', content: [] },
title: 'Old Title',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(call[0].content).toBeTruthy();
expect(call[0].title).toBe('New Title');
expect(call[0].ydoc).toBeDefined();
expect(call[0].lastUpdatedSource).toBe('user');
expect(call[3].treeUpdate).toBeDefined();
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
expect(aiQueue.add).toHaveBeenCalled();
expect(historyQueue.add).toHaveBeenCalled();
expect(collabHistory.addContributors).toHaveBeenCalled();
expect(document.broadcastStateless).toHaveBeenCalled();
});
it('body-only change persists the body without a tree update', async () => {
const document = buildDoc();
const page = basePage({
content: { type: 'doc', content: [] },
title: 'whatever',
});
pageRepo.findById.mockResolvedValue(page);
await ext.onStoreDocument({
documentName: 'page.PAGE_ID',
document,
context,
} as any);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(call[0].content).toBeTruthy();
expect('title' in call[0]).toBe(false);
// No treeUpdate for a body-only save.
expect(call[3]).toBeUndefined();
expect(transclusionService.syncPageTransclusions).toHaveBeenCalled();
expect(aiQueue.add).toHaveBeenCalled();
expect(historyQueue.add).toHaveBeenCalled();
expect(document.broadcastStateless).toHaveBeenCalled();
});
});
describe('onLoadDocument', () => {
it('returns early (no DB read) when the document is not empty', async () => {
const document = { isEmpty: () => false };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
expect(result).toBeUndefined();
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('returns undefined and does not persist when the page is null', async () => {
const document = { isEmpty: () => true };
pageRepo.findById.mockResolvedValue(null);
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
expect(result).toBeUndefined();
expect(pageRepo.updatePage).not.toHaveBeenCalled();
});
it('seeds + persists under a lock when the persisted ydoc lacks a title fragment', async () => {
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
const page = {
id: 'PAGE_ID',
title: 'Legacy Title',
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
content: null,
};
// Both the cheap pre-check and the locked re-read return the same row.
pageRepo.findById.mockResolvedValue(page);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// The locked re-read must take the row lock inside the tx.
const lockedReadCall = pageRepo.findById.mock.calls.find(
(c: any[]) => c[1]?.withLock,
);
expect(lockedReadCall).toBeDefined();
expect(lockedReadCall[1].trx).toBe(trx);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
expect(call[1]).toBe('PAGE_ID');
// Persist must run inside the transaction.
expect(call[2]).toBe(trx);
expect(result).toBeTruthy();
});
it('does NOT lock or persist when the ydoc already has a title fragment', async () => {
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
Y.applyUpdate(src, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Has Title')));
const page = {
id: 'PAGE_ID',
title: 'Has Title',
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
content: null,
};
pageRepo.findById.mockResolvedValue(page);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// Hot path: only the cheap lock-free read, no locked re-read, no write.
expect(pageRepo.findById).toHaveBeenCalledTimes(1);
expect(pageRepo.findById.mock.calls[0][1]?.withLock).toBeFalsy();
expect(pageRepo.updatePage).not.toHaveBeenCalled();
expect(result).toBeTruthy();
});
it('converts legacy content -> ydoc inside a tx and persists a {ydoc} Buffer', async () => {
const page = {
id: 'PAGE_ID',
title: 'T',
ydoc: null,
content: bodyJson,
};
pageRepo.findById.mockResolvedValue(page);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
const lockedReadCall = pageRepo.findById.mock.calls.find(
(c: any[]) => c[1]?.withLock,
);
expect(lockedReadCall).toBeDefined();
expect(lockedReadCall[1].trx).toBe(trx);
expect(pageRepo.updatePage).toHaveBeenCalledTimes(1);
const call = pageRepo.updatePage.mock.calls[0];
expect(Buffer.isBuffer(call[0].ydoc)).toBe(true);
expect(call[2]).toBe(trx);
// The rebuilt doc carries the body.
expect(JSON.stringify(cloneOut(result))).toContain('hello');
});
it('SKIPS rebuild when the locked re-read shows the ydoc was already healed', async () => {
// Simulate a concurrent process: the cheap pre-check sees ydoc=null (legacy
// rebuild path), but by the time we hold the lock another process has
// already persisted a healthy ydoc. We must adopt it, not rebuild/clobber.
const healed = TiptapTransformer.toYdoc(
{ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'healed' }] }] },
'default',
tiptapExtensions,
);
Y.applyUpdate(healed, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Healed Title')));
const healedYdoc = Buffer.from(Y.encodeStateAsUpdate(healed));
const preCheck = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
const lockedRow = {
id: 'PAGE_ID',
title: 'Healed Title',
ydoc: healedYdoc,
content: bodyJson,
};
pageRepo.findById
.mockResolvedValueOnce(preCheck) // cheap pre-check
.mockResolvedValueOnce(lockedRow); // locked re-read
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// The healthy ydoc had a title fragment already, so nothing was rebuilt or
// seeded -> no clobbering write.
expect(pageRepo.updatePage).not.toHaveBeenCalled();
// The returned doc is the healed body, NOT a fresh rebuild of bodyJson.
expect(JSON.stringify(cloneOut(result))).toContain('healed');
});
it('REJECTS the load when the rebuild persist fails (does not return an unpersisted doc)', async () => {
const page = { id: 'PAGE_ID', title: 'T', ydoc: null, content: bodyJson };
pageRepo.findById.mockResolvedValue(page);
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
const errSpy = jest
.spyOn((ext as any).logger, 'error')
.mockImplementation(() => undefined);
const document = { isEmpty: () => true };
await expect(
ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any),
).rejects.toThrow('db down');
expect(errSpy).toHaveBeenCalled();
});
it('seed-only persist FAILURE returns the doc from the existing ydoc (no throw)', async () => {
const src = TiptapTransformer.toYdoc(bodyJson, 'default', tiptapExtensions);
const page = {
id: 'PAGE_ID',
title: 'Legacy Title',
ydoc: Buffer.from(Y.encodeStateAsUpdate(src)),
content: null,
};
pageRepo.findById.mockResolvedValue(page);
pageRepo.updatePage.mockRejectedValue(new Error('db down'));
const errSpy = jest
.spyOn((ext as any).logger, 'error')
.mockImplementation(() => undefined);
const document = { isEmpty: () => true };
const result = await ext.onLoadDocument({
documentName: 'page.PAGE_ID',
document,
} as any);
// Non-fatal: we fall back to the doc loaded from the existing page.ydoc.
expect(result).toBeTruthy();
expect(JSON.stringify(cloneOut(result))).toContain('hello');
expect(errSpy).toHaveBeenCalled();
});
});
});

View File

@@ -9,6 +9,7 @@ import * as Y from 'yjs';
import { Injectable, Logger } from '@nestjs/common';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
buildTitleSeedYdoc,
getPageId,
isEmptyParagraphDoc,
jsonToText,
@@ -116,6 +117,10 @@ export class PersistenceExtension implements Extension {
return;
}
// Cheap, lock-free pre-check (hot path stays lock-free). It tells us whether
// any heal (legacy rebuild and/or title seed) is needed; the heal itself
// re-reads the row FOR UPDATE and re-validates inside a transaction so it
// runs exactly once (see healUnderLock).
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
includeYdoc: true,
@@ -127,33 +132,164 @@ export class PersistenceExtension implements Extension {
}
if (page.ydoc) {
this.logger.debug(`ydoc loaded from db: ${pageId}`);
const doc = new Y.Doc();
const dbState = new Uint8Array(page.ydoc);
Y.applyUpdate(doc, new Uint8Array(page.ydoc));
Y.applyUpdate(doc, dbState);
return doc;
// Legacy pages persisted their title only in the `page.title` column; the
// ydoc has no 'title' fragment. Decide cheaply (no lock) whether a seed is
// needed by inspecting the loaded doc's 'title' fragment. A seed is needed
// only when that fragment is empty AND there is a non-empty column title.
let titleSeedNeeded = false;
try {
const titleFrag = doc.get('title', Y.XmlFragment);
titleSeedNeeded = titleFrag.length === 0 && !!page.title?.trim();
} catch (err) {
// A malformed title fragment must not break loading; skip the seed.
this.logger.warn(`failed to inspect title fragment: ${err?.['message']}`);
titleSeedNeeded = false;
}
if (!titleSeedNeeded) {
// Fully healthy: a ydoc with a title fragment (or nothing to seed).
this.logger.debug(`ydoc loaded from db: ${pageId}`);
return doc;
}
// SEED-ONLY heal: a valid page.ydoc already exists; we only need to add the
// title fragment. If the persist fails we must NOT hand out an unpersisted
// fresh-client-id seed (it could later duplicate the title), so we fall
// back to the healthy doc loaded from the EXISTING page.ydoc, without the
// seed. The title just won't render until a later successful heal —
// non-fatal, non-corrupting.
try {
return await this.healUnderLock(pageId);
} catch (err) {
this.logger.error(
`Failed to persist seeded ydoc for page ${pageId}; serving existing ydoc without title seed`,
err,
);
return doc;
}
}
// if no ydoc state in db convert json in page.content to Ydoc.
// NOTE (offline-sync M1, Goal 2): this per-load self-heal converts +
// title-seeds + persists every legacy page (content set, ydoc null) on its
// first open, which neutralizes the duplication trap incrementally. A
// proactive one-shot BATCH migration over all such pages could be added
// later, but it requires the tiptap schema + TiptapTransformer (Node/Yjs),
// which a Kysely SQL migration cannot run; no runnable-task/CLI convention
// exists in this repo yet, so we deliberately avoid a fragile migration.
//
// If no ydoc state in db, REBUILD a Y.Doc from the JSON in page.content under
// a row lock (see healUnderLock).
if (page.content) {
this.logger.debug(`converting json to ydoc: ${pageId}`);
const ydoc = TiptapTransformer.toYdoc(
page.content,
'default',
tiptapExtensions,
);
Y.encodeStateAsUpdate(ydoc);
return ydoc;
// REBUILD heal: surface failures. If the persist fails we REFUSE the load
// (re-throw) rather than hand out an unpersisted fresh-client-id rebuild —
// returning it would re-arm the duplication trap. A transient DB failure
// means the client reconnects and retries: correctness over availability.
try {
return await this.healUnderLock(pageId);
} catch (err) {
this.logger.error(
`Failed to persist rebuilt ydoc for page ${pageId}; refusing load`,
err,
);
throw err;
}
}
this.logger.debug(`creating fresh ydoc: ${pageId}`);
return new Y.Doc();
}
/**
* Serialize the legacy self-heal (rebuild from page.content and/or seed the
* title fragment, then persist) so it runs exactly ONCE per page, closing the
* Yjs duplication trap. Both TiptapTransformer.toYdoc and buildTitleSeedYdoc
* mint FRESH Yjs client-ids every call, so two concurrent rebuilds (the API
* process via openDirectConnection AND the standalone collab process both
* seeing `ydoc IS NULL`) could each persist a different-client-id state and let
* a long-offline client merge-and-duplicate. We prevent that by re-reading the
* row FOR UPDATE inside a transaction and re-validating state under the lock:
* whoever wins the lock heals; the loser observes the healthy `ydoc` and adopts
* it instead of rebuilding. The persist happens IN THE SAME TX, so a failed
* write rolls back and propagates out (the caller then decides refuse vs.
* fall-back).
*/
private async healUnderLock(pageId: string): Promise<Y.Doc> {
return executeTx(this.db, async (trx) => {
const locked = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
includeYdoc: true,
trx,
});
const doc = new Y.Doc();
let rebuilt = false;
if (locked?.ydoc) {
// Another process already healed (or the page always had a ydoc): adopt
// the healthy persisted state, do NOT rebuild.
Y.applyUpdate(doc, new Uint8Array(locked.ydoc));
} else if (locked?.content) {
this.logger.debug(`converting json to ydoc: ${pageId}`);
const built = TiptapTransformer.toYdoc(
locked.content,
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(built));
rebuilt = true;
}
// else: no ydoc and no content -> a fresh empty doc.
// Idempotent, emptiness-guarded title seed (safe to call always).
const seeded = this.seedTitleFragment(doc, locked?.title ?? null);
if (rebuilt || seeded) {
// Persist IN THE SAME TX. If this throws, the tx rolls back and the
// error propagates out of executeTx to the caller.
await this.pageRepo.updatePage(
{ ydoc: Buffer.from(Y.encodeStateAsUpdate(doc)) },
pageId,
trx,
);
this.logger.debug(`persisted rebuilt/seeded ydoc: ${pageId}`);
}
return doc;
});
}
/**
* Seed the 'title' fragment of `doc` from the `page.title` column for legacy
* pages whose persisted ydoc has no title fragment yet.
*
* Guarded STRICTLY by emptiness: we only seed when the existing 'title'
* fragment is empty AND there is a non-empty column title. Seeding a non-empty
* fragment would re-introduce the Yjs duplication trap, so we never do it.
* Returns true when a seed was applied (so the caller can persist).
* Defensive: a malformed title must not break document loading.
*/
private seedTitleFragment(doc: Y.Doc, title: string | null): boolean {
const trimmed = (title ?? '').trim();
if (!trimmed) return false;
try {
const titleFrag = doc.get('title', Y.XmlFragment);
if (titleFrag.length !== 0) return false;
const titleSeed = buildTitleSeedYdoc(title);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed));
this.logger.debug('seeded title fragment from page.title column');
return true;
} catch (err) {
this.logger.warn(`failed to seed title fragment: ${err?.['message']}`);
return false;
}
}
async onStoreDocument(data: onStoreDocumentPayload) {
const { documentName, document, context } = data;
@@ -171,7 +307,34 @@ export class PersistenceExtension implements Extension {
this.logger.warn('jsonToText' + err?.['message']);
}
// Title lives in the SAME Y.Doc as the body, in a dedicated 'title' fragment
// (the collaborative title-editor contract with the client). Extract it
// defensively: a malformed title fragment must NOT crash the document store.
// `hasTitleFragment` distinguishes "the doc actually carries a title
// fragment" from "legacy doc with no title fragment" — only the former may
// write page.title, so a legacy doc never clobbers the column with ''.
let titleText = '';
let hasTitleFragment = false;
try {
const titleFrag = document.get('title', Y.XmlFragment);
hasTitleFragment = !!titleFrag && titleFrag.length > 0;
if (hasTitleFragment) {
const titleJson = TiptapTransformer.fromYdoc(document, 'title');
titleText = titleJson ? jsonToText(titleJson).trim() : '';
}
} catch (err) {
this.logger.warn('title extraction: ' + err?.['message']);
hasTitleFragment = false;
}
let page: Page = null;
// Tracks whether the BODY ('default') changed in this store. The heavy
// body-only side-effects (transclusion sync, mentions, RAG, history) stay
// gated on this so a title-only change does not trigger them.
let bodyChanged = false;
// Tracks a successful title-only persist so the post-tx contributor folding
// (collabHistory.addContributors) runs for the title-only case too.
let titleOnlyPersisted = false;
const editingUserIds = this.consumeContributors(documentName);
// Sticky agent marker: 'agent' if any agent edit landed in this window, OR
// if the current writer is the agent (covers a store with no prior onChange
@@ -181,85 +344,197 @@ export class PersistenceExtension implements Extension {
context?.actor,
);
try {
await executeTx(this.db, async (trx) => {
page = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
trx,
});
// Persist with a small bounded retry. The in-memory Y.Doc is the ONLY copy
// of the latest edit until this hook returns: hocuspocus destroys/unloads the
// doc right after onStoreDocument resolves (see storeDocumentHooks' finally
// -> unloadDocument). If a transient DB error (deadlock, serialization
// failure, dropped connection) is merely logged and swallowed, the function
// resolves "successfully", the doc is unloaded, and the edit is lost silently
// (#206 persist-1). Retrying here re-attempts the write while we still hold
// the doc; on total failure we clear `page` so the post-store side effects
// (badge broadcast, history snapshot) never report a save that didn't happen.
const MAX_STORE_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_STORE_ATTEMPTS; attempt++) {
try {
await executeTx(this.db, async (trx) => {
page = await this.pageRepo.findById(pageId, {
withLock: true,
includeContent: true,
trx,
});
if (!page) {
this.logger.error(`Page with id ${pageId} not found`);
return;
}
if (isDeepStrictEqual(tiptapJson, page.content)) {
page = null;
return;
}
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
contributorIds = Array.from(
new Set([
...existingContributors,
...editingUserIds,
page.creatorId,
]),
);
} catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']);
}
// Approach A — boundary snapshot before the agent's first edit.
// When this store is the agent's and the page's currently persisted
// state was authored by a human, pin that human state as its own
// history version BEFORE the agent overwrites it. `page` still holds the
// OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is snapshotted
// later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior
// state is already agent-authored (boundary already pinned on the
// user->agent transition), if the page is effectively empty, or if the
// latest existing snapshot already equals this human state (avoid
// duplicates).
if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') {
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true, trx },
);
const humanBaselineMissing =
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
await this.pageHistoryRepo.saveHistory(page, {
contributorIds: page.contributorIds ?? undefined,
trx,
});
if (!page) {
this.logger.error(`Page with id ${pageId} not found`);
return;
}
}
await this.pageRepo.updatePage(
{
content: tiptapJson,
textContent: textContent,
ydoc: ydocState,
lastUpdatedById: context.user.id,
// Human stays the responsible author; these annotate the source.
lastUpdatedSource,
lastUpdatedAiChatId: context?.aiChatId ?? null,
contributorIds: contributorIds,
},
pageId,
trx,
bodyChanged = !isDeepStrictEqual(tiptapJson, page.content);
// Only a populated 'title' fragment may update page.title; compare
// against the current column value (treat null as '').
//
// ANTI-CORRUPTION GUARD (Bug 2): the client's collaborative title-editor
// can momentarily initialize the 'title' fragment as an EMPTY heading
// (so `hasTitleFragment` is true, but the extracted `titleText` is '')
// BEFORE the server's real-title seed has synced. Writing that '' would
// silently wipe a non-empty page.title to "untitled". A wiki page is
// never legitimately retitled to empty via this path, so we treat an
// empty extracted title as "not authoritative" and never persist it.
// The `titleText.length > 0` clause makes this guard apply to BOTH the
// title-only branch and the body+title branch below.
//
// DELIBERATE: this intentionally makes it impossible to retitle a page
// to EMPTY via the collab path — a wiki page is never legitimately
// empty-titled. If a non-empty-title rule ever needs relaxing or
// enforcing differently, the REST UpdatePageDto is the place to validate
// the title, not this collab guard.
const titleChanged =
hasTitleFragment &&
titleText.length > 0 &&
titleText !== (page.title ?? '');
// No-op fast path: neither body nor title changed.
if (!bodyChanged && !titleChanged) {
page = null;
return;
}
// Title-only change: the body is unchanged, so skip the heavy body
// history/contributor logic and persist just the new title and the
// ydoc (the title fragment edit lives in the same ydoc). The early-skip
// used to drop this case entirely, losing the title change.
if (!bodyChanged) {
// Fold the window's editing users into contributors the same way the
// body branch does, so a user who edited ONLY the title is not dropped
// from page.contributorIds.
const contributorIds = Array.from(
new Set([
...(page.contributorIds || []),
...editingUserIds,
page.creatorId,
]),
);
await this.pageRepo.updatePage(
{
title: titleText,
ydoc: ydocState,
lastUpdatedById: context.user.id,
contributorIds,
// A title-only change is not a body-authorship transition; leave
// lastUpdatedSource/aiChatId untouched so the user->agent history
// boundary in the body branch is not bypassed.
},
pageId,
trx,
// Mirror PageService.update's tree snapshot so a collaborative rename
// propagates to other users' sidebar/breadcrumbs like the REST rename.
{
treeUpdate: {
id: pageId,
slugId: page.slugId,
spaceId: page.spaceId,
parentPageId: page.parentPageId ?? null,
title: titleText,
},
},
);
this.logger.debug(`Page title updated: ${pageId} - SlugId: ${page.slugId}`);
titleOnlyPersisted = true;
return;
}
let contributorIds = undefined;
try {
const existingContributors = page.contributorIds || [];
contributorIds = Array.from(
new Set([
...existingContributors,
...editingUserIds,
page.creatorId,
]),
);
} catch (err) {
//this.logger.debug('Contributors error:' + err?.['message']);
}
// Approach A — boundary snapshot before the agent's first edit.
// When this store is the agent's and the page's currently persisted
// state was authored by a human, pin that human state as its own
// history version BEFORE the agent overwrites it. `page` still holds the
// OLD content/provenance here, so saveHistory(page) captures the
// pre-agent state tagged 'user'. The agent's new content is snapshotted
// later by the debounced PAGE_HISTORY job ('agent'). Skip if the prior
// state is already agent-authored (boundary already pinned on the
// user->agent transition), if the page is effectively empty, or if the
// latest existing snapshot already equals this human state (avoid
// duplicates).
if (lastUpdatedSource === 'agent' && page.lastUpdatedSource !== 'agent') {
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
pageId,
{ includeContent: true, trx },
);
const humanBaselineMissing =
!lastHistory || !isDeepStrictEqual(lastHistory.content, page.content);
if (!isEmptyParagraphDoc(page.content as any) && humanBaselineMissing) {
await this.pageHistoryRepo.saveHistory(page, {
contributorIds: page.contributorIds ?? undefined,
trx,
});
}
}
await this.pageRepo.updatePage(
{
content: tiptapJson,
textContent: textContent,
ydoc: ydocState,
lastUpdatedById: context.user.id,
// Human stays the responsible author; these annotate the source.
lastUpdatedSource,
lastUpdatedAiChatId: context?.aiChatId ?? null,
contributorIds: contributorIds,
// Persist the title in the SAME transaction when the title fragment
// changed alongside the body.
...(titleChanged ? { title: titleText } : {}),
},
pageId,
trx,
// Mirror PageService.update's tree snapshot so a collaborative rename
// propagates to other users' sidebar/breadcrumbs like the REST rename.
// Only attach when the title actually changed; a body-only save must
// not trigger a tree broadcast.
titleChanged
? {
treeUpdate: {
id: pageId,
slugId: page.slugId,
spaceId: page.spaceId,
parentPageId: page.parentPageId ?? null,
title: titleText,
},
}
: undefined,
);
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
});
break;
} catch (err) {
this.logger.error(
`Failed to update page ${pageId} (attempt ${attempt}/${MAX_STORE_ATTEMPTS})`,
err,
);
this.logger.debug(`Page updated: ${pageId} - SlugId: ${page.slugId}`);
});
} catch (err) {
this.logger.error(`Failed to update page ${pageId}`, err);
// The write failed and rolled back; clear the partially-assigned `page`
// so the post-store success branch below is skipped (no false "saved"
// broadcast / history snapshot for content that was never persisted).
page = null;
if (attempt < MAX_STORE_ATTEMPTS) {
await new Promise((resolve) => setTimeout(resolve, attempt * 50));
}
}
}
// `page` is truthy whenever anything was persisted (body OR title-only), so
// the page.updated broadcast fires for a title-only change too.
if (page) {
document.broadcastStateless(
JSON.stringify({
@@ -277,11 +552,20 @@ export class PersistenceExtension implements Extension {
: undefined,
}),
);
}
// Record the window's editing users in collab history for a title-only
// change too (the body branch does this below, gated on bodyChanged).
if (page && titleOnlyPersisted) {
await this.collabHistory.addContributors(pageId, editingUserIds);
}
// Body-only side-effects: skip them for a title-only change (body unchanged).
if (page && bodyChanged) {
await this.syncTransclusion(pageId, page.workspaceId, tiptapJson);
}
if (page) {
if (page && bodyChanged) {
await this.collabHistory.addContributors(pageId, editingUserIds);
const mentions = extractMentions(tiptapJson);

View File

@@ -0,0 +1,81 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import { PageTreeBridgePublisher } from './page-tree-bridge.publisher';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
import {
PageEvent,
TreeUpdateSnapshot,
} from '../../database/listeners/page.listener';
const treeUpdate: TreeUpdateSnapshot = {
id: 'page-1',
slugId: 'slug-1',
spaceId: 'space-1',
parentPageId: null,
title: 'Renamed',
icon: '🚀',
};
describe('PageTreeBridgePublisher', () => {
let publisher: PageTreeBridgePublisher;
let redis: { publish: jest.Mock };
beforeEach(async () => {
redis = { publish: jest.fn().mockResolvedValue(1) };
const redisService = { getOrThrow: () => redis } as unknown as RedisService;
const module: TestingModule = await Test.createTestingModule({
providers: [
PageTreeBridgePublisher,
{ provide: RedisService, useValue: redisService },
],
}).compile();
publisher = module.get<PageTreeBridgePublisher>(PageTreeBridgePublisher);
});
it('WITH a `treeUpdate`: publishes the JSON snapshot on the channel', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
treeUpdate,
};
await publisher.onPageUpdated(event);
expect(redis.publish).toHaveBeenCalledTimes(1);
expect(redis.publish).toHaveBeenCalledWith(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(treeUpdate),
);
});
it('content-only save (NO `treeUpdate`): does NOT publish', async () => {
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
};
await publisher.onPageUpdated(event);
expect(redis.publish).not.toHaveBeenCalled();
});
it('a publish rejection is caught (no throw)', async () => {
redis.publish.mockRejectedValueOnce(new Error('redis down'));
const errorSpy = jest
.spyOn(publisher['logger'], 'error')
.mockImplementation(() => undefined);
const event: PageEvent = {
pageIds: ['page-1'],
workspaceId: 'ws-1',
treeUpdate,
};
await expect(publisher.onPageUpdated(event)).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
errorSpy.mockRestore();
});
});

View File

@@ -0,0 +1,55 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
import { EventName } from '../../common/events/event.contants';
import { PageEvent } from '../../database/listeners/page.listener';
import { COLLAB_TREE_UPDATE_CHANNEL } from '../constants';
/**
* Collab-process half of the cross-process tree-update bridge.
*
* The standalone collab process bootstraps `CollabAppModule`, which does NOT
* import `WsModule`/`PageWsListener`. So when a collaborative title/icon rename
* persists and emits `EventName.PAGE_UPDATED` with a `treeUpdate` snapshot, there
* is no listener in this process to broadcast it — the live tree update would be
* lost for 2-process (COLLAB_URL set) deployments.
*
* This publisher fills that gap: it forwards the `treeUpdate` snapshot over a
* Redis pub/sub channel to the API process, which re-broadcasts it via
* `WsTreeService` (the single broadcast authority).
*
* It is registered ONLY in `CollabAppModule.providers`, so it never runs in the
* API process (where `PageWsListener` already broadcasts the same event locally).
* That module placement is what prevents a double broadcast. In single-process
* mode `CollabAppModule` is not loaded at all, so this publisher never runs.
*/
@Injectable()
export class PageTreeBridgePublisher {
private readonly logger = new Logger(PageTreeBridgePublisher.name);
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
@OnEvent(EventName.PAGE_UPDATED)
async onPageUpdated(event: PageEvent): Promise<void> {
// Mirror PageWsListener's gating: only title/icon changes carry a snapshot.
// Content-only saves leave `treeUpdate` undefined and are ignored.
if (!event.treeUpdate) return;
try {
await this.redis.publish(
COLLAB_TREE_UPDATE_CHANNEL,
JSON.stringify(event.treeUpdate),
);
} catch (err) {
// A Redis publish failure must not break the store path.
this.logger.error(
`Failed to publish tree update to ${COLLAB_TREE_UPDATE_CHANNEL}`,
err instanceof Error ? err.stack : String(err),
);
}
}
}

View File

@@ -20,6 +20,7 @@ import { CaslModule } from '../../core/casl/casl.module';
import { ThrottleModule } from '../../integrations/throttle/throttle.module';
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { PageTreeBridgePublisher } from '../listeners/page-tree-bridge.publisher';
@Module({
imports: [
@@ -54,6 +55,6 @@ import KeyvRedis from '@keyv/redis';
? [CollaborationController]
: []),
],
providers: [AppService],
providers: [AppService, PageTreeBridgePublisher],
})
export class CollabAppModule {}

View File

@@ -32,6 +32,7 @@ import { AiTranscriptionService } from './ai-transcription.service';
import {
ChatIdDto,
ExportChatDto,
GeneratePageTitleDto,
GetChatMessagesDto,
RenameChatDto,
} from './dto/ai-chat.dto';
@@ -316,6 +317,43 @@ export class AiChatController {
return { text };
}
/**
* Generate a page title from supplied note content (#199). One-shot,
* non-streaming. Gated by the workspace AI flag (reusing settings.ai.generative,
* the same flag that gates the on-page generative AI menu); returns { title }.
* The endpoint NEVER writes the page — the client applies the title via the
* existing /pages/update route (which enforces edit permission), so access
* checks are not duplicated here. Throttled per user via AI_CHAT_THROTTLER.
*/
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
@Throttle({ [AI_CHAT_THROTTLER]: { limit: 20, ttl: 60000 } })
@Post('generate-page-title')
async generatePageTitle(
@Body() dto: GeneratePageTitleDto,
@AuthWorkspace() workspace: Workspace,
): Promise<{ title: string }> {
const settings = (workspace.settings ?? {}) as {
ai?: { generative?: boolean };
};
if (settings.ai?.generative !== true) {
throw new ForbiddenException('AI title generation is disabled');
}
try {
const title = await this.aiChatService.generatePageTitle(
workspace.id,
dto.content,
);
return { title };
} catch (err) {
// Preserve meaningful HTTP errors (e.g. AiNotConfiguredException -> 503).
if (err instanceof HttpException) throw err;
// Surface the real provider/transport reason instead of an opaque 500.
this.logger.error('AI title generation failed', err as Error);
throw new ServiceUnavailableException(describeProviderError(err));
}
}
/**
* Ensure the chat exists, belongs to this workspace, AND was created by the
* requesting user (per-user isolation). Throws ForbiddenException otherwise.

View File

@@ -0,0 +1,122 @@
import {
ForbiddenException,
HttpException,
ServiceUnavailableException,
} from '@nestjs/common';
import { AiChatController } from './ai-chat.controller';
import { cleanGeneratedTitle } from './ai-chat.service';
import type { Workspace } from '@docmost/db/types/entity.types';
/**
* Pure post-processing of a model-generated title (#199): trims, strips a single
* pair of surrounding quotes, drops a trailing period, and hard-caps the length.
*/
describe('cleanGeneratedTitle', () => {
it('trims surrounding whitespace', () => {
expect(cleanGeneratedTitle(' Hello world ')).toBe('Hello world');
});
it('strips a single pair of surrounding double quotes', () => {
expect(cleanGeneratedTitle('"My note"')).toBe('My note');
});
it('strips surrounding single quotes', () => {
expect(cleanGeneratedTitle("'My note'")).toBe('My note');
});
it('drops a trailing period', () => {
expect(cleanGeneratedTitle('A complete sentence.')).toBe(
'A complete sentence',
);
});
it('caps the result at 255 characters (the page-title column bound)', () => {
expect(cleanGeneratedTitle('x'.repeat(400))).toHaveLength(255);
});
it('returns an empty string for blank/garbage input', () => {
expect(cleanGeneratedTitle(' ')).toBe('');
expect(cleanGeneratedTitle('""')).toBe('');
});
});
/**
* Wiring spec for the #199 `POST /ai-chat/generate-page-title` endpoint. It must:
* gate on settings.ai.generative (403 when off), delegate to the service when on,
* rethrow HttpExceptions verbatim (e.g. AiNotConfiguredException -> 503), and map
* any other provider/transport fault to a 503. Exercised by instantiating the
* controller with hand-rolled mocks — no Nest graph, no DB.
*/
describe('AiChatController.generatePageTitle', () => {
const enabledWorkspace = {
id: 'ws1',
settings: { ai: { generative: true } },
} as unknown as Workspace;
function makeController(generate: jest.Mock) {
const aiChatService = { generatePageTitle: generate };
const controller = new AiChatController(
aiChatService as never,
{} as never,
{} as never,
{} as never,
);
return { controller, aiChatService };
}
it('forbids when the generative AI flag is off', async () => {
const generate = jest.fn();
const { controller } = makeController(generate);
const disabled = { id: 'ws1', settings: {} } as unknown as Workspace;
await expect(
controller.generatePageTitle({ content: 'body' }, disabled),
).rejects.toBeInstanceOf(ForbiddenException);
expect(generate).not.toHaveBeenCalled();
});
it('forbids when settings.ai.generative is anything but exactly true', async () => {
const generate = jest.fn();
const { controller } = makeController(generate);
const ws = {
id: 'ws1',
settings: { ai: { generative: 'yes' } },
} as unknown as Workspace;
await expect(
controller.generatePageTitle({ content: 'body' }, ws),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('returns { title } from the service when enabled', async () => {
const generate = jest.fn().mockResolvedValue('Generated Title');
const { controller } = makeController(generate);
const res = await controller.generatePageTitle(
{ content: 'some markdown body' },
enabledWorkspace,
);
expect(generate).toHaveBeenCalledWith('ws1', 'some markdown body');
expect(res).toEqual({ title: 'Generated Title' });
});
it('rethrows an HttpException from the service verbatim (e.g. 503 not configured)', async () => {
const notConfigured = new ServiceUnavailableException('AI not configured');
const generate = jest.fn().mockRejectedValue(notConfigured);
const { controller } = makeController(generate);
await expect(
controller.generatePageTitle({ content: 'body' }, enabledWorkspace),
).rejects.toBe(notConfigured);
});
it('maps a non-HTTP provider error to a 503', async () => {
const generate = jest.fn().mockRejectedValue(new Error('socket hang up'));
const { controller } = makeController(generate);
// Silence the expected error log.
jest
.spyOn((controller as unknown as { logger: { error: () => void } }).logger, 'error')
.mockImplementation(() => undefined);
const err = await controller
.generatePageTitle({ content: 'body' }, enabledWorkspace)
.catch((e) => e);
expect(err).toBeInstanceOf(ServiceUnavailableException);
expect(err).toBeInstanceOf(HttpException);
});
});

View File

@@ -239,3 +239,32 @@ describe('buildMcpToolingBlock', () => {
expect(block).not.toContain('b_*');
});
});
/**
* Interrupt-resume note (#198). The INTERRUPT_NOTE is injected into the system
* prompt ONLY when `interrupted: true` is passed (the server sets it only after
* confirming against history). It tells the model its previous answer was cut off
* by the user, so it treats the partial assistant message in history as
* incomplete. The note lives inside the safety sandwich (the context section).
*/
describe('buildSystemPrompt interrupt note (#198)', () => {
const workspace = { name: 'Acme' } as unknown as Workspace;
const NOTE_MARKER = 'interrupted by the';
const SAFETY_MARKER = 'Operating rules (always in effect)';
it('injects the interrupt note when interrupted is true', () => {
const prompt = buildSystemPrompt({ workspace, interrupted: true });
expect(prompt).toContain(NOTE_MARKER);
// Still inside the safety sandwich: the trailing SAFETY block follows it.
expect(prompt.lastIndexOf(SAFETY_MARKER)).toBeGreaterThan(
prompt.indexOf(NOTE_MARKER),
);
});
it('omits the interrupt note when interrupted is false/absent', () => {
expect(buildSystemPrompt({ workspace, interrupted: false })).not.toContain(
NOTE_MARKER,
);
expect(buildSystemPrompt({ workspace })).not.toContain(NOTE_MARKER);
});
});

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