Compare commits

..

25 Commits

Author SHA1 Message Date
agent_coder cd3f2b7166 Merge branch 'develop' into feature/offline-sync
# Conflicts:
#	apps/client/src/features/editor/page-editor.tsx
#	apps/server/src/collaboration/extensions/persistence.extension.ts
2026-07-02 15:15:36 +03:00
claude code agent 227 0eecdcba23 test(collab): cover the title-change ⋈ empty-guard intersection (F1)
The develop rebase merged the #120 title-only-change branch and the
#248/#251 store-side empty-guard into one onStoreDocument. The existing 14
tests exercise each only in isolation (empty-guard tests send no title
fragment; title tests send a non-empty body), so none reached the
empty-guard's blocking branch with titleChanged===true. Add two paired
regression tests on that exact junction:

- empty body + a changed non-empty title over non-empty persisted content,
  no intentional-clear → the empty-guard blocks the WHOLE store, dropping
  the simultaneous rename too (updatePage not called); the rich content and
  old title survive.
- the same doc with a deliberate clear armed via the real stateless
  transport → the empty body is allowed and the rename rides along on the
  same body-path updatePage (title + empty content persisted).

The pair makes Test 1 non-vacuous: same doc, only the clear differs, and
Test 2 proves updatePage IS reachable — so Test 1's "not called" is the
guard blocking, not an unreached path. Test-only; no production change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 03:16:40 +03:00
claude code agent 227 03314d747f Merge remote-tracking branch 'gitea/develop' into HEAD
# Conflicts:
#	CHANGELOG.md
#	apps/server/src/collaboration/extensions/persistence.extension.ts
2026-06-30 02:36:02 +03:00
claude code agent 227 dd186406b6 Merge remote-tracking branch 'gitea/develop' into HEAD
# Conflicts:
#	CHANGELOG.md
#	apps/server/src/integrations/environment/environment.service.spec.ts
#	apps/server/src/integrations/environment/environment.service.ts
2026-06-29 18:49:06 +03:00
claude code agent 227 47f37072ab test(offline): rename success case to match its assertion (F5)
The success path calls notifications.show without a color (Mantine default,
not green), and the test asserts color is undefined — rename the case from
'GREEN success toast' to 'success toast with no error color'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:02:44 +03:00
claude code agent 227 5eb92f2cef test(offline): cover the make-available-offline handler's F1 toast gating (F4)
Add space-tree-node-menu.test.tsx driving handleMakeAvailableOffline through
the menu: full success → green 'available offline' toast; ydoc not synced
(result.ok but !didSync) → red toast naming 'editor' (the F1 guarantee a
non-warmed page is not reported available); read-query failures → red toast
listing labels; thrown error → extracted reason. Mutation-checked (inverted
gate flips two cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:09:21 +03:00
claude code agent 227 57b77c35e5 fix(offline): report editor-warm failures honestly; cover failure labels; align docs (F1-F3)
F1: warmPageYdoc now returns didSync (true only on the real 'synced' event,
    false on the 8s timeout / error, which are now logged). The tree menu gates
    the 'available offline' success toast on result.ok AND didSync, and pushes
    'editor' into the failed set otherwise — so a page whose Yjs body never
    warmed is no longer reported as fully offline-available.
F2: tests for the page/space/currentUser failure labels and the didSync
    true/false paths.
F3: correct the mobile-app-plan / mobile-bootstrap present-state sections to
    match shipped code (Capacitor, CORS allowlist, Swagger, offline reading,
    /l in the SW denylist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:31:10 +03:00
claude code agent 227 411c05a9d6 fix(client): add /l vanity route to SW denylist; name failed offline steps (F2, F3)
F2: navigateFallbackDenylist was missing the server's `l/:alias` vanity
short-link, so a top-nav to /l/<alias> after SW registration got the
index.html app shell (which has no /l route) and dead-ended on Error404
instead of the server's 302 redirect. Add /^\/l(\/|$)/ mirroring main.ts.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:49 +03:00
514 changed files with 30331 additions and 53623 deletions
+16 -60
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
@@ -173,21 +186,9 @@ MCP_DOCMOST_PASSWORD=
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
# A pooled connection idle longer than this is closed instead of reused, so a
# NAT / egress firewall / reverse proxy that silently drops idle connections
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Kept under
# common ~5s upstream/middlebox idle cutoffs so undici recycles the socket before
# the network kills it (fewer resets), while still reusing within a burst of
# back-to-back calls. Lower it further if your egress drops idle connections even
# faster. Default 4000 (4 s).
# AI_STREAM_KEEPALIVE_MS=4000
# Number of PRE-RESPONSE connection retries for streaming chat/agent AI calls: a
# reset/timeout BEFORE any response byte (e.g. `read ECONNRESET` on a stale pooled
# socket) is retried on a fresh connection with jittered exponential backoff.
# Total attempts = value + 1, so the default 4 gives 5 attempts — headroom to
# absorb a short BURST of upstream resets without exhausting the budget. Safe to
# retry: a started stream is never replayed, only a connect that never responded.
# 0 disables the retry. Default 4.
# AI_STREAM_PRE_RESPONSE_RETRIES=4
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Lower it if
# your egress drops idle connections faster than ~10s. Default 10000 (10 s).
# AI_STREAM_KEEPALIVE_MS=10000
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
@@ -202,27 +203,6 @@ MCP_DOCMOST_PASSWORD=
# Default 900000 (15 min).
# AI_MCP_CALL_TIMEOUT_MS=900000
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
# active" behavior.
# AI_CHAT_DEFERRED_TOOLS=true
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
# it, and a client reconnects/live-follows the run.
#
# DEPLOY CONSTRAINT — SINGLE-INSTANCE ONLY in phase 1: Stop and the in-process
# AbortController that backs it are process-local, so a Stop only aborts a run
# executing on the SAME replica that owns it (cross-instance pub/sub stop is phase
# 2 and not yet reliable). Do NOT enable autonomousRuns on a horizontally-scaled
# deployment (multiple replicas behind a load balancer, or Docmost cloud
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
# visible, and a startup sweep settles any run left dangling by a restart.
# --- Anonymous public-share AI assistant ---
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
# When enabled, anonymous visitors of a published share can ask an AI about that
@@ -256,27 +236,3 @@ MCP_DOCMOST_PASSWORD=
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
# per rolling day).
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
# --- Observability / perf metrics (#355) ---
#
# Two INDEPENDENT toggles, both OFF by default:
#
# 1) METRICS_PORT — the server-side Prometheus scrape endpoint.
# UNSET (default) => the whole prom subsystem is OFF: no registry, no
# collectors, and NOTHING is exposed on the main app port. There is NO
# default port — leaving it blank disables it. When set to a port (e.g.
# 9464), a SEPARATE bare node:http listener serves GET /metrics on that port
# only (never on the main :3000 app listener), for a scraper such as
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
# METRICS_PORT=9464
#
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
# endpoint is registered and browsers collect + send web-vitals / editor
# metrics into the `client_metrics` table (read directly by Grafana, separate
# from METRICS_PORT). Leave OFF unless you actually consume this data: the
# endpoint is public and the table has NO app-side retention, so enabling it
# requires an EXTERNAL pruner to bound `client_metrics` growth (the deployed
# infra prunes rows >90d via a maintenance container). When off, the endpoint
# does not exist and the client installs no observers.
# CLIENT_TELEMETRY_ENABLED=false
+11 -42
View File
@@ -18,48 +18,12 @@ env:
IMAGE: ghcr.io/vvzvlad/gitmost
jobs:
# Run the reusable test suite. Together with the e2e jobs below it gates the
# publish job (the image push), not the build itself — build runs in parallel.
# Run the reusable test suite first so a failing test blocks the image build.
test:
uses: ./.github/workflows/test.yml
# Runs in parallel with the test/e2e jobs and only warms the buildx cache
# (GHA cache, scope develop-amd64). No push happens here — the publish job
# below is the only one that pushes the image.
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Resolve version
id: version
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
- name: Build develop image (warm cache, no push)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
build-args: |
APP_VERSION=${{ steps.version.outputs.value }}
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
push: false
cache-from: type=gha,scope=develop-amd64
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
# The gate: rebuilds from the cache the build job just wrote (near-instant on
# a cache hit; worst case — cache eviction — a full rebuild, which matches the
# old sequential timing) and pushes :develop only when unit tests AND both
# e2e suites AND the build are green.
publish:
needs: [test, e2e-server, e2e-mcp, build]
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -93,10 +57,13 @@ jobs:
push: true
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 gate the publish (image push), not the build: the :develop image
# is pushed only when unit tests AND both e2e suites pass (publish.needs
# lists them all).
# 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
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
@@ -157,7 +124,9 @@ jobs:
- name: Run server e2e
run: pnpm --filter ./apps/server test:e2e
# Gates the publish too — see the comment above e2e-server.
# 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
timeout-minutes: 20
-8
View File
@@ -72,14 +72,6 @@ jobs:
- name: Build editor-ext
run: pnpm --filter @docmost/editor-ext build
# @docmost/prosemirror-markdown is the shared converter (#293/#326); its
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
# unless it is built first. Build it before the recursive test run.
- name: Build prosemirror-markdown
run: pnpm --filter @docmost/prosemirror-markdown build
- name: Run unit tests
run: pnpm -r test
+6 -16
View File
@@ -4,20 +4,7 @@
data
# compiled output
/dist
node_modules
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
# so src/ and prod can never silently diverge).
packages/git-sync/build/
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
# never committed, so src/ and prod can never silently diverge).
packages/prosemirror-markdown/build/
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
# markdown convention; the package is private and rebuilt at deploy.
packages/mcp/build/
/node_modules
# Logs
logs
@@ -56,11 +43,14 @@ lerna-debug.log*
.nx/cache
.claude/worktrees/
.claude/tmp/
# Local Chrome performance traces recorded by the AI-chat perf harness
.claude/perf-traces/
# 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
+23 -37
View File
@@ -72,10 +72,7 @@ git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
### 4. Push and PR to develop
PRs always target `develop`. Two different mechanisms are involved: **pushing
commits is git-native** (the Gitea MCP cannot push local git history, so the
branch is still pushed with `git push`), while **the PR itself is opened through
the Gitea MCP** (see below). The `claude_code` password lives in the macOS
PRs always target `develop`. The `claude_code` password lives in the macOS
keychain as a **generic password** under service `gitea-claude-code` (do not
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
conflict with the owner's account in the git credential helper):
@@ -97,24 +94,18 @@ git remote set-url gitea "$ORIG_URL"
unset AGENT_PASS SAFE_PASS
```
The PR is opened through the **Gitea MCP** (server `gitea`), not `curl`/`tea`
the MCP authenticates in-process, so no keychain lookup or Basic-Auth is needed.
Call `pull_request_write` with:
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
- `method: "create"`
- `owner: "vvzvlad"`, `repo: "gitmost"`
- `base: "develop"`, `head: "<branch>"`
- `title`, `body` — in the body: what was done, what is out of scope,
verification results (tsc/lint/tests).
```bash
curl -s -X POST \
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \
-H "Content-Type: application/json" \
-d @pr_body.json \
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
```
Manage and read PRs through the same server: `list_pull_requests`,
`pull_request_read` (`get`, `get_diff`, `get_files`, `get_status`),
`pull_request_review_write`.
**Identity note:** the MCP acts under its **own** configured Gitea token (verify
with `get_me`), a different account from the `claude_code` used for git
commits/pushes in §3. Only the forge API calls (PR / issue / review) go through
the MCP account; the commits themselves stay authored as `claude_code`.
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
of scope, verification results (tsc/lint/tests).
> If push fails with `User permission denied for writing`, then `claude_code`
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
@@ -161,25 +152,23 @@ below.
| Agent user (Gitea/git) | `claude_code` |
| Agent email | `claude_code@vvzvlad.xyz` |
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
| Forge API (PR / issue / review / reads) | **Gitea MCP** — server `gitea` (`pull_request_write`, `issue_write`, `list_pull_requests`, `pull_request_read`, `label_read`, …). Authenticated in-process; acts under its own token — check with `get_me`. Repo slug on the server is `gitmost`. |
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
| Base branch | `develop` |
| `origin` | GitHub mirror `vvzvlad/gitmost`**do not push**, updated by the owner's CI |
| `upstream` | The original Docmost — **never push** |
## Creating issues (Gitea MCP)
## Creating issues (Gitea `tea` CLI)
File issues through the **Gitea MCP** (server `gitea`), not a CLI — call
`issue_write` with:
Issues are filed with the official Gitea CLI `tea`, already logged in as
`claude_code` (`tea logins list` shows the `gitea` login as default):
- `method: "create"`
- `owner: "vvzvlad"`, `repo: "gitmost"`
- `title`, `body`
- `labels` — an array of label **IDs** (numbers), *not* names. Resolve a name
such as `feature` to its id first with `label_read` (`method: "list"`), then
pass e.g. `labels: [<id>]`.
```bash
tea issues create --repo vvzvlad/gitmost --labels feature \
--title '<title>' --description "$(cat body.md)"
```
Read issues with `list_issues`, `issue_read`, or `search_issues`. The MCP is
authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
---
@@ -200,8 +189,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp` and `git-sync`; there is exactly ONE copy of the converter now |
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
@@ -279,12 +267,11 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs`**detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
### Client structure
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by both `mcp` and `git-sync` — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
@@ -295,7 +282,6 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
## CI / release
+25 -83
View File
@@ -14,10 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
images as a row that wraps onto the next line on narrow screens. The row is
centered horizontally by default in modern browsers (CSS `:has()`), falling
back to start-aligned rows in browsers without support. Unlike the float
modes, text does not wrap around inline images. The mode round-trips
images as a row that wraps onto the next line on narrow screens. Unlike the
float modes, text does not wrap around inline images. The mode round-trips
losslessly through markdown as `data-align`, like the other alignment
values.
@@ -72,19 +70,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
contain a standalone footnote definition, which canonicalization would drop.
(#228)
- **Detached, autonomous agent runs that survive a browser disconnect.** When the
new `settings.ai.autonomousRuns` workspace flag is on (off by default), an
AI-chat turn becomes a first-class, server-side RUN tracked in a new
`ai_chat_runs` table instead of a socket-bound stream: closing the tab or
losing the connection no longer aborts the turn — it keeps executing and
persisting server-side, and only an explicit Stop ends it. A client can
reconnect and live-follow (or stop) an in-flight run via `POST /ai-chat/run`
(resolve the latest run + its assistant message for a chat) and
`POST /ai-chat/stop` (stop by `runId` or `chatId`). A partial unique index
enforces one active run per chat, and a startup sweep settles any run left
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
not yet reliable); the server warns at startup on a horizontally-scaled
deployment. (#184)
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
@@ -94,58 +79,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`nosniff` + restrictive CSP + attachment disposition for non-image mimes) and
are RAM-only, bound to the instance that created them. Tunable via five
`SANDBOX_*` env vars (see `.env.example`). (#243)
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
children, and comments are cached in IndexedDB (TanStack Query persister plus
`y-indexeddb` for the page's Yjs document), and a PWA service worker
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
offline. The two offline stores (the persisted query cache and the Yjs page
documents) are cleared on logout AND on sign-in so a previous user's private
data does not remain in the browser; the same purge also defensively drops any
legacy service-worker `api-get-cache` left by older clients (current builds
serve `/api` as NetworkOnly, so there is no active service-worker API cache).
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
can request the access JWT in the response body (`data.authToken`) in addition
to the httpOnly cookie (the web client stays cookie-only); an optional
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
- **Inline spoiler mark — hide text behind click-to-reveal blur.** Selected text
can be marked as a spoiler from a new bubble-menu toggle, or typed Discord-style
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
The mark is preserved losslessly through Markdown export/import (as a raw
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
- **Dock the AI chat window into the side menu.** The floating chat window can
be pinned to the sidebar — drag it onto the navbar (a drop-zone highlight
shows where it lands) or use the new "Dock to sidebar" header button; while
docked it fills the sidebar area and follows its live size. "Undock" (or
dragging it back out) restores the floating window, a collapsed/absent
sidebar falls back to floating, and the docked state survives a reload.
(#276, #282)
- **Hovering commented text shows the comment thread in a tooltip.** Pointing
at a highlighted comment mark pops a small card with the author and plain
text of the root comment and its replies, so a thread can be skimmed without
opening the side panel. The card appears after a short delay (no flicker on a
passing glance), skips resolved and text-less threads, and dismisses on
scroll or click — clicking a mark still opens the comments panel. (#268,
#271)
- **"Move to trash" button in the temporary-note banner.** Besides "Make
permanent", the banner on an open temporary note now also offers to trash the
note immediately instead of waiting out its lifetime. It reuses the regular
soft-delete path, so the "Page moved to trash" undo toast is the safety net —
no confirmation dialog. (#273, #277)
- **Code-block controls float as an overlay instead of taking a row above the
code.** The language selector and copy button now sit in the block's top-right
corner, and the selector stays invisible until the block is hovered or the
selector is focused, so reading code is chrome-free. In read-only views only
the copy button renders. (#275, #278)
- **The AI agent is told about your page edits between turns.** The server
snapshots the open page's Markdown at the end of every agent turn and, on the
next turn, injects a unified diff of what changed in between, so the agent
knows its earlier copy of the page is stale and builds on the user's edits
instead of reverting or overwriting them. The diff is whitespace-normalized
(pure formatting churn injects nothing) and size-capped, with a hint to
re-read the full page via `getPage` when truncated. (#274, #281)
- **Stress-accent button (U+0301) in the bubble menu.** Select a vowel and
toggle a combining acute accent over it — a Russian-style stress mark. The
accent is stored as plain text (no custom mark), so it survives Markdown/HTML
export, full-text search and public shares unchanged; the toggle is a single
undo step and re-clicking removes the accent. (#270, #280)
- **Reading position survives a reload.** The editor remembers how far you
scrolled in each page (per tab, in `sessionStorage`) and restores that
position after an F5 or reopening the document, waiting for the collaborative
content to finish laying out first. A URL `#hash` anchor still wins — restore
is a no-op then. (#266, #267)
- **The slash menu finds commands typed in the wrong keyboard layout.** A query
typed with the wrong layout active (e.g. `/сщву` for `/code`, or `/cyjcrf`
for the Cyrillic «сноска» → Footnote) is additionally remapped ЙЦУКЕН↔QWERTY
by physical key position and matched against the commands; genuine Cyrillic
search terms keep priority over remapped candidates, and short wrong-layout
prefixes match by command title. (#283, #285, #287)
### Changed
@@ -166,6 +118,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
JSON-compatible schema (no custom tags / no code execution) behind the same
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
base-URL contract is unchanged. (#229)
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
`app.enableCors()`). The same-origin web client is unaffected, but any
separately-hosted cross-domain client must now be listed in
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
allowed automatically). Requests with no `Origin` header (server-to-server)
are still allowed. **Upgrade note:** the old bare `app.enableCors()` reflected
*any* origin (with `credentials:false`), so any previously-working cross-domain
REST/browser client is now rejected until its origin is added to
`CORS_ALLOWED_ORIGINS` (see `.env.example`).
### Fixed
@@ -211,25 +172,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
emits a single-use "intentional clear" signal that lets exactly that one empty
write through the guard, so genuinely emptying a page is persisted while
accidental empties are blocked. (#248, #251)
- **Ctrl+Z works again right after using a table menu.** Closing a table
row/column menu (grip or chevron) left focus on the menu's portaled target
outside the editor, so undo keystrokes went nowhere until you clicked back
into a cell. The editor is now refocused after the menu closes — unless you
deliberately moved focus to another input or editable (e.g. the page title).
(#269, #279)
- **The AI reindex progress counter no longer freezes at 0.** Right after
"Reindex now" the client could read the stale pre-reindex snapshot of an
already-indexed workspace (`reindexing=false`, all pages counted) as
"finished" and stop polling on the very first tick, leaving the counter
frozen until a manual reload. Polling now keeps going until it has actually
observed the active run. (#262, #264)
- **An MCP edit can no longer be silently lost to a duplicate collab document.**
When the agent addressed a page by its short slugId, the MCP opened a
collaboration document named after that slugId while the web editor always
uses the page's canonical UUID — two independent live documents for one page,
whose debounced stores clobbered each other. The MCP now resolves every page
id to the canonical UUID before opening the collab doc (a UUID input
short-circuits locally; a slugId is resolved once and cached). (#260, #265)
### Security
+2 -24
View File
@@ -5,13 +5,6 @@ RUN npm install -g pnpm@10.4.0
FROM base AS builder
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
# This stage is discarded, so the toolchain can stay installed.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
@@ -45,14 +38,6 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
# markdown-converter.js). Ship the built package + its manifest, or the prod
# install resolves a broken workspace symlink and every ai-chat tool dies with
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
# revisit at step 6 when #119 lands.)
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
# Copy root package files
COPY --from=builder /app/package.json /app/package.json
@@ -64,17 +49,10 @@ COPY --from=builder /app/patches /app/patches
RUN chown -R node:node /app
# Toolchain is needed transiently to compile re2 during the prod install; install
# and purge it in one layer to keep the final image slim. The install itself runs
# as the node user via su to keep node_modules ownership without a costly chown layer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& su node -c "pnpm install --frozen-lockfile --prod" \
&& apt-get purge -y --auto-remove python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
USER node
RUN pnpm install --frozen-lockfile --prod
RUN mkdir -p /app/data/storage
VOLUME ["/app/data/storage"]
+3 -6
View File
@@ -104,7 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
- ✅ **Temporary notes**create a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview.
- ✅ **Temporary notes**mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
### In progress
@@ -187,17 +187,14 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
- Spaces
- Permissions management
- Groups
- Comments (with resolve / re-open and hover tooltips showing the comment text)
- Comments (with resolve / re-open)
- Page history
- Search
- File attachments
- Embeds (Airtable, Loom, Miro and more)
- Translations (10+ languages)
- Embedded MCP server (`/mcp`)
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access); the chat window docks into the side menu, and the agent is told about your in-page edits between turns
- Code-block buttons as an overlay, with the language selector revealed on hover
- Stress-accent button (U+0301) in the bubble menu
- Reading scroll position restored on reload
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access)
### Screenshots
+3 -7
View File
@@ -105,7 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
- ✅ **Временные заметки**создайте временную заметку, и она автоматически уедет в корзину по истечении настраиваемого срока жизни (по умолчанию 24 ч); создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства.
- ✅ **Временные заметки**пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
### В процессе
@@ -174,18 +174,14 @@ dump/restore, существующий каталог данных переис
- Пространства (Spaces)
- Управление правами доступа
- Группы
- Комментарии (с резолвом / переоткрытием и всплывающими подсказками с текстом комментария при наведении)
- Комментарии (с резолвом / переоткрытием)
- История страниц
- Поиск
- Вложения файлов
- Встраивания (Airtable, Loom, Miro и другие)
- Переводы (10+ языков)
- Встроенный MCP-сервер (`/mcp`)
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет); окно чата закрепляется в боковом меню, а агент узнаёт о ваших правках страницы между ходами
- Кнопки код-блока оверлеем, селектор языка появляется при наведении
- Кнопка «Ударение» (U+0301) в bubble-меню
- Позиция чтения (прокрутка) восстанавливается после перезагрузки
- Slash-меню терпимо к неправильной раскладке (ЙЦУКЕН↔QWERTY)
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет)
### Скриншоты
+6 -11
View File
@@ -34,13 +34,11 @@ roles:
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
HOW TO LEAVE COMMENTS
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
- [Minor] — an optional improvement to framing or flow.
Structural fixes (move, merge, cut) can't be expressed as a fragment replacement — a comment is enough for those. But when your proposal boils down to replacing a specific wording in place (a headline, a lead phrase), attach a suggested replacement to the comment (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context.
TONE
Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question.
@@ -87,7 +85,7 @@ roles:
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
HOW TO LEAVE COMMENTS
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise". Tag severity:
- [Critical] — the sentence is unclear or distorts the meaning.
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
- [Minor] — a stylistic improvement to taste.
@@ -128,7 +126,7 @@ roles:
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
HOW TO LEAVE COMMENTS
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. Tag severity:
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
- [Major] — a doubtful or unconfirmed claim that needs a source.
- [Minor] — a small correction, or false precision worth rounding or confirming.
@@ -168,17 +166,14 @@ roles:
- Don't verify facts — that's the Fact-checker.
- Don't make substantive changes. Edits are minimal and mechanical.
HOW TO WORK
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
HOW TO LEAVE COMMENTS
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Open the comment with the label `[Copyedit]`. Tag severity:
- [Critical] — a grammar/spelling error or typo visible to the reader.
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
- [Minor] — optional polish.
TONE
To the point, no explaining the obvious. Don't fold repeated fixes into a single "change it everywhere" note — spread them across the specific spots: ten targeted comments each carrying a ready replacement beat one blanket comment that can't be applied with a button. Don't worry about "spawning" comments — for a copyeditor that's normal.
To the point, no explaining the obvious. Group repeated fixes (e.g. "throughout: straight quotes → curly") so you don't spawn dozens of identical comments.
WHEN UNSURE
If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant.
@@ -277,7 +272,7 @@ roles:
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
═══ HOW TO LEAVE NOTES ═══
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. When one of your options is a single ready-made text (e.g. a new lead phrase), you may attach it as a suggested replacement (the `suggestedText` parameter: the exact new text for the selected fragment, no markup; the fragment must occur exactly once in the text, otherwise extend the selection) — the button imposes nothing, the author is free not to apply it. Comment on what will strengthen the story, not on every little thing.
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. Comment on what will strengthen the story, not on every little thing.
═══ TONE ═══
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
+6 -11
View File
@@ -34,13 +34,11 @@ roles:
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
- [Незначительно] — улучшение подачи или стройности, не обязательное.
Структурные правки (перенести, объединить, вырезать) через замену фрагмента не выражаются — для них достаточно комментария. Но если предложение сводится к замене конкретной формулировки на месте (заголовок, лид-фраза), приложи к комментарию предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом.
ТОН
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
@@ -87,7 +85,7 @@ roles:
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать». Помечай важность:
- [Критично] — предложение непонятно или искажает смысл.
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
- [Незначительно] — стилистическое улучшение на вкус.
@@ -128,7 +126,7 @@ roles:
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. Помечай важность:
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
@@ -169,17 +167,14 @@ roles:
- Не проверяешь достоверность фактов — это фактчекер.
- Не вносишь содержательных изменений. Правки — минимальные и механические.
КАК РАБОТАТЬ
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. Начинай комментарий с метки `[Корректура]`. Помечай важность:
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
- [Незначительно] — необязательная шлифовка.
ТОН
По делу, без объяснений очевидного. Не сворачивай однотипные правки в одно сводное замечание «поменять везде» — разнеси их по конкретным местам: десять целевых комментариев с готовой заменой в каждом лучше одного общего, который нельзя применить кнопкой. Не бойся «плодить» комментарии: для корректора это норма.
По делу, без объяснений очевидного. Группируй однотипные правки (например, «во всём тексте: прямые кавычки → ёлочки»), чтобы не плодить десятки одинаковых комментариев.
ПРИ НЕУВЕРЕННОСТИ
Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант.
@@ -278,7 +273,7 @@ roles:
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Если среди вариантов есть один готовый текст (например, новая формулировка лида), можешь приложить его к комментарию как предложение-замену (параметр `suggestedText`: точный новый текст взамен выделенного фрагмента, без разметки; фрагмент должен встречаться в тексте ровно один раз, иначе расширь выделение) — кнопка ничего не навязывает, автор волен не применять. Комментируй то, что усилит историю, а не каждую мелочь.
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Комментируй то, что усилит историю, а не каждую мелочь.
═══ ТОН ═══
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
+8 -8
View File
@@ -12,15 +12,15 @@ bundles:
- en
roles:
- slug: structural-editor
version: 4
- slug: line-editor
version: 4
- slug: fact-checker
version: 6
- slug: proofreader
version: 8
- slug: narrator
version: 2
- slug: line-editor
version: 2
- slug: fact-checker
version: 3
- slug: proofreader
version: 3
- slug: narrator
version: 1
- id: research
name:
ru: Исследование
+10 -10
View File
@@ -1,26 +1,26 @@
{
"fact-checker": {
"version": 6,
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
"version": 3,
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
},
"line-editor": {
"version": 4,
"hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
"version": 2,
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
},
"narrator": {
"version": 2,
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
"version": 1,
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
},
"proofreader": {
"version": 8,
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
"version": 3,
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
},
"researcher": {
"version": 1,
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
},
"structural-editor": {
"version": 4,
"hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
"version": 2,
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
}
}
+1
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" />
+4 -3
View File
@@ -33,19 +33,21 @@
"@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",
"axios": "1.16.0",
"blueimp-load-image": "5.16.0",
"clsx": "2.1.1",
"diff": "8.0.3",
"dompurify": "3.4.1",
"file-saver": "2.0.5",
"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",
@@ -61,7 +63,6 @@
"react-clear-modal": "^2.0.18",
"react-dom": "^18.3.1",
"react-drawio": "1.0.7",
"web-vitals": "^5.1.0",
"react-error-boundary": "6.1.1",
"react-helmet-async": "3.0.0",
"react-i18next": "16.5.8",
@@ -83,7 +84,6 @@
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.1",
"@vitest/coverage-v8": "4.1.6",
"eslint": "9.28.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
@@ -98,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"
}
}
-50
View File
@@ -1,50 +0,0 @@
/**
* DEV-ONLY entry for the AI chat perf harness (served by the vite dev server at
* /perf/ai-chat-perf.html; never part of the production build, which uses the
* single default index.html entry).
*
* Mounts the minimal provider stack the real ChatThread needs (Mantine, router
* for tool-card Links, react-query, i18n) and patches `window.fetch` BEFORE
* React mounts so ChatThread's DefaultChatTransport requests to
* /api/ai-chat/stream are answered by the synthetic SSE generator.
*/
import "@mantine/core/styles.css";
import ReactDOM from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { mantineCssResolver, theme } from "../src/theme.ts";
// i18n side-effect init (http-backend). Translations load from /locales in dev;
// missing keys fall back to the key text, which is fine for the harness.
import "../src/i18n.ts";
import { installAiChatStreamFetchPatch } from "./synthetic-turn.ts";
import PerfHarness from "./harness.tsx";
// MUST run before React mounts: ChatThread creates its transport with the
// global fetch, so the patch has to be in place before the first send.
installAiChatStreamFetchPatch();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
staleTime: 5 * 60 * 1000,
},
},
});
const container = document.getElementById("root") as HTMLElement;
ReactDOM.createRoot(container).render(
<MemoryRouter>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<QueryClientProvider client={queryClient}>
<PerfHarness />
</QueryClientProvider>
</MantineProvider>
</MemoryRouter>,
);
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI chat perf harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./ai-chat-perf-main.tsx"></script>
</body>
</html>
-390
View File
@@ -1,390 +0,0 @@
/**
* DEV-ONLY perf harness UI for the AI chat feature.
*
* Left panel: controls + live stats. Right side: a bordered box (~real chat
* window size) hosting the REAL ChatThread component.
*
* Scenario A "Open existing chat": mount ChatThread seeded with a large
* persisted transcript and measure click -> post-mount-paint time.
* Scenario B "Live agent stream": mount an empty chat and auto-send a message;
* the fetch patch (see synthetic-turn.ts) answers with a synthetic SSE stream
* through the real useChat pipeline.
*/
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, MutableRefObject } from "react";
import ChatThread from "../src/features/ai-chat/components/chat-thread.tsx";
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
import {
PRESETS,
buildPersistedRows,
buildTurnScript,
setLiveStreamSettings,
type PresetKey,
} from "./synthetic-turn.ts";
const AUTO_SEND_TEXT = "Run the synthetic perf turn";
const AUTO_SEND_TIMEOUT_MS = 1000;
/** Stats display refresh period — 2x/s so the display itself stays cheap. */
const STATS_FLUSH_MS = 500;
// ---------------------------------------------------------------------------
// Shared mutable stats (written from callbacks, flushed to state at 2 Hz)
// ---------------------------------------------------------------------------
interface PerfStats {
longtaskCount: number;
longtaskTotalMs: number;
longtaskMaxMs: number;
fps: number;
sseChunks: number;
sseChars: number;
mountAMs: number | null;
streamState: "idle" | "streaming" | "done" | "aborted";
}
function emptyStats(): PerfStats {
return {
longtaskCount: 0,
longtaskTotalMs: 0,
longtaskMaxMs: 0,
fps: 0,
sseChunks: 0,
sseChars: 0,
mountAMs: null,
streamState: "idle",
};
}
/**
* Self-contained stats panel: owns the longtask observer, the FPS meter and the
* 2 Hz flush interval. Isolated in its OWN component so its periodic setState
* re-renders only this panel NOT the ChatThread under measurement.
*/
function StatsPanel({ stats }: { stats: MutableRefObject<PerfStats> }) {
const [snapshot, setSnapshot] = useState<PerfStats>(() => ({ ...stats.current }));
// Long tasks (main-thread blocks > 50ms).
useEffect(() => {
let observer: PerformanceObserver | null = null;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
stats.current.longtaskCount += 1;
stats.current.longtaskTotalMs += entry.duration;
stats.current.longtaskMaxMs = Math.max(stats.current.longtaskMaxMs, entry.duration);
}
});
observer.observe({ type: "longtask", buffered: true });
} catch {
// longtask entries unsupported in this browser — panel shows zeros.
}
return () => observer?.disconnect();
}, [stats]);
// FPS: frames rendered within the trailing 1s window.
useEffect(() => {
let raf = 0;
const frames: number[] = [];
const loop = (now: number) => {
frames.push(now);
while (frames.length > 0 && frames[0] <= now - 1000) frames.shift();
stats.current.fps = frames.length;
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [stats]);
// Flush the mutable stats into the display at most 2x/s.
useEffect(() => {
const id = window.setInterval(() => setSnapshot({ ...stats.current }), STATS_FLUSH_MS);
return () => window.clearInterval(id);
}, [stats]);
const resetLongtasks = () => {
stats.current.longtaskCount = 0;
stats.current.longtaskTotalMs = 0;
stats.current.longtaskMaxMs = 0;
setSnapshot({ ...stats.current });
};
const row: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8 };
return (
<div style={{ fontFamily: "monospace", fontSize: 12, lineHeight: 1.7 }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>Stats</div>
<div style={row}><span>FPS (1s)</span><span>{snapshot.fps}</span></div>
<div style={row}><span>Long tasks</span><span>{snapshot.longtaskCount}</span></div>
<div style={row}><span>Long total</span><span>{snapshot.longtaskTotalMs.toFixed(0)} ms</span></div>
<div style={row}><span>Long max</span><span>{snapshot.longtaskMaxMs.toFixed(0)} ms</span></div>
<div style={row}><span>SSE chunks</span><span>{snapshot.sseChunks}</span></div>
<div style={row}><span>SSE chars</span><span>{snapshot.sseChars.toLocaleString()}</span></div>
<div style={row}><span>Stream</span><span>{snapshot.streamState}</span></div>
<div style={row}>
<span>Mount A</span>
<span>{snapshot.mountAMs === null ? "—" : `${snapshot.mountAMs.toFixed(0)} ms`}</span>
</div>
<button type="button" onClick={resetLongtasks} style={{ marginTop: 6 }}>
Reset long tasks
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Auto-send (scenario B): drive the REAL composer in the mounted DOM
// ---------------------------------------------------------------------------
/**
* Fill the composer textarea via the native value setter + an `input` event
* (React 18 controlled-input pattern), then click the enabled "Send" button.
* Retried on rAF until the elements exist (ChatThread mounts asynchronously).
*/
function autoSend(host: HTMLElement, text: string): void {
const deadline = performance.now() + AUTO_SEND_TIMEOUT_MS;
const tryClick = () => {
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Send"]');
if (button && !button.disabled) {
button.click();
return;
}
if (performance.now() < deadline) requestAnimationFrame(tryClick);
else console.error("[perf] auto-send: Send button never became clickable");
};
const trySetValue = () => {
const textarea = host.querySelector("textarea");
if (!textarea) {
if (performance.now() < deadline) requestAnimationFrame(trySetValue);
else console.error("[perf] auto-send: textarea not found");
return;
}
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(textarea, text);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
// Click on a later frame so React commits the controlled value (which
// enables the Send button) before we press it.
requestAnimationFrame(tryClick);
};
requestAnimationFrame(trySetValue);
}
// ---------------------------------------------------------------------------
// Harness
// ---------------------------------------------------------------------------
interface MountState {
mode: "A" | "B";
key: number;
chatId: string | null;
rows: IAiChatMessageRow[];
}
const noop = (): void => {};
export default function PerfHarness() {
const [preset, setPreset] = useState<PresetKey>("20k");
const [intervalMs, setIntervalMs] = useState<number>(15);
const [mounted, setMounted] = useState<MountState | null>(null);
const [fixtureInfo, setFixtureInfo] = useState<string | null>(null);
const statsRef = useRef<PerfStats>(emptyStats());
const hostRef = useRef<HTMLDivElement>(null);
const keyCounterRef = useRef(0);
const mountStartRef = useRef(0);
const pendingMountMeasureRef = useRef(false);
// The scripted live turn for the current preset (reused across B runs; the
// script is immutable data, so rebuilding per run is unnecessary).
const liveScript = useMemo(() => buildTurnScript(PRESETS[preset], "live"), [preset]);
const openPage = useMemo(() => ({ id: "page-1", title: "Perf test page" }), []);
// Scenario A: mount ChatThread seeded with a large persisted transcript.
const handleMountA = () => {
const fixture = buildPersistedRows(PRESETS[preset]);
setFixtureInfo(
`Persisted fixture: ${fixture.rows.length} rows, ` +
`${fixture.totalChars.toLocaleString()} chars ≈ ${fixture.approxTokens.toLocaleString()} tokens`,
);
statsRef.current.mountAMs = null;
// Mark AFTER fixture generation: we measure mount cost, not generation cost
// (production receives its rows from the network).
performance.mark("perf:mountA:start");
mountStartRef.current = performance.now();
pendingMountMeasureRef.current = true;
keyCounterRef.current += 1;
setMounted({ mode: "A", key: keyCounterRef.current, chatId: "perf-chat", rows: fixture.rows });
};
// Measure scenario A: effect runs after the mount commit; double rAF lands
// after the first paint of the mounted transcript.
useEffect(() => {
if (!pendingMountMeasureRef.current) return;
pendingMountMeasureRef.current = false;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
statsRef.current.mountAMs = performance.now() - mountStartRef.current;
performance.mark("perf:mountA:end");
try {
performance.measure("perf:mountA", "perf:mountA:start", "perf:mountA:end");
} catch {
// Marks cleared mid-run — ignore.
}
});
});
}, [mounted]);
// Scenario B: mount an empty chat, arm the synthetic stream, auto-send.
const handleStartB = () => {
statsRef.current.sseChunks = 0;
statsRef.current.sseChars = 0;
statsRef.current.streamState = "streaming";
setLiveStreamSettings({
script: liveScript,
chunkIntervalMs: intervalMs,
onProgress: (chunks, chars) => {
statsRef.current.sseChunks = chunks;
statsRef.current.sseChars = chars;
},
onDone: () => {
statsRef.current.streamState = "done";
performance.mark("perf:streamB:end");
try {
performance.measure("perf:streamB", "perf:streamB:start", "perf:streamB:end");
} catch {
// Start mark missing (e.g. marks cleared) — ignore.
}
},
onAbort: () => {
statsRef.current.streamState = "aborted";
},
});
performance.mark("perf:streamB:start");
keyCounterRef.current += 1;
setMounted({ mode: "B", key: keyCounterRef.current, chatId: null, rows: [] });
if (hostRef.current) autoSend(hostRef.current, AUTO_SEND_TEXT);
};
const handleUnmount = () => setMounted(null);
const label: CSSProperties = { display: "block", fontSize: 12, margin: "10px 0 2px" };
const button: CSSProperties = { display: "block", width: "100%", margin: "6px 0", padding: "6px 8px" };
return (
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
{/* Left: controls + stats */}
<div
style={{
width: 260,
flex: "0 0 260px",
padding: 12,
borderRight: "1px solid #ccc",
overflowY: "auto",
boxSizing: "border-box",
}}
>
<div style={{ fontWeight: 700, marginBottom: 4 }}>AI chat perf harness</div>
<label style={label}>Preset</label>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as PresetKey)}
style={{ width: "100%" }}
>
<option value="5k">5k tokens</option>
<option value="20k">20k tokens</option>
<option value="50k">50k tokens</option>
</select>
<label style={label}>Chunk interval (scenario B)</label>
<select
value={intervalMs}
onChange={(e) => setIntervalMs(Number(e.target.value))}
style={{ width: "100%" }}
>
<option value={15}>15 ms (normal)</option>
<option value={5}>5 ms (stress)</option>
</select>
<div style={{ marginTop: 12 }}>
<button type="button" style={button} onClick={handleMountA}>
Mount persisted chat (A)
</button>
<button type="button" style={button} onClick={handleStartB}>
Start live stream (B)
</button>
<button type="button" style={button} onClick={handleUnmount} disabled={!mounted}>
Unmount
</button>
</div>
<div style={{ fontSize: 11, color: "#555", margin: "8px 0" }}>
<div>
Live turn: {liveScript.totalChars.toLocaleString()} chars {" "}
{liveScript.approxTokens.toLocaleString()} tokens
</div>
{fixtureInfo && <div>{fixtureInfo}</div>}
{mounted && (
<div>
Mounted: scenario {mounted.mode} (key {mounted.key})
</div>
)}
</div>
<hr style={{ border: "none", borderTop: "1px solid #ddd" }} />
<StatsPanel stats={statsRef} />
</div>
{/* Right: the real ChatThread inside a real-window-sized box */}
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#f4f4f5",
}}
>
<div
ref={hostRef}
style={{
width: 540,
height: 680,
border: "1px solid #bbb",
borderRadius: 8,
background: "#fff",
padding: 8,
boxSizing: "border-box",
overflow: "hidden",
}}
>
{mounted ? (
<ChatThread
key={mounted.key}
chatId={mounted.chatId}
threadKey={`perf-${mounted.key}`}
initialRows={mounted.rows}
openPage={openPage}
roleId={null}
roles={[]}
onRolePicked={noop}
assistantName="Perf agent"
onTurnFinished={noop}
onServerChatId={noop}
/>
) : (
<div style={{ color: "#888", fontSize: 13, padding: 16 }}>
ChatThread unmounted. Use the controls on the left.
</div>
)}
</div>
</div>
</div>
);
}
-517
View File
@@ -1,517 +0,0 @@
/**
* DEV-ONLY synthetic agent-turn generator for the AI chat perf harness.
*
* Produces one scripted agent turn (reasoning + tool calls + markdown answer)
* from a size config, and materializes it two ways:
* - as an AI SDK v6 UI-message SSE stream (scenario B "live agent stream"),
* served by a `window.fetch` patch that intercepts `/api/ai-chat/stream`;
* - as persisted `IAiChatMessageRow[]` history (scenario A "open existing chat").
*
* Wire format verified against the installed ai@6.0.207 `uiMessageChunkSchema`
* (strict objects only the exact field names below are accepted).
*/
import type { UIMessage } from "@ai-sdk/react";
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
// ---------------------------------------------------------------------------
// Config / presets
// ---------------------------------------------------------------------------
/** 1 token ~= 4 chars — the approximation used throughout this module. */
const CHARS_PER_TOKEN = 4;
export interface TurnConfig {
/** Number of agent steps; each step = one reasoning block + one tool call. */
steps: number;
/** Approximate reasoning tokens generated per step. */
reasoningTokensPerStep: number;
/** Size of each tool call's output `content` filler, in bytes (ASCII). */
toolOutputBytes: number;
/** Approximate size of the final markdown answer, in tokens. */
answerTokens: number;
}
export type PresetKey = "5k" | "20k" | "50k";
export const PRESETS: Record<PresetKey, TurnConfig> = {
"5k": {
steps: 3,
reasoningTokensPerStep: 500,
toolOutputBytes: 10_000,
answerTokens: 600,
},
"20k": {
steps: 6,
reasoningTokensPerStep: 2500,
toolOutputBytes: 20_000,
answerTokens: 1500,
},
"50k": {
steps: 10,
reasoningTokensPerStep: 4000,
toolOutputBytes: 40_000,
answerTokens: 3000,
},
};
// ---------------------------------------------------------------------------
// Text generators
// ---------------------------------------------------------------------------
/** Mixed Russian/English prose sentences cycled to build reasoning text. */
const REASONING_SENTENCES = [
"Пользователь просит проанализировать документ и выделить ключевые тезисы по каждому разделу.",
"First I need to inspect the current page content to understand its overall structure.",
"Судя по оглавлению, раздел с техническими требованиями находится ближе к концу документа.",
"The table in section three contains the migration matrix that I should cross-check against the summary.",
"Проверю, нет ли противоречий между описанием API и приведёнными в тексте примерами вызовов.",
"Let me compare the numbers from the executive summary with the raw data in the appendix.",
"Похоже, автор использует термины «воркспейс» и workspace взаимозаменяемо — это стоит нормализовать.",
"I should keep the page ids from the tool output so the final answer can cite the source pages.",
"Осталось свести найденные несоответствия в одну таблицу и предложить порядок исправлений.",
"The remaining sections look consistent, so I can move on to drafting the structured answer.",
];
/**
* Build realistic prose of ~`targetChars` characters, inserting a newline
* roughly every 200 characters (mirrors how reasoning text tends to wrap).
*/
function makeProse(targetChars: number): string {
const pieces: string[] = [];
let length = 0;
let sinceNewline = 0;
let i = 0;
while (length < targetChars) {
const sentence = REASONING_SENTENCES[i % REASONING_SENTENCES.length];
i += 1;
pieces.push(sentence);
length += sentence.length + 1;
sinceNewline += sentence.length + 1;
if (sinceNewline >= 200) {
pieces.push("\n");
sinceNewline = 0;
} else {
pieces.push(" ");
}
}
return pieces.join("").trimEnd();
}
/** One markdown section (~700 chars): heading, prose, bullets, GFM table, code. */
function markdownSection(n: number): string {
return [
`## Section ${n}: migration analysis`,
``,
`The workspace contains **${n * 12} pages** that still reference the legacy API. ` +
`Most of them live under [Perf test page](/p/page-1) and need the new transport. ` +
`Ниже приведена сводка по разделу с оценкой трудозатрат и основных рисков.`,
``,
`- Update the fetch layer to the v6 transport`,
`- Перенести таблицы соответствия идентификаторов`,
`- Verify citation links after the move`,
`- Проверить отображение длинных ответов в узкой панели`,
``,
`| Область | Страниц | Статус | Риск |`,
`| --- | --- | --- | --- |`,
`| API reference | ${n + 4} | migrated | low |`,
`| Onboarding | ${n + 2} | in progress | medium |`,
`| Release notes | ${n * 3} | pending | high |`,
``,
"```ts",
`export function migrateSection${n}(rows: Row[]): Row[] {`,
` return rows`,
` .filter((row) => row.section === ${n})`,
` .map((row) => ({ ...row, migrated: true }));`,
`}`,
"```",
].join("\n");
}
/** Realistic markdown answer of ~`targetChars` chars (sections repeated to size). */
function makeMarkdownAnswer(targetChars: number): string {
const sections: string[] = [];
let length = 0;
let n = 1;
while (length < targetChars) {
const section = markdownSection(n);
sections.push(section);
length += section.length + 2;
n += 1;
}
return sections.join("\n\n");
}
/** Plain ASCII filler of exactly `bytes` characters for tool outputs. */
function makeFiller(bytes: number): string {
const unit = "Perf filler content for the synthetic getPage tool output. ";
return unit.repeat(Math.ceil(bytes / unit.length)).slice(0, bytes);
}
// ---------------------------------------------------------------------------
// Turn script
// ---------------------------------------------------------------------------
export interface TurnToolCall {
toolCallId: string;
toolName: "getPage";
input: { pageId: string };
output: { id: string; title: string; content: string };
}
export interface TurnStep {
reasoningText: string;
tool: TurnToolCall;
}
export interface TurnScript {
steps: TurnStep[];
answerText: string;
/** Approximate reasoning tokens for the whole turn (chars / 4). */
reasoningTokens: number;
/** Approximate context size after this turn, in tokens. */
contextTokens: number;
maxContextTokens: number;
/** Actual generated visible chars: reasoning + tool outputs + answer. */
totalChars: number;
/** totalChars / 4, rounded. */
approxTokens: number;
}
/**
* Build the scripted agent turn for a config. `idPrefix` keeps tool call ids
* unique when several scripts coexist (e.g. 3 persisted turns in one chat).
*/
export function buildTurnScript(config: TurnConfig, idPrefix = "live"): TurnScript {
const steps: TurnStep[] = [];
let reasoningChars = 0;
let toolChars = 0;
for (let i = 0; i < config.steps; i++) {
const reasoningText = makeProse(config.reasoningTokensPerStep * CHARS_PER_TOKEN);
const content = makeFiller(config.toolOutputBytes);
reasoningChars += reasoningText.length;
toolChars += content.length;
steps.push({
reasoningText,
tool: {
toolCallId: `${idPrefix}-call-${i + 1}`,
toolName: "getPage",
input: { pageId: "page-1" },
output: { id: "page-1", title: "Perf test page", content },
},
});
}
const answerText = makeMarkdownAnswer(config.answerTokens * CHARS_PER_TOKEN);
const totalChars = reasoningChars + toolChars + answerText.length;
return {
steps,
answerText,
reasoningTokens: Math.round(reasoningChars / CHARS_PER_TOKEN),
contextTokens: Math.round(totalChars / CHARS_PER_TOKEN),
maxContextTokens: 200_000,
totalChars,
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
};
}
// ---------------------------------------------------------------------------
// Scenario A: persisted rows
// ---------------------------------------------------------------------------
/** Number of user+assistant pairs the preset is split across for history. */
const HISTORY_TURNS = 3;
const USER_PROMPTS = [
"Проанализируй документ и выдели ключевые тезисы по каждому разделу.",
"Now cross-check the migration matrix against the summary and list every mismatch.",
"Собери финальный план миграции с оценкой рисков по каждой области.",
];
/** Persisted UIMessage parts for one finished assistant turn. */
function scriptToPersistedParts(script: TurnScript): UIMessage["parts"] {
const parts: unknown[] = [];
for (const step of script.steps) {
parts.push({ type: "reasoning", text: step.reasoningText, state: "done" });
parts.push({
type: `tool-${step.tool.toolName}`,
toolCallId: step.tool.toolCallId,
state: "output-available",
input: step.tool.input,
output: step.tool.output,
});
}
parts.push({ type: "text", text: script.answerText, state: "done" });
return parts as UIMessage["parts"];
}
export interface PersistedFixture {
rows: IAiChatMessageRow[];
totalChars: number;
approxTokens: number;
}
/**
* Materialize the preset as a finished 3-turn transcript: user row + assistant
* row per turn, with the preset's steps/answer split across the assistant turns.
* Approximate accounting the actual totals are reported back for display.
*/
export function buildPersistedRows(config: TurnConfig): PersistedFixture {
const rows: IAiChatMessageRow[] = [];
const baseTime = Date.now() - HISTORY_TURNS * 60_000;
let totalChars = 0;
for (let t = 0; t < HISTORY_TURNS; t++) {
// Distribute steps as evenly as possible (earlier turns get the remainder).
const stepsForTurn =
Math.floor(config.steps / HISTORY_TURNS) +
(t < config.steps % HISTORY_TURNS ? 1 : 0);
const turnConfig: TurnConfig = {
steps: Math.max(1, stepsForTurn),
reasoningTokensPerStep: config.reasoningTokensPerStep,
toolOutputBytes: config.toolOutputBytes,
answerTokens: Math.max(50, Math.round(config.answerTokens / HISTORY_TURNS)),
};
const script = buildTurnScript(turnConfig, `hist-${t + 1}`);
totalChars += script.totalChars;
const userText = USER_PROMPTS[t % USER_PROMPTS.length];
rows.push({
id: `perf-row-u${t + 1}`,
role: "user",
content: userText,
metadata: null,
createdAt: new Date(baseTime + t * 60_000).toISOString(),
});
rows.push({
id: `perf-row-a${t + 1}`,
role: "assistant",
content: script.answerText,
metadata: {
parts: scriptToPersistedParts(script),
usage: { reasoningTokens: script.reasoningTokens },
contextTokens: script.contextTokens,
maxContextTokens: script.maxContextTokens,
finishReason: "stop",
},
createdAt: new Date(baseTime + t * 60_000 + 30_000).toISOString(),
});
}
return {
rows,
totalChars,
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
};
}
// ---------------------------------------------------------------------------
// Scenario B: SSE stream
// ---------------------------------------------------------------------------
/** Streaming delta size in chars (reasoning/answer text is split into these). */
const DELTA_CHARS = 200;
function splitDeltas(text: string, size = DELTA_CHARS): string[] {
const deltas: string[] = [];
for (let i = 0; i < text.length; i += size) {
deltas.push(text.slice(i, i + size));
}
return deltas;
}
/** One pre-serialized SSE frame plus its visible-char contribution for stats. */
interface SseFrame {
data: string;
chars: number;
}
function frame(chunk: Record<string, unknown>, chars = 0): SseFrame {
return { data: `data: ${JSON.stringify(chunk)}\n\n`, chars };
}
/**
* Serialize the whole scripted turn into AI SDK v6 UI-message SSE frames
* (excluding the final `data: [DONE]` terminator, appended by the pump).
*/
function buildSseFrames(script: TurnScript, messageId: string, chatId: string): SseFrame[] {
const frames: SseFrame[] = [];
frames.push(frame({ type: "start", messageId, messageMetadata: { chatId } }));
script.steps.forEach((step, i) => {
frames.push(frame({ type: "start-step" }));
const reasoningId = `${messageId}-r${i + 1}`;
frames.push(frame({ type: "reasoning-start", id: reasoningId }));
for (const delta of splitDeltas(step.reasoningText)) {
frames.push(frame({ type: "reasoning-delta", id: reasoningId, delta }, delta.length));
}
frames.push(frame({ type: "reasoning-end", id: reasoningId }));
const { toolCallId, toolName, input, output } = step.tool;
frames.push(frame({ type: "tool-input-start", toolCallId, toolName }));
frames.push(frame({ type: "tool-input-available", toolCallId, toolName, input }));
// The tool result arrives as ONE chunk, like the real server sends it.
frames.push(frame({ type: "tool-output-available", toolCallId, output }, output.content.length));
frames.push(frame({ type: "finish-step" }));
});
// Final step: the markdown answer.
frames.push(frame({ type: "start-step" }));
const textId = `${messageId}-answer`;
frames.push(frame({ type: "text-start", id: textId }));
for (const delta of splitDeltas(script.answerText)) {
frames.push(frame({ type: "text-delta", id: textId, delta }, delta.length));
}
frames.push(frame({ type: "text-end", id: textId }));
frames.push(frame({ type: "finish-step" }));
frames.push(
frame({
type: "finish",
messageMetadata: {
usage: { reasoningTokens: script.reasoningTokens },
contextTokens: script.contextTokens,
maxContextTokens: script.maxContextTokens,
finishReason: "stop",
},
}),
);
return frames;
}
export interface LiveStreamSettings {
script: TurnScript;
/** Delay between SSE chunks (one chunk per tick). */
chunkIntervalMs: number;
/** Progress callback: cumulative emitted chunk count and visible chars. */
onProgress?: (chunks: number, chars: number) => void;
/** Fired once after the `[DONE]` terminator is enqueued. */
onDone?: () => void;
/** Fired if the client aborted the stream (Stop button). */
onAbort?: () => void;
}
/**
* Build a synthetic SSE Response streaming the scripted turn, one chunk every
* `chunkIntervalMs`. Honors the fetch `AbortSignal` so the real Stop button works.
*/
export function buildSseResponse(
settings: LiveStreamSettings,
signal?: AbortSignal | null,
): Response {
const messageId = `m-live-${Date.now()}`;
const frames = buildSseFrames(settings.script, messageId, "perf-chat");
const encoder = new TextEncoder();
let index = 0;
let emittedChars = 0;
let timer: number | undefined;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const stopPump = () => {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
};
const pump = () => {
timer = undefined;
if (signal?.aborted) {
stopPump();
try {
controller.close();
} catch {
// Already closed/cancelled — nothing to do.
}
return;
}
if (index >= frames.length) {
try {
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
} catch {
// Cancelled mid-flight.
}
settings.onDone?.();
return;
}
const next = frames[index];
index += 1;
try {
controller.enqueue(encoder.encode(next.data));
} catch {
stopPump();
return;
}
emittedChars += next.chars;
settings.onProgress?.(index, emittedChars);
timer = window.setTimeout(pump, settings.chunkIntervalMs);
};
signal?.addEventListener(
"abort",
() => {
stopPump();
try {
controller.close();
} catch {
// Reader already cancelled.
}
settings.onAbort?.();
},
{ once: true },
);
timer = window.setTimeout(pump, settings.chunkIntervalMs);
},
cancel() {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
},
});
return new Response(stream, {
status: 200,
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
"x-vercel-ai-ui-message-stream": "v1",
},
});
}
// ---------------------------------------------------------------------------
// window.fetch patch
// ---------------------------------------------------------------------------
let currentLiveSettings: LiveStreamSettings | null = null;
/** Arm the next `/api/ai-chat/stream` request with a scripted turn. */
export function setLiveStreamSettings(settings: LiveStreamSettings): void {
currentLiveSettings = settings;
}
/**
* Patch `window.fetch` BEFORE React mounts: requests to `/api/ai-chat/stream`
* get the synthetic SSE Response; everything else passes through untouched.
*/
export function installAiChatStreamFetchPatch(): void {
const originalFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (url.includes("/api/ai-chat/stream")) {
const settings = currentLiveSettings;
if (!settings) {
return Promise.resolve(
new Response("perf harness: no live stream configured", { status: 500 }),
);
}
return Promise.resolve(buildSseResponse(settings, init?.signal ?? null));
}
return originalFetch(input, init);
};
}
@@ -471,6 +471,18 @@
"Move page": "Move page",
"Move page to a different space.": "Move page to a different space.",
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
"Syncing changes…": "Syncing changes…",
"All changes synced": "All changes synced",
"Update available": "Update available",
"Reload": "Reload",
"Make available offline": "Make available offline",
"Saving page for offline use...": "Saving page for offline use...",
"Page is now available offline": "Page is now available offline",
"Failed to make page available offline": "Failed to make page available offline",
"You're offline": "You're offline",
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
"Retry": "Retry",
"Table of contents": "Table of contents",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share",
@@ -1222,8 +1234,8 @@
"Commented": "Commented",
"Resolved comment": "Resolved comment",
"Ran tool {{name}}": "Ran tool {{name}}",
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
"AI agent {{name}}": "AI agent {{name}}",
"AI-agent": "AI-agent",
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
"Endpoints": "Endpoints",
"where we fetch models": "where we fetch models",
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
@@ -1274,10 +1286,6 @@
"Voice dictation is not configured": "Voice dictation is not configured",
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
"Dictation": "Dictation",
"Dictation becomes available once the page finishes connecting": "Dictation becomes available once the page finishes connecting",
"No connection to the collaboration server — dictation unavailable": "No connection to the collaboration server — dictation unavailable",
"This page is read-only": "This page is read-only",
"Request format": "Request format",
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
@@ -1373,50 +1381,9 @@
"The role catalog is unavailable": "The role catalog is unavailable",
"Please try again later.": "Please try again later.",
"No bundles available": "No bundles available",
"Content": "Content",
"Content language of the roles": "Content language of the roles",
"{{count}} updates available in {{bundles}} bundles": "{{count}} updates available in {{bundles}} bundles",
"Update all ({{count}})": "Update all ({{count}})",
"Updating {{current}}/{{total}}…": "Updating {{current}}/{{total}}…",
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "{{count}} roles are installed in another language. A different language installs separately and appears as new.",
"{{count}} roles": "{{count}} roles",
"{{count}} new — none installed": "{{count}} new — none installed",
"All installed · up to date": "All installed · up to date",
"{{count}} updates · {{installed}} up to date": "{{count}} updates · {{installed}} up to date",
"{{count}} new": "{{count}} new",
"{{count}} installed": "{{count}} installed",
"{{count}} updates": "{{count}} updates",
"Install bundle": "Install bundle",
"Install {{count}} selected": "Install {{count}} selected",
"Install bundle ({{count}})": "Install bundle ({{count}})",
"{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected",
"Select all": "Select all",
"Deselect all": "Deselect all",
"Skipped": "Skipped",
"v{{version}}": "v{{version}}",
"{{count}} roles installed": "{{count}} roles installed",
"{{count}} roles installed · {{renamed}} renamed": "{{count}} roles installed · {{renamed}} renamed",
"{{count}} roles updated": "{{count}} roles updated",
"Installed {{installed}} · {{skipped}} skipped": "Installed {{installed}} · {{skipped}} skipped",
"A role named \"{{name}}\" already exists in this workspace.": "A role named \"{{name}}\" already exists in this workspace.",
"\"{{name}}\" is already installed.": "\"{{name}}\" is already installed.",
"Rename & install": "Rename & install",
"Couldn’t load the catalog": "Couldn’t load the catalog",
"Check your connection and try again. Installed roles are not affected.": "Check your connection and try again. Installed roles are not affected.",
"Retry": "Retry",
"The catalog is empty": "The catalog is empty",
"No role bundles are published for this language yet. Try switching the content language.": "No role bundles are published for this language yet. Try switching the content language.",
"Already up to date": "Already up to date",
"Updated to the latest version": "Updated to the latest version",
"This role is no longer in the catalog": "This role is no longer in the catalog",
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
"Connecting… (read-only)": "Connecting… (read-only)",
"Apply": "Apply",
"Applied": "Applied",
"Suggestion applied": "Suggestion applied",
"Failed to apply suggestion": "Failed to apply suggestion",
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
"Dismiss": "Dismiss",
"Suggestion dismissed": "Suggestion dismissed",
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
"Connecting… (read-only)": "Connecting… (read-only)"
}
@@ -393,17 +393,6 @@
"No speech detected": "Речь не распознана",
"Transcription failed": "Не удалось распознать речь",
"Voice dictation is not configured": "Голосовой ввод не настроен",
"Start dictation": "Начать диктовку",
"Stop recording": "Остановить запись",
"Microphone access denied": "Доступ к микрофону запрещён",
"No microphone found": "Микрофон не найден",
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
"Could not start recording": "Не удалось начать запись",
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
"Dictation": "Диктовка",
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
"This page is read-only": "Страница открыта только для чтения",
"Embed PDF": "Встроить PDF",
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
"Embed as PDF": "Встроить как PDF",
@@ -487,6 +476,18 @@
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
"Syncing changes…": "Синхронизация изменений…",
"All changes synced": "Все изменения синхронизированы",
"Update available": "Доступно обновление",
"Reload": "Перезагрузить",
"Make available offline": "Сделать доступным офлайн",
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
"Page is now available offline": "Страница доступна офлайн",
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
"You're offline": "Вы офлайн",
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "Эта страница не была сохранена для офлайн-доступа, поэтому её нельзя загрузить сейчас. Подключитесь к интернету и попробуйте снова.",
"Retry": "Повторить",
"Table of contents": "Оглавление",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться",
@@ -735,8 +736,7 @@
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
"AI agent {{name}}": "AI-агент {{name}}",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
@@ -1235,51 +1235,10 @@
"The role catalog is unavailable": "Каталог ролей недоступен",
"Please try again later.": "Попробуйте позже.",
"No bundles available": "Наборы недоступны",
"Content": "Язык контента",
"Content language of the roles": "Язык контента ролей",
"{{count}} updates available in {{bundles}} bundles": "Доступно обновлений: {{count}} в наборах: {{bundles}}",
"Update all ({{count}})": "Обновить все ({{count}})",
"Updating {{current}}/{{total}}…": "Обновление {{current}}/{{total}}…",
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "Ролей установлено на другом языке: {{count}}. Другой язык устанавливается отдельно и отображается как новый.",
"{{count}} roles": "ролей: {{count}}",
"{{count}} new — none installed": "новых: {{count}} — ничего не установлено",
"All installed · up to date": "Все установлены · актуальны",
"{{count}} updates · {{installed}} up to date": "обновлений: {{count}} · актуальны: {{installed}}",
"{{count}} new": "новых: {{count}}",
"{{count}} installed": "установлено: {{count}}",
"{{count}} updates": "обновлений: {{count}}",
"Install bundle": "Установить набор",
"Install {{count}} selected": "Установить выбранные ({{count}})",
"Install bundle ({{count}})": "Установить набор ({{count}})",
"{{selected}} of {{total}} selected": "выбрано {{selected}} из {{total}}",
"Select all": "Выбрать все",
"Deselect all": "Снять выбор",
"Skipped": "Пропущено",
"v{{version}}": "v{{version}}",
"{{count}} roles installed": "Установлено ролей: {{count}}",
"{{count}} roles installed · {{renamed}} renamed": "Установлено ролей: {{count}} · переименовано: {{renamed}}",
"{{count}} roles updated": "Обновлено ролей: {{count}}",
"Installed {{installed}} · {{skipped}} skipped": "Установлено: {{installed}} · пропущено: {{skipped}}",
"A role named \"{{name}}\" already exists in this workspace.": "Роль с именем «{{name}}» уже существует в этом рабочем пространстве.",
"\"{{name}}\" is already installed.": "«{{name}}» уже установлена.",
"Rename & install": "Переименовать и установить",
"Couldn’t load the catalog": "Не удалось загрузить каталог",
"Check your connection and try again. Installed roles are not affected.": "Проверьте подключение и попробуйте снова. Установленные роли не затронуты.",
"Retry": "Повторить",
"The catalog is empty": "Каталог пуст",
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
"No roles configured": "Роли не настроены",
"Already up to date": "Уже актуальна",
"Updated to the latest version": "Обновлено до последней версии",
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
"Connecting… (read-only)": "Подключение… (только чтение)",
"Apply": "Применить",
"Applied": "Применено",
"Suggestion applied": "Предложение применено",
"Failed to apply suggestion": "Не удалось применить предложение",
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
"Dismiss": "Не применять",
"Suggestion dismissed": "Предложение отклонено",
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
"Connecting… (read-only)": "Подключение… (только чтение)"
}
+9 -20
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" }
]
}
@@ -14,22 +14,6 @@ import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
import { useTranslation } from "react-i18next";
// The export request uses `responseType: "blob"`, so a server error body arrives
// as a Blob rather than parsed JSON — `err.response?.data.message` is therefore
// always undefined. Read and parse the blob to surface the real error message.
async function extractExportError(err: any): Promise<string> {
const data = err?.response?.data;
if (data instanceof Blob) {
try {
const json = JSON.parse(await data.text());
return json?.message ?? "";
} catch {
return "";
}
}
return data?.message ?? err?.message ?? "";
}
interface ExportModalProps {
id: string;
type: "space" | "page";
@@ -68,9 +52,8 @@ export default function ExportModal({
});
onClose();
} catch (err) {
const message = await extractExportError(err);
notifications.show({
message: t("Export failed") + (message ? `: ${message}` : ""),
message: "Export failed:" + err.response?.data.message,
color: "red",
});
console.error("export error", err);
@@ -12,7 +12,6 @@ import TopMenu from "@/components/layouts/global/top-menu.tsx";
import { Link } from "react-router-dom";
import { useAtom } from "jotai";
import {
NAVBAR_COLLAPSE_BREAKPOINT,
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
@@ -54,13 +53,7 @@ export function AppHeader() {
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
// Must match the AppShell navbar breakpoint (md). The navbar
// collapses to the MOBILE drawer below md, so the mobile toggle
// (which flips mobileOpened) must be the one visible across the
// whole <md band — otherwise at 768-991 the desktop toggle showed
// but flipped the wrong atom, leaving the drawer unopenable (the
// regression from the initial sm->md navbar change).
hiddenFrom={NAVBAR_COLLAPSE_BREAKPOINT}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
@@ -70,7 +63,7 @@ export function AppHeader() {
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom={NAVBAR_COLLAPSE_BREAKPOINT}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
@@ -6,7 +6,6 @@ import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
APP_NAVBAR_ID,
NAVBAR_COLLAPSE_BREAKPOINT,
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom,
@@ -89,13 +88,7 @@ export default function GlobalAppShell({
header={{ height: 45 }}
navbar={{
width: isSpaceRoute ? sidebarWidth : 300,
// `md` (not `sm`): below 992px the fixed ~300px sidebar leaves too little
// room for content — the settings tables (Members/…) overflow the offset
// content area on tablet (~768px) and clip the Role/actions columns
// off-screen with no horizontal scroll. Collapsing the navbar to a toggle
// drawer across the whole tablet band frees the full width for content
// (the mobile drawer is closed by default, so nothing overlaps on load).
breakpoint: NAVBAR_COLLAPSE_BREAKPOINT,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
@@ -104,7 +97,7 @@ export default function GlobalAppShell({
aside={
isPageRoute && {
width: 420,
breakpoint: "md",
breakpoint: "sm",
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
}
}
@@ -7,13 +7,6 @@ import { atom } from "jotai";
// would create a shell -> chat-window -> shell import cycle).
export const APP_NAVBAR_ID = "app-shell-navbar";
// Single source of truth for the navbar collapse breakpoint. The AppShell navbar
// `breakpoint` and BOTH burger toggles' `hiddenFrom`/`visibleFrom` MUST use this
// exact value: if they drift, the sidebar becomes unreachable on tablet widths
// (the round-1 regression of #292). Kept here so the shell and the header share
// one constant the compiler enforces, instead of three hand-synced string literals.
export const NAVBAR_COLLAPSE_BREAKPOINT = "md";
export const mobileSidebarAtom = atom<boolean>(false);
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
@@ -1,183 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AgentAvatarStack } from "./agent-avatar-stack";
import { avatarStyle } from "@/lib/avatar-palette";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
type Props = React.ComponentProps<typeof AgentAvatarStack>;
// The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the
// expected color through the same CSSOM path so the comparison stays exact and
// non-vacuous (an empty string — i.e. no inline background, as in the pre-fix
// Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not
// round-trip a `linear-gradient` in the `background` shorthand, which is why the
// glyph carries an explicit solid `background-color` we assert on here.
function normalizeColor(value: string): string {
const probe = document.createElement("div");
probe.style.backgroundColor = value;
return probe.style.backgroundColor;
}
function renderStack(props: Props) {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const utils = render(
<Provider store={store}>
<MantineProvider>
<AgentAvatarStack {...props} />
</MantineProvider>
</Provider>,
);
return { store, ...utils };
}
describe("AgentAvatarStack", () => {
it("internal chat WITH role: emoji glyph + human launcher badge in front", () => {
const { container } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
// Emoji is used as the glyph (priority 2), NOT the sparkles fallback.
expect(screen.getByText("🔬")).toBeDefined();
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
// Label: bold role name + dimmed "· launcher".
expect(screen.getByText("Researcher")).toBeDefined();
expect(screen.getByText(/·/)).toBeDefined();
expect(screen.getByText("Alice")).toBeDefined();
});
it("emoji glyph applies its per-agent gradient as an inline DOM background", () => {
// Pins the actual fix: the hashed gradient must reach the DOM as an inline
// `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no
// inline background (Mantine's --avatar-bg overrode it), so this fails there.
const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null };
const { container } = renderStack({
agent,
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
const glyph = container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]',
);
expect(glyph).not.toBeNull();
const expected = normalizeColor(avatarStyle(agent.name).bg);
// Non-vacuous: the pre-fix Avatar set no inline background at all.
expect(expected).not.toBe("");
expect(glyph!.style.backgroundColor).toBe(expected);
// (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does
// not round-trip linear-gradient — so its stops/angle are covered by the
// avatarStyle unit tests above, not asserted on the DOM here.)
});
it("agents with distinct styles reach the DOM as distinct backgrounds", () => {
// "Researcher" and "Нарратор" hash to different palette entries, so their
// applied DOM backgrounds must differ — pins "distinct colors reach the DOM".
expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg);
const a = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: null,
aiChatId: null,
});
const b = renderStack({
agent: { name: "Нарратор", emoji: "📖", avatarUrl: null },
launcher: null,
aiChatId: null,
});
const glyphA = a.container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]',
);
const glyphB = b.container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]',
);
expect(glyphA!.style.backgroundColor).not.toBe("");
// Different base colors reach the DOM (the serialized rgb values differ).
expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor);
});
it("showName=false: renders only the avatars, no inline name label", () => {
renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
showName: false,
});
// The agent glyph is still rendered...
expect(screen.getByText("🔬")).toBeDefined();
// ...but neither the agent NOR the launcher inline name label is rendered
// (they live only in the hover tooltip, which is not mounted in the initial
// DOM) — guards against suppressing only the agent name and leaking the
// launcher name.
expect(screen.queryByText("Researcher")).toBeNull();
expect(screen.queryByText("Alice")).toBeNull();
});
it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => {
const { container } = renderStack({
agent: { name: "AI agent", avatarUrl: null },
launcher: { name: "Bob", avatarUrl: null },
aiChatId: "chat-2",
});
// No avatarUrl and no emoji => sparkles glyph (priority 3).
expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull();
expect(screen.getByText("AI agent")).toBeDefined();
expect(screen.getByText("Bob")).toBeDefined();
});
it("external MCP: agent avatar only, NO human launcher badge", () => {
const { container } = renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
});
// avatarUrl provided (priority 1) => not the sparkles fallback.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
expect(screen.getByText("MCP Bot")).toBeDefined();
// No human behind => no "·" separator is rendered.
expect(screen.queryByText(/·/)).toBeNull();
// No internal chat => the stack is not an interactive deep-link button.
expect(screen.queryByRole("button")).toBeNull();
});
it("click deep-links into the chat when aiChatId is present", () => {
const { store } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
const button = screen.getByRole("button");
fireEvent.click(button);
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch
});
it("click is a no-op / not interactive without a chat target", () => {
const onActivate = vi.fn();
renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
onActivate,
});
expect(screen.queryByRole("button")).toBeNull();
expect(onActivate).not.toHaveBeenCalled();
});
});
@@ -1,234 +0,0 @@
import { Box, Group, Text, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// The FRONT identity (the acting agent) and the BEHIND identity (the human who
// launched it). Both are computed server-side (#300) so the client never branches
// on the internal-vs-MCP provenance — it just renders whatever it is handed.
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
const GLYPH_SIZE = 38;
const LAUNCHER_SIZE = 22;
// How far the launcher avatar sticks out past the agent's top-right corner — it
// sits as a small badge over that corner (above the glyph) and stays fully visible.
const LAUNCHER_OVERHANG = 8;
/**
* The front avatar. Image-source priority (#300):
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
* 2. agent.emoji -> the role emoji on a per-agent gradient circle.
* 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle.
*/
function AgentGlyph({ agent }: { agent: AgentInfo }) {
if (agent.avatarUrl) {
return (
<CustomAvatar
size={GLYPH_SIZE}
avatarUrl={agent.avatarUrl}
name={agent.name}
/>
);
}
// Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner
// and split angle all hashed from the agent name via avatarStyle — see
// @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine
// `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background
// (every agent fell back to the theme's violet). The foreground (the sparkles
// icon) uses the ring's WCAG-checked readable text color.
const style = avatarStyle(agent.name);
return (
<Box
data-testid="agent-glyph"
style={{
width: GLYPH_SIZE,
height: GLYPH_SIZE,
borderRadius: "50%",
// Solid base color is the fallback (and the testable value); the gradient
// paints over it in browsers that support it.
backgroundColor: style.bg,
backgroundImage: avatarBackgroundCss(style),
color:
style.text === "white"
? "var(--mantine-color-white)"
: "var(--mantine-color-black)",
display: "flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 1,
}}
>
{agent.emoji ? (
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
{agent.emoji}
</span>
) : (
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
)}
</Box>
);
}
export interface AgentAvatarStackProps {
agent: AgentInfo;
// null/absent => external MCP (front agent avatar only, no human behind).
launcher?: LauncherInfo | null;
// Deep-links into the internal AI chat when present (null for external MCP).
aiChatId?: string | null;
// Fired after the stack deep-links into its chat, so the caller can react
// (e.g. the page-history row closes the history modal). Keeps this ui/ primitive
// free of cross-feature coupling (inherited from the old AiAgentBadge, #143).
onActivate?: () => void;
// Whether to render the inline name label next to the avatars (default true).
// Set false when the caller renders the name itself (e.g. the comment row).
showName?: boolean;
}
/**
* The "agent avatar stack" (#300): the AGENT glyph, and for an internal AI
* chat the HUMAN who launched it as a smaller avatar badge on top, overhanging
* the glyph's top-right corner in FRONT (zIndex 2 > the glyph's zIndex 1) so the
* launcher stays fully visible rather than being half-hidden behind the glyph.
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
* whole stack is a deep-link into that chat (the click the old badge owned moved
* here); the click is contained (stopPropagation) so it does not also trigger an
* enclosing row handler.
*/
export function AgentAvatarStack({
agent,
launcher,
aiChatId,
onActivate,
showName = true,
}: AgentAvatarStackProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const clickable = !!aiChatId;
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching chats must start with a clean composer — clear any unsent draft
// so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
// Internal chat => "role on behalf of person"; external MCP => just the agent.
const tooltip = launcher
? t("AI agent «{{role}}» on behalf of {{person}}", {
role: agent.name,
person: launcher.name,
})
: t("AI agent {{name}}", { name: agent.name });
// The container is only enlarged when there is a launcher to overhang; with no
// human behind it stays tight at the agent glyph size.
const stackSize = launcher ? GLYPH_SIZE + LAUNCHER_OVERHANG : GLYPH_SIZE;
const stack = (
<Box
pos="relative"
style={{
width: stackSize,
height: stackSize,
flexShrink: 0,
// Center the (in-flow) agent glyph vertically so it lines up with its
// name label; the absolutely-positioned launcher is unaffected by flex.
display: "flex",
alignItems: "center",
cursor: clickable ? "pointer" : undefined,
}}
{...(clickable
? {
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{launcher && (
// Launcher badge sits ABOVE the agent glyph (zIndex) at the top-right so
// it is fully visible, not half-hidden behind the agent circle.
<Box pos="absolute" top={0} right={0} style={{ zIndex: 2 }}>
<CustomAvatar
size={LAUNCHER_SIZE}
avatarUrl={launcher.avatarUrl}
name={launcher.name}
style={{ border: "2px solid var(--mantine-color-body)" }}
/>
</Box>
)}
{/* The agent glyph keeps its own size (flex-centered in the container); the
launcher overhangs it by LAUNCHER_OVERHANG at the top-right and stays visible. */}
<Box
style={{
position: "relative",
zIndex: 1,
width: GLYPH_SIZE,
height: GLYPH_SIZE,
}}
>
<AgentGlyph agent={agent} />
</Box>
</Box>
);
return (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Tooltip label={tooltip} withArrow>
{stack}
</Tooltip>
{showName && (
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{agent.name}
</Text>
{launcher && (
<>
<Text size="xs" c="dimmed" fw={400} aria-hidden>
·
</Text>
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
{launcher.name}
</Text>
</>
)}
</Group>
)}
</Group>
);
}
export default AgentAvatarStack;
@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AiAgentBadge } from "./ai-agent-badge";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
return render(
<MantineProvider>
<AiAgentBadge {...props} />
</MantineProvider>,
);
}
// Render a clickable badge inside an explicit jotai store, with a leftover draft
// and an onActivate + parent-click spy, so the deep-link side effects are
// assertable. Returns the store and spies.
function setupClickable() {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const onActivate = vi.fn();
const onParentClick = vi.fn();
render(
<Provider store={store}>
<MantineProvider>
<div onClick={onParentClick}>
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
</div>
</MantineProvider>
</Provider>,
);
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
}
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
}
describe("AiAgentBadge", () => {
it("renders the AI-agent label", () => {
renderBadge({ authorName: "Bot" });
expect(screen.getByText("AI-agent")).toBeDefined();
});
it("is clickable (accessible button) when aiChatId is present", () => {
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
const badge = screen.getByRole("button");
expect(badge).toBeDefined();
expect(badge.textContent).toContain("AI-agent");
});
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
const { store, onActivate, onParentClick, badge } = setupClickable();
fireEvent.click(badge);
expectDeepLinked(store, onActivate);
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
});
it.each(["Enter", " "])(
"keyboard %j activates the deep-link (same side effects as click)",
(key) => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key });
expectDeepLinked(store, onActivate);
},
);
it("an unrelated key does NOT activate the badge", () => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key: "Tab" });
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
expect(onActivate).not.toHaveBeenCalled();
});
it.each([{ aiChatId: null }, {}])(
"is a plain non-clickable label without a chat target (%o)",
(props) => {
renderBadge({ authorName: "Bot", ...props });
expect(screen.getByText("AI-agent")).toBeDefined();
// No interactive role is exposed when there is no chat to deep-link into.
expect(screen.queryByRole("button")).toBeNull();
},
);
});
@@ -0,0 +1,99 @@
import { Badge, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
interface AiAgentBadgeProps {
authorName?: string;
aiChatId?: string | null;
// Fired after the badge deep-links into its chat. The caller handles its own
// context (e.g. the page-history row closes the history modal) so this generic
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
onActivate?: () => void;
}
/**
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
* ADDITIVE shown next to the human author, never replacing them. Reused by the
* page-history list and the comments sidebar.
*
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
* badge deep-links into that chat: it sets the active-chat atom and opens the
* floating AI-chat window, then invokes `onActivate` so the caller can react
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
* external MCP write with no internal ai_chats row), the badge is a plain
* non-clickable label. The click is contained (stopPropagation) so it does not
* also trigger an enclosing row's click handler.
*/
export function AiAgentBadge({
authorName,
aiChatId,
onActivate,
}: AiAgentBadgeProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
name: authorName ?? "",
});
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching to another chat must start with a clean composer — clear any
// unsent draft so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
const badge = (
<Badge
size="sm"
variant="light"
color="violet"
radius="sm"
leftSection={<IconSparkles size={12} stroke={2} />}
style={aiChatId ? { cursor: "pointer" } : undefined}
{...(aiChatId
? {
// Keep the default Badge root element (not a <button>) to avoid an
// invalid <button>-in-<button> nesting inside a row's
// UnstyledButton; expose it as an accessible button via
// role/keyboard.
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{t("AI-agent")}
</Badge>
);
return (
<Tooltip label={tooltip} withArrow>
{badge}
</Tooltip>
);
}
export default AiAgentBadge;
@@ -19,7 +19,7 @@ import {
IconPlus,
IconX,
} from "@tabler/icons-react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useAtom, useSetAtom } from "jotai";
import { useLocation, useMatch } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
@@ -41,24 +41,13 @@ import { extractPageSlugId } from "@/lib";
import {
AI_CHATS_RQ_KEY,
AI_CHAT_MESSAGES_RQ_KEY,
AI_CHAT_RUN_RQ_KEY,
useAiChatMessagesQuery,
useAiChatRunQuery,
useAiChatsQuery,
useAiRolesQuery,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import {
shouldClearLatchOnQueryError,
shouldClearStoppingLatch,
shouldObserveRun,
} from "@/features/ai-chat/utils/run-polling.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
import {
exportAiChat,
stopRun,
} from "@/features/ai-chat/services/ai-chat-service.ts";
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
import {
shouldCollapseOnOutsidePointer,
@@ -245,147 +234,6 @@ export default function AiChatWindow() {
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(activeChatId ?? undefined);
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
// this workspace. The reconnect endpoint itself is NOT flag-gated server-side
// (it is only owner-gated and returns `{ run: null }` when the chat has no
// run); but when the feature is off no runs are ever created, so polling it
// would always come back empty — we gate it off here to avoid pointless polls.
const workspace = useAtomValue(workspaceAtom);
const autonomousRunsEnabled =
workspace?.settings?.ai?.autonomousRuns === true;
// Whether THIS tab is the one actively streaming the open chat's run locally
// (it started the run here and holds the SSE). Reported up from ChatThread. We
// are the STREAMER while true and a passive OBSERVER while false — the basis of
// the observer-vs-streamer detection. Reset to false by the fresh ChatThread's
// mount effect on every chat switch.
const [localStreaming, setLocalStreaming] = useState(false);
const onStreamingChange = useCallback((streaming: boolean) => {
setLocalStreaming(streaming);
}, []);
// #184 Stop wiring. While a detached run is being stopped we SUPPRESS the
// observer merge so the stopping run's still-persisting output does not
// re-stream back into view between the moment the user pressed Stop and the run
// actually settling as 'aborted' server-side. Polling itself keeps running (so
// the terminal transition is still detected) — only the visual merge is gated.
// Cleared when the run is observed terminal (below) or the chat is switched.
const [stoppingRun, setStoppingRun] = useState(false);
// Reset the stopping latch whenever the open chat changes: it is scoped to the
// run of the previously-open chat.
useEffect(() => {
setStoppingRun(false);
}, [activeChatId]);
// Authoritative stop of the open chat's detached run (the Stop button in
// autonomous mode). Latch "stopping" first (suppresses the re-stream flash),
// then request the server stop — the ONLY thing that ends a detached run; a mere
// local SSE abort is a client disconnect the server ignores. On failure we
// release the latch so the observer resumes (better to show the live run than to
// freeze the view) and surface the error.
const handleServerStop = useCallback(
(chatId: string): void => {
setStoppingRun(true);
// #234 F4: drop the PREVIOUS turn's run from the cache so `run` becomes null
// until the CURRENT turn's run is fetched fresh. Without this, once the local
// stream aborts (localStreaming -> false) the run query re-enables and
// react-query SYNCHRONOUSLY returns the still-cached prior terminal run; the
// terminal effect would then clear the stopping latch against that STALE run
// before the current turn's (still-running, detached, growing) run is ever
// observed — re-opening the observer merge and flashing the growing output
// over the frozen row. With the cache cleared the terminal effect's
// `if (!run) return` holds the latch until the current run itself is observed
// terminal (see shouldClearStoppingLatch).
queryClient.removeQueries({ queryKey: AI_CHAT_RUN_RQ_KEY(chatId) });
void stopRun(chatId).catch(() => {
setStoppingRun(false);
notifications.show({
message: t("Failed to stop the run"),
color: "red",
});
});
},
[t, queryClient],
);
// Poll the latest run of the open chat ONLY when we are a passive observer:
// feature on, a chat is open, and we are NOT the local streamer (the streamer
// already has the live SSE — polling/merging too would double-render). The
// query's own status-keyed refetchInterval stops once the run is terminal.
const { data: runData, isError: runQueryFailed } = useAiChatRunQuery(
activeChatId ?? undefined,
autonomousRunsEnabled && !localStreaming,
);
const run = runData?.run ?? null;
// Safety net (#234 F4 review): after handleServerStop clears the run cache,
// `run` is null until the current turn's run is fetched fresh, and the terminal
// effect below holds the latch via `if (!run) return`. If that refetch instead
// ERRORS PERMANENTLY (the GET-run keeps failing) while we are no longer the
// streamer, the run stays null, its status-keyed refetchInterval is off, and
// nothing would ever observe a terminal run — freezing the view with the
// observer merge suppressed. Release the latch on that error so the live view
// resumes rather than stays stuck (the local stopRun may already have succeeded
// independently).
//
// #234 F7: this must NOT fire on a TRANSIENT error while `run` is still an
// ACTIVE held run. In TanStack Query v5 (retry:false) the query's `data` is
// RETAINED on error, so `runQueryFailed` can be true while `run` is still
// pending/running — releasing then would re-open the observer merge and flash
// the growing detached run over the frozen row (the very flash F4 prevents). The
// decision is the pure, unit-tested `shouldClearLatchOnQueryError`, which gates
// on the run NOT being active: it cures only the genuine permanent-null-freeze
// (`run === null`) and never releases against an active run.
useEffect(() => {
if (
shouldClearLatchOnQueryError({
stoppingRun,
isLocalStreaming: localStreaming,
runQueryFailed,
run,
})
)
setStoppingRun(false);
}, [stoppingRun, localStreaming, runQueryFailed, run]);
// The run's incrementally-persisted assistant message to merge into the thread,
// but only while we are an observer (never when we are the streamer — guards
// against a stale poll fighting the live stream). Includes a terminal run so the
// final persisted output is shown on reopen.
const observedRow =
shouldObserveRun(run, localStreaming) && !stoppingRun
? (runData?.message ?? null)
: null;
// When the observed run reaches a terminal status, do a final messages refetch
// so the persisted final state (token/context badge, export source) is shown,
// then the query's refetchInterval has already stopped polling. Deduped per run
// id so it fires exactly once per run, not on every subsequent poll-less render.
const finalizedRunIdRef = useRef<string | null>(null);
useEffect(() => {
if (!run || !activeChatId) return;
if (run.status === "pending" || run.status === "running") {
// Active again (a new run) — re-arm so its terminal transition fires once.
finalizedRunIdRef.current = null;
return;
}
// Terminal: a stop we requested has landed (or the run finished on its own),
// so release the stopping latch — the observer merge can now show the final
// persisted (aborted/finished) output without any live re-stream. The decision
// is the pure, unit-tested `shouldClearStoppingLatch` (run-polling.ts): release
// ONLY when we requested a stop, this tab is no longer the streamer, AND the
// CURRENT run is terminal. The #234 F4 cache removal in handleServerStop makes
// `run` null (this branch's `if (!run) return` above holds) until the current
// turn's run is fetched fresh, so the latch can never clear against a stale
// cached run.
if (shouldClearStoppingLatch({ stoppingRun, run, isLocalStreaming: localStreaming }))
setStoppingRun(false);
if (finalizedRunIdRef.current === run.id) return;
finalizedRunIdRef.current = run.id;
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
});
}, [run, activeChatId, queryClient, stoppingRun, localStreaming]);
// 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"
@@ -1034,18 +882,6 @@ export default function AiChatWindow() {
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
onServerChatId={onServerChatId}
// #184: live-follow a still-running run when we reopened the chat as
// a passive observer; null when there is nothing to observe or this
// tab is the streamer. onStreamingChange lets the window stop polling
// while we are the streamer.
observedRow={observedRow}
onStreamingChange={onStreamingChange}
// #184: in autonomous mode the Stop button must hit the authoritative
// server stop (a local SSE abort is a client disconnect the server
// ignores). onServerStop also arms the "stopping" latch above so the
// stopped run's output does not re-stream via the observer merge.
autonomousRunsEnabled={autonomousRunsEnabled}
onServerStop={handleServerStop}
/>
)}
</div>
@@ -164,8 +164,8 @@
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
rendered markdown <div> it would turn the newlines between block tags
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
margins. The streaming plain-text path that needs pre-wrap sets it
per chunk instead, in PlainChunk (see streaming-plain-text.tsx). */
margins. The plain-text fallback <Text> that needs pre-wrap sets it
inline itself (see reasoning-block.tsx). */
}
.reasoningText p {
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
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
@@ -11,7 +11,6 @@ const h = vi.hoisted(() => ({
onFinish: null as null | ((arg: Record<string, unknown>) => void),
sendMessage: vi.fn(),
stop: vi.fn(),
setMessages: vi.fn(),
transport: null as null | {
prepareSendMessagesRequest: (arg: {
messages: unknown[];
@@ -31,8 +30,6 @@ vi.mock("@ai-sdk/react", () => ({
status: h.state.status,
stop: h.state.stop,
error: null,
// #184: ChatThread reads setMessages to merge a polled observer run.
setMessages: h.state.setMessages,
};
},
}));
@@ -143,144 +140,3 @@ describe("ChatThread — send now (#198)", () => {
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
});
});
// The turn-end decision lives in the `onFinish` handler: given the terminal
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
// it decides whether to CONTINUE (flush the next queued message) or END (leave
// the queue intact for the user), and which stop notice — if any — to show.
// `sendNow` is exercised above; these tests pin down the plain outcomes.
describe("ChatThread — turn-end decision (onFinish)", () => {
beforeEach(() => {
h.state.status = "streaming";
h.state.onFinish = null;
h.state.sendMessage.mockClear();
h.state.stop.mockClear();
h.state.transport = null;
});
// Drive a fresh onFinish with the given terminal flags after queueing a
// message, and report both what the parent was told and whether the queue was
// flushed (a resend to the sendMessage spy).
function finishWith(flags: {
isAbort?: boolean;
isDisconnect?: boolean;
isError?: boolean;
}) {
// Tear down any prior render so the loop-driven "every outcome" case does
// not leave duplicate queue buttons in the DOM.
cleanup();
h.state.sendMessage.mockClear();
const { onTurnFinished } = renderThread();
// Populate the queue while the turn is streaming.
fireEvent.click(screen.getByTestId("queue-btn"));
act(() => {
h.state.onFinish?.({
message: { id: "a", role: "assistant", parts: [] },
isAbort: false,
isDisconnect: false,
isError: false,
...flags,
});
});
return { onTurnFinished };
}
it("CONTINUES — flushes the next queued message on a clean finish", () => {
finishWith({});
// Clean finish (no terminal flag): the queued message is auto-sent.
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
// A clean finish shows no stop notice.
expect(screen.queryByText("Response stopped.")).toBeNull();
});
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
finishWith({ isAbort: true });
// A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the
// queue is preserved for the user to decide.
expect(h.state.sendMessage).not.toHaveBeenCalled();
expect(screen.getByText("Response stopped.")).toBeTruthy();
});
it("ENDS — keeps the queue intact on a disconnect and shows the connection-lost notice", () => {
finishWith({ isDisconnect: true });
expect(h.state.sendMessage).not.toHaveBeenCalled();
expect(
screen.getByText("Connection lost — the answer was interrupted."),
).toBeTruthy();
});
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
finishWith({ isError: true });
// Blindly retrying after a failure would be wrong; the queue is left alone.
expect(h.state.sendMessage).not.toHaveBeenCalled();
// isError clears the neutral notice (the error banner covers this case).
expect(screen.queryByText("Response stopped.")).toBeNull();
});
it("notifies the parent on EVERY terminal outcome", () => {
// The chat-list refresh / new-chat id adoption must run on success and on
// every failure path alike.
for (const flags of [
{},
{ isAbort: true },
{ isDisconnect: true },
{ isError: true },
]) {
const { onTurnFinished } = finishWith(flags);
expect(onTurnFinished).toHaveBeenCalled();
}
});
});
// #184 passive-observer merge: when reconnecting to a still-running run, the
// parent feeds the polled run message via `observedRow`; ChatThread merges it via
// setMessages — but ONLY when this tab is NOT itself streaming (the streamer's
// SSE owns the view, so a stale observedRow must never overwrite it).
describe("ChatThread — observer run merge (#184)", () => {
beforeEach(() => {
h.state.onFinish = null;
h.state.setMessages.mockReset();
});
const observedRow = {
id: "a-run",
role: "assistant",
content: "step 1\nstep 2",
metadata: {
parts: [{ type: "text", text: "step 1\nstep 2" }],
},
createdAt: "2026-01-01T00:00:00Z",
} as const;
function renderObserver(status: string) {
h.state.status = status;
render(
<MantineProvider>
<ChatThread
chatId="c1"
initialRows={[]}
onTurnFinished={vi.fn()}
observedRow={observedRow as never}
/>
</MantineProvider>,
);
}
it("merges the polled run message when this tab is a passive observer", () => {
renderObserver("ready");
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
// The updater replaces/append the observed assistant row by id.
const updater = h.state.setMessages.mock.calls[0][0] as (
prev: { id: string; parts: { text: string }[] }[],
) => { id: string; parts: { text: string }[] }[];
const merged = updater([{ id: "u1", parts: [{ text: "hi" }] }]);
expect(merged).toHaveLength(2);
expect(merged[1].id).toBe("a-run");
expect(merged[1].parts[0].text).toBe("step 1\nstep 2");
});
it("does NOT merge while THIS tab is the streamer (no double-render)", () => {
renderObserver("streaming");
expect(h.state.setMessages).not.toHaveBeenCalled();
});
});
@@ -24,7 +24,6 @@ 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 { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
import {
dequeue,
enqueueMessage,
@@ -87,29 +86,6 @@ interface ChatThreadProps {
* Copy/export button available mid-stream). Distinct from onTurnFinished,
* which fires only at the terminal outcome. */
onServerChatId?: (serverChatId?: string) => void;
/** #184 reconnect-and-live-follow. When THIS tab reopened a chat whose agent
* run is still going (it is a PASSIVE OBSERVER it did not start the run here),
* the parent polls the reconnect endpoint and feeds the run's incrementally-
* persisted assistant message here; we merge it into the live list so new
* steps/tool-calls appear as they are persisted. Null when there is nothing to
* observe (no run, feature off, or this tab IS the streamer). The merge is
* ADDITIONALLY guarded by our own `isStreaming`, so a stale value can never
* fight the local stream when we are the streamer. */
observedRow?: IAiChatMessageRow | null;
/** Report this tab's live streaming status up to the parent, so it can stop
* polling the run while WE are the active streamer (the SSE owns the view) and
* resume once we go idle. Called from an effect on every transition. */
onStreamingChange?: (streaming: boolean) => void;
/** #184: whether detached/autonomous agent runs are enabled for this workspace.
* When true the Stop button must additionally hit the AUTHORITATIVE server stop
* (via onServerStop) aborting only the local SSE is just a client disconnect,
* which the server deliberately ignores, so the detached run would keep going. */
autonomousRunsEnabled?: boolean;
/** #184: request the server-side stop of this chat's active run (the parent owns
* the endpoint call + the "stopping" latch that keeps observer-polling from
* immediately re-streaming the stopping run's output). Called with the resolved
* chat id when the user presses Stop in autonomous mode. */
onServerStop?: (chatId: string) => void;
}
/**
@@ -155,10 +131,6 @@ export default function ChatThread({
assistantName,
onTurnFinished,
onServerChatId,
observedRow,
onStreamingChange,
autonomousRunsEnabled,
onServerStop,
}: ChatThreadProps) {
const { t } = useTranslation();
@@ -244,16 +216,6 @@ export default function ChatThread({
const flushOnAbortRef = useRef(false);
const interruptNextSendRef = useRef(false);
// #234 F5: the user pressed Stop while streaming a BRAND-NEW chat whose server
// chat id has not been adopted yet (the `start` chunk carrying it hadn't landed
// when Stop was pressed). A local SSE abort alone does NOT stop the DETACHED
// autonomous run — it keeps burning tokens and WRITING TO PAGES — so we cannot
// just no-op. We latch the stop as PENDING and fire the authoritative server
// stop the moment onServerChatId adopts the id (below). Read-and-cleared there;
// also defused on every new turn start so it can never fire against a later,
// unrelated turn's run.
const stopPendingRef = 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.
@@ -312,7 +274,7 @@ export default function ChatThread({
[],
);
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
const { messages, sendMessage, status, stop, error } = useChat({
// Stable per-mount key. Existing chats use their real id; new chats use a
// generated client id (never `undefined`) so the store is NOT re-created on
// every render mid-stream (see `chatStoreId` above).
@@ -403,14 +365,7 @@ export default function ChatThread({
return;
lastForwardedChatIdRef.current = serverChatId;
onServerChatId(serverChatId);
// #234 F5: if Stop was pressed before the id was known, the authoritative
// server stop was deferred to this adoption point — fire it now with the
// just-adopted id. One-shot (read-and-clear) so it can't fire twice.
if (stopPendingRef.current) {
stopPendingRef.current = false;
onServerStop?.(serverChatId);
}
}, [messages, onServerChatId, onServerStop]);
}, [messages, onServerChatId]);
// Live "turn was interrupted" marker for the CURRENT session. The red error
// banner (driven by `error`) covers the error case; this covers an aborted
@@ -423,27 +378,6 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
// #184: report our live streaming status up so the parent stops polling the run
// while WE are the streamer (the SSE owns the view) and resumes once we go idle.
// Effect (not render) so it never updates parent state during our own render;
// fires on mount with `false`, which also re-syncs the parent after a chat
// switch remounts this thread (a fresh mount is idle until the user sends).
useEffect(() => {
onStreamingChange?.(isStreaming);
}, [isStreaming, onStreamingChange]);
// #184 passive-observer merge: when the parent feeds a polled run message (we
// reopened a chat whose run is still going and did NOT start it here), merge it
// into the live list so new steps/tool-calls appear as they are persisted. Hard-
// gated by `!isStreaming`: if THIS tab is actually the streamer, the local SSE
// owns the view and a stale observedRow must never overwrite it. `observedRow`
// is a stable per-poll object, so this runs once per poll, not per render.
useEffect(() => {
if (isStreaming || !observedRow) return;
const observed = rowToUiMessage(observedRow);
setMessages((prev) => mergeObservedMessage(prev, observed));
}, [observedRow, isStreaming, setMessages]);
// "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
@@ -475,40 +409,6 @@ export default function ChatThread({
[setQueue, stop],
);
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
// returns to idle immediately. In AUTONOMOUS mode the turn is a DETACHED run:
// aborting the local SSE is only a client disconnect, which the server ignores,
// so the run would keep executing — we ADDITIONALLY request the authoritative
// server-side stop (the parent owns that call + the "stopping" latch that keeps
// observer-polling from re-streaming the stopping run's output). The chat id is
// read live from chatIdRef (adopted early at the stream's `start` chunk); if it
// is not known yet — a brand-new chat in the first moment of its first turn —
// only the local abort happens (there is no server-side run handle to stop yet).
const handleStop = useCallback(() => {
stop();
if (!autonomousRunsEnabled) return;
if (chatIdRef.current) {
onServerStop?.(chatIdRef.current);
} else {
// #234 F5: no chat id yet (brand-new chat in the first moment of its first
// turn, before the `start` chunk adopted the id). Latch the stop as pending;
// the onServerChatId adoption effect fires the deferred server stop as soon
// as the id appears, so the detached run is still authoritatively stopped
// instead of left running by a silent local-only abort.
//
// KNOWN LIMITATION (#234 F5 review): `stop()` above has already aborted the
// local SSE reader. In the rare sub-window where Stop is pressed while still
// `submitted` (request sent, not one chunk read yet), that abort can cancel
// the reader BEFORE the `start` chunk is applied to `messages`, so the
// adoption effect never runs and this pending stop never fires. The detached
// run then keeps going for that turn. This is not a regression (the pre-fix
// behavior sent no server stop at all); closing it fully would require
// deferring the local abort until adoption, which is riskier and out of scope
// for this fix. Documented so a future change can address the abort-ordering.
stopPendingRef.current = true;
}
}, [stop, autonomousRunsEnabled, onServerStop]);
// 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
@@ -520,11 +420,6 @@ export default function ChatThread({
setStopNotice(null);
flushOnAbortRef.current = false;
interruptNextSendRef.current = false;
// #234 F5: a new turn is starting — drop any pending deferred-stop from a
// previous turn that never adopted an id, so it can never fire against this
// (or a later) unrelated turn's run. A deferred stop for the CURRENT turn is
// set AFTER this effect (on the Stop click), so this does not clobber it.
stopPendingRef.current = false;
}
}, [isStreaming]);
@@ -644,7 +539,7 @@ export default function ChatThread({
<ChatInput
onSend={(text) => sendMessage({ text })}
onQueue={enqueue}
onStop={handleStop}
onStop={stop}
isStreaming={isStreaming}
/>
</Stack>
@@ -65,25 +65,6 @@ describe("arePropsEqual", () => {
expect(arePropsEqual(props(m), props(m))).toBe(true);
});
// REGRESSION (stranded reasoning part): a reasoning part is left at
// `state:"streaming"` forever when the turn ends without `reasoning-end`
// (manual Stop during thinking). The signature is EQUAL across that turn-end
// flip (nothing in the message changed), so the comparator must ALSO compare
// `turnStreaming` — otherwise the memo swallows the flip and ReasoningBlock
// never switches from chunked plain text to its one-time markdown parse.
it("returns false when turnStreaming differs despite an equal signature", () => {
const m = msg([
{ type: "reasoning", text: "thinking", state: "streaming" },
{ type: "text", text: "answer" },
]);
expect(
arePropsEqual(
props(m, { turnStreaming: true }),
props(m, { turnStreaming: false }),
),
).toBe(false);
});
it("returns true for the same content in a different message object", () => {
const a = msg([{ type: "text", text: "answer" }]);
const b = msg([{ type: "text", text: "answer" }]);
@@ -52,20 +52,6 @@ interface MessageItemProps {
* absent; the public share passes the configured identity (agent role) name.
*/
assistantName?: string;
/**
* Whether the WHOLE turn is still streaming (MessageList's `isStreaming`).
* A reasoning part may be left `state: "streaming"` forever when the turn
* ends without a `reasoning-end` chunk (manual Stop during the thinking
* phase, or a provider that never emits it) the AI SDK finalizes reasoning
* state ONLY on `reasoning-end`, not on `finish-step`/`finish`. So part-level
* state alone cannot prove liveness; the reasoning part is treated as live
* only while the whole turn is still streaming. Defaults to false.
*
* The parent passes it as "turn is live AND this is the tail row", so a
* stranded part in an EARLIER row never re-activates when a later turn
* streams.
*/
turnStreaming?: boolean;
}
/**
@@ -119,7 +105,6 @@ function MessageItem({
showCitations = true,
neutralizeInternalLinks = false,
assistantName,
turnStreaming = false,
}: MessageItemProps) {
// `signature` is intentionally not read in the body — it exists solely as the
// memo key (see arePropsEqual). The render reads `message` directly.
@@ -170,23 +155,8 @@ function MessageItem({
const text = (part as { text?: string }).text ?? "";
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
return null;
// Absent state (persisted rows) and "done" both mean finalized.
// `messageSignature` already includes each part's `state`, so the
// streaming→done flip changes the row signature and re-renders this
// row — which is what lets ReasoningBlock switch from chunked plain
// text to its one-time markdown parse (see reasoning-block.tsx).
// ALSO require the turn to be live: a part stranded at
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
// the `turnStreaming` prop doc) must still finalize and parse.
const streaming =
turnStreaming && (part as { state?: string }).state === "streaming";
return (
<ReasoningBlock
key={index}
text={text}
tokens={reasoningTokens}
streaming={streaming}
/>
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
);
}
@@ -275,11 +245,7 @@ export function arePropsEqual(
prev.signature === next.signature &&
prev.showCitations === next.showCitations &&
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
prev.assistantName === next.assistantName &&
// The turn-end flip re-renders every row once (cheap, terminal event) —
// that is what converts a stranded `state:"streaming"` reasoning part to
// its one-time markdown parse (see the `turnStreaming` prop doc).
prev.turnStreaming === next.turnStreaming
prev.assistantName === next.assistantName
);
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { UIMessage } from "@ai-sdk/react";
@@ -50,9 +50,8 @@ vi.stubGlobal(
// One assistant message wrapping the given `parts`. Reused across renders in the
// regression test to model how the AI SDK hands back the SAME message object.
// Pass an explicit `id` when a test renders several rows at once.
const msg = (parts: UIMessage["parts"], id = "m1"): UIMessage =>
({ id, role: "assistant", parts }) as UIMessage;
const msg = (parts: UIMessage["parts"]): UIMessage =>
({ id: "m1", role: "assistant", parts }) as UIMessage;
describe("MessageList", () => {
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
@@ -117,102 +116,4 @@ describe("MessageList", () => {
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
).toBe(true);
});
// REGRESSION (stranded reasoning part): the AI SDK sets a reasoning part's
// state to "done" ONLY on the `reasoning-end` chunk — `finish-step`/`finish`
// do NOT finalize it. A manual Stop during the thinking phase (or a provider
// that never emits `reasoning-end`) therefore leaves the part at
// `state:"streaming"` forever. MessageItem must derive ReasoningBlock's
// `streaming` from part state AND turn liveness (MessageList's `isStreaming`,
// forwarded as `turnStreaming`): while the turn streams the expanded block
// shows chunked plain text (no parse); once the turn ends — even though the
// part is still `state:"streaming"` — the block finalizes and does its
// one-time markdown parse. Note the message signature does NOT change across
// that flip, so this also exercises the `turnStreaming` memo comparison in
// arePropsEqual (without it the row would never re-render).
it("finalizes a reasoning part stranded at state:'streaming' when the turn ends", () => {
renderChatMarkdownSpy.mockClear();
const reasoningText = "**bold** thinking";
// Reasoning part stranded mid-stream + a non-empty answer part (a
// reasoning-only message renders nothing — see message-content.ts).
const message = msg([
{ type: "reasoning", text: reasoningText, state: "streaming" },
{ type: "text", text: "partial answer" },
]);
const parsesOfReasoning = () =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
.length;
const { rerender, getByRole, queryByText } = render(
<MantineProvider>
<MessageList messages={[message]} isStreaming />
</MantineProvider>,
);
// Expand the reasoning block (its toggle is the only button in the list).
fireEvent.click(getByRole("button"));
// Turn live + part streaming -> ReasoningBlock received streaming=true:
// the body is chunked plain text (raw markdown syntax), NOT parsed.
expect(queryByText(/bold/)).not.toBeNull();
expect(parsesOfReasoning()).toBe(0);
// The turn ends WITHOUT `reasoning-end`: the part object is untouched
// (still state:"streaming"), only the turn-level flag flips.
rerender(
<MantineProvider>
<MessageList messages={[message]} isStreaming={false} />
</MantineProvider>,
);
// ReasoningBlock now received streaming=false and did its one-time parse.
expect(parsesOfReasoning()).toBe(1);
});
// REGRESSION (turn-global liveness leaking into earlier rows): `isStreaming`
// is turn-global, so forwarding it to EVERY row would re-mark a reasoning
// part stranded at `state:"streaming"` in a PREVIOUS message (see the test
// above) as live again whenever a LATER turn streams — an expanded stranded
// block would flip markdown -> raw plain text -> markdown across turn
// boundaries, re-parsing each time. MessageList must gate `turnStreaming`
// to the TAIL row only.
it("keeps a stranded reasoning part in an earlier message finalized while a later turn streams", () => {
renderChatMarkdownSpy.mockClear();
const reasoningText = "**bold** thinking";
// First (earlier) assistant message: its turn was stopped during the
// thinking phase, leaving the reasoning part at state:"streaming".
const first = msg(
[
{ type: "reasoning", text: reasoningText, state: "streaming" },
{ type: "text", text: "first answer" },
],
"m1",
);
// Second assistant message: the LATER turn, currently streaming.
const second = msg([{ type: "text", text: "second answer" }], "m2");
const parsesOfReasoning = () =>
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
.length;
const { rerender, getByRole, queryByText } = render(
<MantineProvider>
<MessageList messages={[first, second]} isStreaming />
</MantineProvider>,
);
// Expand the first row's reasoning block (the only toggle in the list —
// the second message has no reasoning or tool parts).
fireEvent.click(getByRole("button"));
// The turn is live but the first row is NOT the tail: its ReasoningBlock
// received streaming=false, so the stranded part stays finalized and does
// its one-time markdown parse instead of dropping to chunked plain text.
expect(queryByText(/bold/)).not.toBeNull();
expect(parsesOfReasoning()).toBe(1);
// A later-turn delta re-renders the list; the earlier block must neither
// flip back to streaming nor re-parse.
(second.parts[0] as { text: string }).text = "second answer grows";
rerender(
<MantineProvider>
<MessageList messages={[first, second]} isStreaming />
</MantineProvider>,
);
expect(parsesOfReasoning()).toBe(1);
});
});
@@ -196,7 +196,7 @@ export default function MessageList({
return (
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs">
{messages.map((message, index) => (
{messages.map((message) => (
// `signature` is snapshotted HERE (parent render) into an immutable
// string and handed to MessageItem as its memo key. It must NOT be
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
@@ -210,13 +210,6 @@ export default function MessageList({
showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName}
// Turn-level liveness, gated to the TAIL row: only the tail message
// can belong to the in-flight turn, so a reasoning part stranded at
// `state:"streaming"` in an EARLIER message (its turn ended without
// `reasoning-end`) stays finalized and doesn't flip back to plain
// text (and re-parse) whenever a later turn streams — see
// message-item.tsx.
turnStreaming={isStreaming && index === messages.length - 1}
/>
))}
{typing && (
@@ -1,14 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Spy on the markdown renderer so we can assert it is NOT called while the block
// is collapsed (the #302 fix) and IS called once on expand. The count/fallback
// tests don't depend on real markdown, so a light stub is safe.
vi.mock("@/features/ai-chat/utils/markdown.ts", () => ({
renderChatMarkdown: vi.fn((md: string) => `<p>${md}</p>`),
}));
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
// keeps the assertions on the component's OWN count logic (authoritative vs
// estimate) rather than on translation, and mirrors the t-mock pattern used by
@@ -24,15 +17,10 @@ vi.mock("react-i18next", () => ({
import ReasoningBlock from "./reasoning-block";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBlock(props: {
text: string;
tokens?: number;
streaming?: boolean;
}) {
function renderBlock(props: { text: string; tokens?: number }) {
return render(
<MantineProvider>
<ReasoningBlock {...props} />
@@ -74,68 +62,4 @@ describe("ReasoningBlock", () => {
// either way the text is present in the document.
expect(screen.getByText(/reasoning/)).toBeDefined();
});
it("does not parse the reasoning markdown while collapsed; parses on expand (#302)", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
renderBlock({ text: "**bold** reasoning", tokens: 5 });
// Collapsed is the default. The expensive markdown parse (marked + DOMPurify)
// must NOT run for the hidden body — that O(n^2) re-parse on every streamed
// delta is exactly what froze the chat (#302). The collapsed body shows the
// cheap raw-text fallback instead.
expect(renderSpy).not.toHaveBeenCalled();
// Expanding parses the current text exactly once (a user-initiated click).
fireEvent.click(screen.getByRole("button"));
expect(renderSpy).toHaveBeenCalledTimes(1);
});
it("does not parse while expanded and STREAMING; shows chunked plain text", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
renderBlock({
text: "первый абзац размышлений\n\nвторой абзац растёт",
tokens: 5,
streaming: true,
});
fireEvent.click(screen.getByRole("button"));
// Expanded + still streaming: NO markdown parse and NO innerHTML swaps per
// delta — the body is chunked plain text (only the tail chunk updates).
// This is the O(n²) hole #302 left open (Safari whole-tab freeze).
expect(renderSpy).not.toHaveBeenCalled();
// Both paragraph chunks' raw text is present in the body.
expect(screen.getByText(/первый абзац размышлений/)).toBeDefined();
expect(screen.getByText(/второй абзац растёт/)).toBeDefined();
});
it("parses exactly once when streaming flips to done while expanded", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
const { rerender } = renderBlock({
text: "**bold** reasoning",
tokens: 5,
streaming: true,
});
fireEvent.click(screen.getByRole("button"));
expect(renderSpy).not.toHaveBeenCalled();
// Finalization: the part's state flips streaming→done, the parent
// re-renders the row (the flip changes the message signature), and the
// block does its ONE markdown parse of the now-stable text.
rerender(
<MantineProvider>
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
</MantineProvider>,
);
expect(renderSpy).toHaveBeenCalledTimes(1);
// The parsed html branch rendered (the mock wraps the input in <p>…</p>).
expect(screen.getByText(/reasoning/)).toBeDefined();
// Further re-renders with unchanged props do not re-parse.
rerender(
<MantineProvider>
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
</MantineProvider>,
);
expect(renderSpy).toHaveBeenCalledTimes(1);
});
});
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
import { StreamingPlainText } from "@/features/ai-chat/components/streaming-plain-text.tsx";
import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ReasoningBlockProps {
@@ -16,10 +15,6 @@ interface ReasoningBlockProps {
* step/turn has finished. When absent (or 0) the count is estimated from the
* text length so it ticks live as the reasoning streams in. */
tokens?: number;
/** True while the reasoning part is still streaming (part `state ===
* "streaming"`). False means finalized: persisted history or `state ===
* "done"`. Gates the markdown parse — see the invariant on the memo below. */
streaming?: boolean;
}
/**
@@ -32,30 +27,22 @@ interface ReasoningBlockProps {
* Providers that don't stream reasoning TEXT still render this block from the
* authoritative count alone (header only, empty body) so the cost is visible.
*/
function ReasoningBlock({ text, tokens, streaming = false }: ReasoningBlockProps) {
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Authoritative count wins; otherwise estimate live from the streamed text.
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
const trimmed = text.trim();
// Markdown parse invariant (per throttled ~20Hz stream delta the text GROWS):
// 1. Collapsed -> never parse (#302): the html is only shown inside
// <Collapse in={open}>, so parsing for a hidden body would be an O(n²)
// marked + DOMPurify storm.
// 2. Expanded + STREAMING -> no parse and no innerHTML swaps either: the body
// renders as chunked plain text (StreamingPlainText) with a memoized
// stable prefix, so each delta updates only the tail chunk's text node.
// This closes the O(n²) hole #302 left open ("expanded while streaming")
// that froze the whole tab in Safari when watching the thinking stream.
// 3. Finalized + expanded -> exactly one parse: `trimmed` and `streaming`
// are stable after the part is done, so this memo runs once per expand.
// Memoize the markdown render so toggling `open` (or a parent re-render caused
// by an unrelated streamed delta) does not re-parse the reasoning text; it
// recomputes only when the reasoning text itself changes (while it streams in).
// collapseBlankLines collapses the blank-line gaps the model emits between every
// list item / paragraph so the reasoning renders compactly (tight lists, joined
// paragraphs) — ONLY here, not in the normal answer.
const html = useMemo(
() =>
open && trimmed && !streaming
? renderChatMarkdown(collapseBlankLines(trimmed), {})
: "",
[open, trimmed, streaming],
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
[trimmed],
);
return (
@@ -92,12 +79,12 @@ function ReasoningBlock({ text, tokens, streaming = false }: ReasoningBlockProps
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
// Still streaming (or markdown yielded nothing): chunked plain text.
// The wrapper carries the reasoningText styling; each chunk sets its
// own pre-wrap inline (NOT on this div — see ai-chat.module.css).
<div className={classes.reasoningText}>
<StreamingPlainText text={trimmed} />
</div>
<Text
className={classes.reasoningText}
style={{ whiteSpace: "pre-wrap" }}
>
{trimmed}
</Text>
)}
</Collapse>
)}
@@ -105,7 +92,7 @@ function ReasoningBlock({ text, tokens, streaming = false }: ReasoningBlockProps
);
}
// Memoized: re-renders only when `text`/`tokens`/`streaming` change (primitive
// props, default shallow compare), so a parent re-render during streaming of OTHER
// content does not re-run the markdown parse for an already-finalized reasoning block.
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
// shallow compare), so a parent re-render during streaming of OTHER content does
// not re-run the markdown parse for an already-finalized reasoning block.
export default memo(ReasoningBlock);
@@ -1,146 +0,0 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import {
splitPlainChunks,
StreamingPlainText,
} from "./streaming-plain-text";
describe("splitPlainChunks", () => {
// THE load-bearing property (see the invariant comment in the module): under
// append-only growth, every chunk except the LAST must be byte-identical
// between successive calls, so the memoized chunk components never re-render
// for the stable prefix and each stream delta touches only the tail chunk.
it("keeps all non-last chunks byte-identical across append-only growth", () => {
// A simulated reasoning stream covering: appends inside the last paragraph,
// appends that ADD new blank lines, growth of a trailing newline run, and a
// trailing separator later followed by text.
const steps = [
"Пер",
"Первый абзац",
"Первый абзац\n",
"Первый абзац\n\n",
"Первый абзац\n\n\n",
"Первый абзац\n\n\nВторой",
"Первый абзац\n\n\nВторой абзац растёт",
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий",
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\n",
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\nЧетвёртый",
];
let prev: string[] = [];
for (const text of steps) {
const next = splitPlainChunks(text);
// Lossless: chunks always reassemble into the exact input.
expect(next.join("")).toBe(text);
// Chunk count never shrinks (boundaries never disappear).
expect(next.length).toBeGreaterThanOrEqual(prev.length);
// Every previously-FINAL chunk (all but prev's last) is unchanged.
for (let i = 0; i < prev.length - 1; i++) {
expect(next[i]).toBe(prev[i]);
}
prev = next;
}
// Guard against a vacuous pass: the final split must be multi-chunk.
expect(prev.length).toBeGreaterThanOrEqual(4);
});
it("attaches the blank-line separator run to the preceding chunk", () => {
expect(splitPlainChunks("a\n\nb")).toEqual(["a\n\n", "b"]);
// A longer run is ONE separator, not several boundaries.
expect(splitPlainChunks("a\n\n\n\nb")).toEqual(["a\n\n\n\n", "b"]);
expect(splitPlainChunks("a\n\nb\n\n\nc")).toEqual(["a\n\n", "b\n\n\n", "c"]);
});
it("single newlines are not boundaries", () => {
expect(splitPlainChunks("a\nb\nc")).toEqual(["a\nb\nc"]);
});
// INTENTIONAL: CRLF blank lines are NOT boundaries (the regex is `\n{2,}`
// only). Supporting `(?:\r?\n){2,}` would break the stable-prefix invariant:
// a lone trailing `\r` is not a boundary, but a later-appended `\n` would
// merge with it into a new separator unit and retroactively create a boundary
// INSIDE previously-emitted text, moving old chunk edges. So CRLF input stays
// in one (still lossless) chunk — only granularity is coarser; LLM output is
// `\n` in practice. See the doc comment on splitPlainChunks.
it("keeps CRLF blank lines inside one chunk", () => {
expect(splitPlainChunks("a\r\n\r\nb")).toEqual(["a\r\n\r\nb"]);
// Mixed input: only pure-`\n` runs split.
expect(splitPlainChunks("a\r\n\r\nb\n\nc")).toEqual(["a\r\n\r\nb\n\n", "c"]);
});
it("never emits empty phantom chunks (multi-blank-line / trailing newlines)", () => {
expect(splitPlainChunks("")).toEqual([]);
// A trailing newline run stays inside the last chunk (it may still grow).
expect(splitPlainChunks("a\n")).toEqual(["a\n"]);
expect(splitPlainChunks("a\n\n")).toEqual(["a\n\n"]);
expect(splitPlainChunks("a\n\nb\n\n")).toEqual(["a\n\n", "b\n\n"]);
// Degenerate all-newlines input is a single deterministic chunk.
expect(splitPlainChunks("\n\n\n")).toEqual(["\n\n\n"]);
for (const text of ["a\n\n\nb\n\n", "x\n\n\n\n\ny\n\nz\n"]) {
for (const chunk of splitPlainChunks(text)) {
expect(chunk.length).toBeGreaterThan(0);
}
}
});
});
describe("StreamingPlainText", () => {
it("renders one block per chunk, stripping trailing separator newlines at display time", () => {
const text = "первый абзац\n\nвторой абзац\n\n\nтретий";
const { container } = render(<StreamingPlainText text={text} />);
const blocks = Array.from(container.querySelectorAll("div"));
// One block element per chunk.
expect(blocks.length).toBe(splitPlainChunks(text).length);
// DISPLAY-ONLY strip: each rendered block drops its chunk's trailing
// separator newlines — rendering them inside a pre-wrap block would add an
// empty line ON TOP of the block break (a doubled gap). The RAW chunks
// keep their separators (losslessness is asserted on splitPlainChunks
// above); multi-blank-line runs collapse to one uniform gap, consistent
// with collapseBlankLines on the finalized markdown path.
expect(blocks.map((b) => b.textContent)).toEqual([
"первый абзац",
"второй абзац",
"третий",
]);
// The uniform paragraph gap comes from the block margin instead (matches
// the `.reasoningText p { margin: 0 0 4px }` rhythm of the markdown path).
for (const block of blocks) {
expect((block as HTMLElement).style.marginBottom).toBe("4px");
}
});
it("keeps interior newlines intact — only the trailing run is stripped", () => {
const text = "строка один\nстрока два\n\nхвост";
const { container } = render(<StreamingPlainText text={text} />);
const blocks = Array.from(container.querySelectorAll("div"));
expect(blocks.map((b) => b.textContent)).toEqual([
"строка один\nстрока два",
"хвост",
]);
});
// SECURITY INVARIANT — the load-bearing property of the streaming path: the
// reasoning text is raw, untrusted model output rendered WITHOUT a sanitizer
// (no marked/DOMPurify, no innerHTML). PlainChunk emits it as a React text
// node, which escapes it, so HTML in the model output is inert. This test
// pins that the path is a TEXT sink, not an HTML sink: a future change to
// `dangerouslySetInnerHTML` (reintroducing XSS) MUST fail here.
//
// The existing tests assert via textContent, which strips tags and so cannot
// distinguish an escaped literal from injected DOM. This one asserts on the
// parsed DOM directly: if the markup were injected as HTML, the <img>/<b>
// would become real elements and querySelector would find them.
it("renders HTML-like reasoning as an escaped literal, never as injected DOM", () => {
const text = "<img src=x onerror=alert(1)>\n\n<b>hi</b>";
const { container } = render(<StreamingPlainText text={text} />);
// No DOM elements were created from the payload — it was NOT parsed as HTML.
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("b")).toBeNull();
// The raw markup survived verbatim as text (proving it is escaped, not
// interpreted). textContent alone can't prove this, but combined with the
// querySelector assertions above it does: the literals are present AND no
// elements exist.
expect(container.textContent).toContain("<b>hi</b>");
expect(container.textContent).toContain("<img src=x onerror=alert(1)>");
});
});
@@ -1,90 +0,0 @@
import { memo, useMemo } from "react";
/**
* Split plain text into chunks at blank-line (paragraph) boundaries, keeping
* each separator run attached to the END of the preceding chunk, so the chunks
* always reassemble byte-for-byte into the input.
*
* A boundary is the end of a maximal `\n{2,}` run that is followed by at least
* one more character. A newline run that is a SUFFIX of the text is NOT a
* boundary yet: under append-only growth it may still gain more newlines, and
* cutting there would move the boundary on the next call.
*
* CRITICAL INVARIANT (load-bearing for StreamingPlainText's memoization): for
* APPEND-ONLY growth of `text`, every chunk except the LAST is byte-identical
* between successive calls previously-emitted boundaries never move. Proof
* sketch: appending never modifies existing characters, so (a) an existing
* boundary's newline run and its following character are untouched and the
* boundary persists at the same offset; (b) no NEW boundary can appear strictly
* inside the old text, because a `\n{2,}` run followed by a character entirely
* within the old text would already have been a boundary. New boundaries can
* only materialize at or after the old text's end, i.e. inside the last chunk.
*
* CRLF is deliberately NOT a boundary: supporting `(?:\r?\n){2,}` would BREAK
* the invariant above a lone trailing `\r` is not a boundary, but a later-
* appended `\n` would merge with it into a new separator unit and retroactively
* create a boundary INSIDE previously-emitted text, moving old chunk edges.
* With `\n`-only runs, appended characters can never extend a run that is
* already followed by a non-`\n` character, so old boundaries are immutable.
* CRLF blank lines therefore intentionally stay inside one chunk: correctness/
* losslessness are unaffected, only chunk granularity for CRLF input (LLM
* output is `\n` in practice).
*/
export function splitPlainChunks(text: string): string[] {
const chunks: string[] = [];
let start = 0;
for (const match of text.matchAll(/\n{2,}/g)) {
const end = match.index + match[0].length;
// Suffix run: not a stable boundary yet (see the invariant above).
if (end >= text.length) break;
chunks.push(text.slice(start, end));
start = end;
}
if (start < text.length) chunks.push(text.slice(start));
return chunks;
}
/**
* One immutable chunk. Memoized on its string prop: during streaming only the
* TAIL chunk's text changes (see the splitPlainChunks invariant), so React
* skips every stable chunk and the per-delta DOM work is a single text-node
* update. `pre-wrap` is set per chunk (like the old raw-text fallback did), NOT
* on the surrounding markdown-styled container see the note in
* ai-chat.module.css. Font/size/color are inherited from that container.
*
* DISPLAY-ONLY newline strip: the raw chunk keeps its trailing `\n{2,}`
* separator run attached (the splitPlainChunks invariant, load-bearing for the
* memo), but rendering those newlines inside a pre-wrap block would add an
* empty line ON TOP of the block break a doubled gap. So the RENDERED string
* drops trailing newlines and the paragraph gap comes from `marginBottom: 4`
* instead, matching the `.reasoningText p { margin: 0 0 4px }` rhythm of the
* finalized markdown. Multi-blank-line runs thus collapse to one uniform gap,
* consistent with `collapseBlankLines` on the markdown path. The last chunk
* usually has no trailing newlines (strip is a no-op); its margin is harmless.
*/
const PlainChunk = memo(function PlainChunk({ text }: { text: string }) {
return (
<div style={{ whiteSpace: "pre-wrap", marginBottom: 4 }}>
{text.replace(/\n+$/, "")}
</div>
);
});
/**
* Renders still-streaming plain text as a list of paragraph chunks where only
* the tail chunk changes per delta. No markdown, no sanitizer, no innerHTML
* this is the cheap streaming-time stand-in for the one-time markdown parse
* that happens after the part is finalized (see reasoning-block.tsx).
*/
export function StreamingPlainText({ text }: { text: string }) {
const chunks = useMemo(() => splitPlainChunks(text), [text]);
return (
<>
{chunks.map((chunk, index) => (
// Index keys are stable here: chunks are append-only (the invariant),
// so an index never gets a different chunk's content mid-stream.
<PlainChunk key={index} text={chunk} />
))}
</>
);
}
@@ -27,9 +27,7 @@ export function useOpenAiChatForCurrentPage() {
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
// see :pageSlug — match the full path against the authenticated page route.
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
// A page slugId (10-char nanoid), NOT a uuid; the server resolves it to the
// real page uuid (PageRepo.findById accepts slugId or uuid).
const slugId = extractPageSlugId(match?.params?.pageSlug);
const pageId = extractPageSlugId(match?.params?.pageSlug);
return useCallback(async () => {
// Re-clicks while the window is already open (incl. minimized) must NOT
@@ -42,9 +40,9 @@ export function useOpenAiChatForCurrentPage() {
// connection the first click reads as a hung control until the POST returns.
setWindowOpen(true);
let resolved: string | null = activeChatId; // off-a-page: keep current
if (slugId) {
if (pageId) {
try {
resolved = await getBoundChat(slugId); // null => fresh chat
resolved = await getBoundChat(pageId); // null => fresh chat
} catch {
resolved = null; // fail-soft: a fresh chat is always a safe fallback
}
@@ -60,7 +58,7 @@ export function useOpenAiChatForCurrentPage() {
}, [
windowOpen,
activeChatId,
slugId,
pageId,
setWindowOpen,
setActiveChatId,
setDraft,
@@ -1,7 +1,6 @@
import {
useInfiniteQuery,
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
@@ -13,7 +12,6 @@ import {
deleteAiChat,
deleteAiRole,
getAiChatMessages,
getAiChatRun,
getAiChats,
getAiRoleCatalog,
getAiRoleCatalogBundle,
@@ -26,7 +24,6 @@ import {
import {
IAiChat,
IAiChatMessageRow,
IAiChatRunResponse,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
@@ -37,7 +34,6 @@ import {
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts";
import { runPollInterval } from "@/features/ai-chat/utils/run-polling.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"];
@@ -55,18 +51,16 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages",
chatId,
];
export const AI_CHAT_RUN_RQ_KEY = (chatId: string) => ["ai-chat-run", chatId];
/** Paginated list of the current user's chats (auto-loads further pages). */
export function useAiChatsQuery() {
const query = useInfiniteQuery({
queryKey: AI_CHATS_RQ_KEY,
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
queryFn: ({ pageParam }) =>
getAiChats({ cursor: pageParam, limit: 50 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage
? (lastPage.meta.nextCursor ?? undefined)
: undefined,
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
});
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
@@ -96,9 +90,7 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage
? (lastPage.meta.nextCursor ?? undefined)
: undefined,
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
enabled: !!chatId,
});
@@ -139,34 +131,6 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
};
}
/**
* Reconnect to a chat's latest agent run and LIVE-FOLLOW it (#184). While the run
* is active the query re-polls every {@link runPollInterval} ms (driven off the
* fetched `run.status`, the same status-keyed refetchInterval pattern as the
* embeddings reindex polling); once the run reaches a terminal status or there
* is no run the interval returns `false` and polling stops on its own. Polling
* is thus naturally bounded by the run terminating; no separate timeout cap.
*
* `enabled` gates the whole thing: callers pass `false` when the autonomous-runs
* feature is off (the endpoint is NOT flag-gated server-side, but with the feature
* off the chat has no runs, so polling would only ever return `{ run: null }`) OR
* when THIS tab is the one actively streaming the run (the live SSE owns the view,
* so we must not also poll/merge). The global `retry: false` means a failed fetch
* leaves `data` undefined, so refetchInterval(undefined run) returns false a
* failed fetch can never spin a tight loop.
*/
export function useAiChatRunQuery(
chatId: string | undefined,
enabled: boolean,
) {
return useQuery<IAiChatRunResponse, Error>({
queryKey: AI_CHAT_RUN_RQ_KEY(chatId ?? ""),
queryFn: () => getAiChatRun(chatId as string),
enabled: !!chatId && enabled,
refetchInterval: (query) => runPollInterval(query.state.data?.run),
});
}
export function useRenameAiChatMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -308,29 +272,6 @@ export function useAiRoleCatalogBundleQuery(
});
}
/**
* Eagerly open EVERY listed bundle's content in parallel for one language. The
* redesigned catalog shows each bundle's status summary in its COLLAPSED header,
* which needs every role's install state up front so contents can no longer be
* lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one
* cached read per bundle, sharing the same cache keys as
* `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a
* resolved language) so nothing fetches while the modal is closed.
*/
export function useAiRoleCatalogBundlesQueries(
bundleIds: string[],
language: string,
enabled: boolean,
) {
return useQueries({
queries: bundleIds.map((bundleId) => ({
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
enabled: enabled && !!language,
})),
});
}
export function useImportAiRolesFromCatalogMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -339,14 +280,11 @@ export function useImportAiRolesFromCatalogMutation() {
mutationFn: (payload) => importAiRolesFromCatalog(payload),
onSuccess: (result) => {
notifications.show({
message: t(
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
{
created: result.created,
renamed: result.renamed,
skipped: result.skipped,
},
),
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
created: result.created,
renamed: result.renamed,
skipped: result.skipped,
}),
});
// Surface partial failures (e.g. unique-name races) as a red warning.
if (result.errors.length > 0) {
@@ -1,92 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IAiChatRunResponse } from "@/features/ai-chat/types/ai-chat.types.ts";
// react-i18next is pulled in transitively by ai-chat-query.ts (the mutation hooks
// use it); stub it so the module imports cleanly in this hook test.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
// Mock the whole service module; only getAiChatRun is exercised here, but the
// other named exports must exist so ai-chat-query.ts imports resolve.
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
getAiChatRun: vi.fn(),
getAiChatMessages: vi.fn(),
getAiChats: vi.fn(),
getAiRoleCatalog: vi.fn(),
getAiRoleCatalogBundle: vi.fn(),
getAiRoles: vi.fn(),
importAiRolesFromCatalog: vi.fn(),
createAiRole: vi.fn(),
deleteAiChat: vi.fn(),
deleteAiRole: vi.fn(),
renameAiChat: vi.fn(),
updateAiRole: vi.fn(),
updateAiRoleFromCatalog: vi.fn(),
}));
import { getAiChatRun } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useAiChatRunQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
const runningResponse: IAiChatRunResponse = {
run: { id: "run-1", chatId: "c1", status: "running" },
message: {
id: "a1",
role: "assistant",
content: "working...",
createdAt: "2026-01-01T00:00:00Z",
},
};
describe("useAiChatRunQuery — enable gating", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches the run when enabled (passive observer, feature on)", async () => {
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
const { result } = renderHook(() => useAiChatRunQuery("c1", true), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(getAiChatRun).toHaveBeenCalledWith("c1");
expect(result.current.data?.run?.status).toBe("running");
});
it("does NOT fetch when disabled (this tab is the streamer / feature off)", async () => {
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
renderHook(() => useAiChatRunQuery("c1", false), {
wrapper: createWrapper(),
});
// Give any errant fetch a chance to fire, then assert none did.
await new Promise((r) => setTimeout(r, 20));
expect(getAiChatRun).not.toHaveBeenCalled();
});
it("does NOT fetch when there is no chat id", async () => {
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
renderHook(() => useAiChatRunQuery(undefined, true), {
wrapper: createWrapper(),
});
await new Promise((r) => setTimeout(r, 20));
expect(getAiChatRun).not.toHaveBeenCalled();
});
});
@@ -77,14 +77,7 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
});
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
await runMutation({
created: 3,
renamed: 1,
skipped: 2,
errors: [],
createdRoles: [],
skippedRoles: [],
});
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
expect(notificationsShowMock).toHaveBeenCalledWith({
message: "Imported 3, renamed 1, skipped 2",
@@ -100,8 +93,6 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
{ slug: "a", message: "name taken" },
{ slug: "b", message: "name taken" },
],
createdRoles: [{ slug: "ok", name: "Ok" }],
skippedRoles: [],
});
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
@@ -5,7 +5,6 @@ import {
IAiChatListParams,
IAiChatMessageRow,
IAiChatMessagesParams,
IAiChatRunResponse,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
@@ -43,47 +42,13 @@ export async function getAiChatMessages(
return req.data;
}
/**
* Reconnect to the latest agent run of a chat (#184). Returns the run's
* persisted lifecycle state and the assistant message it materializes (the
* partial output while the run is in-flight, the final output once it finished).
* The DB is the source of truth, so this works for an in-flight run (the browser
* dropped, the run kept going) and a finished one alike; `{ run: null }` when the
* chat has never had a run. Owner-gated server-side (the requesting user must own
* the chat); it is NOT flag-gated when the feature is off the chat simply has no
* runs, so the endpoint returns `{ run: null }`.
*/
export async function getAiChatRun(
chatId: string,
): Promise<IAiChatRunResponse> {
const req = await api.post<IAiChatRunResponse>("/ai-chat/run", { chatId });
return req.data;
}
/**
* Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing
* that ends a DETACHED run a mere browser disconnect (aborting the local SSE)
* is deliberately ignored server-side, so the client must call this to actually
* stop an autonomous run. Targeted by `chatId` (the server resolves whatever run
* is active on it); owner-gated server-side. Returns `{ stopped }` false when
* there was nothing active to stop.
*/
export async function stopRun(
chatId: string,
): Promise<{ stopped: boolean }> {
const req = await api.post<{ stopped: boolean }>("/ai-chat/stop", { chatId });
return req.data;
}
/**
* Resolve the chat bound to a document (the current user's most-recent chat
* created on that page), or null when there is none. Drives auto-open-on-page.
*/
export async function getBoundChat(slugId: string): Promise<string | null> {
// The `pageId` body field accepts a page slugId or a uuid; the server resolves
// it to the real page uuid (the wire key stays `pageId` for the DTO).
export async function getBoundChat(pageId: string): Promise<string | null> {
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
pageId: slugId,
pageId,
});
return req.data.chatId;
}
@@ -108,25 +108,12 @@ export interface IAiRoleImportPayload {
conflict: "skip" | "rename";
}
/**
* Import result (mirrors `importFromCatalog()`). The counters (`created`,
* `skipped`, `renamed`) drive the summary notification; the per-role lists
* (`createdRoles`, `skippedRoles`) drive the redesigned catalog modal's inline
* result plaque which roles were installed (and any rename) and which were
* skipped and why (so the plaque can name the conflicting role and offer
* "Rename & install").
*/
/** Import result counts (mirrors `importFromCatalog()`). */
export interface IAiRoleImportResult {
created: number;
skipped: number;
renamed: number;
errors: { slug: string; message: string }[];
createdRoles: { slug: string; name: string; renamedTo?: string }[];
skippedRoles: {
slug: string;
name: string;
reason: "name-conflict" | "already-installed";
}[];
}
/**
@@ -213,38 +200,6 @@ export interface IAiChatMessageRow {
createdAt: string;
}
/**
* A persisted agent-run row (#184), mirroring the `ai_chat_runs` fields the
* client reads from `POST /ai-chat/run`. Only `status` is load-bearing for the
* reconnect-and-live-update UX (it drives the poll cadence); the rest are carried
* for display/diagnostics. The DB is the source of truth, so this resolves for an
* in-flight run (the browser dropped, the run kept going) and a finished one.
*/
export interface IAiChatRun {
id: string;
chatId: string;
// 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'. The first two are
// ACTIVE (keep polling); the rest are TERMINAL (stop polling).
status: "pending" | "running" | "succeeded" | "failed" | "aborted" | string;
error?: string | null;
stepCount?: number;
assistantMessageId?: string | null;
startedAt?: string | null;
finishedAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
/**
* Response of `POST /ai-chat/run` (#184): the latest run of a chat and the
* assistant message it materializes (the partial/final output, projected from the
* persisted rows). Both are `null` when the chat has never had a run.
*/
export interface IAiChatRunResponse {
run: IAiChatRun | null;
message: IAiChatMessageRow | null;
}
export interface IAiChatListParams extends QueryParams {}
export interface IAiChatMessagesParams {
@@ -1,234 +0,0 @@
import { describe, it, expect } from "vitest";
import {
bundleCounts,
bundlePhase,
installedLangForRole,
mapBundleRolesToView,
mapCatalogRoleToView,
nameConflictSlugs,
partialOffersRename,
type CatalogViewRole,
} from "./catalog-bundle-model.ts";
import type {
IAiRole,
IAiRoleCatalogRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
function installedRole(
source: { slug: string; language: string; version: number },
overrides: Partial<IAiRole> = {},
): IAiRole {
return {
id: `role-${source.slug}-${source.language}`,
name: source.slug,
emoji: null,
description: null,
enabled: true,
autoStart: true,
launchMessage: null,
source,
...overrides,
};
}
function catalogRole(
overrides: Partial<IAiRoleCatalogRole> = {},
): IAiRoleCatalogRole {
return {
slug: "writer",
emoji: "✍️",
name: "Writer",
description: "Drafts copy.",
instructions: "be a writer",
autoStart: true,
launchMessage: null,
version: 3,
...overrides,
};
}
// Build a minimal view role for bundlePhase tests.
function viewRole(status: CatalogViewRole["status"]): CatalogViewRole {
return { slug: `s-${status}`, name: status, description: "", version: 1, status };
}
describe("bundlePhase", () => {
it("empty bundle -> empty", () => {
expect(bundlePhase([])).toBe("empty");
});
it("all importable, none installed -> allNew", () => {
expect(bundlePhase([viewRole("import"), viewRole("import")])).toBe(
"allNew",
);
});
it("nothing to import or update -> allInstalled", () => {
expect(bundlePhase([viewRole("installed"), viewRole("installed")])).toBe(
"allInstalled",
);
});
it("updates present, nothing to import -> updates", () => {
expect(bundlePhase([viewRole("update"), viewRole("installed")])).toBe(
"updates",
);
});
it("import + installed (no updates) -> mixed", () => {
expect(bundlePhase([viewRole("import"), viewRole("installed")])).toBe(
"mixed",
);
});
it("import + update -> mixed", () => {
expect(bundlePhase([viewRole("import"), viewRole("update")])).toBe("mixed");
});
it("a skipped role with nothing installed -> mixed (NOT allInstalled)", () => {
// F1: a bundle whose only non-installed role was skipped has 0 installed for
// it, so the collapsed 'All installed · up to date' header would contradict
// the open 'Installed 0 · 1 skipped' plaque. It must be mixed until resolved.
expect(bundlePhase([viewRole("skipped")])).toBe("mixed");
});
it("installed + a skipped role -> mixed (partial success is not allInstalled)", () => {
expect(bundlePhase([viewRole("installed"), viewRole("skipped")])).toBe(
"mixed",
);
});
});
describe("bundleCounts", () => {
it("tallies each status once", () => {
expect(
bundleCounts([
viewRole("import"),
viewRole("import"),
viewRole("installed"),
viewRole("update"),
viewRole("skipped"),
]),
).toEqual({ importable: 2, installed: 1, update: 1, skipped: 1 });
});
});
describe("nameConflictSlugs / partialOffersRename (reason -> action)", () => {
it("only name-conflict skips become the transient overlay / offer rename", () => {
const skipped = [
{ slug: "writer", name: "Writer", reason: "name-conflict" as const },
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
];
expect(nameConflictSlugs(skipped)).toEqual(["writer"]);
expect(partialOffersRename(skipped)).toBe(true);
});
it("an already-installed-only skip is informational: no overlay, no rename", () => {
const skipped = [
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
];
expect(nameConflictSlugs(skipped)).toEqual([]);
expect(partialOffersRename(skipped)).toBe(false);
});
});
describe("installedLangForRole", () => {
it("returns the other language when the same slug is installed elsewhere", () => {
const roles = [installedRole({ slug: "writer", language: "ru", version: 2 })];
expect(installedLangForRole("writer", roles, "en")).toBe("ru");
});
it("returns undefined when the same slug is installed in the SAME language", () => {
const roles = [installedRole({ slug: "writer", language: "en", version: 2 })];
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
});
it("returns undefined when no install of the slug exists", () => {
expect(installedLangForRole("writer", [], "en")).toBeUndefined();
});
it("ignores manually-created roles (no source)", () => {
const roles = [
installedRole({ slug: "writer", language: "ru", version: 2 }, {
source: null,
}),
];
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
});
});
describe("mapCatalogRoleToView", () => {
it("no install -> import status, catalog version, emoji preserved", () => {
const view = mapCatalogRoleToView(catalogRole(), [], "en");
expect(view).toMatchObject({
slug: "writer",
emoji: "✍️",
name: "Writer",
description: "Drafts copy.",
status: "import",
version: 3,
});
expect(view.installedRoleId).toBeUndefined();
expect(view.installedLang).toBeUndefined();
});
it("import with the slug installed in another language -> installedLang set", () => {
const roles = [installedRole({ slug: "writer", language: "ru", version: 9 })];
const view = mapCatalogRoleToView(catalogRole(), roles, "en");
expect(view.status).toBe("import");
expect(view.installedLang).toBe("ru");
});
it("installed (up to date) -> installed status, catalog version, installedRoleId", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 3,
});
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
expect(view).toMatchObject({
status: "installed",
version: 3,
installedRoleId: installed.id,
});
});
it("update -> version=from, newVersion=to, installedRoleId", () => {
const installed = installedRole({
slug: "writer",
language: "en",
version: 1,
});
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
expect(view).toMatchObject({
status: "update",
version: 1,
newVersion: 3,
installedRoleId: installed.id,
});
});
it("missing emoji -> emoji undefined; null description -> empty string", () => {
const view = mapCatalogRoleToView(
catalogRole({ emoji: null, description: null }),
[],
"en",
);
expect(view.emoji).toBeUndefined();
expect(view.description).toBe("");
});
});
describe("mapBundleRolesToView", () => {
it("maps a bundle's roles preserving order", () => {
const roles = [
catalogRole({ slug: "a", name: "A", version: 1 }),
catalogRole({ slug: "b", name: "B", version: 1 }),
];
const installed = [installedRole({ slug: "a", language: "en", version: 1 })];
const view = mapBundleRolesToView(roles, installed, "en");
expect(view.map((r) => r.slug)).toEqual(["a", "b"]);
expect(view[0].status).toBe("installed");
expect(view[1].status).toBe("import");
});
});
@@ -1,206 +0,0 @@
import type {
IAiRole,
IAiRoleCatalogRole,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
/**
* The redesigned catalog modal renders bundles as cards with a summary status
* (readable without expanding) and a single primary action. The per-role and
* per-bundle view model that drives that UI is derived here as PURE functions so
* the mapping, the "installed in another language" hint, and the bundle-phase
* computation are unit-testable without mounting the component (mirrors the
* `catalogRoleInstallState` precedent).
*/
/**
* A role's status in the catalog view model.
* - `import` not installed in the current content language.
* - `installed` installed and up to date.
* - `update` installed, but the catalog ships a newer version.
* - `skipped` TRANSIENT client-only status set after a conflicted import
* (a name collision under `conflict:'skip'`); never from the
* backend.
*/
export type RoleStatus = "import" | "installed" | "update" | "skipped";
/** A catalog role mapped into the modal's view model. */
export interface CatalogViewRole {
// Slug is the stable identity within a bundle; used as the row key and as the
// `slugs[]` payload for import.
slug: string;
// Optional in the catalog — the row reserves space and renders nothing when
// absent.
emoji?: string;
name: string;
description: string;
// For `installed`/`import`: the catalog version. For `update`: the installed
// (from) version, with `newVersion` holding the catalog (to) version.
version: number;
newVersion?: number;
status: RoleStatus;
// The language a same-slug role is installed under, when it differs from the
// current content language (drives the Р5 hint). Only set for `import` roles.
installedLang?: string;
// The workspace role id, present for `installed`/`update` — needed to call the
// update-from-catalog mutation.
installedRoleId?: string;
}
/**
* The summary phase of a bundle, derived from its roles' statuses. Determines
* the collapsed-header summary and the bundle's single primary action.
* - `empty` the bundle has no roles.
* - `allNew` everything is importable, nothing installed.
* - `allInstalled` everything installed & up to date; nothing else pending.
* - `updates` updates available and nothing left to import.
* - `mixed` any other combination.
*/
export type BundlePhase =
| "empty"
| "allNew"
| "allInstalled"
| "updates"
| "mixed";
/** Per-status tallies for a bundle's roles (the single source of truth). */
export interface BundleCounts {
importable: number;
installed: number;
update: number;
skipped: number;
}
/**
* Count a bundle's roles by status ONCE. Both `bundlePhase` and the panel derive
* from this, so the tally logic lives in exactly one place (no rescans / drift).
*/
export function bundleCounts(roles: CatalogViewRole[]): BundleCounts {
const counts: BundleCounts = {
importable: 0,
installed: 0,
update: 0,
skipped: 0,
};
for (const r of roles) {
if (r.status === "import") counts.importable += 1;
else if (r.status === "installed") counts.installed += 1;
else if (r.status === "update") counts.update += 1;
else if (r.status === "skipped") counts.skipped += 1;
}
return counts;
}
export function bundlePhase(roles: CatalogViewRole[]): BundlePhase {
if (roles.length === 0) return "empty";
const { importable, installed, update, skipped } = bundleCounts(roles);
// A `skipped` role is a pending post-import conflict (0 installed for it), so a
// bundle that has ANY skipped role is NOT "all installed & up to date" — that
// would make the collapsed green "up to date" header contradict the open
// panel's "Installed 0 · 1 skipped" plaque. It is `mixed` until resolved.
if (importable === 0 && update === 0 && skipped === 0) return "allInstalled";
if (update > 0 && importable === 0 && skipped === 0) return "updates";
if (importable > 0 && installed === 0 && update === 0 && skipped === 0)
return "allNew";
return "mixed";
}
/**
* The subset of a skip result that should be shown as a TRANSIENT `skipped`
* overlay in the bundle (so the row offers a re-import path). Only NAME-CONFLICT
* skips qualify: an `already-installed` skip (a concurrent-import race) has
* nothing to act on re-importing the same slug would just skip again so it
* must NOT be overlaid (else the row shows a misleading "Rename & install" that
* self-heals into a false "installed"). Pure so both reason branches are tested.
*/
export function nameConflictSlugs(
skipped: { slug: string; reason: "name-conflict" | "already-installed" }[],
): string[] {
return skipped
.filter((s) => s.reason === "name-conflict")
.map((s) => s.slug);
}
/**
* Whether a partial-import result should offer the "Rename & install" action:
* only when at least one skip is a name conflict (renameable). An
* `already-installed`-only partial is informational.
*/
export function partialOffersRename(
skipped: { reason: "name-conflict" | "already-installed" }[],
): boolean {
return skipped.some((s) => s.reason === "name-conflict");
}
/**
* For a role NOT installed in the current `language`, find a workspace role with
* the same catalog `slug` installed under a DIFFERENT language, and return that
* language. Drives the "installed in another language" hint (Р5): a different
* language of the same slug is a separate install and appears as `import`.
*/
export function installedLangForRole(
slug: string,
workspaceRoles: IAiRole[],
language: string,
): string | undefined {
const other = workspaceRoles.find(
(r) =>
r.source?.slug === slug &&
!!r.source?.language &&
r.source.language !== language,
);
return other?.source?.language;
}
/**
* Map one catalog role to the view model, computing its install status against
* the workspace roles (via `catalogRoleInstallState`) and, for importable roles,
* the other-language hint.
*/
export function mapCatalogRoleToView(
role: IAiRoleCatalogRole,
workspaceRoles: IAiRole[],
language: string,
): CatalogViewRole {
const state = catalogRoleInstallState(role, workspaceRoles, language);
const base = {
slug: role.slug,
emoji: role.emoji ?? undefined,
name: role.name,
description: role.description ?? "",
};
if (state.state === "update") {
return {
...base,
status: "update",
version: state.fromVersion,
newVersion: state.toVersion,
installedRoleId: state.installed.id,
};
}
if (state.state === "installed") {
return {
...base,
status: "installed",
version: role.version,
installedRoleId: state.installed.id,
};
}
return {
...base,
status: "import",
version: role.version,
installedLang: installedLangForRole(role.slug, workspaceRoles, language),
};
}
/**
* Map a whole bundle's catalog roles to the view model, preserving order.
*/
export function mapBundleRolesToView(
roles: IAiRoleCatalogRole[],
workspaceRoles: IAiRole[],
language: string,
): CatalogViewRole[] {
return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language));
}
@@ -1,303 +0,0 @@
import { describe, it, expect } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
import {
RUN_POLL_INTERVAL_MS,
isRunActive,
runPollInterval,
shouldObserveRun,
shouldClearStoppingLatch,
shouldClearLatchOnQueryError,
mergeObservedMessage,
} from "./run-polling.ts";
function makeRun(status: string): IAiChatRun {
return { id: "run-1", chatId: "c1", status };
}
function makeMsg(id: string, text: string): UIMessage {
return {
id,
role: "assistant",
parts: [{ type: "text", text }],
} as UIMessage;
}
describe("isRunActive", () => {
it("treats pending and running as active", () => {
expect(isRunActive(makeRun("pending"))).toBe(true);
expect(isRunActive(makeRun("running"))).toBe(true);
});
it("treats terminal / unknown / nullish as not active", () => {
expect(isRunActive(makeRun("succeeded"))).toBe(false);
expect(isRunActive(makeRun("failed"))).toBe(false);
expect(isRunActive(makeRun("aborted"))).toBe(false);
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
expect(isRunActive(null)).toBe(false);
expect(isRunActive(undefined)).toBe(false);
});
});
describe("runPollInterval (the refetchInterval helper)", () => {
it("returns 2000ms while the run is pending/running", () => {
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
});
it("returns false (stop polling) once the run is terminal", () => {
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
expect(runPollInterval(makeRun("failed"))).toBe(false);
expect(runPollInterval(makeRun("aborted"))).toBe(false);
});
it("returns false (no polling) when there is no run", () => {
expect(runPollInterval(null)).toBe(false);
expect(runPollInterval(undefined)).toBe(false);
});
});
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
it("observes an active run when this tab is NOT the local streamer", () => {
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
});
it("observes a terminal run too (so the final output shows on reopen)", () => {
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
});
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
});
it("does NOT observe when there is no run", () => {
expect(shouldObserveRun(null, false)).toBe(false);
expect(shouldObserveRun(undefined, false)).toBe(false);
});
});
describe("shouldClearStoppingLatch (#234 latch-release decision)", () => {
// The one case the latch SHOULD clear: we requested a stop, we are the passive
// observer (not streaming), and the CURRENT run is terminal.
it("clears only when stopping, observing, and the run is terminal", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("aborted"),
isLocalStreaming: false,
}),
).toBe(true);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("succeeded"),
isLocalStreaming: false,
}),
).toBe(true);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("failed"),
isLocalStreaming: false,
}),
).toBe(true);
});
// Round-3 regression: clearing while THIS tab is still the local streamer would
// re-open the flash for the current turn the moment we switch to observer role.
// A predicate lacking the streaming gate would (wrongly) return true here.
it("does NOT clear while this tab is the local streamer", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("aborted"),
isLocalStreaming: true,
}),
).toBe(false);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("succeeded"),
isLocalStreaming: true,
}),
).toBe(false);
});
// The detached run keeps growing after a local abort — while it is still
// active the latch MUST hold so the observer merge stays suppressed.
it("does NOT clear while the run is still active", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("running"),
isLocalStreaming: false,
}),
).toBe(false);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("pending"),
isLocalStreaming: false,
}),
).toBe(false);
});
// #234 F4: on Stop the stale PREVIOUS-turn run is removed from the cache, so the
// observed `run` is null until the current turn's run is fetched fresh. A null
// run HOLDS the latch — it can never clear against the just-removed stale run,
// only against the current turn's own terminal run once observed.
it("does NOT clear against a removed/absent run (F4 stale-run guard)", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: null,
isLocalStreaming: false,
}),
).toBe(false);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: undefined,
isLocalStreaming: false,
}),
).toBe(false);
});
it("does NOT clear when no stop was requested", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: false,
run: makeRun("aborted"),
isLocalStreaming: false,
}),
).toBe(false);
});
});
describe("shouldClearLatchOnQueryError (#234 F7 error-safety-net decision)", () => {
// This guards the REAL anti-flash decision the component's run-query-error
// safety-net effect uses (ai-chat-window.tsx wires the effect to THIS helper,
// not a copy — so the test is non-vacuous vs the live code).
// (b) The F7 hole: a TRANSIENT run-query error while `run` is STILL ACTIVE must
// NOT clear the latch. TanStack Query v5 retains `data` on error, so
// runQueryFailed can be true while the held run is still pending/running.
// Against the PRE-F7 condition (without `!isRunActive(run)`) this would return
// true — so this assertion fails on the buggy code (non-vacuous).
it("does NOT clear on a transient error while the run is still ACTIVE (F7)", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: makeRun("running"),
}),
).toBe(false);
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: makeRun("pending"),
}),
).toBe(false);
});
// (a) The genuine permanent-null-freeze: run cache cleared by removeQueries +
// the refetch keeps ERRORING, so `run === null`. This is the ONLY case the
// safety-net exists to cure — it MUST clear so the frozen view resumes.
it("clears on a permanent error when the run is null (permanent-null-freeze)", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: null,
}),
).toBe(true);
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: undefined,
}),
).toBe(true);
});
// A TERMINAL run also satisfies `!isRunActive`; clearing then is harmless — the
// terminal effect (shouldClearStoppingLatch) already clears for a terminal run,
// so this only ever agrees with it. Asserted so the (c) reasoning is pinned.
it("clears on an error when the run is terminal (harmless, agrees with terminal effect)", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: makeRun("aborted"),
}),
).toBe(true);
});
it("does NOT clear without an actual query error", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: false,
run: null,
}),
).toBe(false);
});
it("does NOT clear while this tab is the local streamer", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: true,
runQueryFailed: true,
run: null,
}),
).toBe(false);
});
it("does NOT clear when no stop was requested", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: false,
isLocalStreaming: false,
runQueryFailed: true,
run: null,
}),
).toBe(false);
});
});
describe("mergeObservedMessage", () => {
it("replaces the message with the same id in place (per-step growth)", () => {
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
const observed = makeMsg("a1", "step 1\nstep 2");
const next = mergeObservedMessage(prev, observed);
expect(next).toHaveLength(2);
expect(next[1]).toBe(observed);
expect(next[0]).toBe(prev[0]); // untouched
expect(next).not.toBe(prev); // new array (never mutates input)
});
it("appends when the observed message is not yet present", () => {
const prev = [makeMsg("u1", "hi")];
const observed = makeMsg("a1", "first token");
const next = mergeObservedMessage(prev, observed);
expect(next).toHaveLength(2);
expect(next[1]).toBe(observed);
});
it("returns the original list unchanged when there is nothing to merge", () => {
const prev = [makeMsg("u1", "hi")];
expect(mergeObservedMessage(prev, null)).toBe(prev);
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
});
});
@@ -1,151 +0,0 @@
import type { UIMessage } from "@ai-sdk/react";
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Reconnect-and-live-follow helpers (#184). When a chat is reopened while its
* agent run is STILL going, this tab is a PASSIVE OBSERVER: it did not start the
* run here (no local SSE stream), so it catches up by POLLING the reconnect
* endpoint (`POST /ai-chat/run`) and merging the run's incrementally-persisted
* assistant message into the rendered thread. These are the small pure decisions
* that machinery hangs off, extracted so they can be unit-tested in isolation
* (mirrors how reindex polling / editor-sync-state are tested).
*/
/** How often to re-poll the reconnect endpoint while a run is ACTIVE. */
export const RUN_POLL_INTERVAL_MS = 2000;
// 'pending' and 'running' are the two ACTIVE statuses; 'succeeded' | 'failed' |
// 'aborted' are TERMINAL (and any unknown future status is treated as terminal,
// so a stale/odd value never polls forever).
const ACTIVE_STATUSES = new Set(["pending", "running"]);
/** Whether a run is still going (worth polling / merging live updates from). */
export function isRunActive(run: IAiChatRun | null | undefined): boolean {
return !!run && ACTIVE_STATUSES.has(run.status);
}
/**
* The TanStack Query `refetchInterval` value for the run query: poll every
* {@link RUN_POLL_INTERVAL_MS} while the run is active, and `false` (stop) once
* it is terminal or there is no run. Polling is thus naturally bounded by the run
* reaching a terminal status no separate timeout cap is needed.
*/
export function runPollInterval(
run: IAiChatRun | null | undefined,
): number | false {
return isRunActive(run) ? RUN_POLL_INTERVAL_MS : false;
}
/**
* Observer-vs-streamer decision. We render the polled run message (catch up +
* keep advancing) ONLY when this tab is a passive observer: there IS a run AND
* this tab is NOT the one locally streaming it (we reconnected, we didn't start
* it here). When this tab is the streamer, the live SSE stream owns the view, so
* we neither poll nor merge avoiding a double-render fight. Terminal runs still
* merge (so the final persisted output is shown on reopen); the poll itself is
* stopped separately by {@link runPollInterval}.
*/
export function shouldObserveRun(
run: IAiChatRun | null | undefined,
localStreaming: boolean,
): boolean {
return !!run && !localStreaming;
}
/**
* Should the "stopping" latch which suppresses the observer re-stream flash
* after the user pressed Stop be RELEASED now? All three must hold:
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
* - `!isLocalStreaming`: this tab is NOT the local streamer. While we are the
* streamer the run query is disabled, so the observed `run` is not the run we
* are following releasing the latch then would re-open the flash for the
* current turn the instant we switch to observer role;
* - the observed `run` EXISTS and has reached a TERMINAL status.
*
* The null / still-active `run` case is the #234 F4 invariant. On Stop the stale
* PREVIOUS-turn run is removed from the query cache (`removeQueries`), so `run`
* is null until the CURRENT turn's run is re-fetched fresh; a null or active run
* therefore HOLDS the latch, so it can only ever clear against the current turn's
* OWN terminal run never a stale cached one. (The cache removal itself is
* integration-level in AiChatWindow; this predicate encodes the decision given
* whatever run is currently observed, and a stale terminal run is
* indistinguishable from a current terminal run at the predicate level hence
* the cache removal is what guarantees only the current run is ever passed here.)
*/
export function shouldClearStoppingLatch(args: {
stoppingRun: boolean;
run: IAiChatRun | null | undefined;
isLocalStreaming: boolean;
}): boolean {
const { stoppingRun, run, isLocalStreaming } = args;
if (!stoppingRun || isLocalStreaming) return false;
return !!run && !isRunActive(run);
}
/**
* Should the "stopping" latch be RELEASED by the run-query ERROR safety-net?
* (#234 F7 a NEW path of the same re-stream flash the F4 latch exists to
* prevent.) After Stop, `handleServerStop` clears the run cache; the terminal
* effect then holds the latch via `if (!run) return` until the CURRENT turn's run
* is fetched fresh. If that refetch instead ERRORS permanently, `run` stays null,
* its status-keyed refetchInterval is off, and nothing would ever observe a
* terminal run freezing the view with the observer merge suppressed. This
* safety-net cures ONLY that genuine permanent-null-freeze.
*
* All four must hold:
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
* - `!isLocalStreaming`: this tab is NOT the local streamer (same reason as
* {@link shouldClearStoppingLatch});
* - `runQueryFailed`: the run query is in its error state (TanStack Query v5 with
* retry:false isError);
* - `!isRunActive(run)`: the observed `run` is NOT an active (pending/running)
* held run. This is the F7 gate. In TanStack Query v5 the query's `data` is
* RETAINED on error, so `runQueryFailed` can be true while `run` is STILL an
* ACTIVE run (a single transient GET-run failure in the window between Stop and
* settle). Without this gate a transient error would release the latch early
* re-opening the observer merge and flashing the growing detached run over the
* frozen row (exactly the F4 flash). Gating on the run NOT being active means we
* only ever cure the permanent-null-freeze (`run === null`, so
* `isRunActive(null)` is false), never release against an active run.
*
* (A terminal `run` also satisfies `!isRunActive(run)`; clearing then is harmless
* the terminal effect's {@link shouldClearStoppingLatch} already clears the
* latch for a terminal run, so this only ever agrees with it, never conflicts.)
*
* INVARIANT (do not break): clearing the latch on the `run === null` branch is safe
* ONLY because the run query's `refetchInterval` (see {@link runPollInterval}) stops
* polling when the data is empty so after we clear on null+error there is no
* subsequent auto-poll that could return a still-active detached run and re-open the
* merge. If `refetchInterval` is ever changed to keep polling on `run === null`/on
* error, this null-branch clear would re-open the F7 flash through the null path.
* Do not change the run query's refetchInterval without re-checking this path.
*/
export function shouldClearLatchOnQueryError(args: {
stoppingRun: boolean;
isLocalStreaming: boolean;
runQueryFailed: boolean;
run: IAiChatRun | null | undefined;
}): boolean {
const { stoppingRun, isLocalStreaming, runQueryFailed, run } = args;
return (
stoppingRun && !isLocalStreaming && runQueryFailed && !isRunActive(run)
);
}
/**
* Merge an observed assistant message into the rendered list: replace the message
* with the same id in place (the in-progress assistant row is already seeded from
* history, so per-step growth replaces it), or append it when absent. Returns a
* new array; the input is never mutated.
*/
export function mergeObservedMessage(
messages: UIMessage[],
observed: UIMessage | null | undefined,
): UIMessage[] {
if (!observed) return messages;
const idx = messages.findIndex((m) => m.id === observed.id);
if (idx === -1) return [...messages, observed];
const next = messages.slice();
next[idx] = observed;
return next;
}
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
// react-i18next: identity t() so the hook renders without an i18n provider.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (k: string) => k }),
}));
// react-router-dom: only useNavigate is used by the hook.
const navigateMock = vi.fn();
vi.mock("react-router-dom", () => ({
useNavigate: () => navigateMock,
}));
// The auth service is the network boundary; stub login/logout per test.
const loginMock = vi.fn();
const logoutMock = vi.fn();
vi.mock("@/features/auth/services/auth-service", () => ({
login: (...args: unknown[]) => loginMock(...args),
logout: (...args: unknown[]) => logoutMock(...args),
forgotPassword: vi.fn(),
passwordReset: vi.fn(),
setupWorkspace: vi.fn(),
verifyUserToken: vi.fn(),
}));
vi.mock("@/features/workspace/services/workspace-service.ts", () => ({
acceptInvitation: vi.fn(),
}));
// The offline cache purge is the unit under test — assert it is invoked.
const clearOfflineCacheMock = vi.fn();
vi.mock("@/features/offline/clear-offline-cache", () => ({
clearOfflineCache: () => clearOfflineCacheMock(),
}));
// app-route helpers are pure config; provide deterministic values.
vi.mock("@/lib/app-route.ts", () => ({
default: { AUTH: { LOGIN: "/login" }, HOME: "/home" },
getPostLoginRedirect: () => "/home",
}));
// Mantine notifications: avoid touching the DOM-bound notification system.
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
import useAuth from "./use-auth";
beforeEach(() => {
navigateMock.mockReset();
loginMock.mockReset();
loginMock.mockResolvedValue(undefined);
logoutMock.mockReset();
logoutMock.mockResolvedValue(undefined);
clearOfflineCacheMock.mockReset();
clearOfflineCacheMock.mockResolvedValue(undefined);
});
describe("useAuth.handleSignIn", () => {
it("clears the offline cache BEFORE logging in (cross-user leak guard)", async () => {
const order: string[] = [];
clearOfflineCacheMock.mockImplementation(async () => {
order.push("clear");
});
loginMock.mockImplementation(async () => {
order.push("login");
});
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.signIn({ email: "b@x", password: "pw" } as any);
});
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
expect(loginMock).toHaveBeenCalledTimes(1);
// The purge must run before the new session's login resolves.
expect(order).toEqual(["clear", "login"]);
expect(navigateMock).toHaveBeenCalledWith("/home");
});
it("does not block sign-in when the cache purge throws (best-effort)", async () => {
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.signIn({ email: "b@x", password: "pw" } as any);
});
// Login still proceeds despite the cleanup failure.
expect(loginMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith("/home");
});
});
describe("useAuth.handleLogout", () => {
const replaceMock = vi.fn();
let originalLocation: Location;
beforeEach(() => {
replaceMock.mockReset();
// window.location.replace is the post-logout redirect. jsdom's real `replace`
// is a non-configurable method that warns "not implemented", so swap the
// whole location object for one whose `replace` we can capture.
originalLocation = window.location;
Object.defineProperty(window, "location", {
configurable: true,
writable: true,
value: { replace: replaceMock },
});
});
afterEach(() => {
Object.defineProperty(window, "location", {
configurable: true,
writable: true,
value: originalLocation,
});
});
it("purges the offline cache exactly once BEFORE redirecting (cross-user leak guard)", async () => {
const order: string[] = [];
clearOfflineCacheMock.mockImplementation(async () => {
order.push("clear");
});
replaceMock.mockImplementation((url: string) => {
order.push(`replace:${url}`);
});
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.logout();
});
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
// Purge must complete before the redirect (which would otherwise interrupt
// the async cleanup).
expect(order).toEqual(["clear", "replace:/login?logout=1"]);
});
it("still redirects when the cache purge throws (best-effort, never blocks logout)", async () => {
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.logout();
});
// The thrown purge error is swallowed and the redirect still fires.
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
expect(replaceMock).toHaveBeenCalledTimes(1);
expect(replaceMock).toHaveBeenCalledWith("/login?logout=1");
});
});
@@ -23,7 +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 { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
export default function useAuth() {
const { t } = useTranslation();
@@ -34,6 +34,20 @@ export default function useAuth() {
const handleSignIn = async (data: ILogin) => {
setIsLoading(true);
// Purge any previous user's offline data BEFORE signing in (mirrors logout).
// On a shared/kiosk device the prior session may have ended WITHOUT an
// explicit logout (cookie/JWT expiry, tab close, force-quit), leaving user
// A's persisted query cache (gitmost-rq-cache) and Yjs page bodies
// (page.<id>) in IndexedDB. Without this purge user B would briefly read A's
// cached currentUser/pages/comments on first render (UserProvider serves the
// cached user) and A's page bodies would stay readable offline. Best-effort:
// never block sign-in on cache cleanup.
try {
await clearOfflineCache();
} catch {
// best-effort: never block sign-in on cache cleanup
}
try {
await login(data);
setIsLoading(false);
@@ -123,12 +137,14 @@ export default function useAuth() {
const handleLogout = async () => {
setCurrentUser(RESET);
// Purge the persisted sidebar tree caches (they contain page titles) so the
// cached page titles aren't left readable in localStorage on a shared
// machine. (Only the tree caches are swept; other localStorage entries
// remain.)
clearPersistedTreeCaches();
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`);
};
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { AxiosError } from "axios";
import { collabTokenRetry } from "./auth-query";
// Regression for the offline white-screen (#237/#238): offline the collab-token
// POST rejects as an axios NETWORK error (isAxiosError === true but
// error.response === undefined). The old predicate read `error.response.status`
// without a guard and threw an uncaught TypeError inside the React Query retryer
// BEFORE React mounted, blanking the whole app. The predicate must stay total.
describe("collabTokenRetry", () => {
it("does NOT throw and returns a retryable value for a network error with no response (offline)", () => {
// An axios error with no `response` is exactly the offline/network-failure shape.
const networkError = new AxiosError("Network Error");
expect(networkError.response).toBeUndefined();
let result: boolean | number = false;
expect(() => {
result = collabTokenRetry(0, networkError);
}).not.toThrow();
// Network failures stay retryable (truthy), matching the original intent.
expect(result).toBe(true);
});
it("returns false (no retry) for a real 404 response", () => {
const notFound = new AxiosError("Not Found");
notFound.response = { status: 404 } as AxiosError["response"];
expect(collabTokenRetry(0, notFound)).toBe(false);
});
it("retries for a non-404 response (e.g. 500)", () => {
const serverError = new AxiosError("Server Error");
serverError.response = { status: 500 } as AxiosError["response"];
expect(collabTokenRetry(0, serverError)).toBe(true);
});
it("does not throw and retries for a non-axios error", () => {
let result: boolean | number = false;
expect(() => {
result = collabTokenRetry(0, new Error("boom"));
}).not.toThrow();
expect(result).toBe(true);
});
});
@@ -3,6 +3,27 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service";
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
import { isAxiosError } from "axios";
/**
* Retry predicate for the collab-token query.
*
* Offline (or any network failure) the POST rejects as an axios NETWORK error:
* `isAxiosError(error) === true` but `error.response === undefined`. Reading
* `error.response.status` without a guard threw an uncaught TypeError inside the
* React Query retryer BEFORE React mounted, white-screening the whole app on an
* offline cold boot (#237/#238). Optional-chaining `error.response?.status`
* keeps the predicate total: a network error (no response) is retryable, a real
* 404 is not. Extracted (and exported) so it can be unit-tested in isolation.
*/
export function collabTokenRetry(
_failureCount: number,
error: Error,
): boolean {
if (isAxiosError(error) && error.response?.status === 404) {
return false;
}
return true;
}
export function useVerifyUserTokenQuery(
verify: IVerifyUserToken,
): UseQueryResult<any, Error> {
@@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
//refetchIntervalInBackground: true,
refetchOnMount: true,
//@ts-ignore
retry: (failureCount, error) => {
if (isAxiosError(error) && error.response.status === 404) {
return false;
}
return 10;
},
retry: collabTokenRetry,
retryDelay: (retryAttempt) => {
// Exponential backoff: 5s, 10s, 20s, etc.
return 5000 * Math.pow(2, retryAttempt - 1);
@@ -1,250 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// The fallback path renders the full TipTap editor; stub it so we can assert the
// safety valve fired without pulling in the editor stack.
vi.mock("@/features/comment/components/comment-editor", () => ({
default: () => <div data-testid="comment-editor-fallback" />,
}));
// Mention rendering hits react-query; stub the page/share queries so the mention
// case renders in isolation.
vi.mock("@/features/page/queries/page-query.ts", () => ({
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
}));
vi.mock("@/features/share/queries/share-query.ts", () => ({
useSharePageQuery: () => ({ data: undefined }),
}));
import { CommentContentView } from "./comment-content-view";
function renderView(content: string | object) {
return render(
<MantineProvider>
<MemoryRouter>
<CommentContentView content={content} />
</MemoryRouter>
</MantineProvider>,
);
}
const doc = (content: any[]) => JSON.stringify({ type: "doc", content });
const para = (content: any[]) => ({ type: "paragraph", content });
const text = (t: string, marks?: any[]) => ({ type: "text", text: t, marks });
describe("CommentContentView", () => {
it("renders paragraphs as <p> with text", () => {
const { container } = renderView(doc([para([text("Hello world")])]));
expect(screen.getByText("Hello world")).toBeDefined();
expect(container.querySelector("p")).not.toBeNull();
});
it("reproduces the read-only CommentEditor DOM nesting for CSS parity", () => {
const { container } = renderView(doc([para([text("x")])]));
// outer .commentEditor > .ProseMirror (module) > .ProseMirror (global) > p
const globalPm = container.querySelector("div.ProseMirror > p");
expect(globalPm).not.toBeNull();
});
it("renders the bold mark as <strong>", () => {
const { container } = renderView(
doc([para([text("bold", [{ type: "bold" }])])]),
);
const el = container.querySelector("strong");
expect(el?.textContent).toBe("bold");
});
it("renders the italic mark as <em>", () => {
const { container } = renderView(
doc([para([text("it", [{ type: "italic" }])])]),
);
expect(container.querySelector("em")?.textContent).toBe("it");
});
it("renders the strike mark as <s>", () => {
const { container } = renderView(
doc([para([text("st", [{ type: "strike" }])])]),
);
expect(container.querySelector("s")?.textContent).toBe("st");
});
it("renders the underline mark as <u> (not the editor fallback)", () => {
const { container } = renderView(
doc([para([text("un", [{ type: "underline" }])])]),
);
expect(container.querySelector("u")?.textContent).toBe("un");
// Underline is a supported mark, so no degrade to the editor fallback.
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
});
it("renders the code mark as <code>", () => {
const { container } = renderView(
doc([para([text("co", [{ type: "code" }])])]),
);
expect(container.querySelector("code")?.textContent).toBe("co");
});
it("renders the link mark as an anchor with safe rel/target", () => {
const { container } = renderView(
doc([
para([
text("click", [
{ type: "link", attrs: { href: "https://example.com" } },
]),
]),
]),
);
const a = container.querySelector("a");
expect(a?.getAttribute("href")).toBe("https://example.com");
expect(a?.getAttribute("target")).toBe("_blank");
expect(a?.getAttribute("rel")).toBe("noopener noreferrer nofollow");
expect(a?.textContent).toBe("click");
});
it("neutralizes a javascript: link href (stored XSS) while keeping the text", () => {
const { container } = renderView(
doc([
para([
text("click", [
{ type: "link", attrs: { href: "javascript:alert(1)" } },
]),
]),
]),
);
const a = container.querySelector("a");
expect(a).not.toBeNull();
// No navigable javascript: href — attribute is absent (or empty).
expect(a?.getAttribute("href")).toBeFalsy();
// The link text is still rendered.
expect(a?.textContent).toBe("click");
});
it("neutralizes a control-char-obfuscated javascript: href", () => {
const { container } = renderView(
doc([
para([
text("x", [
{ type: "link", attrs: { href: "java\tscript:alert(1)" } },
]),
]),
]),
);
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
});
it("neutralizes a data: link href", () => {
const { container } = renderView(
doc([
para([
text("x", [
{
type: "link",
attrs: { href: "data:text/html,<script>alert(1)</script>" },
},
]),
]),
]),
);
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
});
it("preserves a mailto: link href (allowlisted scheme)", () => {
const { container } = renderView(
doc([
para([
text("mail", [
{ type: "link", attrs: { href: "mailto:a@b.com" } },
]),
]),
]),
);
expect(container.querySelector("a")?.getAttribute("href")).toBe(
"mailto:a@b.com",
);
});
it("preserves a relative link href (no scheme, not a script vector)", () => {
const { container } = renderView(
doc([
para([
text("rel", [{ type: "link", attrs: { href: "/some/path" } }]),
]),
]),
);
expect(container.querySelector("a")?.getAttribute("href")).toBe(
"/some/path",
);
});
it("nests multiple marks on one text node", () => {
const { container } = renderView(
doc([para([text("x", [{ type: "bold" }, { type: "italic" }])])]),
);
// bold wraps italic (or vice versa) — both elements exist around the text.
expect(container.querySelector("strong")).not.toBeNull();
expect(container.querySelector("em")).not.toBeNull();
expect(screen.getByText("x")).toBeDefined();
});
it("renders hardBreak as <br/>", () => {
const { container } = renderView(
doc([para([text("a"), { type: "hardBreak" }, text("b")])]),
);
expect(container.querySelector("br")).not.toBeNull();
});
it("renders a user mention as a styled span", () => {
const { container } = renderView(
doc([
para([
{
type: "mention",
attrs: { label: "Alice", entityType: "user", entityId: "u1" },
},
]),
]),
);
expect(screen.getByText("@Alice")).toBeDefined();
// No fallback to the editor.
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
});
it("renders a page mention as a link", () => {
const { container } = renderView(
doc([
para([
{
type: "mention",
attrs: {
label: "Some Page",
entityType: "page",
slugId: "pg1",
},
},
]),
]),
);
expect(container.querySelector("a")).not.toBeNull();
expect(screen.getByText("Some Page")).toBeDefined();
});
it("renders a legacy plain-text (non-JSON) string as plain text", () => {
renderView("just a legacy string");
expect(screen.getByText("just a legacy string")).toBeDefined();
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
});
it("falls back to CommentEditor for an unknown node type", () => {
renderView(doc([{ type: "codeBlock", content: [text("x")] }]));
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
});
it("falls back to CommentEditor for malformed JSON", () => {
renderView('{"type":"doc","content":[');
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
});
});
@@ -1,199 +0,0 @@
import React from "react";
import classes from "./comment.module.css";
import { MentionContent } from "@/features/editor/components/mention/mention-view";
import CommentEditor from "@/features/comment/components/comment-editor";
// Static, editor-free renderer of a comment body (ProseMirror JSON). It walks the
// document and emits plain DOM, avoiding the cost of a full TipTap/ProseMirror
// instance per comment (the panel used to spin up 400+ editors on mount).
//
// The supported node/mark set MUST mirror what CommentEditor enables
// (StarterKit + Mention + LinkExtension). Anything outside that set makes the
// whole comment degrade to the read-only CommentEditor via the fallback below,
// so we never show a half-rendered comment.
// Sentinel thrown when we hit a node/mark we don't know how to render statically.
// Caught at the top level to trigger the CommentEditor fallback for the whole comment.
class UnknownNodeError extends Error {}
// Protocol allowlist mirroring @tiptap/extension-link's default (the read-only
// CommentEditor path relies on it to blank javascript:/data: hrefs). The static
// renderer must apply the SAME sanitization because the backend stores comment
// content verbatim and React does not neutralize javascript: in an href.
const ALLOWED_URI_SCHEMES = /^(?:https?|ftps?|mailto|tel|callto|sms|cid|xmpp):/i;
function safeHref(href: unknown): string | undefined {
if (typeof href !== "string") return undefined;
// Strip control chars/whitespace that could smuggle a scheme past the test
// (e.g. "java\tscript:").
const cleaned = href.replace(/[\u0000-\u0020]/g, "").trim();
// Allow relative/anchor/protocol-relative links (no scheme) — not script vectors.
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return href;
return ALLOWED_URI_SCHEMES.test(cleaned) ? href : undefined;
}
interface PMMark {
type: string;
attrs?: Record<string, any>;
}
interface PMNode {
type: string;
attrs?: Record<string, any>;
content?: PMNode[];
text?: string;
marks?: PMMark[];
}
// Wrap a text node's string in its marks (marks nest, e.g. bold + italic).
function renderMarks(
text: React.ReactNode,
marks: PMMark[] | undefined,
keyPrefix: string,
): React.ReactNode {
if (!marks || marks.length === 0) return text;
return marks.reduce<React.ReactNode>((acc, mark, i) => {
const key = `${keyPrefix}-m${i}`;
switch (mark.type) {
case "bold":
return <strong key={key}>{acc}</strong>;
case "italic":
return <em key={key}>{acc}</em>;
case "strike":
return <s key={key}>{acc}</s>;
case "underline":
// StarterKit enables the Underline extension by default (Mod-u) and
// CommentEditor does not disable it, so real comments can carry this
// mark. Render it here rather than degrading the whole comment.
return <u key={key}>{acc}</u>;
case "code":
return <code key={key}>{acc}</code>;
case "link": {
// LinkExtension (TiptapLink) opens links in a new tab; keep the same
// safe rel semantics the editor produces. Sanitize the href against the
// extension's protocol allowlist — a disallowed scheme (javascript:,
// data:) yields undefined so the anchor is non-navigable but still shows
// its text, matching how extension-link blanks a bad href.
const href = safeHref(mark.attrs?.href);
return (
<a
key={key}
href={href}
target="_blank"
rel="noopener noreferrer nofollow"
>
{acc}
</a>
);
}
default:
throw new UnknownNodeError(`Unknown mark type: ${mark.type}`);
}
}, text);
}
function renderNode(node: PMNode, key: string): React.ReactNode {
switch (node.type) {
case "paragraph":
return <p key={key}>{renderChildren(node.content, key)}</p>;
case "text":
return (
<React.Fragment key={key}>
{renderMarks(node.text ?? "", node.marks, key)}
</React.Fragment>
);
case "hardBreak":
return <br key={key} />;
case "mention":
return (
<span key={key} style={{ display: "inline" }}>
<MentionContent attrs={node.attrs as any} />
</span>
);
default:
throw new UnknownNodeError(`Unknown node type: ${node.type}`);
}
}
function renderChildren(
content: PMNode[] | undefined,
keyPrefix: string,
): React.ReactNode {
if (!content) return null;
return content.map((child, i) => renderNode(child, `${keyPrefix}-${i}`));
}
// Reproduce the exact DOM nesting the read-only CommentEditor renders so the
// scoped CSS in comment.module.css (which targets
// `.commentEditor .ProseMirror :global(.ProseMirror)` and `.ProseMirror p`)
// applies pixel-for-pixel. Read-only => no data-editable / data-surface attrs.
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className={classes.commentEditor}>
<div className={classes.ProseMirror}>
<div className="ProseMirror">{children}</div>
</div>
</div>
);
}
interface CommentContentViewProps {
content: string | object;
}
export function CommentContentView({ content }: CommentContentViewProps) {
// Degrade this single comment to the old editor-based render (safety valve).
const fallback = () => {
if (import.meta.env.DEV) {
console.warn(
"CommentContentView: unsupported comment content, falling back to editor",
);
}
return <CommentEditor defaultContent={content} editable={false} />;
};
let doc: unknown = content;
if (typeof content === "string") {
try {
doc = JSON.parse(content);
} catch {
const trimmed = content.trim();
// Looks like it was meant to be JSON but is malformed -> safety-valve fallback.
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return fallback();
}
// Otherwise it's a legacy plain-text comment: render as a single paragraph.
return (
<Shell>
<p>{content}</p>
</Shell>
);
}
}
// Double-stringified / legacy plain-text stored as a JSON string.
if (typeof doc === "string") {
return (
<Shell>
<p>{doc}</p>
</Shell>
);
}
try {
const pmDoc = doc as PMNode;
if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
throw new UnknownNodeError("Not a ProseMirror doc");
}
return <Shell>{renderChildren(pmDoc.content, "n")}</Shell>;
} catch (err) {
if (err instanceof UnknownNodeError) {
return fallback();
}
throw err;
}
}
export default CommentContentView;
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { IComment } from "@/features/comment/types/comment.types";
@@ -7,75 +7,18 @@ import { IComment } from "@/features/comment/types/comment.types";
// The comment mutation hooks reach out to react-query/network — stub them so the
// component renders in isolation. We only assert the AI-badge rendering branch.
const applyMutateAsync = vi.fn();
const dismissMutateAsync = vi.fn();
const updateMutateAsync = vi.fn();
vi.mock("@/features/comment/queries/comment-query", () => ({
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
useApplySuggestionMutation: () => ({
mutateAsync: applyMutateAsync,
isPending: false,
}),
useDismissSuggestionMutation: () => ({
mutateAsync: dismissMutateAsync,
isPending: false,
}),
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
}));
// The document the mocked editor emits via onUpdate when the edit form is open.
// Duplicated inside the mock factory (below) to keep the factory self-contained.
const EDITED_DOC = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
],
};
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
// so the edit->save/cancel flow can be driven without a live editor.
vi.mock("@/features/comment/components/comment-editor", () => {
const doc = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
],
};
return {
default: ({ onUpdate, onSave }: any) => (
<div data-testid="comment-editor">
<button
type="button"
data-testid="editor-emit-update"
onClick={() => onUpdate?.(doc)}
/>
<button
type="button"
data-testid="editor-emit-save"
onClick={() => onSave?.()}
/>
</div>
),
};
});
// CommentContentView (used for the read-only body) imports the mention view,
// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
// renders in isolation without the app entry side-effect.
vi.mock("@/features/page/queries/page-query.ts", () => ({
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
}));
vi.mock("@/features/share/queries/share-query.ts", () => ({
useSharePageQuery: () => ({ data: undefined }),
vi.mock("@/features/comment/components/comment-editor", () => ({
default: () => <div data-testid="comment-editor" />,
}));
import CommentListItem from "./comment-list-item";
import {
canShowApply,
canShowDismiss,
} from "@/features/comment/utils/suggestion";
const baseComment = (over?: Partial<IComment>): IComment =>
({
@@ -89,372 +32,28 @@ const baseComment = (over?: Partial<IComment>): IComment =>
...over,
}) as IComment;
function renderItem(
comment: IComment,
canEdit = true,
canComment = true,
userSpaceRole?: string,
) {
function renderItem(comment: IComment) {
return render(
<MantineProvider>
<CommentListItem
comment={comment}
pageId="page-1"
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
</MantineProvider>,
);
}
describe("CommentListItem — agent avatar stack", () => {
it('flips the hierarchy for an agent comment: agent primary, launcher shown once', () => {
// Internal-chat shape with DISTINCT names so absence-of-duplication is
// assertable: creator is the human "Alice", the acting agent is "Researcher".
renderItem(
baseComment({
creator: { id: "user-1", name: "Alice", avatarUrl: null } as any,
createdSource: "agent",
aiChatId: "chat-1",
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
}),
);
// The AGENT is the primary label (the flipped hierarchy).
expect(screen.getByText("Researcher")).toBeDefined();
// The human launcher name shows exactly once — it is no longer duplicated as
// a separate creator name (that duplication is the bug this fixes).
expect(screen.getAllByText("Alice").length).toBe(1);
});
it('external MCP agent comment (no launcher): shows the agent name, no separator', () => {
// aiChatId null => external MCP: the agent IS the account, no human behind.
renderItem(
baseComment({
creator: { id: "bot-1", name: "MCP Bot", avatarUrl: null } as any,
createdSource: "agent",
aiChatId: null,
agent: { name: "MCP Bot", avatarUrl: null },
launcher: null,
}),
);
expect(screen.getByText("MCP Bot")).toBeDefined();
// No launcher => no dimmed "·" separator in the header.
expect(screen.queryByText("·")).toBeNull();
});
it('does NOT render the stack for a normal user comment (createdSource "user")', () => {
const { container } = renderItem(baseComment({ createdSource: "user" }));
// No agent glyph (sparkles) is present for a plain human comment.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
describe("CommentListItem — AI badge", () => {
it('renders the AI-agent badge when createdSource === "agent"', () => {
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
expect(screen.getByText("AI-agent")).toBeDefined();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
// only guards the insertion gate (agent → stack, user → no stack).
});
describe("CommentListItem — suggested edit (#315)", () => {
const suggestion = (over?: Partial<IComment>): IComment =>
baseComment({
selection: "old wording here",
suggestedText: "new wording here",
...over,
});
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
const { container } = renderItem(suggestion(), true);
// Old text appears as the selection quote (a single unsplit Text node).
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
// The new line is now rendered as per-fragment spans (intraline diff, #331),
// so it is no longer a single text node — assert the concatenated content.
expect(container.textContent).toContain("new wording here");
// Apply button is present.
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
// No Applied badge yet.
expect(screen.queryByText("Applied")).toBeNull();
});
it("hides the Apply button when canEdit is false", () => {
const { container } = renderItem(suggestion(), false);
// Diff still renders (as per-fragment spans, #331)...
expect(container.textContent).toContain("new wording here");
// ...but no Apply button.
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
expect(screen.getByText("Applied")).toBeDefined();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides the Apply button once the thread is resolved", () => {
renderItem(suggestion({ resolvedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("calls the apply mutation when the Apply button is clicked", () => {
applyMutateAsync.mockClear();
renderItem(suggestion(), true);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(applyMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
it("does not render the diff block for a reply (child) comment", () => {
renderItem(
suggestion({ parentCommentId: "c-0" }),
true,
);
expect(screen.queryByText("new wording here")).toBeNull();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
});
describe("CommentListItem — dismiss suggestion (#329)", () => {
const suggestion = (over?: Partial<IComment>): IComment =>
baseComment({
selection: "old wording here",
suggestedText: "new wording here",
...over,
});
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
// regardless of who authored the comment; the tests below use it as the lever
// since the currentUser atom is unseeded (null) in this harness.
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
renderItem(suggestion(), true, true, "admin");
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
renderItem(suggestion(), false, true, "admin");
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("hides Dismiss when the viewer cannot comment", () => {
renderItem(suggestion(), false, false, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
// canComment=true but NOT a space admin and NOT the comment owner (the
// currentUser atom is null while the comment is authored by user-1), so the
// server would 403 a dismiss — the button must not be shown at all.
renderItem(suggestion(), false, true, "member");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("hides Dismiss once the thread is resolved", () => {
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("hides Dismiss (shows the Applied badge) once applied", () => {
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
expect(screen.getByText("Applied")).toBeDefined();
});
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
dismissMutateAsync.mockClear();
renderItem(suggestion(), true, true, "admin");
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(dismissMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
});
describe("canShowApply predicate", () => {
const c = (over?: Partial<IComment>): IComment =>
({ suggestedText: "x", ...over }) as IComment;
it("true when suggestion present, editable, not applied/resolved, top-level", () => {
expect(canShowApply(c(), true)).toBe(true);
});
it("false without edit permission", () => {
expect(canShowApply(c(), false)).toBe(false);
});
it("false when no suggestion", () => {
expect(canShowApply(c({ suggestedText: null }), true)).toBe(false);
});
it("false when already applied", () => {
expect(canShowApply(c({ suggestionAppliedAt: new Date() }), true)).toBe(
false,
);
});
it("false when resolved", () => {
expect(canShowApply(c({ resolvedAt: new Date() }), true)).toBe(false);
});
it("false for a reply comment", () => {
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
});
});
describe("canShowDismiss predicate", () => {
const c = (over?: Partial<IComment>): IComment =>
({ suggestedText: "x", ...over }) as IComment;
it("true when suggestion present, can comment, owner/admin, not applied/resolved, top-level", () => {
expect(canShowDismiss(c(), true, true)).toBe(true);
});
it("false without comment permission", () => {
expect(canShowDismiss(c(), false, true)).toBe(false);
});
it("false when not owner and not admin (#338 F5)", () => {
expect(canShowDismiss(c(), true, false)).toBe(false);
});
it("false when no suggestion", () => {
expect(canShowDismiss(c({ suggestedText: null }), true, true)).toBe(false);
});
it("false when already applied", () => {
expect(canShowDismiss(c({ suggestionAppliedAt: new Date() }), true, true)).toBe(
false,
);
});
it("false when resolved", () => {
expect(canShowDismiss(c({ resolvedAt: new Date() }), true, true)).toBe(false);
});
it("false for a reply comment", () => {
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
});
});
describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
const body = (t: string) =>
JSON.stringify({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
});
// The edit menu item is gated on the viewer owning the comment
// (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
// so seed localStorage to make the viewer the owner (creatorId "user-1").
beforeEach(() => {
updateMutateAsync.mockClear();
localStorage.setItem(
"currentUser",
JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
);
});
afterEach(() => {
localStorage.clear();
});
async function openEditor() {
// Open the comment menu, then click "Edit comment" to toggle into edit mode.
fireEvent.click(screen.getByLabelText("Comment menu"));
fireEvent.click(await screen.findByText("Edit comment"));
// Edit form (mocked editor + actions) is now mounted.
await screen.findByTestId("comment-editor");
}
it("saves the edited content and, on cache update, shows the new body", async () => {
const { rerender } = renderItem(
baseComment({ content: body("original body") }),
);
// Static body first.
expect(screen.getByText("original body")).toBeDefined();
await openEditor();
// Editor emits an update (populates editContentRef), then Save is clicked.
fireEvent.click(screen.getByTestId("editor-emit-update"));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
// mutateAsync is called with the stringified edited doc.
expect(updateMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
content: JSON.stringify(EDITED_DOC),
});
// On success the form closes (isEditing -> false); the static body renders
// from the comment.content prop again.
await waitFor(() =>
expect(screen.queryByTestId("comment-editor")).toBeNull(),
);
// Simulate the cache invalidation swapping in a new comment object with the
// updated content — the static body reflects it.
rerender(
<MantineProvider>
<CommentListItem
comment={baseComment({ content: body("updated body after save") })}
pageId="page-1"
canComment={true}
canEdit={true}
/>
</MantineProvider>,
);
expect(screen.getByText("updated body after save")).toBeDefined();
expect(screen.queryByText("original body")).toBeNull();
});
it("cancel restores the static body and does not call the update mutation", async () => {
renderItem(baseComment({ content: body("original body") }));
await openEditor();
// Type something (editContentRef set), then cancel.
fireEvent.click(screen.getByTestId("editor-emit-update"));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
// Editor unmounts, static body restored, no save happened.
await waitFor(() =>
expect(screen.queryByTestId("comment-editor")).toBeNull(),
);
expect(screen.getByText("original body")).toBeDefined();
expect(updateMutateAsync).not.toHaveBeenCalled();
});
it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
renderItem(baseComment({ content: body("original body") }));
// Cancel path clears editContentRef...
await openEditor();
fireEvent.click(screen.getByTestId("editor-emit-update"));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() =>
expect(screen.queryByTestId("comment-editor")).toBeNull(),
);
// ...so re-opening and saving WITHOUT an update falls back to comment.content.
await openEditor();
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(updateMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
content: JSON.stringify(body("original body")),
});
});
});
describe("CommentListItem — read-only body renders statically", () => {
it("renders the comment body as static text without a TipTap editor", () => {
renderItem(
baseComment({
content: JSON.stringify({
type: "doc",
content: [
{
type: "paragraph",
content: [{ type: "text", text: "Hello static world" }],
},
],
}),
}),
);
// Body text is present...
expect(screen.getByText("Hello static world")).toBeDefined();
// ...and it did NOT go through the (mocked) CommentEditor instance.
expect(screen.queryByTestId("comment-editor")).toBeNull();
});
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
renderItem(baseComment({ createdSource: "user" }));
expect(screen.queryByText("AI-agent")).toBeNull();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
// the insertion gate (agent → badge, user → no badge) above (#143 review).
});
@@ -1,29 +1,21 @@
import { Group, Text, Box, Badge, Button } from "@mantine/core";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import React, { useMemo, useRef, useState } from "react";
import { Group, Text, Box } from "@mantine/core";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { useTimeAgo } from "@/hooks/use-time-ago";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentContentView from "@/features/comment/components/comment-content-view";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu";
import ResolveComment from "@/features/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks";
import {
useApplySuggestionMutation,
useDeleteCommentMutation,
useDismissSuggestionMutation,
useResolveCommentMutation,
useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import {
canShowApply,
canShowDismiss,
computeSuggestionDiff,
} from "@/features/comment/utils/suggestion";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
@@ -32,10 +24,6 @@ interface CommentListItemProps {
comment: IComment;
pageId: string;
canComment: boolean;
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
// allowed to comment cannot apply edits).
canEdit?: boolean;
userSpaceRole?: string;
}
@@ -43,7 +31,6 @@ function CommentListItem({
comment,
pageId,
canComment,
canEdit,
userSpaceRole,
}: CommentListItemProps) {
const { t } = useTranslation();
@@ -51,43 +38,30 @@ function CommentListItem({
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom);
const [content, setContent] = useState<string>(comment.content);
const editContentRef = useRef<any>(null);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const applySuggestionMutation = useApplySuggestionMutation();
const dismissSuggestionMutation = useDismissSuggestionMutation();
const [currentUser] = useAtom(currentUserAtom);
const createdAtAgo = useTimeAgo(comment.createdAt);
// Intraline "before -> after" diff (#331) for a suggested edit: only the
// fragments that actually changed get emphasised inside the red/green block,
// instead of striking through / greening the whole line. Memoised on the
// (selection, suggestedText) pair so it recomputes only when they change.
const suggestionDiff = useMemo(
() =>
comment.suggestedText != null
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
: null,
[comment.selection, comment.suggestedText],
);
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
// render an action the server will 403.
const isOwnerOrAdmin =
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
useEffect(() => {
setContent(comment.content);
}, [comment]);
async function handleUpdateComment() {
try {
setIsLoading(true);
const commentToUpdate = {
commentId: comment.id,
content: JSON.stringify(editContentRef.current ?? comment.content),
content: JSON.stringify(editContentRef.current ?? content),
};
await updateCommentMutation.mutateAsync(commentToUpdate);
editContentRef.current = null;
if (editContentRef.current) {
setContent(editContentRef.current);
editContentRef.current = null;
}
setIsEditing(false);
} catch (error) {
console.error("Failed to update comment:", error);
@@ -121,31 +95,6 @@ function CommentListItem({
}
}
async function handleApplySuggestion() {
try {
await applySuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Errors surface via the mutation's onError notification (incl. 409).
console.error("Failed to apply suggestion:", error);
}
}
async function handleDismissSuggestion() {
try {
await dismissSuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Idempotent races are reconciled to success in the mutation's onError;
// anything else surfaces there as a notification.
console.error("Failed to dismiss suggestion:", error);
}
}
function handleCommentClick(comment: IComment) {
const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`,
@@ -170,44 +119,24 @@ function CommentListItem({
return (
<Box ref={ref} pb={6}>
<Group gap="xs">
{comment.createdSource === "agent" && comment.agent ? (
<AgentAvatarStack
agent={comment.agent}
launcher={comment.launcher}
aiChatId={comment.aiChatId}
showName={false}
/>
) : (
<CustomAvatar
size="sm"
avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
/>
)}
<CustomAvatar
size="sm"
avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
/>
<div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
{comment.createdSource === "agent" && comment.agent ? (
<>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{comment.agent.name}
</Text>
{comment.launcher && (
<>
<Text size="xs" c="dimmed" fw={400} aria-hidden>
·
</Text>
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
{comment.launcher.name}
</Text>
</>
)}
</>
) : (
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name}
</Text>
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name}
</Text>
{comment.createdSource === "agent" && (
<AiAgentBadge
authorName={comment.creator?.name}
aiChatId={comment.aiChatId}
/>
)}
</Group>
@@ -221,7 +150,7 @@ function CommentListItem({
/>
)}
{isOwnerOrAdmin && (
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
<CommentMenu
onEditComment={handleEditToggle}
onDeleteComment={handleDeleteComment}
@@ -262,93 +191,12 @@ function CommentListItem({
</Box>
)}
{/* Suggested-edit (#315): "было стало" diff for a top-level comment
carrying a suggestion. Old text struck-through/red, new text green. */}
{!comment.parentCommentId && comment.suggestedText && (
<Box className={classes.suggestionBlock}>
{comment.selection && (
// Old line: read as removed as a whole (line-through/red); only the
// changed fragments carry the extra intraline emphasis.
<Text size="xs" className={classes.suggestionOld}>
{suggestionDiff?.old.map((segment, index) => (
<span
key={index}
className={segment.changed ? classes.suggestionChanged : undefined}
>
{segment.text}
</span>
))}
</Text>
)}
<Text size="xs" className={classes.suggestionNew}>
{suggestionDiff?.new.map((segment, index) => (
<span
key={index}
className={segment.changed ? classes.suggestionChanged : undefined}
>
{segment.text}
</span>
))}
</Text>
{comment.suggestionAppliedAt ? (
<Badge
size="sm"
color="green"
variant="light"
mt={6}
aria-label={t("Applied")}
>
{t("Applied")}
</Badge>
) : (
(canShowApply(comment, canEdit) ||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
<Group gap="xs" mt={6}>
{canShowApply(comment, canEdit) && (
<Button
size="compact-xs"
variant="light"
color="green"
onClick={handleApplySuggestion}
loading={applySuggestionMutation.isPending}
disabled={
applySuggestionMutation.isPending ||
dismissSuggestionMutation.isPending
}
>
{t("Apply")}
</Button>
)}
{/* Dismiss ("Не применять", #329): removes the suggestion
without changing the page text. Gated on canComment. */}
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={handleDismissSuggestion}
loading={dismissSuggestionMutation.isPending}
disabled={
applySuggestionMutation.isPending ||
dismissSuggestionMutation.isPending
}
>
{t("Dismiss")}
</Button>
)}
</Group>
)
)}
</Box>
)}
{!isEditing ? (
<CommentContentView content={comment.content} />
<CommentEditor defaultContent={content} editable={false} />
) : (
<>
<CommentEditor
defaultContent={comment.content}
defaultContent={content}
editable={true}
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
onSave={handleUpdateComment}
@@ -368,6 +216,4 @@ function CommentListItem({
);
}
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
// comment's object identity) re-renders that one thread, not all ~356 items.
export default React.memo(CommentListItem);
export default CommentListItem;
@@ -1,108 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { IComment } from "@/features/comment/types/comment.types";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
// the lazy reply editor's mount transition can be observed without the editor.
vi.mock("@/features/comment/components/comment-editor", () => ({
default: () => <div data-testid="comment-editor" />,
}));
// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
// pulled in transitively so importing the module is side-effect free.
vi.mock("@/features/page/queries/page-query.ts", () => ({
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
}));
vi.mock("@/features/share/queries/share-query.ts", () => ({
useSharePageQuery: () => ({ data: undefined }),
}));
// space-query -> main.tsx (createRoot) is another module side effect; stub it.
vi.mock("@/features/space/queries/space-query.ts", () => ({
useGetSpaceBySlugQuery: () => ({ data: undefined }),
}));
import {
buildChildrenByParent,
CommentEditorWithActions,
} from "./comment-list-with-tabs";
const c = (id: string, parentCommentId: string | null = null): IComment =>
({ id, parentCommentId }) as IComment;
describe("buildChildrenByParent (childrenByParent grouping)", () => {
it("returns an empty map for undefined or empty input", () => {
expect(buildChildrenByParent(undefined).size).toBe(0);
expect(buildChildrenByParent([]).size).toBe(0);
});
it("does not index a top-level comment (parentCommentId null)", () => {
const map = buildChildrenByParent([c("p1", null)]);
expect(map.size).toBe(0);
expect(map.has("p1")).toBe(false);
});
it("groups replies under the correct parent, including reply-to-reply nesting", () => {
const p1 = c("p1", null);
const r1 = c("r1", "p1");
const r2 = c("r2", "r1"); // a reply to a reply
const map = buildChildrenByParent([p1, r1, r2]);
expect(map.get("p1")).toEqual([r1]);
expect(map.get("r1")).toEqual([r2]);
// The top-level comment itself is never a key.
expect(map.has("p1") && map.get("p1")?.length).toBe(1);
});
it("still groups a reply whose parent is not present in items", () => {
const orphan = c("o1", "missing-parent");
const map = buildChildrenByParent([orphan]);
expect(map.get("missing-parent")).toEqual([orphan]);
});
it("preserves insertion order among sibling replies", () => {
const map = buildChildrenByParent([
c("a", "p1"),
c("b", "p1"),
c("d", "p1"),
]);
expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
});
});
function renderReplyEditor() {
return render(
<MantineProvider>
<CommentEditorWithActions commentId="c-1" onSave={vi.fn()} />
</MantineProvider>,
);
}
describe("CommentEditorWithActions — lazy reply editor activation", () => {
it("shows only the stub initially (no editor instance mounted)", () => {
renderReplyEditor();
expect(screen.getByRole("button")).toBeDefined();
expect(screen.queryByTestId("comment-editor")).toBeNull();
});
it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
renderReplyEditor();
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("comment-editor")).toBeDefined();
// The stub button is replaced by the editor subtree.
expect(screen.queryByRole("button")).toBeNull();
});
it("mounts the editor when the stub receives focus", () => {
renderReplyEditor();
fireEvent.focus(screen.getByRole("button"));
expect(screen.getByTestId("comment-editor")).toBeDefined();
});
it("mounts the editor on Enter keydown of the stub", () => {
renderReplyEditor();
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
expect(screen.getByTestId("comment-editor")).toBeDefined();
});
});
@@ -23,6 +23,7 @@ import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
@@ -35,24 +36,6 @@ interface CommentListWithTabsProps {
onClose?: () => void;
}
// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
// thread. Replies whose parent is not in `items` are still grouped under their
// parentCommentId (they simply won't be reached by the top-level walk).
// Exported for unit testing.
export function buildChildrenByParent(
items: IComment[] | undefined,
): Map<string, IComment[]> {
const m = new Map<string, IComment[]>();
for (const c of items ?? []) {
if (c.parentCommentId) {
const arr = m.get(c.parentCommentId);
if (arr) arr.push(c);
else m.set(c.parentCommentId, [c]);
}
}
return m;
}
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
@@ -63,15 +46,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
isError,
} = useCommentsQuery({ pageId: page?.id });
const createCommentMutation = useCreateCommentMutation();
// mutateAsync is a stable reference across renders; depend on it (not the
// mutation object) so the reply/comment callbacks stay stable.
const createCommentAsync = createCommentMutation.mutateAsync;
const [isLoading, setIsLoading] = useState(false);
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const canComment =
canEdit ||
(page?.permissions?.canEdit ?? false) ||
(space?.settings?.comments?.allowViewerComments === true);
// Separate active and resolved comments
@@ -94,21 +73,13 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
return { activeComments: active, resolvedComments: resolved };
}, [comments]);
// Index replies by their parent once, instead of an O(n^2) filter per thread.
// The map ref changes on any comments update, so MemoizedChildComments re-runs
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
const childrenByParent = useMemo(
() => buildChildrenByParent(comments?.items),
[comments?.items],
);
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
const handleAddPageComment = useCallback(
async (_commentId: string, content: string) => {
try {
setIsPageCommentLoading(true);
const createdComment = await createCommentAsync({
const createdComment = await createCommentMutation.mutateAsync({
pageId: page?.id,
content: JSON.stringify(content),
});
@@ -127,26 +98,27 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
setIsPageCommentLoading(false);
}
},
[createCommentAsync, page?.id],
[createCommentMutation, page?.id],
);
const handleAddReply = useCallback(
async (commentId: string, content: string) => {
// Pending state lives inside CommentEditorWithActions so sending a reply
// does not churn renderComments and re-render the whole list.
try {
setIsLoading(true);
const commentData = {
pageId: page?.id,
parentCommentId: commentId,
content: JSON.stringify(content),
};
await createCommentAsync(commentData);
await createCommentMutation.mutateAsync(commentData);
} catch (error) {
console.error("Failed to post comment:", error);
} finally {
setIsLoading(false);
}
},
[createCommentAsync, page?.id],
[createCommentMutation, page?.id],
);
const renderComments = useCallback(
@@ -165,15 +137,13 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
comment={comment}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
childrenByParent={childrenByParent}
comments={comments}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
/>
</div>
@@ -184,19 +154,13 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
isLoading={isLoading}
/>
</>
)}
</Paper>
),
[
childrenByParent,
handleAddReply,
page?.id,
space?.membership?.role,
canComment,
canEdit,
],
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
);
if (isCommentsLoading) {
@@ -228,11 +192,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
<Tabs
defaultValue="open"
variant="default"
// Default to not mounting an inactive tab (the heavy Resolved list stays
// unmounted while Open is shown). The Open panel overrides this with its
// own keepMounted (below) so an in-progress reply/edit draft survives an
// Open -> Resolved -> Open switch.
keepMounted={false}
style={{
flex: "1 1 auto",
display: "flex",
@@ -291,10 +250,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
type="scroll"
>
<div style={{ paddingBottom: "8px" }}>
{/* keepMounted keeps the Open panel alive even while Resolved is
active, so a lazily-mounted reply editor's draft (and an
in-progress edit) is not discarded on tab switch. */}
<Tabs.Panel value="open" pt="xs" keepMounted>
<Tabs.Panel value="open" pt="xs">
{activeComments.length === 0 ? (
<Center py="xl">
<Stack align="center" gap="xs">
@@ -340,40 +296,42 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
}
interface ChildCommentsProps {
childrenByParent: Map<string, IComment[]>;
comments: IPagination<IComment>;
parentId: string;
pageId: string;
canComment: boolean;
canEdit?: boolean;
userSpaceRole?: string;
}
const ChildComments = ({
childrenByParent,
comments,
parentId,
pageId,
canComment,
canEdit,
userSpaceRole,
}: ChildCommentsProps) => {
const children = childrenByParent.get(parentId) ?? [];
const getChildComments = useCallback(
(parentId: string) =>
comments.items.filter(
(comment: IComment) => comment.parentCommentId === parentId,
),
[comments.items],
);
return (
<div>
{children.map((childComment) => (
{getChildComments(parentId).map((childComment) => (
<div key={childComment.id}>
<CommentListItem
comment={childComment}
pageId={pageId}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
<MemoizedChildComments
childrenByParent={childrenByParent}
comments={comments}
parentId={childComment.id}
pageId={pageId}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
</div>
@@ -384,61 +342,22 @@ const ChildComments = ({
const MemoizedChildComments = memo(ChildComments);
export const CommentEditorWithActions = ({
const CommentEditorWithActions = ({
commentId,
onSave,
isLoading,
placeholder = undefined,
}) => {
const { t } = useTranslation();
// Lazily mount the TipTap reply editor: until the user interacts with the
// stub, no editor instance is created for this thread. Once mounted it stays
// mounted so the draft is preserved.
const [mounted, setMounted] = useState(false);
const [content, setContent] = useState("");
const [isSending, setIsSending] = useState(false);
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const activate = useCallback(() => setMounted(true), []);
const handleSave = useCallback(async () => {
try {
setIsSending(true);
await onSave(commentId, content);
setContent("");
commentEditorRef.current?.clearContent();
} finally {
setIsSending(false);
}
const handleSave = useCallback(() => {
onSave(commentId, content);
setContent("");
commentEditorRef.current?.clearContent();
}, [commentId, content, onSave]);
if (!mounted) {
return (
<div
role="button"
tabIndex={0}
onClick={activate}
onFocus={activate}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
activate();
}
}}
style={{
padding: "6px",
fontSize: "var(--mantine-font-size-sm)",
lineHeight: 1.4,
color: "var(--mantine-color-placeholder)",
cursor: "text",
borderRadius: "var(--mantine-radius-sm)",
}}
>
{placeholder || t("Reply...")}
</div>
);
}
return (
<div ref={ref}>
<CommentEditor
@@ -447,9 +366,8 @@ export const CommentEditorWithActions = ({
onSave={handleSave}
editable={true}
placeholder={placeholder}
autofocus={true}
/>
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
</div>
);
};
@@ -21,53 +21,6 @@
box-sizing: border-box;
}
/* Suggested-edit (#315) "было → стало" diff block. */
.suggestionBlock {
margin-top: 8px;
margin-left: 6px;
padding: 6px;
border-radius: var(--mantine-radius-sm);
border: 1px solid var(--mantine-color-default-border);
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.suggestionOld {
text-decoration: line-through;
color: var(--mantine-color-red-7);
background: var(--mantine-color-red-light);
border-radius: 2px;
padding: 1px 3px;
}
.suggestionNew {
color: var(--mantine-color-green-9);
background: var(--mantine-color-green-light);
border-radius: 2px;
padding: 1px 3px;
margin-top: 4px;
}
/* Intraline diff (#331): the fragment that actually changed within the
red "before" / green "after" block. It inherits the surrounding red/green
framing and adds a stronger tint plus bold weight so the eye lands on the
changed letters/words (git/GitHub-style) rather than the whole line. The
container's line-through (old) / green (new) still marks the full line. */
.suggestionChanged {
/* Stronger tint of the surrounding red/green so the changed fragment pops
within the block. `currentColor` follows the parent's red (old) or green
(new) text colour. No `text-decoration` here on purpose: the old block's
inherited line-through must survive on the changed letters too. */
background: color-mix(in srgb, currentColor 22%, transparent);
border-radius: 2px;
font-weight: 700;
}
.commentEditor {
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
@@ -1,279 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import {
QueryClient,
QueryClientProvider,
InfiniteData,
} from "@tanstack/react-query";
/**
* Coverage for the ephemeral-suggestion (#329) cache reconciliation in
* useApplySuggestionMutation / useDismissSuggestionMutation: the mutations act on
* the server `outcome` 'deleted' drops the comment from the local list,
* 'resolved' relocates it (by stamping resolvedAt, which the tabs split on).
*/
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
vi.mock("@/features/comment/services/comment-service", () => ({
applySuggestion: vi.fn(),
dismissSuggestion: vi.fn(),
createComment: vi.fn(),
updateComment: vi.fn(),
deleteComment: vi.fn(),
resolveComment: vi.fn(),
getPageComments: vi.fn(),
}));
import { notifications } from "@mantine/notifications";
import {
applySuggestion,
dismissSuggestion,
} from "@/features/comment/services/comment-service";
import {
useApplySuggestionMutation,
useDismissSuggestionMutation,
RQ_KEY,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
const PAGE_ID = "page-1";
function seededClient(comment: IComment) {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const seed: InfiniteData<any> = {
pageParams: [undefined],
pages: [{ items: [comment], meta: { hasNextPage: false, nextCursor: null } }],
};
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { queryClient, wrapper };
}
function items(queryClient: QueryClient): IComment[] {
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
| InfiniteData<any>
| undefined;
return cache?.pages.flatMap((p) => p.items) ?? [];
}
const comment = (over?: Partial<IComment>): IComment =>
({
id: "c-1",
pageId: PAGE_ID,
content: "{}",
creatorId: "u-1",
workspaceId: "ws-1",
createdAt: new Date(),
suggestedText: "new",
...over,
}) as IComment;
describe("useApplySuggestionMutation — outcome handling (#329)", () => {
beforeEach(() => vi.clearAllMocks());
it("outcome=deleted → removes the comment from the list", async () => {
vi.mocked(applySuggestion).mockResolvedValue({
id: "c-1",
pageId: PAGE_ID,
outcome: "deleted",
} as any);
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useApplySuggestionMutation(), {
wrapper,
});
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(items(queryClient)).toHaveLength(0);
});
it("outcome=resolved → keeps the comment and stamps resolvedAt/applied fields", async () => {
const resolvedAt = new Date();
vi.mocked(applySuggestion).mockResolvedValue({
id: "c-1",
pageId: PAGE_ID,
outcome: "resolved",
resolvedAt,
resolvedById: "u-1",
resolvedBy: { id: "u-1", name: "A" },
suggestionAppliedAt: resolvedAt,
suggestionAppliedById: "u-1",
} as any);
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useApplySuggestionMutation(), {
wrapper,
});
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const list = items(queryClient);
expect(list).toHaveLength(1);
expect(list[0].resolvedAt).toBe(resolvedAt);
expect(list[0].suggestionAppliedAt).toBe(resolvedAt);
});
});
describe("useDismissSuggestionMutation — outcome handling (#329)", () => {
beforeEach(() => vi.clearAllMocks());
it("outcome=deleted → removes the comment from the list", async () => {
vi.mocked(dismissSuggestion).mockResolvedValue({
id: "c-1",
pageId: PAGE_ID,
outcome: "deleted",
} as any);
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useDismissSuggestionMutation(), {
wrapper,
});
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(items(queryClient)).toHaveLength(0);
});
it("outcome=resolved → keeps the comment and stamps resolvedAt", async () => {
const resolvedAt = new Date();
vi.mocked(dismissSuggestion).mockResolvedValue({
id: "c-1",
pageId: PAGE_ID,
outcome: "resolved",
resolvedAt,
resolvedById: "u-1",
resolvedBy: { id: "u-1", name: "A" },
} as any);
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useDismissSuggestionMutation(), {
wrapper,
});
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const list = items(queryClient);
expect(list).toHaveLength(1);
expect(list[0].resolvedAt).toBe(resolvedAt);
});
it("idempotent race (404) → treated as success, comment removed from the list", async () => {
vi.mocked(dismissSuggestion).mockRejectedValue({
response: { status: 404 },
});
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useDismissSuggestionMutation(), {
wrapper,
});
// mutateAsync rejects even though onError reconciles the cache; swallow it.
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(items(queryClient)).toHaveLength(0);
// #338 F3: the idempotent race must still fire the SUCCESS toast, not just
// silently drop the comment.
expect(notifications.show).toHaveBeenCalledWith({
message: "Suggestion dismissed",
});
});
it("dismiss 400 (thread still alive) → NOT a success, comment kept, no green toast (#338 F2)", async () => {
// 400 means the thread is alive (already resolved / a reply raced in).
// Narrowed onError: only 404 is a success-noop; 400 must surface a real error
// and keep the comment in the cache.
vi.mocked(dismissSuggestion).mockRejectedValue({
response: { status: 400 },
});
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useDismissSuggestionMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
// Comment NOT dropped from the cache.
expect(items(queryClient)).toHaveLength(1);
// A real (red) error, never the success message.
expect(notifications.show).toHaveBeenCalledWith(
expect.objectContaining({ color: "red" }),
);
expect(notifications.show).not.toHaveBeenCalledWith({
message: "Suggestion dismissed",
});
});
it("APPLY idempotent race (404) → treated as success, comment removed from the list", async () => {
// After #329 an applied reply-less suggestion is hard-deleted, so a racing
// second apply hits 404 — must reconcile to success like dismiss, not a red
// error (restores the #315 apply idempotency).
vi.mocked(applySuggestion).mockRejectedValue({
response: { status: 404 },
});
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useApplySuggestionMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
expect(items(queryClient)).toHaveLength(0);
// #338 F3: the idempotent race must still fire the SUCCESS toast.
expect(notifications.show).toHaveBeenCalledWith({
message: "Suggestion applied",
});
});
it("APPLY 400 (thread resolved, not applied) → NOT a success, comment kept, red error (#338 F2)", async () => {
// apply's only 400 is "Cannot apply … on a resolved comment thread" — the
// thread was resolved (often with discussion) but NOT applied. It must be a
// real error surfacing the server message, and must NOT drop the live thread.
vi.mocked(applySuggestion).mockRejectedValue({
response: {
status: 400,
data: {
message: "Cannot apply a suggested edit on a resolved comment thread",
},
},
});
const { queryClient, wrapper } = seededClient(comment());
const { result } = renderHook(() => useApplySuggestionMutation(), {
wrapper,
});
await result.current
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
.catch(() => undefined);
await waitFor(() => expect(result.current.isError).toBe(true));
// The live thread is NOT dropped from the cache.
expect(items(queryClient)).toHaveLength(1);
// Surfaces the server's specific message as a red error, never a success.
expect(notifications.show).toHaveBeenCalledWith(
expect.objectContaining({
message: "Cannot apply a suggested edit on a resolved comment thread",
color: "red",
}),
);
expect(notifications.show).not.toHaveBeenCalledWith({
message: "Suggestion applied",
});
});
});
@@ -5,10 +5,8 @@ import {
InfiniteData,
} from "@tanstack/react-query";
import {
applySuggestion,
createComment,
deleteComment,
dismissSuggestion,
getPageComments,
resolveComment,
updateComment,
@@ -17,12 +15,12 @@ import {
ICommentParams,
IComment,
IResolveComment,
ISuggestionOutcome,
} from "@/features/comment/types/comment.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react";
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
@@ -53,10 +51,7 @@ export function useCommentsQuery(params: ICommentParams) {
return {
data,
// Paint the first page as soon as it arrives instead of blocking until every
// page has loaded; the background effect above keeps streaming the rest
// (tab counts grow as pages arrive).
isLoading: query.isLoading,
isLoading: query.isLoading || query.hasNextPage,
isError: query.isError,
};
}
@@ -66,6 +61,9 @@ export function useCreateCommentMutation() {
const { t } = useTranslation();
return useMutation<IComment, Error, Partial<IComment>>({
// Stable key so a paused comment-create restored from IndexedDB after an
// offline reload finds its default mutationFn and is replayed on reconnect.
mutationKey: offlineMutationKeys.createComment,
mutationFn: (data) => createComment(data),
onSuccess: (newComment) => {
const cache = queryClient.getQueryData(
@@ -182,196 +180,6 @@ function updateCommentInCache(
};
}
function removeCommentFromCache(
cache: InfiniteData<IPagination<IComment>>,
commentId: string,
): InfiniteData<IPagination<IComment>> {
return {
...cache,
pages: cache.pages.map((page) => ({
...page,
items: page.items.filter((comment) => comment.id !== commentId),
})),
};
}
// Reconcile the local comment cache with an ephemeral-suggestion outcome (#329)
// returned by apply/dismiss: 'deleted' → drop the comment (it disappeared);
// 'resolved' → the thread had replies and was resolved, so carry the resolved
// state through (which relocates it to the resolved tab).
function applySuggestionOutcomeToCache(
queryClient: ReturnType<typeof useQueryClient>,
pageId: string,
commentId: string,
data: ISuggestionOutcome,
) {
const cache = queryClient.getQueryData(RQ_KEY(pageId)) as
| InfiniteData<IPagination<IComment>>
| undefined;
if (!cache) return;
if (data.outcome === "deleted") {
queryClient.setQueryData(RQ_KEY(pageId), removeCommentFromCache(cache, commentId));
return;
}
// 'resolved' (or an older server that omits outcome): reflect the resolved
// state and the applied stamps (apply sets them; dismiss leaves them null).
queryClient.setQueryData(
RQ_KEY(pageId),
updateCommentInCache(cache, commentId, (comment) => ({
...comment,
suggestionAppliedAt: data.suggestionAppliedAt,
suggestionAppliedById: data.suggestionAppliedById,
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
})),
);
}
export function useApplySuggestionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
ISuggestionOutcome,
any,
{ commentId: string; pageId: string }
>({
// No optimistic update: apply can fail with 409 (the commented text drifted),
// so we only mutate the cache once the server confirms.
mutationFn: ({ commentId }) => applySuggestion(commentId),
onSuccess: (data, variables) => {
// Ephemeral (#329): the server hard-deletes the applied suggestion when the
// thread has no replies ('deleted') or resolves it when it does ('resolved').
applySuggestionOutcomeToCache(
queryClient,
variables.pageId,
variables.commentId,
data,
);
notifications.show({ message: t("Suggestion applied") });
},
onError: (err: any, variables) => {
const status = err?.response?.status;
// Idempotent race (double-click, or apply↔dismiss): after #329 an applied
// reply-less suggestion is hard-deleted, so a second/racing apply hits 404
// (already gone). ONLY 404 is a real success-noop — drop it from the cache
// and report success, the user's intent is already satisfied (restores the
// #315 apply idempotency the ephemeral delete would otherwise break).
//
// 400 is NOT success (#338 F2): apply's only 400 is "Cannot apply … on a
// resolved comment thread" — the thread was resolved (often WITH a live
// discussion) but the edit was NOT applied. Treating it as "Suggestion
// applied" is a false success that also drops a live thread from the cache.
// The #315 idempotent repeat does NOT produce 400 (childless → 404;
// with-replies → 200), so we never lose idempotency by excluding it here.
if (status === 404) {
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
| InfiniteData<IPagination<IComment>>
| undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
removeCommentFromCache(cache, variables.commentId),
);
}
notifications.show({ message: t("Suggestion applied") });
return;
}
// 400 => the thread was resolved and the edit could not be applied. Show a
// real error and KEEP the comment in the cache (it is still alive). Prefer
// the server's specific message when it carries one.
if (status === 400) {
const serverMsg = err?.response?.data?.message;
notifications.show({
message:
typeof serverMsg === "string" && serverMsg.length > 0
? serverMsg
: t("Failed to apply suggestion"),
color: "red",
});
return;
}
// 409 => the commented text changed since the suggestion was made. Surface
// a specific message (with the current text) rather than a generic error.
const currentText = err?.response?.data?.currentText;
if (status === 409 && typeof currentText === "string") {
const shortText =
currentText.length > 80
? `${currentText.slice(0, 80)}`
: currentText;
notifications.show({
title: t(
"The commented text changed since this suggestion was made; it was not applied.",
),
message: shortText,
color: "red",
});
return;
}
notifications.show({
message: t("Failed to apply suggestion"),
color: "red",
});
},
});
}
export function useDismissSuggestionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
ISuggestionOutcome,
any,
{ commentId: string; pageId: string }
>({
mutationFn: ({ commentId }) => dismissSuggestion(commentId),
onSuccess: (data, variables) => {
// Ephemeral (#329): dismiss hard-deletes the suggestion when the thread has
// no replies ('deleted') or resolves it when it does ('resolved').
applySuggestionOutcomeToCache(
queryClient,
variables.pageId,
variables.commentId,
data,
);
notifications.show({ message: t("Suggestion dismissed") });
},
onError: (err: any, variables) => {
// Idempotent race (double-click, or apply↔dismiss): the comment is already
// gone (404). ONLY 404 is a real success-noop — drop it from the cache and
// report success, the user's intent (make it disappear) is satisfied.
//
// 400 is NOT success (#338 F2): it means the thread is still ALIVE (already
// resolved, or a reply raced in), so treating it as "dismissed" would drop
// a live thread from the cache. Show a real error and keep the comment.
const status = err?.response?.status;
if (status === 404) {
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
| InfiniteData<IPagination<IComment>>
| undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
removeCommentFromCache(cache, variables.commentId),
);
}
notifications.show({ message: t("Suggestion dismissed") });
return;
}
notifications.show({
message: t("Failed to dismiss suggestion"),
color: "red",
});
},
});
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -3,7 +3,6 @@ import {
ICommentParams,
IComment,
IResolveComment,
ISuggestionOutcome,
} from "@/features/comment/types/comment.types";
import { IPagination } from "@/lib/types.ts";
@@ -19,24 +18,6 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
return req.data;
}
export async function applySuggestion(
commentId: string,
): Promise<ISuggestionOutcome> {
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
// the 409 body (`{ message, currentText }`) off err.response.data.
const req = await api.post("/comments/apply-suggestion", { commentId });
return req.data.data ?? req.data;
}
export async function dismissSuggestion(
commentId: string,
): Promise<ISuggestionOutcome> {
// Dismiss ("Не применять") a suggested edit (#329): the server hard-deletes
// the comment (or resolves it when it has replies) and returns the outcome.
const req = await api.post("/comments/dismiss-suggestion", { commentId });
return req.data.data ?? req.data;
}
export async function updateComment(
data: Partial<IComment>,
): Promise<IComment> {
@@ -1,9 +1,5 @@
import { IUser } from "@/features/user/types/user.types";
import { QueryParams } from "@/lib/types.ts";
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
export interface IComment {
id: string;
@@ -28,18 +24,6 @@ export interface IComment {
createdSource?: string;
aiChatId?: string | null;
resolvedSource?: string | null;
// Suggested-edit (#315): when an agent proposes a replacement for the
// commented `selection`, `suggestedText` holds the "стало" text. Once a user
// applies it server-side the backend stamps `suggestionAppliedAt` /
// `suggestionAppliedById` and auto-resolves the thread.
suggestedText?: string | null;
suggestionAppliedAt?: Date | string | null;
suggestionAppliedById?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// createdSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
yjsSelection?: {
anchor: any;
head: any;
@@ -60,15 +44,6 @@ export interface IResolveComment {
resolved: boolean;
}
// Result of applying or dismissing an ephemeral suggested edit (#329). The
// server hard-deletes the comment (`deleted`) unless the thread has replies, in
// which case it is resolved (`resolved`). The returned comment fields carry the
// resolved-branch state; `outcome` tells the client which optimistic action to
// take (drop the comment vs. move it to the resolved tab).
export type ISuggestionOutcome = IComment & {
outcome?: "deleted" | "resolved";
};
export interface ICommentParams extends QueryParams {
pageId: string;
}
@@ -1,102 +0,0 @@
import { describe, it, expect } from "vitest";
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
// Reconstruct the plain string from a segment stream — the diff must be
// lossless (concatenating every fragment yields the original input).
const join = (segments: Segment[]): string =>
segments.map((s) => s.text).join("");
// The subset of segments (in order) that the UI would emphasise.
const changed = (segments: Segment[]): string[] =>
segments.filter((s) => s.changed).map((s) => s.text);
// Find the segment that contains a substring, to assert its `changed` flag.
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
segments.find((s) => s.text.includes(needle));
describe("computeSuggestionDiff", () => {
it("highlights only the single changed letter in a one-letter edit", () => {
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
// Lossless.
expect(join(old)).toBe("заведем");
expect(join(neu)).toBe("заведём");
// Old side: exactly the `е` is changed, the rest is common.
expect(changed(old)).toEqual(["е"]);
expect(old).toEqual([
{ text: "завед", changed: false },
{ text: "е", changed: true },
{ text: "м", changed: false },
]);
// New side: exactly the `ё` is changed.
expect(changed(neu)).toEqual(["ё"]);
expect(neu).toEqual([
{ text: "завед", changed: false },
{ text: "ё", changed: true },
{ text: "м", changed: false },
]);
});
it("marks the differing words changed but keeps the shared word common", () => {
const { old, new: neu } = computeSuggestionDiff(
"привет мир",
"здравствуй мир",
);
// Lossless.
expect(join(old)).toBe("привет мир");
expect(join(neu)).toBe("здравствуй мир");
// The shared trailing word stays common on both sides (no per-letter noise
// leaking across the differing words into `мир`).
expect(segmentWith(old, "мир")?.changed).toBe(false);
expect(segmentWith(neu, "мир")?.changed).toBe(false);
// The differing words are emphasised somewhere on each side.
expect(changed(old).length).toBeGreaterThan(0);
expect(changed(neu).length).toBeGreaterThan(0);
expect(changed(old).join("")).toContain("п"); // from `привет`
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
// No changed fragment on either side touches the word `мир`.
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
});
it("marks a whole inserted word changed and leaves the old line common", () => {
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
expect(join(old)).toBe("a c");
expect(join(neu)).toBe("a b c");
// Old line has no changed fragment (nothing was removed).
expect(changed(old)).toEqual([]);
// The inserted word is the only changed fragment on the new side.
expect(neu).toContainEqual({ text: "b ", changed: true });
expect(changed(neu)).toEqual(["b "]);
});
it("marks a whole deleted word changed and leaves the new line common", () => {
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
expect(join(old)).toBe("a b c");
expect(join(neu)).toBe("a c");
// The deleted word is the only changed fragment on the old side.
expect(old).toContainEqual({ text: "b ", changed: true });
expect(changed(old)).toEqual(["b "]);
// New line has no changed fragment (nothing was added).
expect(changed(neu)).toEqual([]);
});
it("marks everything common for identical strings", () => {
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
expect(old).toEqual([{ text: "hello", changed: false }]);
expect(neu).toEqual([{ text: "hello", changed: false }]);
expect(changed(old)).toEqual([]);
expect(changed(neu)).toEqual([]);
});
});
@@ -1,139 +0,0 @@
import { diffWordsWithSpace, diffChars } from "diff";
import { IComment } from "@/features/comment/types/comment.types";
// Whether the suggested-edit (#315) "Apply" button should be shown for a
// comment: it must carry a suggestion, not already be applied or resolved, be a
// top-level comment, and the viewer must be able to edit the page.
export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
return Boolean(
canEdit &&
comment.suggestedText &&
!comment.suggestionAppliedAt &&
!comment.resolvedAt &&
!comment.parentCommentId,
);
}
// One contiguous run of text within a suggestion's "before" or "after" line.
// `changed` marks the fragment that actually differs from the other side, so
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
// the whole line.
export interface Segment {
text: string;
changed: boolean;
}
// A pure "before -> after" intraline diff (#331): the old line split into
// common vs. removed-and-changed fragments, and the new line split into common
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
// the original strings.
export interface SuggestionDiff {
old: Segment[];
new: Segment[];
}
// Push a segment, coalescing runs of the same `changed` flag on the same side
// so the render emits as few spans as possible and tests stay predictable.
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
if (text === "") return;
const last = segments[segments.length - 1];
if (last && last.changed === changed) {
last.text += text;
} else {
segments.push({ text, changed });
}
}
// Compute an intraline diff between the old `selection` and the new
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
//
// Hybrid word + char algorithm (per #331):
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
// `diffChars`: shared characters stay common, differing characters are
// marked `changed` on their respective side. This is what keeps a
// one-letter edit (заведем -> заведём) from highlighting the whole word.
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
// fragment `changed`.
// 4. An unchanged part is `common` on both sides.
//
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
export function computeSuggestionDiff(
oldStr: string,
newStr: string,
): SuggestionDiff {
const oldSegments: Segment[] = [];
const newSegments: Segment[] = [];
const parts = diffWordsWithSpace(oldStr, newStr);
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const next = parts[i + 1];
// A word replacement: a removed part immediately followed by an added part
// (or the reverse). Refine it character-by-character so only the differing
// letters are highlighted while shared letters stay common.
const isReplacementPair =
next &&
((part.removed && next.added) || (part.added && next.removed));
if (isReplacementPair) {
const removedPart = part.removed ? part : next;
const addedPart = part.added ? part : next;
const charParts = diffChars(removedPart.value, addedPart.value);
for (const cp of charParts) {
if (cp.added) {
pushSegment(newSegments, cp.value, true);
} else if (cp.removed) {
pushSegment(oldSegments, cp.value, true);
} else {
// Shared character: common on both sides.
pushSegment(oldSegments, cp.value, false);
pushSegment(newSegments, cp.value, false);
}
}
i++; // consume the paired part as well
continue;
}
if (part.added) {
// Lone insertion: only present in the new line, wholly changed.
pushSegment(newSegments, part.value, true);
} else if (part.removed) {
// Lone deletion: only present in the old line, wholly changed.
pushSegment(oldSegments, part.value, true);
} else {
// Unchanged: common on both sides.
pushSegment(oldSegments, part.value, false);
pushSegment(newSegments, part.value, false);
}
}
return { old: oldSegments, new: newSegments };
}
// Whether the suggested-edit (#329) "Не применять" (Dismiss) button should be
// shown. Dismiss does NOT change the page text (so it needs only canComment, not
// canEdit), BUT a childless dismiss IRREVERSIBLY hard-deletes the comment, so the
// server gates it on comment-owner-OR-space-admin (#338 F5). The button must
// mirror that authz or a non-owner non-admin sees a live Dismiss that always
// 403s → red error. Hence isOwnerOrAdmin is required IN ADDITION to canComment.
// Same not-applied/not-resolved/top-level conditions as Apply.
export function canShowDismiss(
comment: IComment,
canComment?: boolean,
isOwnerOrAdmin?: boolean,
): boolean {
return Boolean(
canComment &&
isOwnerOrAdmin &&
comment.suggestedText &&
!comment.suggestionAppliedAt &&
!comment.resolvedAt &&
!comment.parentCommentId,
);
}
@@ -1,92 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// A disabled mic must explain WHY it is unavailable rather than silently saying
// "Start dictation". This renders MicButton in its idle+disabled state with a
// forwarded reason and asserts the accessible label resolves to that reason's
// text via the shared resolver (dictation-status.resolveUnavailableLabel).
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// Pass i18n keys through verbatim so we assert the exact resolved string.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (s: string) => s }),
}));
// Keep both controllers inert and idle so MicButton renders the idle branch.
const idleCtl = {
status: "idle" as const,
start: vi.fn(async () => {}),
stop: vi.fn(),
cancel: vi.fn(),
audioLevel: 0,
errorMessage: null,
};
vi.mock("@/features/dictation/hooks/use-dictation", () => ({
useDictation: () => idleCtl,
}));
vi.mock("@/features/dictation/hooks/use-streaming-dictation", () => ({
useStreamingDictation: () => idleCtl,
}));
import { MicButton } from "./mic-button";
function renderButton(props: React.ComponentProps<typeof MicButton>) {
render(
<MantineProvider>
<MicButton {...props} />
</MantineProvider>,
);
}
describe("MicButton — disabled reason label", () => {
// jsdom has no MediaRecorder / mediaDevices, so isDictationSupported() would
// report "unsupported" and mask the forwarded reason. Stub both so the button
// is considered supported and the availability reason is what surfaces.
beforeEach(() => {
(globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder =
class {};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: vi.fn() },
});
});
afterEach(() => {
delete (globalThis as unknown as { MediaRecorder?: unknown }).MediaRecorder;
});
it("shows the cause-specific reason instead of 'Start dictation' when disabled with a reason", () => {
renderButton({ onText: () => {}, disabled: true, unavailableReason: "offline" });
const expected =
"No connection to the collaboration server — dictation unavailable";
// The reason surfaces as the accessible label (and the tooltip text).
const button = screen.getByRole("button", { name: expected });
expect(button).toBeDefined();
// It is marked disabled the Mantine way (data-disabled), NOT the native
// `disabled` attribute — otherwise pointer-events:none would kill the tooltip.
expect(button.getAttribute("data-disabled")).toBe("true");
expect(button.hasAttribute("disabled")).toBe(false);
// And it no longer silently reads "Start dictation".
expect(screen.queryByRole("button", { name: "Start dictation" })).toBeNull();
});
it("reads 'Start dictation' when enabled with no reason", () => {
renderButton({ onText: () => {} });
expect(
screen.getByRole("button", { name: "Start dictation" }),
).toBeDefined();
});
it("does not advertise 'Start dictation' when disabled with no reason", () => {
// A consumer passing bare `disabled` (e.g. the AI chat's isStreaming) with no
// unavailableReason must not get a hoverable mic whose tooltip invites
// "Start dictation" on a click that is rejected.
renderButton({ onText: () => {}, disabled: true });
expect(
screen.queryByRole("button", { name: "Start dictation" }),
).toBeNull();
const button = screen.getByRole("button");
expect(button.getAttribute("data-disabled")).toBe("true");
});
});
@@ -4,11 +4,6 @@ import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useDictation } from "@/features/dictation/hooks/use-dictation";
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
import {
isDictationSupported,
resolveUnavailableLabel,
type DictationUnavailableReason,
} from "@/features/dictation/dictation-status";
import classes from "./mic-button.module.css";
interface MicButtonProps {
@@ -26,9 +21,6 @@ interface MicButtonProps {
// When true, use the streaming (Silero-VAD) dictation controller, which emits
// text progressively as the user pauses; otherwise use the batch controller.
streaming?: boolean;
// When the mic is disabled for an availability reason, this is the cause the
// idle tooltip explains (e.g. pre-sync "connecting", "offline", "read-only").
unavailableReason?: DictationUnavailableReason;
}
/**
@@ -45,7 +37,6 @@ export const MicButton: FC<MicButtonProps> = ({
color,
iconSize,
streaming = false,
unavailableReason,
}) => {
const { t } = useTranslation();
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
@@ -55,7 +46,7 @@ export const MicButton: FC<MicButtonProps> = ({
const batchCtl = useDictation({ onText, onStart });
const streamingCtl = useStreamingDictation({ onText, onStart });
const ctl = streaming ? streamingCtl : batchCtl;
const { status, start, stop, audioLevel, errorMessage } = ctl;
const { status, start, stop, audioLevel } = ctl;
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
if (status === "recording") {
@@ -91,28 +82,15 @@ export const MicButton: FC<MicButtonProps> = ({
) {
// "loading" (streaming hook fetching the VAD model on first use) shows the
// same spinner+disabled state so the first click is visibly acknowledged and
// a confusing second click can't fire while the model loads. The error case
// explains the failure via the hook's resolved errorMessage instead of the
// transient "Transcribing…" label.
const label =
status === "error"
? (errorMessage ?? t("Transcription failed"))
: status === "loading"
? t("Preparing…")
: t("Transcribing…");
// a confusing second click can't fire while the model loads.
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
return (
<Tooltip label={label} withArrow>
<ActionIcon
size={size}
variant="subtle"
color={color}
// Mark disabled the Mantine way (data-disabled/aria-disabled) rather
// than the native `disabled` attribute: native `disabled` sets
// `pointer-events:none`, which suppresses hover so the Tooltip never
// fires. This is a status display with no click action to guard, so
// keeping it hoverable simply lets the error reason be read on hover.
data-disabled
aria-disabled
disabled
aria-label={label}
>
<Loader size="xs" />
@@ -121,56 +99,18 @@ export const MicButton: FC<MicButtonProps> = ({
);
}
// Idle branch. A grey/disabled mic must explain WHY it can't record. An
// unsupported browser/context is detected here; otherwise the parent forwards
// a cause-specific reason. We must NOT pass the native `disabled` prop: Mantine
// renders `<button disabled>` with `pointer-events:none`, which suppresses
// hover so the Tooltip never fires. Instead mark it disabled the Mantine way
// (data-disabled/aria-disabled) — keeping it hoverable and in the a11y tree —
// and guard the click ourselves.
const unsupported = !isDictationSupported();
const isDisabled = disabled || unsupported;
const reason: DictationUnavailableReason | undefined = unsupported
? "unsupported"
: unavailableReason;
const reasonLabel = reason ? resolveUnavailableLabel(reason, t) : undefined;
// A disabled mic with a known reason surfaces it on hover; an enabled mic
// invites "Start dictation". But a mic disabled with NO reason (e.g. a
// consumer that passes bare `disabled` — the AI chat's isStreaming, with no
// unavailableReason) must NOT hover a misleading, actionable "Start dictation"
// tooltip on a control that rejects the click. In that case we render the icon
// without a Tooltip and give it a neutral accessible label instead.
const ariaLabel = reasonLabel ?? (isDisabled ? t("Dictation") : t("Start dictation"));
const icon = (
<ActionIcon
size={size}
variant="subtle"
color={color}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
return;
}
void start();
}}
data-disabled={isDisabled || undefined}
aria-disabled={isDisabled}
aria-label={ariaLabel}
>
<IconMicrophone size={resolvedIconSize} />
</ActionIcon>
);
// Suppress the tooltip on a disabled mic that has nothing to explain — hovering
// a grey, unclickable mic should not advertise "Start dictation".
if (isDisabled && !reasonLabel) {
return icon;
}
return (
<Tooltip
label={reasonLabel ?? t("Start dictation")}
withArrow
>
{icon}
<Tooltip label={t("Start dictation")} withArrow>
<ActionIcon
size={size}
variant="subtle"
color={color}
onClick={() => void start()}
disabled={disabled}
aria-label={t("Start dictation")}
>
<IconMicrophone size={resolvedIconSize} />
</ActionIcon>
</Tooltip>
);
};
@@ -1,156 +0,0 @@
import { describe, it, expect } from "vitest";
import {
classifyGetUserMediaError,
classifyTranscriptionError,
dictationErrorMessage,
resolveUnavailableLabel,
isDictationSupported,
} from "./dictation-status";
// Unit tests for the shared dictation-status resolvers (dictation-status.ts).
// Both dictation hooks and the mic button form their user-facing strings here,
// so a regression in the classification or message mapping would silently swap
// what a user reads when the mic is grey or a recording fails. A fake `t`
// returns its key verbatim so we assert the exact i18n key each branch selects.
const t = (k: string) => k;
describe("classifyGetUserMediaError", () => {
it("maps NotAllowedError / SecurityError to mic-denied", () => {
expect(classifyGetUserMediaError({ name: "NotAllowedError" })).toBe(
"mic-denied",
);
expect(classifyGetUserMediaError({ name: "SecurityError" })).toBe(
"mic-denied",
);
});
it("maps NotFoundError / OverconstrainedError to no-mic", () => {
expect(classifyGetUserMediaError({ name: "NotFoundError" })).toBe("no-mic");
expect(classifyGetUserMediaError({ name: "OverconstrainedError" })).toBe(
"no-mic",
);
});
it("maps NotReadableError / AbortError to mic-in-use", () => {
expect(classifyGetUserMediaError({ name: "NotReadableError" })).toBe(
"mic-in-use",
);
expect(classifyGetUserMediaError({ name: "AbortError" })).toBe(
"mic-in-use",
);
});
it("maps anything else / undefined to unknown", () => {
expect(classifyGetUserMediaError({ name: "WeirdError" })).toBe("unknown");
expect(classifyGetUserMediaError(undefined)).toBe("unknown");
expect(classifyGetUserMediaError({})).toBe("unknown");
});
});
describe("classifyTranscriptionError", () => {
it("returns the verbatim server message when present", () => {
const err = { response: { status: 500, data: { message: "provider 404" } } };
expect(classifyTranscriptionError(err)).toEqual({
code: "transcription-failed",
serverMessage: "provider 404",
});
});
it("maps 503 / 403 (no server message) to stt-not-configured", () => {
expect(classifyTranscriptionError({ response: { status: 503 } })).toEqual({
code: "stt-not-configured",
});
expect(classifyTranscriptionError({ response: { status: 403 } })).toEqual({
code: "stt-not-configured",
});
});
it("falls back to transcription-failed with no server message otherwise", () => {
expect(classifyTranscriptionError({ response: { status: 500 } })).toEqual({
code: "transcription-failed",
});
expect(classifyTranscriptionError(new Error("network"))).toEqual({
code: "transcription-failed",
});
// Blank server message is ignored (does not win as verbatim text).
expect(
classifyTranscriptionError({ response: { data: { message: " " } } }),
).toEqual({ code: "transcription-failed" });
});
});
describe("dictationErrorMessage", () => {
it("maps each code to the expected i18n key", () => {
expect(dictationErrorMessage("mic-denied", t)).toBe(
"Microphone access denied",
);
expect(dictationErrorMessage("no-mic", t)).toBe("No microphone found");
expect(dictationErrorMessage("mic-in-use", t)).toBe(
"Microphone is unavailable or already in use",
);
expect(dictationErrorMessage("no-media-devices", t)).toBe(
"Audio recording is not available in this browser/context",
);
expect(dictationErrorMessage("stt-not-configured", t)).toBe(
"Voice dictation is not configured",
);
expect(dictationErrorMessage("transcription-failed", t)).toBe(
"Transcription failed",
);
expect(dictationErrorMessage("recorder-failed", t)).toBe(
"Could not start recording",
);
expect(dictationErrorMessage("vad-init-failed", t)).toBe(
"Could not start recording",
);
expect(dictationErrorMessage("unknown", t)).toBe(
"Could not start recording",
);
});
it("returns the server message verbatim for transcription-failed (not the t key)", () => {
expect(
dictationErrorMessage("transcription-failed", t, {
serverMessage: "quota exceeded",
}),
).toBe("quota exceeded");
});
it("appends the detail to recorder-failed / unknown", () => {
expect(
dictationErrorMessage("recorder-failed", t, { detail: "boom" }),
).toBe("Could not start recording: boom");
expect(dictationErrorMessage("unknown", t, { detail: "nope" })).toBe(
"Could not start recording: nope",
);
});
it("appends the detail to transcription-failed when there is no server message", () => {
expect(
dictationErrorMessage("transcription-failed", t, { detail: "timeout" }),
).toBe("Transcription failed: timeout");
});
});
describe("resolveUnavailableLabel", () => {
it("maps each reason to its expected i18n key", () => {
expect(resolveUnavailableLabel("connecting", t)).toBe(
"Dictation becomes available once the page finishes connecting",
);
expect(resolveUnavailableLabel("offline", t)).toBe(
"No connection to the collaboration server — dictation unavailable",
);
expect(resolveUnavailableLabel("read-only", t)).toBe(
"This page is read-only",
);
expect(resolveUnavailableLabel("unsupported", t)).toBe(
"Audio recording is not available in this browser/context",
);
});
});
describe("isDictationSupported", () => {
it("returns a boolean", () => {
expect(typeof isDictationSupported()).toBe("boolean");
});
});
@@ -1,110 +0,0 @@
// Single source of truth for "why dictation is unavailable" and "why it failed".
// Both dictation hooks and the mic button pull their user-facing strings from
// the resolvers here so the wording lives in exactly one place.
export type DictationUnavailableReason =
| "connecting"
| "offline"
| "read-only"
| "unsupported";
export type DictationErrorCode =
| "no-media-devices"
| "mic-denied"
| "no-mic"
| "mic-in-use"
| "recorder-failed"
| "vad-init-failed"
| "stt-not-configured"
| "transcription-failed"
| "unknown";
// True if this browser/context can record audio.
export function isDictationSupported(): boolean {
return (
typeof MediaRecorder !== "undefined" &&
typeof navigator !== "undefined" &&
!!navigator.mediaDevices?.getUserMedia
);
}
// getUserMedia / VAD.start rejection -> code, by DOMException .name.
export function classifyGetUserMediaError(err: unknown): DictationErrorCode {
const name = (err as { name?: string })?.name;
if (name === "NotAllowedError" || name === "SecurityError")
return "mic-denied";
if (name === "NotFoundError" || name === "OverconstrainedError")
return "no-mic";
if (name === "NotReadableError" || name === "AbortError") return "mic-in-use";
return "unknown";
}
// Transcription HTTP failure -> code (+ verbatim server message when present).
export function classifyTranscriptionError(err: unknown): {
code: DictationErrorCode;
serverMessage?: string;
} {
const resp = (
err as { response?: { status?: number; data?: { message?: string } } }
)?.response;
const serverMessage = resp?.data?.message;
if (serverMessage && serverMessage.trim().length > 0)
return { code: "transcription-failed", serverMessage };
if (resp?.status === 503 || resp?.status === 403)
return { code: "stt-not-configured" };
return { code: "transcription-failed" };
}
type TFn = (key: string) => string;
// Code -> user text. The ONE place runtime error strings are formed.
// serverMessage (verbatim) wins for transcription-failed; detail is appended
// to the generic "could not start"/"transcription failed" strings.
export function dictationErrorMessage(
code: DictationErrorCode,
t: TFn,
extra?: { serverMessage?: string; detail?: string },
): string {
const detail = extra?.detail;
switch (code) {
case "mic-denied":
return t("Microphone access denied");
case "no-mic":
return t("No microphone found");
case "mic-in-use":
return t("Microphone is unavailable or already in use");
case "no-media-devices":
return t("Audio recording is not available in this browser/context");
case "stt-not-configured":
return t("Voice dictation is not configured");
case "transcription-failed":
if (extra?.serverMessage && extra.serverMessage.trim().length > 0)
return extra.serverMessage;
return `${t("Transcription failed")}${detail ? `: ${detail}` : ""}`;
case "recorder-failed":
case "vad-init-failed":
case "unknown":
default:
return `${t("Could not start recording")}${detail ? `: ${detail}` : ""}`;
}
}
// Unavailable reason -> tooltip text (the ONE place these strings are formed).
export function resolveUnavailableLabel(
r: DictationUnavailableReason,
t: TFn,
): string {
switch (r) {
case "connecting":
return t("Dictation becomes available once the page finishes connecting");
case "offline":
return t(
"No connection to the collaboration server — dictation unavailable",
);
case "read-only":
return t("This page is read-only");
case "unsupported":
default:
return t("Audio recording is not available in this browser/context");
}
}
@@ -2,11 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
import {
classifyGetUserMediaError,
classifyTranscriptionError,
dictationErrorMessage,
} from "@/features/dictation/dictation-status";
// "loading" is set only by the streaming hook while it lazily loads the VAD
// model on first use; the batch hook never sets it. It exists so the streaming
@@ -31,8 +26,6 @@ interface UseDictationResult {
cancel: () => void;
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
audioLevel: number;
// The last error shown to the user (null until one occurs / on a new start).
errorMessage: string | null;
}
// Candidate container/codec combinations in preference order. The first one the
@@ -74,8 +67,6 @@ export function useDictation(
const { t } = useTranslation();
const [status, setStatus] = useState<DictationStatus>("idle");
const [audioLevel, setAudioLevel] = useState(0);
// Last error message shown to the user; the mic button reads it for its tooltip.
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Keep the latest callbacks in a ref so the recorder's onstop closure always
// calls the current handlers without re-creating the recorder.
@@ -203,16 +194,15 @@ export function useDictation(
if (startingRef.current || recorderRef.current || streamRef.current) return;
if (status !== "idle") return;
startingRef.current = true;
// Clear any stale error from a previous attempt.
setErrorMessage(null);
if (!navigator.mediaDevices?.getUserMedia) {
const reason =
"navigator.mediaDevices.getUserMedia is unavailable in this context";
console.error("[dictation] " + reason);
const message = dictationErrorMessage("no-media-devices", t);
notifications.show({ color: "red", message });
setErrorMessage(message);
notifications.show({
color: "red",
message: t("Audio recording is not available in this browser/context"),
});
setStatus("idle");
startingRef.current = false;
return;
@@ -225,16 +215,19 @@ export function useDictation(
// Always log the full error for diagnosis (name, message, stack).
console.error("[dictation] getUserMedia failed", err);
const name = (err as { name?: string })?.name;
const rawDetail = (err as { message?: string })?.message ?? String(err);
// Prefix the DOMException name (e.g. "TypeError: …") so the generic
// resolver branch reproduces this hook's original "Could not start
// recording: <name>: <detail>" text. Each caller owns its own detail; the
// streaming hook intentionally does not add the name.
const detail = `${name ? `${name}: ` : ""}${rawDetail}`;
const code = classifyGetUserMediaError(err);
const message = dictationErrorMessage(code, t, { detail });
const detail = (err as { message?: string })?.message ?? String(err);
let message: string;
if (name === "NotAllowedError" || name === "SecurityError") {
message = t("Microphone access denied");
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
message = t("No microphone found");
} else if (name === "NotReadableError" || name === "AbortError") {
message = t("Microphone is unavailable or already in use");
} else {
// Unknown failure: show the real reason instead of a generic string.
message = `${t("Could not start recording")}: ${name ? `${name}: ` : ""}${detail}`;
}
notifications.show({ color: "red", message });
setErrorMessage(message);
setStatus("idle");
startingRef.current = false;
return;
@@ -256,10 +249,10 @@ export function useDictation(
// The stream was acquired but the recorder failed to construct; stop the
// tracks so the MediaStream does not leak before bailing out.
stopTracks();
const detail = (err as { message?: string })?.message ?? String(err);
const message = dictationErrorMessage("recorder-failed", t, { detail });
notifications.show({ color: "red", message });
setErrorMessage(message);
notifications.show({
color: "red",
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
});
setStatus("idle");
startingRef.current = false;
return;
@@ -300,14 +293,21 @@ export function useDictation(
.catch((err: unknown) => {
// Log the full error for diagnosis (status + body + stack).
console.error("[dictation] transcription failed", err);
const { code, serverMessage } = classifyTranscriptionError(err);
const detail = (err as { message?: string })?.message ?? String(err);
const message = dictationErrorMessage(code, t, {
serverMessage,
detail,
});
const resp = (
err as { response?: { status?: number; data?: { message?: string } } }
)?.response;
const serverMsg = resp?.data?.message;
let message: string;
if (serverMsg && serverMsg.trim().length > 0) {
// The server already explains the cause (e.g. provider 404, bad
// format, STT not configured) — show it verbatim.
message = serverMsg;
} else if (resp?.status === 503 || resp?.status === 403) {
message = t("Voice dictation is not configured");
} else {
message = `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
}
notifications.show({ color: "red", message });
setErrorMessage(message);
setStatus("error");
if (errorTimerRef.current !== null) {
clearTimeout(errorTimerRef.current);
@@ -332,10 +332,10 @@ export function useDictation(
stopTracks();
recorderRef.current = null;
startingRef.current = false;
const detail = (err as { message?: string })?.message ?? String(err);
const message = dictationErrorMessage("recorder-failed", t, { detail });
notifications.show({ color: "red", message });
setErrorMessage(message);
notifications.show({
color: "red",
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
});
setStatus("idle");
return;
}
@@ -405,5 +405,5 @@ export function useDictation(
};
}, [clearTimer, stopTracks, stopMeter]);
return { status, start, stop, cancel, audioLevel, errorMessage };
return { status, start, stop, cancel, audioLevel };
}
@@ -4,11 +4,6 @@ import { useTranslation } from "react-i18next";
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
import {
classifyGetUserMediaError,
classifyTranscriptionError,
dictationErrorMessage,
} from "@/features/dictation/dictation-status";
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
@@ -32,8 +27,6 @@ interface UseStreamingDictationResult {
cancel: () => void;
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
audioLevel: number;
// The last error shown to the user (null until one occurs / on a new start).
errorMessage: string | null;
}
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
@@ -67,8 +60,6 @@ export function useStreamingDictation(
const { t } = useTranslation();
const [status, setStatus] = useState<DictationStatus>("idle");
const [audioLevel, setAudioLevel] = useState(0);
// Last error message shown to the user; the mic button reads it for its tooltip.
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
// current handlers without re-creating the VAD.
@@ -167,6 +158,26 @@ export function useStreamingDictation(
}
}, []);
// Map a transcription error to a user-facing message, mirroring the batch hook.
const transcriptionErrorMessage = useCallback(
(err: unknown): string => {
const resp = (
err as { response?: { status?: number; data?: { message?: string } } }
)?.response;
const serverMsg = resp?.data?.message;
if (serverMsg && serverMsg.trim().length > 0) {
// The server already explains the cause (e.g. provider 404, bad format,
// STT not configured) — show it verbatim.
return serverMsg;
}
if (resp?.status === 503 || resp?.status === 403) {
return t("Voice dictation is not configured");
}
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
},
[t],
);
// Handle one ended speech segment: encode to WAV and transcribe. Results are
// buffered by seq and flushed in order. A single failed segment does NOT kill
// the session: log + one notification, then advance past that seq so later
@@ -193,14 +204,10 @@ export function useStreamingDictation(
if (epoch !== epochRef.current) return;
// Log the full error for diagnosis (status + body + stack).
console.error("[dictation] segment transcription failed", err);
const { code, serverMessage } = classifyTranscriptionError(err);
const detail = (err as { message?: string })?.message ?? String(err);
const message = dictationErrorMessage(code, t, {
serverMessage,
detail,
notifications.show({
color: "red",
message: transcriptionErrorMessage(err),
});
notifications.show({ color: "red", message });
setErrorMessage(message);
// Skip this seq so later segments can still flush in order.
if (nextEmitSeqRef.current === seq) {
nextEmitSeqRef.current += 1;
@@ -219,7 +226,7 @@ export function useStreamingDictation(
}
});
},
[drainResults, t],
[drainResults, transcriptionErrorMessage],
);
const start = useCallback(async (): Promise<void> => {
@@ -229,8 +236,6 @@ export function useStreamingDictation(
if (startingRef.current || vadRef.current || activeRef.current) return;
if (status !== "idle") return;
startingRef.current = true;
// Clear any stale error from a previous attempt.
setErrorMessage(null);
// Notify the caller right when dictation begins (before any async work) so the
// editor can snapshot the caret position.
@@ -349,9 +354,10 @@ export function useStreamingDictation(
// actually runs.)
console.error("[dictation] VAD init failed", err);
const detail = (err as { message?: string })?.message ?? String(err);
const message = dictationErrorMessage("vad-init-failed", t, { detail });
notifications.show({ color: "red", message });
setErrorMessage(message);
notifications.show({
color: "red",
message: `${t("Could not start recording")}: ${detail}`,
});
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
// don't leak it.
destroyVad();
@@ -373,11 +379,19 @@ export function useStreamingDictation(
} catch (err) {
// Always log the full error for diagnosis (name, message, stack).
console.error("[dictation] VAD.start failed", err);
const name = (err as { name?: string })?.name;
const detail = (err as { message?: string })?.message ?? String(err);
const code = classifyGetUserMediaError(err);
const message = dictationErrorMessage(code, t, { detail });
let message: string;
if (name === "NotAllowedError" || name === "SecurityError") {
message = t("Microphone access denied");
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
message = t("No microphone found");
} else if (name === "NotReadableError" || name === "AbortError") {
message = t("Microphone is unavailable or already in use");
} else {
message = `${t("Could not start recording")}: ${detail}`;
}
notifications.show({ color: "red", message });
setErrorMessage(message);
activeRef.current = false;
destroyVad();
setStatus("idle");
@@ -456,5 +470,5 @@ export function useStreamingDictation(
};
}, [clearTimer, destroyVad]);
return { status, start, stop, cancel, audioLevel, errorMessage };
return { status, start, stop, cancel, audioLevel };
}
@@ -1,7 +1,6 @@
import { atom } from "jotai";
import { Editor } from "@tiptap/core";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
export const pageEditorAtom = atom<Editor | null>(null);
@@ -11,20 +10,14 @@ 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 showLinkMenuAtom = atom(false);
// Current page's edit mode — initialized from the user's saved preference on
// first load, can be toggled locally without persisting to the server.
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
// Whether the dictation mic can start, and (when it can't) the cause-specific
// reason the mic button surfaces as a tooltip. Published by the page editor,
// consumed by DictationGroup -> MicButton.
export type DictationAvailability = {
isEditable: boolean;
reason: DictationUnavailableReason | null;
};
export const dictationAvailabilityAtom = atom<DictationAvailability>({
isEditable: false,
reason: null,
});
@@ -1,60 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, act } from "@testing-library/react";
import { Provider, createStore } from "jotai";
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
// Regression test for the byline mic staying stuck disabled (#311 / #309): on a
// page the user can edit, the mic must un-grey once the body becomes editable.
// #311 first fixed this by reading `editor.isEditable` via `useEditorState`; #309
// superseded that with a reactive `dictationAvailabilityAtom` that page-editor
// publishes (carrying both the editable gate AND the unavailable reason). The mic
// now gates on `dictationAvailability.isEditable`, so a change to that atom must
// re-render the group and flip the disabled state (jotai drives the subscription).
// Detectable stand-in that surfaces the `disabled` prop the component computes.
vi.mock("@/features/dictation/components/mic-button", () => ({
MicButton: ({ disabled }: any) => (
<button data-testid="mic" disabled={disabled} />
),
}));
import { DictationGroup } from "./dictation-group";
// Minimal editor stand-in matching the surface DictationGroup uses (handleStart /
// handleText). The disabled gate no longer reads this — it reads the atom.
function makeFakeEditor() {
return {
isEditable: false,
isDestroyed: false,
state: { selection: { from: 0, to: 0 }, doc: { content: { size: 0 } } },
} as any;
}
describe("DictationGroup editable reactivity (#309 atom / #311)", () => {
it("re-enables the mic when dictationAvailability flips isEditable false -> true", () => {
const editor = makeFakeEditor();
const store = createStore();
// Pre-sync: page editor publishes not-editable (with a reason).
store.set(dictationAvailabilityAtom, {
isEditable: false,
reason: "connecting",
});
const { getByTestId } = render(
<Provider store={store}>
<DictationGroup editor={editor} />
</Provider>,
);
// Not editable yet -> disabled (preserves the #218 pre-sync intent).
expect(getByTestId("mic").hasAttribute("disabled")).toBe(true);
// Collab sync -> page editor republishes editable; the atom change must
// re-render the group and enable the mic.
act(() => {
store.set(dictationAvailabilityAtom, { isEditable: true, reason: null });
});
expect(getByTestId("mic").hasAttribute("disabled")).toBe(false);
});
});
@@ -1,8 +1,7 @@
import { FC, useRef } from "react";
import { Editor } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { MicButton } from "@/features/dictation/components/mic-button";
interface Props {
@@ -17,8 +16,6 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
const workspace = useAtomValue(workspaceAtom);
const streamingDictation =
workspace?.settings?.ai?.dictationStreaming === true;
// Cause-specific reason the mic is unavailable (published by the page editor).
const dictationAvailability = useAtomValue(dictationAvailabilityAtom);
// Caret snapshot taken when dictation starts (where the first segment lands).
const rangeRef = useRef<{ from: number; to: number } | null>(null);
// Running insertion point: after each inserted segment we remember the caret
@@ -83,8 +80,7 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
streaming={streamingDictation}
onStart={handleStart}
onText={handleText}
disabled={!dictationAvailability.isEditable}
unavailableReason={dictationAvailability.reason ?? undefined}
disabled={!editor.isEditable}
color={color}
iconSize={iconSize}
/>
@@ -11,19 +11,9 @@ import {
import { extractPageSlugId } from "@/lib";
import classes from "./mention.module.css";
interface MentionAttrs {
label?: string;
entityType?: string;
entityId?: string;
slugId?: string;
anchorId?: string;
}
// Presentational mention renderer (no NodeViewWrapper). Shared by the editor
// NodeView (MentionView) and the static comment renderer (CommentContentView)
// so mention click/nav/icon behavior stays identical outside of an editor.
export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
const { label, entityType, slugId, anchorId } = attrs;
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const isPageMention = entityType === "page";
const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams();
@@ -66,7 +56,7 @@ export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
});
return (
<>
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
{entityType === "user" && (
<Text className={classes.userMention} component="span">
@{label}
@@ -149,14 +139,6 @@ export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
</span>
</Anchor>
)}
</>
);
}
export default function MentionView(props: NodeViewProps) {
return (
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
<MentionContent attrs={props.node.attrs} />
</NodeViewWrapper>
);
}
@@ -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);
}
@@ -1,10 +1,6 @@
import { describe, it, expect } from "vitest";
import { WebSocketStatus } from "@hocuspocus/provider";
import {
isCollabSynced,
isBodyEditable,
computeDictationAvailability,
} from "./editor-sync-state";
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
describe("isCollabSynced", () => {
it("is true only when Connected and synced", () => {
@@ -34,77 +30,3 @@ describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
});
});
describe("computeDictationAvailability (mic reason precedence, #309)", () => {
const base = {
editable: true,
inEditMode: true,
showStatic: false,
isDisconnected: false,
};
it("is available with no reason once synced (showStatic false)", () => {
expect(computeDictationAvailability(base)).toEqual({
isEditable: true,
reason: null,
});
});
it("reports 'offline' during pre-sync while disconnected", () => {
expect(
computeDictationAvailability({
...base,
showStatic: true,
isDisconnected: true,
}),
).toEqual({ isEditable: false, reason: "offline" });
});
it("reports 'connecting' during pre-sync while still connecting", () => {
expect(
computeDictationAvailability({
...base,
showStatic: true,
isDisconnected: false,
}),
).toEqual({ isEditable: false, reason: "connecting" });
});
it("reports 'read-only' without edit permission", () => {
expect(
computeDictationAvailability({ ...base, editable: false }),
).toEqual({ isEditable: false, reason: "read-only" });
});
it("reports 'read-only' when not in edit mode", () => {
expect(
computeDictationAvailability({ ...base, inEditMode: false }),
).toEqual({ isEditable: false, reason: "read-only" });
});
// Lack of edit permission takes precedence over the pre-sync reason: a
// read-only viewer who is ALSO inside the pre-sync window (showStatic) must
// still read "read-only", never "offline"/"connecting". This pins the
// `opts.editable &&` guard on the pre-sync branch.
it("prefers 'read-only' over pre-sync when a read-only viewer is disconnected", () => {
expect(
computeDictationAvailability({
editable: false,
inEditMode: true,
showStatic: true,
isDisconnected: true,
}),
).toEqual({ isEditable: false, reason: "read-only" });
});
it("prefers 'read-only' over pre-sync when a read-only viewer is still connecting", () => {
expect(
computeDictationAvailability({
editable: false,
inEditMode: true,
showStatic: true,
isDisconnected: false,
}),
).toEqual({ isEditable: false, reason: "read-only" });
});
});
@@ -1,5 +1,4 @@
import { WebSocketStatus } from "@hocuspocus/provider";
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
/**
* The collab document is usable only once the provider is Connected AND has
@@ -31,32 +30,3 @@ export function isBodyEditable(opts: {
}): boolean {
return opts.editable && opts.inEditMode && !opts.showStatic;
}
/**
* Whether dictation can start and, when it can't, the cause-specific reason the
* mic button surfaces. Derives editability from `isBodyEditable` (the single,
* tested gate) so the published `isEditable` can never diverge from the actual
* body-editable state and make the tooltip lie (#309).
*
* `isDisconnected` is the caller's own boolean (collab connection is in the
* Disconnected state), passed in so this module stays free of the collab enum.
*/
export function computeDictationAvailability(opts: {
editable: boolean;
inEditMode: boolean;
showStatic: boolean;
isDisconnected: boolean;
}): { isEditable: boolean; reason: DictationUnavailableReason | null } {
const isEditable = isBodyEditable({
editable: opts.editable,
inEditMode: opts.inEditMode,
showStatic: opts.showStatic,
});
if (isEditable) return { isEditable, reason: null };
// Permitted to edit and in edit mode but not yet synced (showStatic) → pre-sync.
if (opts.editable && opts.inEditMode && opts.showStatic) {
return { isEditable, reason: opts.isDisconnected ? "offline" : "connecting" };
}
// No edit permission or not in edit mode.
return { isEditable, reason: "read-only" };
}
@@ -1,231 +0,0 @@
import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { ySyncPluginKey } from "@tiptap/y-tiptap";
import {
CustomTypography,
undoGuardKey,
findChangedRange,
mapRangeThroughChange,
} from "./custom-typography";
/**
* PR #296 the collab-safe typography undo-guard is exercised through the REAL
* editor path: a fresh Editor with the CustomTypography extension, transactions
* tagged exactly the way prosemirror-history / y-tiptap tag undo & remote
* changes (`setMeta("history$", …)` and `setMeta(ySyncPluginKey, …)`), plus
* direct unit tests of the two pure diff helpers. No hand-poke of plugin state.
*
* ARMING MECHANISM (verified against custom-typography.ts source):
* - A transaction arms the guard only when it is BOTH history/remote
* (`getMeta("history$")` truthy, or `isChangeOrigin` via the ySync meta)
* AND an undo/redo (`getMeta("history$")` truthy, or ySync
* `isUndoRedoOperation`), AND its whole-doc diff is a REPLACE
* (change.oldTo > change.from && change.newTo > change.from).
* - `history$` is the stringified PluginKey of the single prosemirror-history
* plugin; ProseMirror stores meta under `key.key`, so setMeta("history$")
* in a test is read identically by the extension's getMeta("history$").
*/
const singlePara = (text: string) => ({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
const makeEditor = (text: string) =>
new Editor({
extensions: [Document, Paragraph, Text, CustomTypography],
content: singlePara(text),
});
// Build a before/after EditorState pair by applying one plain transaction.
const mutate = (text: string, apply: (tr: any, schema: any) => void) => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: singlePara(text),
});
const before = editor.state;
const tr = before.tr;
apply(tr, before.schema);
editor.view.dispatch(tr);
const after = editor.state;
return { before, after, editor };
};
describe("findChangedRange", () => {
it("returns null for identical docs", () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text],
content: singlePara("hello"),
});
expect(findChangedRange(editor.state, editor.state)).toBeNull();
editor.destroy();
});
it("returns the minimal range for a normal middle insertion", () => {
// "hello world" (text at 1..12); insert "there " at pos 6.
const { before, after, editor } = mutate("hello world", (tr) =>
tr.insertText("there ", 6),
);
expect(findChangedRange(before, after)).toEqual({
from: 6,
oldTo: 6,
newTo: 12,
});
editor.destroy();
});
it("normalizes the INSERTION overlapping-bounds branch (repeated content)", () => {
// Insert one more 'a' into "aaaaa" at pos 3. findDiffStart lands at the end
// (6) while findDiffEnd reports an end BEFORE it ({a:1,b:2}); both ends must
// be pushed forward by the same delta -> a non-degenerate range.
const { before, after, editor } = mutate("aaaaa", (tr) =>
tr.insertText("a", 3),
);
const change = findChangedRange(before, after)!;
expect(change).toEqual({ from: 6, oldTo: 6, newTo: 7 });
// Invariant the guard logic relies on: never degenerate.
expect(change.from).toBeLessThanOrEqual(change.oldTo);
expect(change.from).toBeLessThanOrEqual(change.newTo);
editor.destroy();
});
it("normalizes the DELETION overlapping-bounds branch (F2 fix)", () => {
// Delete one repeated 'a' from the middle of "aaaaa" ([3,4)). Here
// findDiffEnd reports newTo < start, the symmetric case the old one-sided
// normalization missed -> it used to yield a degenerate range (newTo < from).
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(3, 4));
const change = findChangedRange(before, after)!;
expect(change).toEqual({ from: 5, oldTo: 6, newTo: 5 });
// The whole point of F2: from <= newTo (and from <= oldTo) still holds.
expect(change.from).toBeLessThanOrEqual(change.newTo);
expect(change.from).toBeLessThanOrEqual(change.oldTo);
editor.destroy();
});
it("normalizes a multi-char repeated deletion (F2 fix)", () => {
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(2, 4));
const change = findChangedRange(before, after)!;
expect(change).toEqual({ from: 4, oldTo: 6, newTo: 4 });
expect(change.from).toBeLessThanOrEqual(change.newTo);
editor.destroy();
});
});
describe("mapRangeThroughChange", () => {
const range = { from: 5, to: 10 };
it("RELEASES on a strict intersection (edit inside the guarded range)", () => {
// change straddles the interior of the guard.
expect(
mapRangeThroughChange(range, { from: 6, oldTo: 8, newTo: 7 }),
).toBeNull();
});
it("does NOT release on a boundary touch at the guard END", () => {
// Edit begins exactly at range.to (10): from < to is false -> no intersect.
expect(
mapRangeThroughChange(range, { from: 10, oldTo: 10, newTo: 12 }),
).toEqual(range);
});
it("does NOT release on a boundary touch at the guard START", () => {
// Edit ends exactly at range.from (5): oldTo > from is false -> no intersect;
// it is treated as a change fully before, shifting the guard.
expect(
mapRangeThroughChange(range, { from: 3, oldTo: 5, newTo: 8 }),
).toEqual({ from: 8, to: 13 });
});
it("SHIFTS the guard for a change fully before it", () => {
// Insert 2 chars entirely before the range (oldTo 3 <= from 5): +2 delta.
expect(
mapRangeThroughChange(range, { from: 2, oldTo: 3, newTo: 5 }),
).toEqual({ from: 7, to: 12 });
});
it("leaves the guard untouched for a change fully after it", () => {
expect(
mapRangeThroughChange(range, { from: 12, oldTo: 14, newTo: 16 }),
).toBe(range);
});
});
describe("undo-guard arming (integration)", () => {
it("arms {from, to:newTo} on a LOCAL undo-replace (history meta)", () => {
// Undo of an em-dash substitution: "a—b" restored to "a--b" — the em-dash
// (pos 2..3) is REPLACED by "--", tagged with the history plugin's meta.
const editor = makeEditor("a—b");
const { state } = editor;
const tr = state.tr
.replaceWith(2, 3, state.schema.text("--"))
.setMeta("history$", { redo: false });
editor.view.dispatch(tr);
expect(editor.state.doc.textContent).toBe("a--b");
// from = diff start (2), to = newTo = end of the inserted "--" (4).
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 2, to: 4 });
editor.destroy();
});
it("does NOT arm on a REMOTE change-origin replace (no undo meta)", () => {
// Same replace, but tagged only as a y-sync remote change: history/remote
// yes, undo/redo NO -> must not arm.
const editor = makeEditor("a—b");
const { state } = editor;
const tr = state.tr
.replaceWith(2, 3, state.schema.text("--"))
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
editor.view.dispatch(tr);
expect(editor.state.doc.textContent).toBe("a--b");
expect(undoGuardKey.getState(editor.state)).toBeNull();
editor.destroy();
});
it("does NOT arm on an ordinary local edit", () => {
const editor = makeEditor("a—b");
editor.view.dispatch(
editor.state.tr.replaceWith(2, 3, editor.state.schema.text("--")),
);
expect(undoGuardKey.getState(editor.state)).toBeNull();
editor.destroy();
});
});
describe("undo-guard release / shift (integration)", () => {
it("RELEASES when a later edit lands inside the guarded region", () => {
const editor = makeEditor("a—b");
editor.view.dispatch(
editor.state.tr
.replaceWith(2, 3, editor.state.schema.text("--"))
.setMeta("history$", { redo: false }),
);
const guard = undoGuardKey.getState(editor.state)!;
expect(guard).toEqual({ from: 2, to: 4 });
// Type a character inside the restored region -> guard is dropped.
editor.view.dispatch(editor.state.tr.insertText("x", guard.from + 1));
expect(undoGuardKey.getState(editor.state)).toBeNull();
editor.destroy();
});
it("keeps and SHIFTS the guard when a later edit lands before it", () => {
const editor = makeEditor("zz a—b");
// "zz a—b": em-dash at pos 5; replace the 'a' at 4..5 with "--" to arm.
editor.view.dispatch(
editor.state.tr
.replaceWith(4, 5, editor.state.schema.text("--"))
.setMeta("history$", { redo: false }),
);
const guard = undoGuardKey.getState(editor.state)!;
expect(guard).toEqual({ from: 4, to: 6 });
// Insert one char at the very start (before the guard) -> guard shifts +1.
editor.view.dispatch(editor.state.tr.insertText("Q", 1));
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 5, to: 7 });
editor.destroy();
});
});
@@ -1,193 +0,0 @@
import { InputRule } from "@tiptap/core";
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from "@tiptap/pm/state";
import { Typography } from "@tiptap/extension-typography";
import { isChangeOrigin } from "@tiptap/extension-collaboration";
import { ySyncPluginKey } from "@tiptap/y-tiptap";
// Region restored by the latest undo — while it is intact, typography
// input rules overlapping it must not fire again.
interface UndoGuardRange {
from: number;
to: number;
}
// Exported for tests: the plugin key lets a test read the armed guard state,
// and the two pure helpers below are unit-tested directly.
export const undoGuardKey = new PluginKey<UndoGuardRange | null>(
"typographyUndoGuard",
);
// prosemirror-history does not export its plugin key, so template-editor
// undo/redo is detected via the stable stringified key. Only one
// PluginKey("history") exists in the dependency tree, so "history$" is stable.
const HISTORY_META = "history$";
const isUndoRedoTransaction = (tr: Transaction): boolean => {
if (tr.getMeta(HISTORY_META)) {
return true;
}
// Read yjs undo/redo meta via the real ySyncPluginKey object (imported, not
// a fragile stringified key), which y-tiptap sets on Y.UndoManager changes.
const ySyncMeta = tr.getMeta(ySyncPluginKey) as
| { isUndoRedoOperation?: boolean }
| undefined;
return !!ySyncMeta?.isUndoRedoOperation;
};
interface DocChange {
from: number;
oldTo: number;
newTo: number;
}
// Compute the minimal changed region between two docs. yjs undo/redo (and any
// remote change) arrives as a whole-document replace step, so the transaction
// step maps are useless — diff the docs to recover the real minimal change.
// Returns null when the docs are identical.
export const findChangedRange = (
oldState: EditorState,
newState: EditorState,
): DocChange | null => {
const start = oldState.doc.content.findDiffStart(newState.doc.content);
const end = oldState.doc.content.findDiffEnd(newState.doc.content);
if (start == null || end == null) {
return null;
}
let { a: oldTo, b: newTo } = end;
// findDiffEnd can report an end BEFORE the diff start when the changed text
// abuts repeated content (insertion -> oldTo<start, deletion -> newTo<start).
// Push both ends forward by the same delta so the range stays non-degenerate
// (from <= oldTo and from <= newTo), matching ProseMirror's own diff bounds.
const minTo = Math.min(oldTo, newTo);
if (minTo < start) {
const delta = start - minTo;
oldTo += delta;
newTo += delta;
}
return { from: start, oldTo, newTo };
};
// Map an armed guard range across a single document change described by a diff.
// Returns null when the change touches the guarded text itself (the restored
// substitution was edited, so the guard must be released).
export const mapRangeThroughChange = (
range: UndoGuardRange,
change: DocChange,
): UndoGuardRange | null => {
// Strict intersection: an edit exactly at a guard boundary (e.g. the user
// typing the suppressed space right after the restored text, or deleting it)
// must NOT drop the guard.
if (change.from < range.to && change.oldTo > range.from) {
return null;
}
// Change fully before the guard: shift the guard by the length delta.
if (change.oldTo <= range.from) {
const delta = change.newTo - change.oldTo;
return { from: range.from + delta, to: range.to + delta };
}
// Change fully after the guard: positions are unaffected.
return range;
};
// Detect history/remote transactions that may arrive as a whole-document
// replace step: prosemirror-history undo/redo, or any yjs remote-origin change
// (isChangeOrigin is the canonical predicate already used across the app).
const isHistoryOrRemoteTransaction = (tr: Transaction): boolean =>
!!tr.getMeta(HISTORY_META) || isChangeOrigin(tr);
export const CustomTypography = Typography.extend({
addProseMirrorPlugins() {
return [
...(this.parent?.() ?? []),
new Plugin({
key: undoGuardKey,
state: {
init: () => null,
apply(tr, prev, oldState, newState): UndoGuardRange | null {
if (tr.docChanged && isHistoryOrRemoteTransaction(tr)) {
const change = findChangedRange(oldState, newState);
if (change == null) {
// Attribute-only or otherwise content-neutral change: keep the
// guard.
return prev;
}
// Arm the guard only when the LOCAL user's undo/redo REPLACED text
// (deleted + inserted) — the signature of reverting an input-rule
// substitution. Pure insertions/deletions and remote peer edits
// must not arm it.
if (
isUndoRedoTransaction(tr) &&
change.oldTo > change.from &&
change.newTo > change.from
) {
return { from: change.from, to: change.newTo };
}
// Non-arming history/remote change: map the existing guard through
// the real diff instead of the (whole-document) step map.
if (!prev) {
return null;
}
return mapRangeThroughChange(prev, change);
}
if (!prev) {
return null;
}
if (!tr.docChanged) {
return prev;
}
// Ordinary local edit: minimal step maps are accurate and cheap.
let range: UndoGuardRange | null = prev;
for (const stepMap of tr.mapping.maps) {
const { from: rangeFrom, to: rangeTo } = range;
let touched = false;
stepMap.forEach((fromA, toA) => {
if (fromA < rangeTo && toA > rangeFrom) {
touched = true;
}
});
if (touched) {
range = null;
break;
}
range = {
from: stepMap.map(rangeFrom, 1),
to: stepMap.map(rangeTo, -1),
};
}
return range && range.to > range.from ? range : null;
},
},
}),
];
},
addInputRules() {
// Wrap every typography rule: skip it when its match overlaps the text
// just restored by undo, so an undone substitution is not re-applied.
return (this.parent?.() ?? []).map(
(rule) =>
new InputRule({
find: rule.find,
undoable: rule.undoable,
handler: (props) => {
const guard = undoGuardKey.getState(props.state);
if (
guard &&
props.range.from < guard.to &&
props.range.to > guard.from
) {
// Returning null skips this rule and lets the typed character
// be inserted as plain text.
return null;
}
return rule.handler(props);
},
}),
);
},
});
@@ -6,7 +6,7 @@ import { TaskList, TaskItem } from "@tiptap/extension-list";
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
import { Superscript } from "@tiptap/extension-superscript";
import SubScript from "@tiptap/extension-subscript";
import { CustomTypography } from "./custom-typography";
import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import { Youtube } from "@tiptap/extension-youtube";
@@ -245,9 +245,7 @@ export const mainExtensions = [
return ReactMarkViewRenderer(SpoilerView);
},
}),
// Typography with an undo guard: does not re-apply a substitution the user
// just undid (e.g. Ctrl+Z on "1/2" -> "½" followed by another space).
CustomTypography,
Typography,
TrailingNode,
GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
@@ -1,9 +1,5 @@
import { describe, it, expect } from "vitest";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
normalizeTableColumnWidths,
classifyClipboardSelection,
} from "./markdown-clipboard";
import { normalizeTableColumnWidths } from "./markdown-clipboard";
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
function root(html: string): HTMLElement {
@@ -128,171 +124,3 @@ describe("normalizeTableColumnWidths", () => {
).toEqual([null, null]);
});
});
describe("classifyClipboardSelection", () => {
it("serializes a list of 2+ items as markdown", () => {
expect(
classifyClipboardSelection([{ name: "bulletList", childCount: 2 }]),
).toEqual({ asMarkdown: true, wrapBareRows: false });
});
it("leaves a single-item list as plain text", () => {
expect(
classifyClipboardSelection([{ name: "bulletList", childCount: 1 }]),
).toEqual({ asMarkdown: false, wrapBareRows: false });
});
it("serializes a whole table without wrapping bare rows", () => {
expect(
classifyClipboardSelection([{ name: "table", childCount: 3 }]),
).toEqual({ asMarkdown: true, wrapBareRows: false });
});
it("serializes a partial cell selection (bare rows) and flags wrapping", () => {
expect(
classifyClipboardSelection([
{ name: "tableRow", childCount: 2 },
{ name: "tableRow", childCount: 2 },
]),
).toEqual({ asMarkdown: true, wrapBareRows: true });
});
it("leaves plain paragraphs as plain text", () => {
expect(
classifyClipboardSelection([{ name: "paragraph", childCount: 1 }]),
).toEqual({ asMarkdown: false, wrapBareRows: false });
});
it("does not wrap when rows are mixed with other block types", () => {
expect(
classifyClipboardSelection([
{ name: "tableRow", childCount: 2 },
{ name: "paragraph", childCount: 1 },
]),
).toEqual({ asMarkdown: false, wrapBareRows: false });
});
});
// Output-level tests for the table clipboard regression: copying a table must
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
// These exercise the actual markdown produced by htmlToMarkdown (the same
// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
// shape that the classifier-flag tests above do not cover.
describe("table clipboard markdown output (htmlToMarkdown)", () => {
// Trim each line and drop blanks so structural assertions are whitespace-robust.
function lines(md: string): string[] {
return md
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
}
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
// of the padding turndown emits.
function isSeparatorRow(line: string): boolean {
const compact = line.replace(/\s+/g, "");
return /^\|(?:-{3,}\|)+$/.test(compact);
}
// Split a pipe-delimited row into trimmed cell values.
function cells(line: string): string[] {
return line
.replace(/^\|/, "")
.replace(/\|$/, "")
.split("|")
.map((c) => c.trim());
}
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
// Mirror the serializer's `wrapBareRows` branch exactly: bare <tr> nodes are
// wrapped in <table><tbody> and htmlToMarkdown(div.innerHTML) is called.
// See markdown-clipboard.ts clipboardTextSerializer:
// const table = document.createElement("table");
// const tbody = document.createElement("tbody");
// tbody.appendChild(fragment); table.appendChild(tbody);
// div.appendChild(table);
// return htmlToMarkdown(div.innerHTML);
const div = document.createElement("div");
const table = document.createElement("table");
const tbody = document.createElement("tbody");
for (const [c1, c2] of [
["a", "b"],
["c", "d"],
]) {
const tr = document.createElement("tr");
const td1 = document.createElement("td");
td1.textContent = c1;
const td2 = document.createElement("td");
td2.textContent = c2;
tr.appendChild(td1);
tr.appendChild(td2);
tbody.appendChild(tr);
}
table.appendChild(tbody);
div.appendChild(table);
const md = htmlToMarkdown(div.innerHTML);
const ls = lines(md);
// Valid GFM: a header/data separator row is present (an empty header is
// synthesized by the GFM turndown plugin for a header-less table — fine).
expect(ls.some(isSeparatorRow)).toBe(true);
// NOT the old broken "one value per line" shape: every line is pipe-delimited
// and no line is a bare cell value on its own.
expect(ls.every((l) => l.includes("|"))).toBe(true);
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
// The cell values land in real pipe-delimited data rows.
const dataRows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
expect(dataRows).toContainEqual(["a", "b"]);
expect(dataRows).toContainEqual(["c", "d"]);
});
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
// Mirror the serializer's non-wrap branch: the full <table> node is appended
// directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
const div = document.createElement("div");
const table = document.createElement("table");
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
for (const h of ["Name", "Age"]) {
const th = document.createElement("th");
th.textContent = h;
headerRow.appendChild(th);
}
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = document.createElement("tbody");
for (const [name, age] of [
["Alice", "30"],
["Bob", "25"],
]) {
const tr = document.createElement("tr");
const td1 = document.createElement("td");
td1.textContent = name;
const td2 = document.createElement("td");
td2.textContent = age;
tr.appendChild(td1);
tr.appendChild(td2);
tbody.appendChild(tr);
}
table.appendChild(tbody);
div.appendChild(table);
const md = htmlToMarkdown(div.innerHTML);
const ls = lines(md);
// Proper GFM structure: separator row + all rows pipe-delimited.
expect(ls.some(isSeparatorRow)).toBe(true);
expect(ls.every((l) => l.includes("|"))).toBe(true);
const rows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
// Header row comes first, followed by both data rows.
expect(rows[0]).toEqual(["Name", "Age"]);
expect(rows).toContainEqual(["Alice", "30"]);
expect(rows).toContainEqual(["Bob", "25"]);
// Headline regression: the table is NOT concatenated one-value-per-line.
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
});
});
@@ -27,36 +27,24 @@ export const MarkdownClipboard = Extension.create({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const topLevelNodes: { name: string; childCount: number }[] = [];
const listTypes = ["bulletList", "orderedList", "taskList"];
let topLevelCount = 0;
let hasList = false;
slice.content.forEach((node) => {
topLevelNodes.push({
name: node.type.name,
childCount: node.childCount,
});
if (listTypes.includes(node.type.name)) {
hasList = true;
topLevelCount += node.childCount;
} else {
topLevelCount++;
}
});
const { asMarkdown, wrapBareRows } =
classifyClipboardSelection(topLevelNodes);
if (!asMarkdown) return null;
if (!hasList || topLevelCount < 2) return null;
const div = document.createElement("div");
const serializer = DOMSerializer.fromSchema(this.editor.schema);
const fragment = serializer.serializeFragment(slice.content);
if (wrapBareRows) {
// A partial table cell-selection serializes to bare <tr> nodes
// (prosemirror-tables returns the whole `table` node only when the
// entire table is selected). Bare <tr> would be foster-parented
// away by the HTML parser inside htmlToMarkdown, so wrap them in
// <table><tbody> first for the GFM turndown rule to detect them.
const table = document.createElement("table");
const tbody = document.createElement("tbody");
tbody.appendChild(fragment);
table.appendChild(tbody);
div.appendChild(table);
} else {
div.appendChild(fragment);
}
div.appendChild(fragment);
return htmlToMarkdown(div.innerHTML);
},
handlePaste: (view, event, slice) => {
@@ -165,55 +153,6 @@ export const MarkdownClipboard = Extension.create({
},
});
/**
* Decide whether a copied slice's plain-text clipboard payload should be
* serialized as Markdown (instead of ProseMirror's default text serializer,
* which joins block leaves with newlines the "one value per line" bug for
* tables).
*
* Serialize as Markdown for structured content:
* - lists with 2+ total items (a single copied bullet stays literal text);
* - a whole table (top-level `table` node);
* - a partial table cell-selection, which prosemirror-tables copies as bare
* `tableRow` nodes (only a full-table selection yields a `table` node).
*
* `wrapBareRows` flags the bare-rows case so the caller wraps the serialized
* <tr> nodes in <table><tbody> before the HTML->Markdown step. Plain paragraphs
* return asMarkdown=false so a simple text copy stays literal, and internal
* copy/paste keeps using the richer text/html clipboard payload.
*/
export function classifyClipboardSelection(
nodes: { name: string; childCount: number }[],
): { asMarkdown: boolean; wrapBareRows: boolean } {
const listTypes = ["bulletList", "orderedList", "taskList"];
let topLevelCount = 0;
let hasList = false;
let hasTable = false;
let tableRowCount = 0;
let nonRowCount = 0;
for (const node of nodes) {
if (listTypes.includes(node.name)) {
hasList = true;
topLevelCount += node.childCount;
nonRowCount++;
} else {
if (node.name === "table") hasTable = true;
if (node.name === "tableRow") tableRowCount++;
else nonRowCount++;
topLevelCount++;
}
}
// Bare tableRow nodes at the top level only occur for a partial cell
// selection; a slice never mixes bare rows with other block types, so
// "every top-level node is a row" is a safe signal to wrap-and-serialize.
const wrapBareRows = tableRowCount > 0 && nonRowCount === 0;
const asMarkdown =
(hasList && topLevelCount >= 2) || hasTable || wrapBareRows;
return { asMarkdown, wrapBareRows };
}
/**
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
+39 -25
View File
@@ -34,6 +34,8 @@ import {
} 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);
@@ -80,16 +82,24 @@ export function FullEditor({
// AI title generation is gated by the general AI chat flag (the same toggle
// that enables the chat agent); the server enforces it too (#199).
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
// `user` can momentarily be null during logout teardown (the currentUser atom
// is reset before this subtree unmounts). Optional-chain every access so the
// teardown render does not throw "Cannot read properties of null (reading
// 'settings')".
const fullPageWidth = user?.settings?.preferences?.fullPageWidth;
const editorToolbarEnabled =
user.settings?.preferences?.editorToolbar ?? false;
user?.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
const isEditMode = currentPageEditMode === PageEditMode.Edit;
// Single shared Y.Doc + HocuspocusProvider for both the title and body
// editors (title lives in the 'title' fragment of the same doc).
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
// Apply the user's saved preference only once on initial load, not on every
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
@@ -110,28 +120,32 @@ export function FullEditor({
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTemporaryNoteBanner slugId={slugId} />
<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
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>
);
}
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// jwt-decode is mocked so we can drive the four token states deterministically
// (decode success with a chosen exp, or a thrown decode error).
const decodeMock = vi.hoisted(() => vi.fn());
vi.mock("jwt-decode", () => ({
jwtDecode: decodeMock,
}));
import { collabTokenNeedsRefresh } from "./collab-token";
const NOW_MS = 1_000_000_000; // fixed "now" in ms (so NOW_MS/1000 seconds)
beforeEach(() => {
decodeMock.mockReset();
});
describe("collabTokenNeedsRefresh", () => {
it("returns true when there is no token (fetch a fresh one)", () => {
expect(collabTokenNeedsRefresh(undefined, NOW_MS)).toBe(true);
// jwtDecode must not even be called for a missing token.
expect(decodeMock).not.toHaveBeenCalled();
});
it("returns true when the token is malformed (jwtDecode throws)", () => {
decodeMock.mockImplementation(() => {
throw new Error("invalid token");
});
expect(collabTokenNeedsRefresh("garbage", NOW_MS)).toBe(true);
});
it("returns false for a valid, not-yet-expired token (no reconnect)", () => {
// exp is in the future relative to NOW.
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 + 60 });
expect(collabTokenNeedsRefresh("good", NOW_MS)).toBe(false);
});
it("returns true for a valid but expired token (refresh + reconnect)", () => {
// exp is in the past relative to NOW.
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 - 60 });
expect(collabTokenNeedsRefresh("expired", NOW_MS)).toBe(true);
});
it("treats exp exactly equal to now as expired (>= boundary)", () => {
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 });
expect(collabTokenNeedsRefresh("boundary", NOW_MS)).toBe(true);
});
});
@@ -0,0 +1,26 @@
import { jwtDecode } from "jwt-decode";
/**
* Decide whether a collab token must be refreshed before reconnecting after an
* onAuthenticationFailed event. Pure and side-effect free so the four token
* states can be unit-tested directly:
* - no token -> true (fetch a fresh one and reconnect)
* - undecodable/malformed -> true (jwtDecode throws -> refresh)
* - valid, not expired -> false (token is still good; do NOT reconnect)
* - valid, expired -> true (refresh + reconnect)
*
* `nowMs` is injectable for deterministic tests; it defaults to `Date.now()`.
*/
export function collabTokenNeedsRefresh(
token: string | undefined,
nowMs: number = Date.now(),
): boolean {
if (!token) return true;
try {
const payload = jwtDecode<{ exp: number }>(token);
return nowMs / 1000 >= payload.exp;
} catch {
// malformed/undecodable token -> refresh
return true;
}
}
@@ -139,7 +139,7 @@ describe("useGeneratePageTitle", () => {
);
});
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
it("happy path: applies the title, refreshes cache, broadcasts, and does NOT write the editor", async () => {
const store = createStore();
const titleEditor = makeTitleEditor();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
@@ -157,9 +157,11 @@ describe("useGeneratePageTitle", () => {
title: "Generated Title",
});
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
"Generated Title",
);
// The title editor is bound to the Yjs `title` fragment; the server REST
// update reseeds that fragment and the reseed reaches the bound editor on
// its own. Writing here too would double/garble the title, so the hook must
// NOT touch the editor (regression guard for the Yjs duplication trap).
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
expect(localEmitMock).toHaveBeenCalled();
expect(emitMock).toHaveBeenCalled();
expect(notificationsShowMock).toHaveBeenCalledWith(
@@ -167,7 +169,7 @@ describe("useGeneratePageTitle", () => {
);
});
it("does NOT write the visible title field when the user navigated away during generation", async () => {
it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
const store = createStore();
const titleEditor = makeTitleEditor(); // persistent across navigation
store.set(pageEditorAtom as never, makePageEditor("pageA"));
@@ -203,55 +205,9 @@ describe("useGeneratePageTitle", () => {
pageId: "pageA",
title: "Generated Title",
});
// ...but we must NOT stamp page A's title into page B's visible field.
// ...the hook never writes the editor regardless of navigation...
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
// 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();
// ...and the change is still broadcast to other clients.
expect(emitMock).toHaveBeenCalled();
});
@@ -1,13 +1,9 @@
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 { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import {
updatePageData,
useUpdateTitlePageMutation,
@@ -33,18 +29,9 @@ const MAX_CONTENT_CHARS = 20000;
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;
@@ -70,33 +57,15 @@ export function useGeneratePageTitle(pageId: string) {
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);
}
// Do NOT write the title into the editor here. The title editor is bound to
// the Yjs `title` fragment and Yjs is the source of truth. The server REST
// /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
// a full clear+replace) and the reseed reaches the bound title editor on
// its own as a remote provider update. The old REST-era setContent here
// would race that reseed and double/garble the title (the "Yjs duplication
// trap"), so it is intentionally omitted. The DB write above is keyed by
// the captured `pageId`, so it stays correct even if the user navigated
// away during generation.
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
const event: UpdateEvent = {
@@ -0,0 +1,189 @@
import { useEffect, useRef, useState } from "react";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import {
HocuspocusProvider,
onStatusParameters,
WebSocketStatus,
HocuspocusProviderWebsocket,
onSyncedParameters,
onStatelessParameters,
} from "@hocuspocus/provider";
import { useAtom, useSetAtom } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import {
isLocalSyncedAtom,
isRemoteSyncedAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
import { extractPageSlugId } from "@/lib";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { collabTokenNeedsRefresh } from "@/features/editor/hooks/collab-token";
import { pageYdocName } from "@/features/editor/page-ydoc-name";
import { pageKeys } from "@/features/page/queries/page-query";
export interface PageCollabProviders {
ydoc: Y.Doc | null;
remote: HocuspocusProvider | null;
socket: HocuspocusProviderWebsocket | null;
providersReady: boolean;
}
/**
* Owns the full collaboration provider lifecycle for a page so that the title
* and body editors can share a single Y.Doc + HocuspocusProvider. The behavior
* is relocated verbatim from page-editor.tsx: it creates the providers once per
* pageId, connects/disconnects on idle/visibility, attaches each render,
* destroys on unmount, refreshes the collab token on auth failure, and applies
* the onStateless 'page.updated' cache update.
*/
export function usePageCollabProviders(pageId: string): PageCollabProviders {
const collaborationURL = useCollaborationUrl();
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
// The provider-creating effect runs only once per pageId, so any token read
// inside its handlers would be captured STALE (the old token at first render).
// Mirror the latest token into a ref the auth-failure handler can read live.
const collabTokenRef = useRef<string | undefined>(undefined);
useEffect(() => {
collabTokenRef.current = collabQuery?.token;
}, [collabQuery?.token]);
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility();
const { pageSlug } = useParams();
const slugId = extractPageSlugId(pageSlug);
// Providers only created once per pageId
const providersRef = useRef<{
ydoc: Y.Doc;
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
useEffect(() => {
if (!providersRef.current) {
const documentName = pageYdocName(pageId);
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
setIsLocalSyncedAtom(true);
};
const onStatusHandler = (event: onStatusParameters) => {
setYjsConnectionStatus(event.status);
};
const onSyncedHandler = (event: onSyncedParameters) => {
setIsRemoteSyncedAtom(event.state);
};
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
try {
const message = JSON.parse(payload);
if (message?.type !== "page.updated" || !message.updatedAt) return;
const pageData = queryClient.getQueryData<IPage>(
pageKeys.detail(slugId),
);
if (pageData) {
queryClient.setQueryData(pageKeys.detail(slugId), {
...pageData,
updatedAt: message.updatedAt,
...(message.lastUpdatedBy && {
lastUpdatedBy: message.lastUpdatedBy,
}),
});
}
} catch {
// ignore unrelated stateless messages
}
};
const onAuthenticationFailedHandler = () => {
// Read the token from the ref, not the closed-over `collabQuery`: this
// handler is created once and would otherwise decode a stale token after
// a refetch. A missing/malformed token must NOT crash the handler —
// jwtDecode(undefined) throws — so treat any decode failure as "needs
// refresh" and proceed to refetch + reconnect instead of getting stuck.
if (!collabTokenNeedsRefresh(collabTokenRef.current)) return;
refetchCollabToken().then((result) => {
if (result.data?.token) {
socket.disconnect();
setTimeout(() => {
remote.configuration.token = result.data.token;
socket.connect();
}, 100);
}
});
};
const remote = new HocuspocusProvider({
websocketProvider: socket,
name: documentName,
document: ydoc,
token: collabQuery?.token,
onAuthenticationFailed: onAuthenticationFailedHandler,
onStatus: onStatusHandler,
onSynced: onSyncedHandler,
onStateless: onStatelessHandler,
});
local.on("synced", onLocalSyncedHandler);
providersRef.current = { ydoc, socket, local, remote };
setProvidersReady(true);
} else {
setProvidersReady(true);
}
// Only destroy on final unmount
return () => {
providersRef.current?.socket.destroy();
providersRef.current?.remote.destroy();
providersRef.current?.local.destroy();
providersRef.current = null;
// Reset shared sync state on page change/unmount.
setIsLocalSyncedAtom(false);
setIsRemoteSyncedAtom(false);
};
}, [pageId]);
// Only connect/disconnect on tab/idle, not destroy
useEffect(() => {
if (!providersReady || !providersRef.current) return;
const socket = providersRef.current.socket;
if (
isIdle &&
documentState === "hidden" &&
yjsConnectionStatus === WebSocketStatus.Connected
) {
socket.disconnect();
return;
}
if (
documentState === "visible" &&
yjsConnectionStatus === WebSocketStatus.Disconnected
) {
resetIdle();
socket.connect();
}
}, [isIdle, documentState, providersReady, resetIdle]);
// Attach here, to make sure the connection gets properly established
providersRef.current?.remote.attach();
return {
ydoc: providersRef.current?.ydoc ?? null,
remote: providersRef.current?.remote ?? null,
socket: providersRef.current?.socket ?? null,
providersReady,
};
}
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useScrollPosition, hasSavedReadingPosition } from "./use-scroll-position";
import { useScrollPosition } from "./use-scroll-position";
const KEY_PREFIX = "gitmost:scroll-position:";
@@ -100,7 +100,7 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => {
it("(a3) restores at most once per mount even if called again", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
@@ -111,12 +111,8 @@ describe("useScrollPosition", () => {
});
expect(window.scrollTo).toHaveBeenCalledTimes(1);
// Simulate the browser now being at the restored position.
setScrollY(500);
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
// restoreScrollPosition]) must NOT scroll again: the redundancy guard sees
// the window is already at the target and does nothing.
// restoreScrollPosition]) must NOT scroll again and yank the reader.
act(() => {
result.current.restoreScrollPosition();
});
@@ -166,84 +162,6 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("g1"));
// The reader shows scroll intent before restore is triggered.
act(() => {
window.dispatchEvent(new Event("wheel"));
});
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(h) aborts an in-flight restore poll when the reader scrolls", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}h1`, "500");
setInnerHeight(800);
setScrollHeight(100); // maxScroll = -700: target not reachable yet, so it polls.
const { result } = renderHook(() => useScrollPosition("h1"));
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled(); // still polling
// The reader takes over mid-poll: this cancels the in-flight poll.
act(() => {
window.dispatchEvent(new Event("wheel"));
});
// Content of the page grows tall enough and time passes: the cancelled poll
// must NOT resurrect and yank the reader.
setScrollHeight(2000);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(i) a non-scroll keydown does NOT abort restore", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("i1"));
// A non-scroll key (e.g. typing, a shortcut) must NOT count as scroll intent.
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
});
act(() => {
result.current.restoreScrollPosition();
});
// Restore still happens: the innocuous keypress did not disable it.
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(j) a scroll keydown (Space) DOES abort restore", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("j1"));
// Space scrolls the page: this is real scroll intent and must abort restore.
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
});
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
// Nothing saved.
const a = renderHook(() => useScrollPosition("nope"));
@@ -303,55 +221,6 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
});
it("(k) shares ONE timeout budget across re-triggers (does not restart the clock)", () => {
// The static->live editor swap re-invokes restore. The shared budget
// (restoreStartRef) must measure the MAX_RESTORE_WAIT_MS (5000) deadline
// from the FIRST trigger, not restart it on every re-trigger. This pins
// the `if (restoreStartRef.current === null)` guard: a mutant that resets
// `restoreStartRef.current = Date.now()` on every trigger would push the
// deadline out to t=8000 (3000 + 5000) and fail the t=5000 assertion below.
vi.useFakeTimers();
vi.setSystemTime(0);
window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "5000");
setInnerHeight(800);
setScrollHeight(1000); // maxScroll = 200, never reaches 5000 -> it polls.
const { result } = renderHook(() => useScrollPosition("k1"));
// First trigger at t=0: starts the shared budget and begins polling.
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
// Advance to t=3000 (still polling: content short, not yet timed out).
act(() => {
vi.advanceTimersByTime(3000);
});
expect(window.scrollTo).not.toHaveBeenCalled();
// Second trigger at t=3000 (the swap re-assert). Under the real code the
// budget is shared, so `start` stays 0; under the reset-mutant it becomes 3000.
act(() => {
result.current.restoreScrollPosition();
});
// At t=4900 the FIRST budget has not yet elapsed (4900 - 0 < 5000): no clamp.
act(() => {
vi.advanceTimersByTime(1900);
});
expect(window.scrollTo).not.toHaveBeenCalled();
// At t=5000 the shared budget (measured from t=0) times out and clamps to the
// furthest reachable position (maxScroll = 200). The reset-mutant, measuring
// from t=3000, would still be waiting (5000 - 3000 = 2000 < 5000) and would
// NOT have scrolled here -> this assertion fails against that mutant.
act(() => {
vi.advanceTimersByTime(100);
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
});
it("(e) never throws when storage access throws", () => {
const err = new Error("storage denied");
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
@@ -372,23 +241,3 @@ describe("useScrollPosition", () => {
}).not.toThrow();
});
});
describe("hasSavedReadingPosition", () => {
beforeEach(() => {
window.sessionStorage.clear();
});
it("returns false when nothing is saved for the page", () => {
expect(hasSavedReadingPosition("none")).toBe(false);
});
it("returns false when the saved value is 0 (page stays at the top)", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
expect(hasSavedReadingPosition("zero")).toBe(false);
});
it("returns true when a positive position is saved", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}deep`, "500");
expect(hasSavedReadingPosition("deep")).toBe(true);
});
});
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import type { Editor } from "@tiptap/react";
import { useCallback, useEffect, useRef } from "react";
// Throttle interval for persisting the scroll position while the user reads.
const SAVE_THROTTLE_MS = 250;
@@ -14,18 +13,6 @@ const RESTORE_POLL_MS = 100;
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
const STORAGE_PREFIX = "gitmost:scroll-position:";
// Keys that scroll the window. Only these count as scroll intent for keydown;
// other keys (shortcuts, modifiers, typing) must NOT disable scroll restore.
const SCROLL_KEYS = new Set([
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" ", // Space (and Shift+Space) scroll the page
]);
function storageKey(pageId: string): string {
return `${STORAGE_PREFIX}${pageId}`;
}
@@ -57,56 +44,36 @@ function writeStorage(pageId: string, scrollY: number): void {
}
}
/**
* Whether a positive reading position is saved for this page i.e. the page
* will be scrolled away from the top on load. Used by the title editor to avoid
* auto-focusing (and thus placing the caret in) the now-off-screen title.
* Returns false when nothing is saved or storage is unavailable.
*/
export function hasSavedReadingPosition(pageId: string): boolean {
const y = readStorage(pageId);
return typeof y === "number" && y > 0;
}
/**
* Persists and restores the window scroll position per page so a reader keeps
* their place across a reload (F5) or reopening the document.
*
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
* (early, while the static/cached content is laid out, and again after the
* static->live editor swap); it is idempotent, so re-asserting the same target is
* a no-op. The two scroll mechanisms are mutually exclusive: if the URL has a
* `#hash` anchor, the existing anchor-scroll logic wins and restore is a no-op.
* Returns `restoreScrollPosition`, which the page editor calls once the live
* (non-static) content is laid out. The two scroll mechanisms are mutually
* exclusive: if the URL has a `#hash` anchor, the existing anchor-scroll logic
* wins and restore is a no-op.
*/
export function useScrollPosition(pageId: string): {
restoreScrollPosition: () => void;
} {
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
// hook instance with fresh refs. Restore is idempotent and interaction-gated
// (not single-shot): it may be called from several triggers and re-asserts the
// SAME captured target, which is a no-op once the window is already positioned.
// The per-mount refs that latch are `initialTargetRef` (the captured target)
// and `userInteractedRef` (the reader has taken over scrolling). They are NOT
// reset when `pageId` changes in place (only the effect re-runs on [pageId]).
// If that `key={page.id}` is ever removed, restore would silently break on the
// 2nd page (refs would hold the first page's target / interaction flag) — in
// that case the refs must be reset on a pageId change.
// hook instance with fresh refs. These refs latch per-mount and are NOT reset
// when `pageId` changes in place (only the effect re-runs on [pageId]). If that
// `key={page.id}` is ever removed, restore would silently break on the 2nd page
// (refs would hold the first page's target / already-restored flag) — in that
// case the refs must be reset on a pageId change.
//
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
// handler can overwrite the stored value with a fresh 0 (the page starts
// scrolled to top on load). `null` means "not yet captured".
const initialTargetRef = useRef<number | null>(null);
// Set once the reader shows unambiguous scroll intent; restore must never yank
// a reader who has already started scrolling.
const userInteractedRef = useRef(false);
// Guards so restore runs at most once per page mount.
const hasRestoredRef = useRef(false);
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
const pollTimerRef = useRef<number | null>(null);
// Timestamp of the FIRST restore attempt so re-triggers (e.g. the static→live
// editor swap) share ONE bounded timeout budget instead of restarting it.
const restoreStartRef = useRef<number | null>(null);
// Capture the previously-saved value synchronously during render, before the
// effect below registers handlers that would persist the current (0) scrollY.
@@ -147,43 +114,14 @@ export function useScrollPosition(pageId: string): {
}
};
// User scroll-intent signals. wheel and touch are unconditional scroll
// intent; keydown is filtered to actual scroll keys only (SCROLL_KEYS) so
// shortcuts, lone modifiers, and typing do not abort restore. Our own
// window.scrollTo does NOT emit these, so restore can never self-abort via
// them. Once the reader shows intent we mark it and cancel any in-flight
// restore poll so restore can never yank them back. (Scrollbar-drag via
// pointer is an accepted small gap — it is not covered here.)
const onUserIntent = (event: Event) => {
// wheel/touchstart are unambiguous scroll intent; for keydown, only real
// scroll keys count — a shortcut or typing must not abort restore.
if (
event.type === "keydown" &&
!SCROLL_KEYS.has((event as KeyboardEvent).key)
) {
return;
}
userInteractedRef.current = true;
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("pagehide", onPageHide);
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("wheel", onUserIntent, { passive: true });
window.addEventListener("touchstart", onUserIntent, { passive: true });
window.addEventListener("keydown", onUserIntent);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("pagehide", onPageHide);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("wheel", onUserIntent);
window.removeEventListener("touchstart", onUserIntent);
window.removeEventListener("keydown", onUserIntent);
if (throttleTimer !== null) {
window.clearTimeout(throttleTimer);
throttleTimer = null;
@@ -199,8 +137,9 @@ export function useScrollPosition(pageId: string): {
}, [pageId]);
const restoreScrollPosition = useCallback(() => {
// The reader took over — never yank them back.
if (userInteractedRef.current) return;
// Run at most once per page mount.
if (hasRestoredRef.current) return;
hasRestoredRef.current = true;
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
if (window.location.hash) return;
@@ -209,26 +148,9 @@ export function useScrollPosition(pageId: string): {
// Nothing meaningful to restore to.
if (targetY <= 0) return;
// Cancel any in-flight poll before (re)starting, so overlapping triggers can
// never run two concurrent polls against the same target.
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
// Share one timeout budget across re-triggers instead of restarting it.
if (restoreStartRef.current === null) {
restoreStartRef.current = Date.now();
}
const start = restoreStartRef.current;
const start = Date.now();
const tryRestore = () => {
// Bail mid-poll if the reader started scrolling while we were waiting.
if (userInteractedRef.current) {
pollTimerRef.current = null;
return;
}
const maxScroll =
document.documentElement.scrollHeight - window.innerHeight;
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
@@ -236,12 +158,10 @@ export function useScrollPosition(pageId: string): {
// Restore once the content is tall enough to reach the target, or bail out
// after the timeout and scroll as far as currently possible.
if (maxScroll >= targetY || timedOut) {
const top = Math.min(targetY, Math.max(maxScroll, 0));
// Redundancy guard: re-asserting the SAME target when already positioned
// is a no-op, so this hook can be called from multiple triggers safely.
if (Math.abs(window.scrollY - top) > 1) {
window.scrollTo({ top, behavior: "auto" });
}
window.scrollTo({
top: Math.min(targetY, Math.max(maxScroll, 0)),
behavior: "auto",
});
pollTimerRef.current = null;
return;
}
@@ -255,37 +175,3 @@ export function useScrollPosition(pageId: string): {
return { restoreScrollPosition };
}
/**
* Wires `useScrollPosition` to the page editor's static->live swap lifecycle.
*
* Extracted from PageEditor so the exact restore triggers (their deps and the
* post-swap `&& editor` guard) are directly unit-testable rather than mirrored.
* Behaviour is unchanged: `restoreScrollPosition` is idempotent, so re-asserting
* the same target from either trigger is a no-op.
*
* @param pageId the page whose scroll position is persisted/restored.
* @param editor the tiptap editor instance, or `null` until it is ready.
* @param showStatic whether the static (cached) content is still shown.
*/
export function useScrollRestoreOnSwap(
pageId: string,
editor: Editor | null,
showStatic: boolean,
): void {
const { restoreScrollPosition } = useScrollPosition(pageId);
// Restore as early as the static (cached) content is laid out, before paint,
// so the reader's position is applied without a visible jump. Aborts itself if
// the reader has already started scrolling (handled inside the hook).
useLayoutEffect(() => {
restoreScrollPosition();
}, [restoreScrollPosition]);
// Re-assert once after the static -> live editor swap in case the swap reset
// the window scroll. Idempotent: a no-op when the position is already correct,
// and a no-op after the reader has interacted.
useLayoutEffect(() => {
if (!showStatic && editor) restoreScrollPosition();
}, [showStatic, editor, restoreScrollPosition]);
}
@@ -1,164 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { RefObject } from "react";
import { useSwapHeightReservation } from "./use-swap-height-reservation";
// Controllable fake requestAnimationFrame. jsdom's rAF is timer-driven and hard
// to step deterministically, so we install a manual queue: `tickRaf()` drains the
// callbacks scheduled so far (a callback that reschedules enqueues a new one for
// the NEXT tick), letting each test advance the release loop frame by frame.
let rafQueue: Array<{ id: number; cb: FrameRequestCallback }> = [];
let nextRafId = 1;
let realRaf: typeof globalThis.requestAnimationFrame;
let realCancel: typeof globalThis.cancelAnimationFrame;
function tickRaf(): void {
const current = rafQueue;
rafQueue = [];
for (const { cb } of current) cb(0);
}
// A mutable stand-in for the live-content container. The hook only reads
// `scrollHeight`, so tests drive the release condition by mutating this.
function makeMenuRef(): {
ref: RefObject<HTMLElement | null>;
setScrollHeight: (h: number) => void;
} {
const el = { scrollHeight: 0 };
return {
ref: { current: el } as unknown as RefObject<HTMLElement | null>,
setScrollHeight: (h: number) => {
el.scrollHeight = h;
},
};
}
const H = 1000;
describe("useSwapHeightReservation", () => {
beforeEach(() => {
rafQueue = [];
nextRafId = 1;
realRaf = globalThis.requestAnimationFrame;
realCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
const id = nextRafId++;
rafQueue.push({ id, cb });
return id;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame = ((id: number) => {
rafQueue = rafQueue.filter((e) => e.id !== id);
}) as typeof globalThis.cancelAnimationFrame;
});
afterEach(() => {
globalThis.requestAnimationFrame = realRaf;
globalThis.cancelAnimationFrame = realCancel;
vi.useRealTimers();
vi.restoreAllMocks();
});
// (a) reserve-on-swap: the captured height becomes `reservedHeight`, the value
// that drives the swap wrapper's minHeight. Captured while static is still up,
// then the swap flips showStatic; before any release frame runs the reservation
// is held at exactly H.
it("(a) holds the captured height as reservedHeight after the swap (drives minHeight)", () => {
const { ref, setScrollHeight } = makeMenuRef();
setScrollHeight(0); // live content not laid out yet -> release cannot fire.
const { result, rerender } = renderHook(
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
{ initialProps: { showStatic: true } },
);
// Capture happens synchronously at the swap point (static still shown).
act(() => {
result.current.captureReservation(H);
});
// The swap flips to the live branch.
rerender({ showStatic: false });
expect(result.current.reservedHeight).toBe(H);
});
// (b) release when the live content is tall enough. Guard is `>=`: with
// liveHeight === H the reservation releases. This FAILS if the guard direction
// were `<` (liveHeight === H is not `< H`, so it would never release).
it("(b) releases once live content reaches the reserved height", () => {
const { ref, setScrollHeight } = makeMenuRef();
setScrollHeight(0);
const { result, rerender } = renderHook(
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
{ initialProps: { showStatic: true } },
);
act(() => {
result.current.captureReservation(H);
});
rerender({ showStatic: false });
expect(result.current.reservedHeight).toBe(H); // still reserved (short live doc)
// Live editor finishes laying out to the reserved height.
setScrollHeight(H);
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBeNull();
});
// (c) cap escape: the live content never reaches the reserved height, so the
// height match never fires; the reservation must still release at the 4000ms
// cap (no stuck reservation / dead space). This FAILS if there were no cap: the
// loop would poll forever while scrollHeight stays below H.
it("(c) releases at the 4000ms cap when live content stays too short", () => {
// Only fake Date so `Date.now()` (the cap clock) is controllable; leave our
// manual rAF queue in place (default fake timers would replace it).
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(0);
const { ref, setScrollHeight } = makeMenuRef();
setScrollHeight(H - 100); // always shorter than reserved -> height match never fires.
const { result, rerender } = renderHook(
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
{ initialProps: { showStatic: true } },
);
act(() => {
result.current.captureReservation(H);
});
rerender({ showStatic: false });
// A few frames pass but time has not reached the cap: still reserved.
act(() => {
tickRaf();
});
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBe(H);
// Advance past the cap; the next frame releases even though the live content
// is still shorter than the reservation.
vi.setSystemTime(4001);
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBeNull();
});
// (d) non-swap: without a capture (and while static is shown) there is no
// reservation and the release loop never arms, so no rAF is scheduled.
it("(d) reserves nothing and arms no loop when the swap never happens", () => {
const { ref } = makeMenuRef();
const { result } = renderHook(() =>
useSwapHeightReservation(true, ref),
);
expect(result.current.reservedHeight).toBeNull();
expect(rafQueue.length).toBe(0); // release loop never armed
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBeNull();
});
});
@@ -1,79 +0,0 @@
import { RefObject, useCallback, useEffect, useState } from "react";
// Last-resort release deadline. The primary release is the live-content height
// match below; this cap only exists so a slow/short live doc can never pin the
// reservation forever. It is generous (well past when the live content normally
// reaches the reserved height — it renders the SAME content as the static copy)
// so a slow load doesn't release mid-render and reintroduce the collapse.
const RELEASE_CAP_MS = 4000;
/**
* Reserves the document height across the static -> live editor swap.
*
* The live editor lays out its content over a few frames, so replacing the
* (full-height) static copy with it momentarily shrinks the document; the
* browser then clamps window scroll to the top, which yanked the reader off
* their restored reading position (and threw their scroll to 0 if they were
* scrolling at that moment). Pinning a min-height on the swap wrapper keeps the
* document tall through the swap so the scroll position simply survives (#266).
* `reservedHeight === null` means no reservation is active.
*
* The capture is intentionally a CALLBACK the page editor invokes, NOT something
* this hook derives by watching `showStatic`. The height MUST be read
* synchronously while the static content is still mounted (full natural height),
* right before the flip to the live branch. By the time any post-transition
* effect here could run, `showStatic` is already false and the wrapper shows the
* live/collapsed content, so `offsetHeight` would be wrong. So page-editor calls
* `captureReservation(wrapper.offsetHeight)` inside its collab-sync effect,
* before `setShowStatic(false)`, preserving that exact timing.
*
* @param showStatic whether the static (cached) content is still shown.
* @param menuContainerRef the live-branch content container. It is a descendant
* of the swap wrapper inside the live branch, so its `scrollHeight` is the live
* content height (not inflated by the ancestor min-height reservation).
*/
export function useSwapHeightReservation(
showStatic: boolean,
menuContainerRef: RefObject<HTMLElement | null>,
): {
reservedHeight: number | null;
captureReservation: (height: number | null) => void;
} {
const [reservedHeight, setReservedHeight] = useState<number | null>(null);
// Capture the current (static, full-height) content height BEFORE the swap so
// the wrapper can reserve it while the live editor lays out — otherwise the
// transient shrink clamps window scroll to the top. The caller reads
// `offsetHeight` synchronously at the swap point and hands it here.
const captureReservation = useCallback(
(height: number | null) => setReservedHeight(height),
[],
);
// Release the reserved height once the live editor's content has laid out to
// at least the reserved height (so removing the reservation cannot collapse
// the document). The primary release is that height match; the cap is only a
// last-resort so we never pin forever. A shorter-than-reserved live doc (rare:
// stale/longer cache) releases at the cap, leaving only harmless bottom dead
// space until then.
useEffect(() => {
if (showStatic || reservedHeight == null) return;
let raf = 0;
const startedAt = Date.now();
const check = () => {
const liveHeight = menuContainerRef.current?.scrollHeight ?? 0;
if (
liveHeight >= reservedHeight ||
Date.now() - startedAt > RELEASE_CAP_MS
) {
setReservedHeight(null);
return;
}
raf = requestAnimationFrame(check);
};
raf = requestAnimationFrame(check);
return () => cancelAnimationFrame(raf);
}, [showStatic, reservedHeight, menuContainerRef]);
return { reservedHeight, captureReservation };
}
@@ -1,50 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTitleAutofocus } from "./use-title-autofocus";
const KEY_PREFIX = "gitmost:scroll-position:";
function fakeEditor(overrides = {}) {
return { isInitialized: true, commands: { focus: vi.fn() }, ...overrides } as any;
}
describe("useTitleAutofocus", () => {
beforeEach(() => {
window.sessionStorage.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("skips auto-focus when a saved reading position exists", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}saved`, "500");
const editor = fakeEditor();
renderHook(() => useTitleAutofocus(editor, "saved"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
it("auto-focuses a new page (no saved position) with scrollIntoView: false", () => {
const editor = fakeEditor();
renderHook(() => useTitleAutofocus(editor, "fresh"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).toHaveBeenCalledWith("end", { scrollIntoView: false });
});
it("does not focus before initialization", () => {
const editor = fakeEditor({ isInitialized: false });
renderHook(() => useTitleAutofocus(editor, "fresh2"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
it("cancels the pending focus on unmount", () => {
const editor = fakeEditor();
const { unmount } = renderHook(() => useTitleAutofocus(editor, "fresh3"));
unmount();
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
});
@@ -1,45 +0,0 @@
import { useEffect, useRef } from "react";
import type { Editor } from "@tiptap/react";
import { hasSavedReadingPosition } from "./use-scroll-position";
// Delay before auto-focusing the title on load — guards a tiptap init race
// ("Cannot access view['hasFocus']" if focused too early).
const TITLE_AUTOFOCUS_DELAY_MS = 300;
/**
* Auto-focus the page title shortly after mount UNLESS a saved reading position
* will be restored (then the viewport scrolls away from the top, and focusing the
* top-of-page title would drop the caret off-screen). When it does focus, it uses
* `{ scrollIntoView: false }` so placing the caret never moves the viewport
* (tiptap's focus scrolls the focused node into view by default, which otherwise
* yanks the window to the top and fights scroll-position restoration).
*
* Extracted from TitleEditor so this exact decision is unit-testable.
*
* CONTRACT: relies on TitleEditor remounting per page (page.tsx renders
* `<MemoizedFullEditor key={page.id}>`), so `hasSavedScrollRef` is captured fresh
* per page. It is read synchronously on first render, before any scroll-save
* handler can clobber the stored value to 0 matching `useScrollPosition`'s own
* synchronous capture of `initialTargetRef`.
*/
export function useTitleAutofocus(
titleEditor: Editor | null,
pageId: string,
): void {
const hasSavedScrollRef = useRef<boolean | null>(null);
if (hasSavedScrollRef.current === null) {
hasSavedScrollRef.current = hasSavedReadingPosition(pageId);
}
useEffect(() => {
if (hasSavedScrollRef.current) return;
const timer = setTimeout(() => {
// guard against "Cannot access view['hasFocus']" before init
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end", { scrollIntoView: false });
}, TITLE_AUTOFOCUS_DELAY_MS);
// Clear the pending focus if the editor changes or the component unmounts
// (also fixes the previously-uncancelled timer).
return () => clearTimeout(timer);
}, [titleEditor]);
}
@@ -1,141 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act } from "@testing-library/react";
import type { Editor } from "@tiptap/react";
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
const KEY_PREFIX = "gitmost:scroll-position:";
// NOTE ON SCOPE (F2 — reviewer-approved lighter variant).
//
// The real UX wiring lives in the exported `useScrollRestoreOnSwap` hook (two
// useLayoutEffects around useScrollPosition), which PageEditor calls with the
// same signature. A FULL PageEditor component test is impractical here and has no
// precedent in this client: PageEditor directly constructs a
// HocuspocusProviderWebsocket + IndexeddbPersistence, a tiptap `useEditor` with
// collab extensions, reads jotai atoms, react-router params, the shared
// `queryClient` from main.tsx, i18n, and mounts ~12 editor menu children. Worse,
// the static->live swap (`showStatic` -> false) is gated on
// `isCollabSynced(status, isLocalSynced && isRemoteSynced)`, which can only flip
// by driving the mocked collab provider's async sync callbacks. The heaviest
// component-test precedent in the repo (comment-hover-preview.test.tsx) mounts a
// single leaf component with ONE mocked query; nothing mounts a feature root of
// this weight. Reproducing all of that would test the mocks, not the wiring.
//
// So this file tests the REAL `useScrollRestoreOnSwap` hook — the exact code
// PageEditor imports and calls — driving its `showStatic`/`editor` inputs the way
// the swap does. Because it exercises the real hook (not a copy), dropping the
// `&& editor` guard or changing the effect deps makes these tests fail; they
// guard the production code directly (verified: removing `&& editor` reddens the
// first test).
//
// Both tests observe the real effect via `window.scrollTo`. The stubbed
// `window.scrollTo` never mutates `window.scrollY`, and the target is left
// unreached, so every restore invocation that passes the guard yields exactly one
// `scrollTo` call — making the call count a faithful proxy for restore invocations.
function setScrollY(value: number): void {
Object.defineProperty(window, "scrollY", { configurable: true, value });
}
function setScrollHeight(value: number): void {
Object.defineProperty(document.documentElement, "scrollHeight", {
configurable: true,
value,
});
}
function setInnerHeight(value: number): void {
Object.defineProperty(window, "innerHeight", { configurable: true, value });
}
// Minimal stand-in for the tiptap editor: the hook only truthiness-checks it.
const fakeEditor = { id: "editor" } as unknown as Editor;
// Thin host that calls the REAL hook so a rerender drives showStatic/editor
// exactly like the page-editor swap does.
function Host({
pageId,
showStatic,
editor,
}: {
pageId: string;
showStatic: boolean;
editor: Editor | null;
}) {
useScrollRestoreOnSwap(pageId, editor, showStatic);
return null;
}
describe("PageEditor scroll-restore wiring (useScrollRestoreOnSwap)", () => {
beforeEach(() => {
window.sessionStorage.clear();
setScrollY(0);
setScrollHeight(0);
setInnerHeight(800);
window.scrollTo = vi.fn();
window.location.hash = "";
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
window.location.hash = "";
});
it("re-invokes restore after the swap, with the [showStatic, editor] deps/guard", () => {
// Target is immediately reachable, so each restore that passes the guard
// scrolls synchronously. `window.scrollY` stays 0 (stubbed scrollTo never
// updates it), so scrollTo is called once per effective restore — a proxy for
// the restore invocation count.
window.sessionStorage.setItem(`${KEY_PREFIX}guard`, "500");
setInnerHeight(800);
setScrollHeight(2000); // maxScroll = 1200 >= 500: reachable, no polling.
// Pre-swap: static content shown, live editor not ready. Only the early
// pre-paint restore fires; the post-swap effect's guard (!showStatic) blocks it.
const { rerender } = render(
<Host pageId="guard" showStatic={true} editor={null} />,
);
expect(window.scrollTo).toHaveBeenCalledTimes(1);
// Collab reports synced (showStatic flips false) but the editor is not ready
// yet: the swap effect re-runs (deps [showStatic, editor] changed) but the
// `&& editor` guard must keep it a no-op. The early effect does NOT re-fire
// (its dep [restoreScrollPosition] is a stable useCallback([])).
// (Pins the guard: dropping `&& editor` would restore against a null editor,
// producing a 2nd scrollTo and failing this expectation.)
rerender(<Host pageId="guard" showStatic={false} editor={null} />);
expect(window.scrollTo).toHaveBeenCalledTimes(1);
// The static -> live swap completes (showStatic false AND editor present): the
// post-swap effect re-asserts the restore exactly once more, driven solely by
// the [showStatic, editor] deps changing.
rerender(<Host pageId="guard" showStatic={false} editor={fakeEditor} />);
expect(window.scrollTo).toHaveBeenCalledTimes(2);
});
it("the post-swap re-assert drives a REAL restore (window.scrollTo) via the hook", () => {
// End-to-end through the real useScrollPosition (inside the hook): the swap
// re-invocation is the CAUSE of the scroll (nothing scrolls before it).
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}peg`, "500");
setInnerHeight(800);
setScrollHeight(100); // maxScroll = -700: target not reachable yet -> polls.
// Pre-swap: the early restore runs but content is too short, so it starts
// polling (a pending timer) without scrolling. We never advance timers, so the
// early poll cannot fire on its own — isolating the swap as the sole cause.
const { rerender } = render(
<Host pageId="peg" showStatic={true} editor={null} />,
);
expect(window.scrollTo).not.toHaveBeenCalled();
// The live content is now laid out tall enough to reach the target.
setScrollHeight(2000); // maxScroll = 1200 >= 500
// The static -> live swap: the post-swap useLayoutEffect re-invokes the real
// hook, whose synchronous tryRestore now reaches the target and scrolls.
act(() => {
rerender(<Host pageId="peg" showStatic={false} editor={fakeEditor} />);
});
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
});

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